xtraderClient/src/views/TradeDetail.vue
2026-07-04 21:35:07 +08:00

2533 lines
73 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<v-container fluid class="trade-detail-container" :class="{
'trade-detail-container--settled-bar': showSettledOutcomeBar,
}">
<v-pull-to-refresh class="trade-detail-pull-refresh" @load="onRefresh">
<div class="trade-detail-pull-refresh-inner">
<!-- findPmEvent 请求中仅显示 loading -->
<v-card v-if="detailLoading && !eventDetail" class="trade-detail-loading-card" elevation="0" rounded="lg">
<div class="trade-detail-loading-placeholder">
<v-progress-circular indeterminate color="primary" size="48" />
<p>{{ t('common.loading') }}</p>
</div>
</v-card>
<v-row v-else align="stretch" class="trade-detail-row" style="--v-col-gap-y: 0px;">
<!-- 左侧分时图 + 订单簿宽度弹性 -->
<v-col cols="12" class="chart-col">
<!-- 分时图卡片Polymarket 样式 -->
<v-card class="chart-card polymarket-chart" elevation="0" rounded="lg">
<!-- 顶部标题当前概率日期与图表模式 -->
<div class="chart-header">
<h1 class="chart-title">
{{ detailLoading && !eventDetail ? t('common.loading') : marketTitle }}
</h1>
<p v-if="detailError" class="chart-error">{{ detailError }}</p>
<div class="chart-controls-row">
<v-btn class="date-pill" size="small" rounded="pill">{{ resolutionDate }}</v-btn>
<v-btn-group v-if="cryptoSymbol" variant="outlined" density="compact" divided
class="chart-mode-toggle">
<v-btn :class="{ active: chartMode === 'yesno' }" size="small" icon
:aria-label="t('chart.yesnoTimeSeries')" @click="setChartMode('yesno')">
<v-icon size="20">mdi-chart-timeline-variant</v-icon>
</v-btn>
<v-btn :class="{ active: chartMode === 'crypto' }" size="small" class="chart-mode-crypto-btn"
:aria-label="t('chart.cryptoPrice')" @click="setChartMode('crypto')">
<span class="chart-crypto-ticker-label">{{ cryptoSymbol.toUpperCase() }}</span>
</v-btn>
</v-btn-group>
</div>
<div class="chart-chance">
<template v-if="chartMode === 'crypto' && cryptoSymbol">
${{ currentCryptoPrice }} {{ cryptoSymbol.toUpperCase() }}
</template>
<template v-else> {{ currentChance }}% {{ t('common.chance') }} </template>
</div>
<p v-if="currentMarketClosed" class="chart-closed-banner">
{{ t('trade.marketClosedBanner') }}
</p>
</div>
<!-- 图表区域 -->
<div class="chart-wrapper">
<div v-if="cryptoChartLoading || chartYesNoLoading" class="chart-loading-overlay">
<v-progress-circular indeterminate color="primary" size="32" />
</div>
<div ref="chartContainerRef" class="chart-container"></div>
</div>
<!-- 底部成交量/到期日 | 时间粒度 -->
<div class="chart-footer">
<div class="chart-footer-left">
<span class="chart-volume">{{ marketVolume }} Vol.</span>
<span v-if="marketExpiresAt" class="chart-expires">| {{ marketExpiresAt }}</span>
</div>
<div class="chart-time-ranges">
<v-btn v-for="r in timeRanges" :key="r.value"
:class="['time-range-btn', { active: selectedTimeRange === r.value }]" variant="text" size="small"
@click="selectTimeRange(r.value)">
{{ r.label }}
</v-btn>
</div>
</div>
</v-card>
<!-- 移动端无右侧交易栏时展示已关闭说明 -->
<v-card v-if="isMobile && currentMarketClosed" class="market-closed-mobile-card" elevation="0" rounded="lg">
<div class="market-closed-pane market-closed-pane--mobile">
<v-icon size="40" color="grey-darken-1">mdi-lock-outline</v-icon>
<h2 class="market-closed-pane-title">{{ t('trade.marketClosedTitle') }}</h2>
<p class="market-closed-pane-desc">{{ t('trade.marketClosedDesc') }}</p>
</div>
</v-card>
<!-- 持仓 / 限价订单簿上方 -->
<v-card class="positions-orders-card" elevation="0" rounded="lg">
<v-tabs v-model="positionsOrdersTab" class="positions-orders-tabs" density="comfortable">
<v-tab value="positions">{{ t('activity.myPositions') }}</v-tab>
<v-tab value="orders">{{ t('activity.openOrders') }}</v-tab>
</v-tabs>
<v-window v-model="positionsOrdersTab" class="positions-orders-window">
<v-window-item value="positions" class="detail-pane">
<div v-if="positionLoading" class="placeholder-pane placeholder-pane--loading">
<v-progress-circular indeterminate size="36" width="2" />
<span>{{ t('common.loading') }}</span>
</div>
<div v-else-if="marketPositionsFiltered.length === 0" class="placeholder-pane">
{{ t('activity.noPositionsInMarket') }}
</div>
<div v-else class="positions-list">
<div v-for="pos in marketPositionsFiltered" :key="pos.id" class="position-row-item">
<div class="position-row-header">
<div class="position-row-icon" :class="pos.iconClass">
<img v-if="pos.imageUrl" :src="pos.imageUrl" alt="" class="position-row-icon-img" />
<span v-else class="position-row-icon-char">{{
pos.iconChar || '•'
}}</span>
</div>
<span class="position-row-title">{{ pos.market }}</span>
</div>
<div class="position-row-main">
<span :class="['position-outcome-pill', pos.outcomePillClass]">{{
pos.outcomeTag
}}</span>
<span v-if="pos.locked" class="position-lock-badge">
{{
pos.lockedSharesNum != null && Number.isFinite(pos.lockedSharesNum)
? t('activity.positionLockedWithAmount', { n: pos.lockedSharesNum })
: t('activity.positionLocked')
}}
</span>
<span class="position-shares">{{ pos.shares }}</span>
<span class="position-value">{{ pos.value }}</span>
<v-btn variant="outlined" size="small" color="primary" class="position-sell-btn" :disabled="currentMarketClosed ||
!(pos.availableSharesNum != null && pos.availableSharesNum > 0)
" @click="openSellFromPosition(pos)">
{{ t('trade.sell') }}
</v-btn>
</div>
<div class="position-row-meta">{{ pos.bet }} {{ pos.toWin }}</div>
</div>
</div>
</v-window-item>
<v-window-item value="orders" class="detail-pane">
<div v-if="openOrderLoading" class="placeholder-pane placeholder-pane--loading">
<v-progress-circular indeterminate size="36" width="2" />
<span>{{ t('common.loading') }}</span>
</div>
<div v-else-if="marketOpenOrders.length === 0" class="placeholder-pane">
{{ t('activity.noOpenOrdersInMarket') }}
</div>
<div v-else class="orders-list">
<div v-for="ord in marketOpenOrders" :key="ord.id" class="order-row-item">
<div class="order-row-main">
<span :class="['order-side-pill', ord.side === 'Yes' ? 'side-yes' : 'side-no']">
{{ ord.actionLabel || `Buy ${ord.outcome}` }}
</span>
<span class="order-price">{{ ord.price }}</span>
<span class="order-filled">{{ ord.filled }}</span>
<span class="order-total">{{ ord.total }}</span>
</div>
<div class="order-row-actions">
<v-btn variant="text" size="small" color="error"
:disabled="cancelOrderLoading || ord.fullyFilled" @click="cancelMarketOrder(ord)">
{{ t('activity.cancelOrder') }}
</v-btn>
</div>
</div>
</div>
</v-window-item>
</v-window>
</v-card>
<!-- Order Book Section -->
<v-card v-if="!currentMarketClosed" class="order-book-card" elevation="0" rounded="lg">
<OrderBook :asks-yes="orderBookAsksYes" :bids-yes="orderBookBidsYes" :asks-no="orderBookAsksNo"
:bids-no="orderBookBidsNo" :anchor-best-bid-yes="orderBookBestBidYesCents"
:anchor-lowest-ask-yes="orderBookLowestAskYesCents" :anchor-best-bid-no="orderBookBestBidNoCents"
:anchor-lowest-ask-no="orderBookLowestAskNoCents"
:outcome-price-anchor-yes-cents="orderBookOutcomeAnchorYesCents"
:outcome-price-anchor-no-cents="orderBookOutcomeAnchorNoCents" :last-price-yes="clobLastPriceYes"
:last-price-no="clobLastPriceNo" :spread-yes="clobSpreadYes" :spread-no="clobSpreadNo"
:loading="clobLoading" :connected="clobConnected" :yes-label="yesLabel" :no-label="noLabel" />
</v-card>
<!-- Comments / Top Holders / Activity与左侧图表订单簿同宽 -->
<v-card class="activity-card" elevation="0" rounded="lg">
<div class="rules-pane">
<div v-if="!eventDetail?.description && !eventDetail?.resolutionSource" class="placeholder-pane">
{{ t('activity.rulesEmpty') }}
</div>
<template v-else>
<div v-if="eventDetail?.description" class="rules-section">
<h3 class="rules-title">{{ t('activity.rulesDescription') }}</h3>
<div class="rules-text" :class="{
'rules-text--multi-collapsed':
rulesDescriptionCollapsible && !rulesDescriptionExpanded,
}">
{{ eventDetail.description }}
</div>
<v-btn v-if="rulesDescriptionCollapsible" variant="text" size="small" density="compact"
class="rules-description-toggle" @click="rulesDescriptionExpanded = !rulesDescriptionExpanded">
{{
rulesDescriptionExpanded
? t('eventMarkets.collapseDescription')
: t('eventMarkets.expandDescription')
}}
</v-btn>
</div>
<div v-if="eventDetail?.resolutionSource" class="rules-section">
<h3 class="rules-title">{{ t('activity.rulesSource') }}</h3>
<a v-if="isResolutionSourceUrl" :href="eventDetail.resolutionSource" target="_blank"
rel="noopener noreferrer" class="rules-link">
{{ eventDetail.resolutionSource }}
<v-icon size="14">mdi-open-in-new</v-icon>
</a>
<div v-else class="rules-text">{{ eventDetail.resolutionSource }}</div>
</div>
</template>
</div>
</v-card>
</v-col>
<!-- 右侧交易组件固定宽度传入当前市场以便 Split 调用拆单接口移动端隐藏改用底部栏+弹窗 -->
<v-col v-if="!isMobile" cols="12" class="trade-col">
<div v-if="currentMarketClosed" class="trade-sidebar market-closed-pane">
<v-icon size="44" color="grey-darken-1">mdi-lock-outline</v-icon>
<h2 class="market-closed-pane-title">{{ t('trade.marketClosedTitle') }}</h2>
<p class="market-closed-pane-desc">{{ t('trade.marketClosedDesc') }}</p>
</div>
<div v-else-if="tradeMarketPayload" class="trade-sidebar">
<TradeComponent ref="tradeComponentRef" :market="tradeMarketPayload"
:positions="tradePositionsForComponent" @merge-success="onMergeSuccess"
@split-success="onSplitSuccess" />
</div>
</v-col>
<!-- 移动端固定底部 Yes/No + 三点菜单Merge/Split -->
<template v-if="isMobile && tradeMarketPayload && !currentMarketClosed">
<div class="mobile-trade-bar-spacer" aria-hidden="true"></div>
<div class="mobile-trade-bar">
<v-btn class="mobile-bar-btn mobile-bar-yes" variant="flat" rounded="sm"
@click="openSheetWithOption('yes')">
{{ yesLabel }} {{ yesPriceCents }}¢
</v-btn>
<v-btn class="mobile-bar-btn mobile-bar-no" variant="flat" rounded="sm"
@click="openSheetWithOption('no')">
{{ noLabel }} {{ noPriceCents }}¢
</v-btn>
<v-menu v-model="mobileMenuOpen" :close-on-content-click="true" location="top"
transition="scale-transition">
<template #activator="{ props: menuProps }">
<v-btn v-bind="menuProps" class="mobile-bar-more-btn" variant="flat" icon rounded="pill"
aria-label="更多操作">
<v-icon size="20">mdi-dots-horizontal</v-icon>
</v-btn>
</template>
<v-list density="compact">
<v-list-item @click="openMergeFromBar">
<v-list-item-title>{{ t('trade.merge') }}</v-list-item-title>
</v-list-item>
<v-list-item @click="openSplitFromBar">
<v-list-item-title>{{ t('trade.split') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<v-bottom-sheet v-model="tradeSheetOpen" content-class="trade-detail-trade-sheet">
<TradeComponent v-if="tradeSheetRenderContent" ref="mobileTradeComponentRef" :market="tradeMarketPayload"
:initial-option="tradeInitialOptionFromBar" :initial-tab="tradeInitialTabFromBar"
:positions="tradePositionsForComponent" embedded-in-sheet @order-success="onOrderSuccess"
@merge-success="onMergeSuccess" @split-success="onSplitSuccess" />
</v-bottom-sheet>
</template>
<!-- 从持仓点击 Sell 弹出的交易组件桌面/移动端通用 -->
<v-dialog v-model="sellDialogOpen" max-width="420" content-class="trade-detail-sell-dialog"
transition="dialog-transition">
<TradeComponent v-if="sellDialogRenderContent" :market="tradeMarketPayload"
:initial-option="sellInitialOption" :initial-tab="'sell'" :positions="tradePositionsForComponent"
@order-success="onSellOrderSuccess" @merge-success="onMergeSuccess" @split-success="onSplitSuccess" />
</v-dialog>
</v-row>
</div>
</v-pull-to-refresh>
<!-- 已结算结算结果固定在视口底部滚动时仍可见 -->
<div v-if="showSettledOutcomeBar" class="trade-settled-outcome-bar" role="status">
{{ t('trade.marketClosedOutcome', { outcome: settledOutcomeLabel }) }}
</div>
<AppFooter />
</v-container>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import {
createChart,
LineSeries,
LineType,
LastPriceAnimationMode,
TickMarkType,
type BarPrice,
type IChartApi,
type ISeriesApi,
type Time,
} from 'lightweight-charts'
import { toLwcData, toLwcPoint } from '../composables/useLightweightChart'
import { InPanePriceScalePrimitive } from '../composables/inPanePriceScalePrimitive'
import OrderBook from '../components/OrderBook.vue'
import TradeComponent, { type TradePositionItem } from '../components/TradeComponent.vue'
import {
findPmEvent,
getMarketId,
getClobTokenId,
getPmMarketSettledWinner,
isPmMarketClosed,
type FindPmEventParams,
type PmEventListItem,
} from '../api/event'
import { getClobWsUrl } from '../api/request'
import { useUserStore } from '../stores/user'
import { useToastStore } from '../stores/toast'
import { useLocaleStore } from '../stores/locale'
import { useAuthError } from '../composables/useAuthError'
import {
getPositionList,
mapPositionToDisplayItem,
type PositionDisplayItem,
} from '../api/position'
import {
getOrderList,
mapOrderToOpenOrderItem,
OrderStatus,
type OpenOrderDisplayItem,
} from '../api/order'
import { cancelOrder as apiCancelOrder } from '../api/order'
import type { ChartDataPoint } from '../api/chart'
import {
getPmPriceHistoryPublic,
getTimeRangeSeconds,
priceHistoryToChartData,
} from '../api/priceHistory'
import {
parseCryptoSymbolFromTicker,
formatCryptoChartPrice,
fetchCryptoChart,
subscribeCryptoRealtime,
alignCryptoChartTimeMs,
CRYPTO_CHART_TIME_STEP_MS,
CRYPTO_30S_RETENTION_MS,
} from '../api/cryptoChart'
const { t } = useI18n()
import { ClobSdk, type PriceSizePolyMsg, type TradePolyMsg } from '../../sdk/clobSocket'
/**
* 分时图服务端推送数据格式约定
*
* 图表所需单点格式(与 ECharts 时间轴一致):
* [timestamp, value]
* - timestamp: numberUnix 毫秒时间戳
* - value: number当前概率/价格(如 0100 的百分比)
*
* 服务端可选用两种推送方式:
*
* 1) 全量快照(切换时间粒度或首次加载时):
* { range?: string, data: [number, number][] }
* - range: 可选,'1H' | '6H' | '1D' | '1W' | '1M' | 'ALL'
* - data: 按时间升序的 [timestamp, value] 数组
*
* 2) 增量点(实时推送最新点):
* { point: [number, number] }
* - point: 单点 [timestamp, value],前端会追加并按当前 range 保留最近 N 个点
*
* 示例WebSocket 消息体):
* 全量: { "data": [[1707206400000, 25.5], [1707210000000, 26.1], ...] }
* 增量: { "point": [1707213600000, 27.2] }
*/
export type ChartPoint = [number, number]
export type ChartSnapshot = { range?: string; data: ChartPoint[] }
export type ChartIncrement = { point: ChartPoint }
const route = useRoute()
const userStore = useUserStore()
const { formatAuthError } = useAuthError()
const localeStore = useLocaleStore()
const { mobile } = useDisplay()
const isMobile = computed(() => mobile.value)
// 详情接口 GET /PmEvent/findPmEvent 返回的数据
const eventDetail = ref<PmEventListItem | null>(null)
const detailLoading = ref(false)
const detailError = ref<string | null>(null)
function formatVolume(volume: number | undefined): string {
if (volume == null || !Number.isFinite(volume)) return '$0 Vol.'
if (volume >= 1000) return `$${(volume / 1000).toFixed(1)}k Vol.`
return `$${Math.round(volume)} Vol.`
}
function formatExpiresAt(endDate: string | undefined): string {
if (!endDate) return ''
try {
const d = new Date(endDate)
if (Number.isNaN(d.getTime())) return endDate
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch {
return endDate
}
}
async function loadEventDetail() {
const idRaw = route.params.id
const idStr = String(idRaw ?? '').trim()
if (!idStr) {
detailError.value = t('error.invalidId')
eventDetail.value = null
return
}
const numId = parseInt(idStr, 10)
const isNumericId = Number.isFinite(numId) && String(numId) === idStr && numId >= 1
const params: FindPmEventParams = {
id: isNumericId ? numId : undefined,
slug: isNumericId ? undefined : idStr,
}
detailError.value = null
detailLoading.value = true
try {
const res = await findPmEvent(params, {
headers: userStore.getAuthHeaders(),
})
if (res.code === 0 || res.code === 200) {
eventDetail.value = res.data ?? null
// 单个市场详情数据打印,便于分析
const ev = eventDetail.value
console.log('[TradeDetail] 单个市场详情:', {
id: ev?.ID ?? ev?.id,
title: ev?.title,
slug: ev?.slug,
volume: ev?.volume,
endDate: ev?.endDate,
marketsCount: ev?.markets?.length ?? 0,
firstMarket: ev?.markets?.[0]
? {
question: ev.markets[0].question,
outcomePrices: ev.markets[0].outcomePrices,
marketId: ev.markets[0].marketId,
}
: null,
})
} else {
detailError.value = res.msg || t('error.loadFailed')
eventDetail.value = null
}
} catch (e) {
detailError.value = formatAuthError(e, t('error.loadFailed'))
eventDetail.value = null
} finally {
detailLoading.value = false
}
}
function onRefresh({ done }: { done: () => void }) {
Promise.all([loadEventDetail(), loadMarketPositions(), loadMarketOpenOrders()]).finally(() =>
done(),
)
}
// 标题、成交量、到期日:仅使用接口详情(路径只带 slug/id无 query
const marketTitle = computed(() => eventDetail.value?.title ?? '')
const marketVolume = computed(() => {
if (eventDetail.value?.volume != null) return formatVolume(eventDetail.value.volume)
return formatVolume(undefined)
})
const marketExpiresAt = computed(() => {
if (eventDetail.value?.endDate) return formatExpiresAt(eventDetail.value.endDate)
return ''
})
const resolutionDate = computed(() => {
const s = marketExpiresAt.value
return s ? s.replace(/,?\s*\d{4}$/, '').trim() || 'Mar 31' : 'Mar 31'
})
const isResolutionSourceUrl = computed(() => {
const src = eventDetail.value?.resolutionSource
return typeof src === 'string' && (src.startsWith('http://') || src.startsWith('https://'))
})
/** 是否为加密货币类型事件(可切换 YES/NO 分时 vs 加密货币价格图) */
/** 从 event.ticker 首段解析的加密货币符号(如 bnb-updown-… → bnb无映射则不展示切换器 */
const cryptoSymbol = computed(() => parseCryptoSymbolFromTicker(eventDetail.value?.ticker))
watch(cryptoSymbol, (sym) => {
if (!sym && chartMode.value === 'crypto') {
chartMode.value = 'yesno'
if (selectedTimeRange.value === '30S') selectedTimeRange.value = '1D'
void updateChartData()
}
})
/** 图表模式yesno=YES/NO 分时crypto=加密货币价格 */
const chartMode = ref<'yesno' | 'crypto'>('yesno')
/** 加密货币模式下当前价格(最新数据点) */
const currentCryptoPrice = computed(() => {
const d = data.value
const last = d.length > 0 ? d[d.length - 1] : undefined
if (last != null && chartMode.value === 'crypto') {
return formatCryptoChartPrice(last[1])
}
return '—'
})
function setChartMode(mode: 'yesno' | 'crypto') {
if (mode === 'yesno' && selectedTimeRange.value === '30S') {
selectedTimeRange.value = '1D'
} else if (mode === 'crypto') {
selectedTimeRange.value = '30S'
}
chartMode.value = mode
updateChartData()
}
/** 当前市场(用于交易组件与 Split 拆单):取接口返回的第一个市场 */
const currentMarket = computed(() => eventDetail.value?.markets?.[0] ?? null)
const currentMarketClosed = computed(() => isPmMarketClosed(currentMarket.value))
const settledOutcomeLabel = computed(() => {
const m = currentMarket.value
if (!m) return ''
const w = getPmMarketSettledWinner(m)
return w?.label ?? ''
})
/** 底部固定栏:仅在市场已关闭且能解析出结算结果文案时展示 */
const showSettledOutcomeBar = computed(
() => currentMarketClosed.value && Boolean((settledOutcomeLabel.value ?? '').trim()),
)
/** 详情页接口若含多个子市场,描述区首屏折叠 */
const isEventMultiMarket = computed(() => (eventDetail.value?.markets?.length ?? 0) > 1)
const RULES_DESC_COLLAPSE_MIN_LEN = 80
const rulesDescriptionExpanded = ref(false)
const rulesDescriptionCollapsible = computed(() => {
if (!isEventMultiMarket.value) return false
const d = eventDetail.value?.description?.trim() ?? ''
return d.length > RULES_DESC_COLLAPSE_MIN_LEN
})
// --- CLOB WebSocket 订单簿与成交 ---
// 按 token 索引区分0 = Yes1 = No
type OrderBookRows = { price: number; shares: number; priceRaw?: number }[]
const clobSdkRef = ref<ClobSdk | null>(null)
const orderBookByToken = ref<Record<number, { asks: OrderBookRows; bids: OrderBookRows }>>({
0: { asks: [], bids: [] },
1: { asks: [], bids: [] },
})
const clobLastPriceByToken = ref<Record<number, number | undefined>>({ 0: undefined, 1: undefined })
const clobSpreadByToken = ref<Record<number, number | undefined>>({ 0: undefined, 1: undefined })
const clobConnected = ref(false)
const clobLoading = ref(false)
/** 当前订阅的 tokenIds用于根据 msg.m 匹配 Yes(0)/No(1) */
const clobTokenIdsRef = ref<string[]>([])
const orderBookAsksYes = computed(() => orderBookByToken.value[0]?.asks ?? [])
const orderBookBidsYes = computed(() => orderBookByToken.value[0]?.bids ?? [])
const orderBookAsksNo = computed(() => orderBookByToken.value[1]?.asks ?? [])
const orderBookBidsNo = computed(() => orderBookByToken.value[1]?.bids ?? [])
/** 订单簿 Yes 卖单最低价(分),无数据时为 0 */
const orderBookLowestAskYesCents = computed(() => {
const asks = orderBookAsksYes.value
if (!asks.length) return 0
return Math.min(...asks.map((a) => a.price))
})
/** 订单簿 No 卖单最低价(分),无数据时为 0 */
const orderBookLowestAskNoCents = computed(() => {
const asks = orderBookAsksNo.value
if (!asks.length) return 0
return Math.min(...asks.map((a) => a.price))
})
/** 订单簿 Yes 买单最高价(分),无数据时为 0市价卖出时用于计算将收到金额 */
const orderBookBestBidYesCents = computed(() => {
const bids = orderBookBidsYes.value
if (!bids.length) return 0
return Math.max(...bids.map((b) => b.price))
})
/** 订单簿 No 买单最高价(分),无数据时为 0市价卖出时用于计算将收到金额 */
const orderBookBestBidNoCents = computed(() => {
const bids = orderBookBidsNo.value
if (!bids.length) return 0
return Math.max(...bids.map((b) => b.price))
})
/** outcomePrices 单项 → 订单簿锚价(美分):概率 01 或已是美分 1100 */
function outcomePriceToAnchorCents(raw: string | number | undefined): number {
if (raw == null || (typeof raw === 'string' && raw.trim() === '')) return 0
const p = parseFloat(String(raw))
if (!Number.isFinite(p)) return 0
if (p > 1 && p <= 100) return Math.min(99, Math.max(1, Math.round(p)))
return Math.min(99, Math.max(0, Math.round(p * 100)))
}
/** 无真实盘口时模拟 Yes 簿:锚定 outcomePrices[0] 为最优卖一 */
const orderBookOutcomeAnchorYesCents = computed(() =>
outcomePriceToAnchorCents(currentMarket.value?.outcomePrices?.[0]),
)
/** 无真实盘口时模拟 No 簿:优先 outcomePrices[1],否则与 Yes 互补100Yes¢ */
const orderBookOutcomeAnchorNoCents = computed(() => {
const m = currentMarket.value
const raw1 = m?.outcomePrices?.[1]
if (raw1 != null && String(raw1).trim() !== '') {
return outcomePriceToAnchorCents(raw1)
}
const y = orderBookOutcomeAnchorYesCents.value
if (y > 0) return Math.min(99, Math.max(2, 100 - y))
return outcomePriceToAnchorCents(m?.outcomePrices?.[0])
})
const clobLastPriceYes = computed(() => clobLastPriceByToken.value[0])
const clobLastPriceNo = computed(() => clobLastPriceByToken.value[1])
const clobSpreadYes = computed(() => clobSpreadByToken.value[0])
const clobSpreadNo = computed(() => clobSpreadByToken.value[1])
/** 订单簿份额接口按 6 位小数传1_000_000 = 1 share需除以该系数转为展示值 */
const ORDER_BOOK_SIZE_SCALE = 1_000_000
/** 接口 key p 为价格(与 price 美分同源,除以 100 为美分priceRaw 为美元单价保留原始精度供订单簿算总价 */
function priceSizeToRows(record: Record<string, number> | undefined): OrderBookRows {
if (!record) return []
return Object.entries(record)
.filter(([, rawShares]) => rawShares > 0)
.map(([p, rawShares]) => {
const numP = parseFloat(p)
const priceCents = Math.round(numP / 100)
const priceRawDollars = Number.isFinite(numP) ? numP / 10000 : priceCents / 100
return {
price: priceCents,
shares: rawShares / ORDER_BOOK_SIZE_SCALE,
priceRaw: priceRawDollars,
}
})
}
function getTokenIndex(msg: PriceSizePolyMsg): number {
const ids = clobTokenIdsRef.value
const m = msg.m
if (m != null && ids.length >= 2) {
const idx = ids.findIndex((id) => String(id) === String(m))
if (idx === 0 || idx === 1) return idx
}
if (typeof msg.i === 'number' && (msg.i === 0 || msg.i === 1)) return msg.i
return 0
}
function applyPriceSizeAll(msg: PriceSizePolyMsg) {
const idx = getTokenIndex(msg)
const asks = priceSizeToRows(msg.s).sort((a, b) => a.price - b.price)
const bids = priceSizeToRows(msg.b).sort((a, b) => b.price - a.price)
orderBookByToken.value = {
...orderBookByToken.value,
[idx]: { asks, bids },
}
updateSpreadForToken(idx)
}
function applyPriceSizeDelta(msg: PriceSizePolyMsg) {
const idx = getTokenIndex(msg)
const mergeDelta = (
current: OrderBookRows,
delta: Record<string, number> | undefined,
asc: boolean,
) => {
const map = new Map<string, { price: number; shares: number; priceRaw: number }>()
const keyOf = (price: number, priceRaw: number) => priceRaw.toFixed(8)
current.forEach((r) => {
const pr = r.priceRaw ?? r.price / 100
map.set(keyOf(r.price, pr), { price: r.price, shares: r.shares, priceRaw: pr })
})
if (delta) {
Object.entries(delta).forEach(([p, rawShares]) => {
const numP = parseFloat(p)
const price = Math.round(numP / 100)
const priceRaw = Number.isFinite(numP) ? numP / 10000 : price / 100
const shares = rawShares / ORDER_BOOK_SIZE_SCALE
const key = keyOf(price, priceRaw)
if (shares <= 0) map.delete(key)
else map.set(key, { price, shares, priceRaw })
})
}
return Array.from(map.values()).sort((a, b) => (asc ? a.price - b.price : b.price - a.price))
}
const prev = orderBookByToken.value[idx] ?? { asks: [], bids: [] }
const asks = mergeDelta(prev.asks, msg.s, true)
const bids = mergeDelta(prev.bids, msg.b, false)
orderBookByToken.value = {
...orderBookByToken.value,
[idx]: { asks, bids },
}
updateSpreadForToken(idx)
}
function updateSpreadForToken(idx: number) {
const book = orderBookByToken.value[idx]
if (!book) return
const bestAsk = book.asks[0]?.price
const bestBid = book.bids[0]?.price
if (bestAsk != null && bestBid != null) {
clobSpreadByToken.value = { ...clobSpreadByToken.value, [idx]: bestAsk - bestBid }
}
}
function connectClob(tokenIds: string[]) {
clobSdkRef.value?.disconnect()
clobSdkRef.value = null
clobLoading.value = true
clobConnected.value = false
clobTokenIdsRef.value = tokenIds
orderBookByToken.value = { 0: { asks: [], bids: [] }, 1: { asks: [], bids: [] } }
clobLastPriceByToken.value = { 0: undefined, 1: undefined }
clobSpreadByToken.value = { 0: undefined, 1: undefined }
const options = {
url: getClobWsUrl(),
autoReconnect: true,
reconnectInterval: 2000,
}
const sdk = new ClobSdk(tokenIds, options)
sdk.onConnect(() => {
clobConnected.value = true
clobLoading.value = false
})
sdk.onDisconnect(() => {
clobConnected.value = false
})
sdk.onError(() => {
clobLoading.value = false
})
sdk.onPriceSizeAll(applyPriceSizeAll)
sdk.onPriceSizeDelta(applyPriceSizeDelta)
sdk.onTrade((msg: TradePolyMsg) => {
const priceNum = parseFloat(msg.p)
if (Number.isFinite(priceNum)) {
const priceCents = Math.round(priceNum * 100)
const side = msg.side?.toLowerCase()
const tokenIdx = side === 'buy' ? 0 : 1
clobLastPriceByToken.value = { ...clobLastPriceByToken.value, [tokenIdx]: priceCents }
}
})
clobSdkRef.value = sdk
sdk.connect()
}
function disconnectClob() {
clobSdkRef.value?.disconnect()
clobSdkRef.value = null
clobConnected.value = false
clobLoading.value = false
}
/**
* 传给 TradeComponentyesPrice/noPrice 优先取对应 token 订单簿 **卖单最低价**(美元 01
* 取不到再用当前市场的 outcomePrices。
*/
const tradeMarketPayload = computed(() => {
const m = currentMarket.value
const yC = orderBookLowestAskYesCents.value
const nC = orderBookLowestAskNoCents.value
let yesPrice = yC > 0 ? yC / 100 : 0
let noPrice = nC > 0 ? nC / 100 : 0
const bestBidYesCents = orderBookBestBidYesCents.value
const bestBidNoCents = orderBookBestBidNoCents.value
if (m) {
if (yesPrice <= 0) {
const c = outcomePriceToAnchorCents(m.outcomePrices?.[0])
if (c > 0) yesPrice = c / 100
}
if (noPrice <= 0) {
const raw1 = m.outcomePrices?.[1]
if (raw1 != null && String(raw1).trim() !== '') {
const c = outcomePriceToAnchorCents(raw1)
if (c > 0) noPrice = c / 100
} else if (yesPrice > 0) {
noPrice = Math.min(1, Math.max(0, 1 - yesPrice))
} else {
const c = outcomePriceToAnchorCents(m.outcomePrices?.[0])
if (c > 0) noPrice = c / 100
}
}
return {
marketId: getMarketId(m),
yesPrice,
noPrice,
title: m.question,
clobTokenIds: m.clobTokenIds,
outcomes: m.outcomes,
outcomePrices: m.outcomePrices,
bestBidYesCents,
bestBidNoCents,
}
}
return undefined
})
/** 移动端底部栏点击 Yes/No 时传给弹窗内 TradeComponent 的初始选项 */
const tradeInitialOptionFromBar = ref<'yes' | 'no' | undefined>(undefined)
/** 移动端弹窗初始 Tab从持仓 Sell 打开时为 'sell',从底部栏 Yes/No 打开时为 undefined默认 Buy */
const tradeInitialTabFromBar = ref<'buy' | 'sell' | undefined>(undefined)
/** 移动端交易弹窗开关 */
const tradeSheetOpen = ref(false)
/** 控制底部栏内 TradeComponent 的渲染,延迟挂载以避免 slot 竞态警告 */
const tradeSheetRenderContent = ref(false)
/** 从三点菜单点击 Merge/Split 时待打开的弹窗,等 TradeComponent 挂载后执行 */
const pendingMergeSplitDialog = ref<'merge' | 'split' | null>(null)
/** 从持仓 Sell 打开的弹窗 */
const sellDialogOpen = ref(false)
/** 控制 Sell 弹窗内 TradeComponent 的渲染,延迟卸载以避免 emitsOptions 竞态 */
const sellDialogRenderContent = ref(false)
/** 从持仓 Sell 时预选的 Yes/No */
const sellInitialOption = ref<'yes' | 'no'>('yes')
/** 移动端三点菜单开关 */
const mobileMenuOpen = ref(false)
/** 桌面端 TradeComponent 引用Merge/Split */
const tradeComponentRef = ref<InstanceType<typeof TradeComponent> | null>(null)
/** 移动端弹窗内 TradeComponent 引用 */
const mobileTradeComponentRef = ref<InstanceType<typeof TradeComponent> | null>(null)
const yesPriceCents = computed(() =>
tradeMarketPayload.value ? Math.round(tradeMarketPayload.value.yesPrice * 100) : 0,
)
const noPriceCents = computed(() =>
tradeMarketPayload.value ? Math.round(tradeMarketPayload.value.noPrice * 100) : 0,
)
const yesLabel = computed(() => currentMarket.value?.outcomes?.[0] ?? 'Yes')
const noLabel = computed(() => currentMarket.value?.outcomes?.[1] ?? 'No')
function openSheetWithOption(side: 'yes' | 'no') {
tradeInitialOptionFromBar.value = side
tradeInitialTabFromBar.value = undefined
tradeSheetOpen.value = true
}
function openMergeFromBar() {
mobileMenuOpen.value = false
pendingMergeSplitDialog.value = 'merge'
tradeSheetOpen.value = true
}
function openSplitFromBar() {
mobileMenuOpen.value = false
pendingMergeSplitDialog.value = 'split'
tradeSheetOpen.value = true
}
const toastStore = useToastStore()
function onOrderSuccess() {
tradeSheetOpen.value = false
toastStore.show(t('toast.orderSuccess'))
loadMarketPositions()
loadMarketOpenOrders()
}
/** 合并成功后刷新持仓(根据 tokenId 更新本地持仓数据) */
function onMergeSuccess() {
toastStore.show(t('toast.mergeSuccess'))
loadMarketPositions()
}
/** 拆分成功后刷新持仓 */
function onSplitSuccess() {
loadMarketPositions()
}
/** 从持仓项点击 Sell弹出交易组件并切到 Sell、对应 Yes/No。持仓类型用 outcome 字段与 noLabel 匹配。 */
function openSellFromPosition(pos: PositionDisplayItem) {
if (pos.availableSharesNum == null || pos.availableSharesNum <= 0) {
toastStore.show(t('activity.noAvailableSharesToSell'), 'warning')
return
}
const option = pos.outcome === noLabel.value ? 'no' : 'yes'
if (isMobile.value) {
tradeInitialOptionFromBar.value = option
tradeInitialTabFromBar.value = 'sell'
tradeSheetOpen.value = true
} else {
sellInitialOption.value = option
sellDialogOpen.value = true
}
}
function onSellOrderSuccess() {
sellDialogOpen.value = false
onOrderSuccess()
}
// 延迟卸载 Sell 弹窗内容,等 dialog transition 完成,避免 emitsOptions 竞态
let sellDialogUnmountTimer: ReturnType<typeof setTimeout> | undefined
watch(
sellDialogOpen,
(open) => {
if (open) {
if (sellDialogUnmountTimer) {
clearTimeout(sellDialogUnmountTimer)
sellDialogUnmountTimer = undefined
}
sellDialogRenderContent.value = true
} else {
sellDialogUnmountTimer = setTimeout(() => {
sellDialogRenderContent.value = false
sellDialogUnmountTimer = undefined
}, 350)
}
},
{ immediate: true },
)
onUnmounted(() => {
if (sellDialogUnmountTimer) clearTimeout(sellDialogUnmountTimer)
if (tradeSheetUnmountTimer) clearTimeout(tradeSheetUnmountTimer)
if (tradeSheetMountTimer) clearTimeout(tradeSheetMountTimer)
})
// 底部栏 TradeComponent 延迟挂载/卸载,避免 transition 期间 slot 竞态
let tradeSheetUnmountTimer: ReturnType<typeof setTimeout> | undefined
let tradeSheetMountTimer: ReturnType<typeof setTimeout> | undefined
watch(
tradeSheetOpen,
(open) => {
if (open) {
if (tradeSheetUnmountTimer) {
clearTimeout(tradeSheetUnmountTimer)
tradeSheetUnmountTimer = undefined
}
if (tradeSheetMountTimer) clearTimeout(tradeSheetMountTimer)
tradeSheetMountTimer = setTimeout(() => {
tradeSheetRenderContent.value = true
tradeSheetMountTimer = undefined
nextTick(() => {
const pending = pendingMergeSplitDialog.value
if (pending === 'merge') {
pendingMergeSplitDialog.value = null
mobileTradeComponentRef.value?.openMergeDialog?.()
} else if (pending === 'split') {
pendingMergeSplitDialog.value = null
mobileTradeComponentRef.value?.openSplitDialog?.()
}
})
}, 50)
} else {
pendingMergeSplitDialog.value = null
if (tradeSheetMountTimer) {
clearTimeout(tradeSheetMountTimer)
tradeSheetMountTimer = undefined
}
tradeSheetUnmountTimer = setTimeout(() => {
tradeSheetRenderContent.value = false
tradeSheetUnmountTimer = undefined
}, 350)
}
},
{ immediate: true },
)
// 当前市场的 marketID用于筛选持仓和订单
const currentMarketId = computed(() => getMarketId(currentMarket.value))
// 持仓列表(仅当前市场)
const marketPositions = ref<PositionDisplayItem[]>([])
const positionLoading = ref(false)
/** 过滤掉份额为 0 的持仓项 */
const marketPositionsFiltered = computed(() =>
marketPositions.value.filter((p) => {
const n = parseFloat(p.shares?.replace(/[^0-9.]/g, '') ?? '')
return Number.isFinite(n) && n > 0
}),
)
/** 转为 TradeComponent 所需的 TradePositionItem[]sharesNum 用 available最大可卖份额available > 0 表示有订单可卖outcome 与 noLabel 匹配则为 No */
const tradePositionsForComponent = computed<TradePositionItem[]>(() => {
const no = noLabel.value
return marketPositionsFiltered.value.map((p) => ({
id: p.id,
outcomeWord: (p.outcome === no ? 'No' : 'Yes') as 'Yes' | 'No',
shares: p.shares,
sharesNum:
p.availableSharesNum != null && Number.isFinite(p.availableSharesNum)
? p.availableSharesNum
: parseFloat(p.shares?.replace(/[^0-9.]/g, '')) || undefined,
locked: p.locked,
}))
})
async function loadMarketPositions() {
const marketID = currentMarketId.value
if (!marketID) {
marketPositions.value = []
return
}
const headers = userStore.getAuthHeaders()
if (!headers) {
marketPositions.value = []
return
}
const uid = userStore.user?.id ?? userStore.user?.ID
const userID = uid != null ? Number(uid) : undefined
if (!userID || !Number.isFinite(userID)) {
marketPositions.value = []
return
}
positionLoading.value = true
try {
const res = await getPositionList({ page: 1, pageSize: 50, marketID, userID }, { headers })
if (res.code === 0 || res.code === 200) {
marketPositions.value = res.data?.list?.map(mapPositionToDisplayItem) ?? []
} else {
marketPositions.value = []
}
} catch {
marketPositions.value = []
} finally {
positionLoading.value = false
}
}
// 限价未成交订单(仅当前市场)
const marketOpenOrders = ref<OpenOrderDisplayItem[]>([])
const openOrderLoading = ref(false)
async function loadMarketOpenOrders() {
const marketID = currentMarketId.value
if (!marketID) {
marketOpenOrders.value = []
return
}
const headers = userStore.getAuthHeaders()
if (!headers) {
marketOpenOrders.value = []
return
}
const uid = userStore.user?.id ?? userStore.user?.ID
const userID = uid != null ? Number(uid) : undefined
if (!userID || !Number.isFinite(userID)) {
marketOpenOrders.value = []
return
}
openOrderLoading.value = true
try {
const res = await getOrderList(
{ page: 1, pageSize: 50, marketID, userID, status: OrderStatus.Live },
{ headers },
)
if (res.code === 0 || res.code === 200) {
const list = res.data?.list ?? []
const liveOnly = list.filter((o) => (o.status ?? 1) === OrderStatus.Live)
marketOpenOrders.value = liveOnly.map(mapOrderToOpenOrderItem)
} else {
marketOpenOrders.value = []
}
} catch {
marketOpenOrders.value = []
} finally {
openOrderLoading.value = false
}
}
const cancelOrderLoading = ref(false)
async function cancelMarketOrder(ord: OpenOrderDisplayItem) {
if (ord.fullyFilled) return
const orderID = ord.orderID ?? 0
const tokenID = ord.tokenID ?? ''
const uid = userStore.user?.id ?? userStore.user?.ID
const userID = uid != null ? Number(uid) : 0
if (!Number.isFinite(userID) || userID <= 0 || !tokenID) return
const headers = userStore.getAuthHeaders()
if (!headers) return
cancelOrderLoading.value = true
try {
const res = await apiCancelOrder({ orderID, tokenID, userID }, { headers })
if (res.code === 0 || res.code === 200) {
marketOpenOrders.value = marketOpenOrders.value.filter((o) => o.id !== ord.id)
userStore.fetchUsdcBalance()
}
} finally {
cancelOrderLoading.value = false
}
}
// 持仓/限价 tab订单簿上方
const positionsOrdersTab = ref<'positions' | 'orders'>('positions')
// 切换持仓/限价 tab 或市场变化时加载
watch(
[() => positionsOrdersTab.value, currentMarketId],
([tab]) => {
if (tab === 'positions') loadMarketPositions()
else if (tab === 'orders') loadMarketOpenOrders()
},
{ immediate: true },
)
// Rules仅保留 Rules 面板)
// 时间粒度
const selectedTimeRange = ref('1D')
const timeRangesYesno = [
{ label: '1H', value: '1H' },
{ label: '6H', value: '6H' },
{ label: '1D', value: '1D' },
{ label: '1W', value: '1W' },
{ label: '1M', value: '1M' },
{ label: 'ALL', value: 'ALL' },
]
const timeRangesCrypto = [{ label: '30S', value: '30S' }, ...timeRangesYesno]
const timeRanges = computed(() =>
chartMode.value === 'crypto' ? timeRangesCrypto : timeRangesYesno,
)
const chartContainerRef = ref<HTMLElement | null>(null)
const data = ref<[number, number][]>([])
/** Yes/No 折线图接口返回的完整数据time 已转 ms用于分时筛选 */
const rawChartData = ref<ChartDataPoint[]>([])
const cryptoChartLoading = ref(false)
const chartYesNoLoading = ref(false)
let chartInstance: IChartApi | null = null
let chartSeries: ISeriesApi<'Line'> | null = null
/** 主图内自绘 Y 轴刻度(不占用右侧价轴条带) */
let inPanePriceScale: InPanePriceScalePrimitive | null = null
const currentChance = computed(() => {
const ev = eventDetail.value
const market = ev?.markets?.[0]
if (market?.outcomePrices?.[0] != null) {
const p = parseFloat(String(market.outcomePrices[0]))
if (Number.isFinite(p)) return Math.min(100, Math.max(0, Math.round(p * 100)))
}
const d = data.value
const last = d.length > 0 ? d[d.length - 1] : undefined
if (last != null) return last[1]
return 0
})
const lineColor = '#2563eb'
/** 叠加价格轴 ID曲线绑定在 overlay scale主图占满宽Y 轴数字由 InPanePriceScalePrimitive 画在图内 */
const CHART_OVERLAY_PRICE_SCALE_ID = 'overlay-price'
/** 右侧留白pxfitContent 时压缩可用宽度线头略离开物理右缘lastPrice 水波能露出一半且不依赖超大 rightOffsetbars */
const CHART_RIPPLE_RIGHT_PAD_PX = 0
function ensureChartSeries() {
if (!chartInstance || !chartContainerRef.value) return
if (chartSeries) {
if (inPanePriceScale) {
chartSeries.detachPrimitive(inPanePriceScale)
inPanePriceScale = null
}
chartInstance.removeSeries(chartSeries)
chartSeries = null
}
const isCrypto = chartMode.value === 'crypto'
chartSeries = chartInstance.addSeries(LineSeries, {
color: lineColor,
lineWidth: 2,
lineType: LineType.Curved,
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
crosshairMarkerVisible: true,
lastValueVisible: true,
priceFormat: isCrypto
? {
type: 'custom',
minMove: 1e-10,
formatter: (priceValue: BarPrice) => formatCryptoChartPrice(priceValue as number),
}
: { type: 'percent', precision: 1 },
priceScaleId: CHART_OVERLAY_PRICE_SCALE_ID,
})
chartInstance.priceScale(CHART_OVERLAY_PRICE_SCALE_ID).applyOptions({
borderVisible: false,
scaleMargins: { top: 0.1, bottom: 0.1 },
})
inPanePriceScale = new InPanePriceScalePrimitive({
textColor: 'rgba(107, 114, 128, 0.6)',
side: 'right',
tickSpacingPx: isCrypto ? 40 : 48,
fontSizePx: 9,
sideMarginPx: 6,
})
chartSeries.attachPrimitive(inPanePriceScale)
applyCryptoChartTimeScaleOptions()
}
function setChartData(chartData: [number, number][]) {
if (!chartSeries) return
chartSeries.setData(toLwcData(chartData))
}
function initChart() {
if (!chartContainerRef.value) return
data.value = []
const el = chartContainerRef.value
chartInstance = createChart(el, {
width: el.clientWidth,
height: el.clientHeight || 320,
layout: {
background: { color: '#ffffff' },
textColor: '#6b7280',
fontFamily: 'inherit',
fontSize: 9,
attributionLogo: false,
},
grid: { vertLines: { color: '#f3f4f6' }, horzLines: { color: '#f3f4f6' } },
leftPriceScale: { visible: false },
// 关闭右侧价轴条带Y 轴数值由 InPanePriceScalePrimitive 绘在主图内
rightPriceScale: { visible: false },
overlayPriceScales: {
borderVisible: false,
scaleMargins: { top: 0.1, bottom: 0.1 },
},
timeScale: {
borderColor: '#e5e7eb',
timeVisible: true,
secondsVisible: false,
rightOffsetPixels: CHART_RIPPLE_RIGHT_PAD_PX,
},
handleScroll: false,
handleScale: false,
crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } },
})
ensureChartSeries()
}
/** 从 GET /pmPriceHistory/getPmPriceHistoryPublic 拉取价格历史market 传 YES 对应的 clobTokenId */
async function loadChartFromApi(marketParam: string, range: string): Promise<ChartDataPoint[]> {
const ev = eventDetail.value
const timeRange = getTimeRangeSeconds(
range,
ev ? { startDate: ev.startDate, endDate: ev.endDate } : undefined,
)
// 增加每次请求的 points 数量,避免图表在较长时间粒度下出现“最新数据提前被截断”
const pageSizeByRange: Record<string, number> = {
'1H': 200,
'6H': 600,
'1D': 1200,
'1W': 2000,
'1M': 2000,
ALL: 2500,
}
const pageSize = pageSizeByRange[range] ?? 500
const res = await getPmPriceHistoryPublic({
market: marketParam,
page: 1,
pageSize,
...(timeRange && { startTs: timeRange.startTs, endTs: timeRange.endTs }),
})
const list = res.data?.list ?? []
return priceHistoryToChartData(list)
}
const MINUTE_MS = 60 * 1000
let cryptoWsUnsubscribe: (() => void) | null = null
/** 防止重叠的 updateChartData旧请求若在晚请求已 subscribe 之后才结束,会再挂一层 WS/hold 定时器,插值越来越快 */
let cryptoLoadGeneration = 0
let cryptoScrollToRtRaf = 0
/** 每帧最多跟一次盘避免插值过密时线头跑出视口shiftVisibleRangeOnNewBar 对 update 新点偶发不跟) */
function scheduleCryptoScrollToRealtime() {
if (!chartInstance || chartMode.value !== 'crypto') return
if (cryptoScrollToRtRaf) return
cryptoScrollToRtRaf = requestAnimationFrame(() => {
cryptoScrollToRtRaf = 0
chartInstance?.timeScale().scrollToRealTime()
})
}
/** 加密货币细粒度推送:显示到秒+毫秒;新点推入时视窗右移并每帧 scrollToRealTime 咬住线头 */
function applyCryptoChartTimeScaleOptions() {
if (!chartInstance) return
if (chartMode.value === 'crypto') {
chartInstance.applyOptions({
timeScale: {
rightOffsetPixels: CHART_RIPPLE_RIGHT_PAD_PX,
secondsVisible: true,
shiftVisibleRangeOnNewBar: true,
tickMarkFormatter: (time: Time, tickMarkType: TickMarkType) => {
if (typeof time !== 'number') return null
if (tickMarkType !== TickMarkType.TimeWithSeconds) return null
const d = new Date(time * 1000)
const pad2 = (n: number) => String(n).padStart(2, '0')
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}`
},
},
})
} else {
chartInstance.applyOptions({
timeScale: {
rightOffsetPixels: CHART_RIPPLE_RIGHT_PAD_PX,
secondsVisible: false,
shiftVisibleRangeOnNewBar: true,
tickMarkFormatter: () => null,
},
})
}
}
function applyCryptoRealtimePoint(point: [number, number]) {
if (chartMode.value !== 'crypto') return
const list = [...data.value]
let ts = alignCryptoChartTimeMs(point[0])
const price = point[1]
const prevLastT = list.length > 0 ? list[list.length - 1]![0] : null
// 本地补点用 now成交用 Binance TT 可能略早于上一根 → update() 报 Cannot update oldest data
if (prevLastT != null && ts < prevLastT) {
ts = alignCryptoChartTimeMs(prevLastT + CRYPTO_CHART_TIME_STEP_MS)
}
const range = selectedTimeRange.value
let useIncrementalUpdate = false
let updatePoint: [number, number] | null = null
if (range === '30S') {
const cutoff = Date.now() - CRYPTO_30S_RETENTION_MS
if (prevLastT != null && ts === prevLastT) {
list[list.length - 1] = [ts, price]
} else {
list.push([ts, price])
}
const filtered = list.filter(([t]) => t >= cutoff).sort((a, b) => a[0] - b[0])
data.value = filtered
// 仅当未过滤掉任何点时可用 update 实现平滑动画
useIncrementalUpdate = filtered.length === list.length
if (useIncrementalUpdate) updatePoint = [ts, price]
} else {
const last = list[list.length - 1]
const minuteStart = Math.floor(ts / MINUTE_MS) * MINUTE_MS
const sameMinute = last && Math.floor(last[0] / MINUTE_MS) === Math.floor(ts / MINUTE_MS)
const baseMax =
{ '1H': 60, '6H': 360, '1D': 1440, '1W': 1680, '1M': 43200, ALL: 10080 }[range] ?? 60
const extra = Math.min(200, Math.round(baseMax * 0.1))
const max = baseMax + extra
if (sameMinute) {
list[list.length - 1] = [last![0], price]
data.value = list.slice(-max)
useIncrementalUpdate = true
updatePoint = [last![0], price]
} else {
list.push([minuteStart, price])
const sliced = list.slice(-max)
data.value = sliced
// 仅当未从头部裁剪时可用 update否则需全量 setData
useIncrementalUpdate = sliced.length === list.length
if (useIncrementalUpdate) updatePoint = [minuteStart, price]
}
}
if (chartSeries && useIncrementalUpdate && updatePoint) {
chartSeries.update(toLwcPoint(updatePoint))
} else {
setChartData(data.value)
}
scheduleCryptoScrollToRealtime()
}
async function updateChartData() {
if (chartMode.value === 'crypto') {
const sym = cryptoSymbol.value
if (sym) {
const gen = ++cryptoLoadGeneration
cryptoWsUnsubscribe?.()
cryptoWsUnsubscribe = null
cryptoChartLoading.value = true
try {
const res = await fetchCryptoChart({
symbol: sym,
range: selectedTimeRange.value,
})
if (gen !== cryptoLoadGeneration || chartMode.value !== 'crypto') {
return
}
data.value = (res.data ?? []) as [number, number][]
ensureChartSeries()
setChartData(data.value)
nextTick(() => scheduleCryptoScrollToRealtime())
cryptoWsUnsubscribe = subscribeCryptoRealtime(sym, applyCryptoRealtimePoint)
} finally {
if (gen === cryptoLoadGeneration) {
cryptoChartLoading.value = false
}
}
return
}
chartMode.value = 'yesno'
if (selectedTimeRange.value === '30S') selectedTimeRange.value = '1D'
}
cryptoLoadGeneration++
cryptoWsUnsubscribe?.()
cryptoWsUnsubscribe = null
chartYesNoLoading.value = true
try {
const yesTokenId = clobTokenIds.value[0]
const points = yesTokenId ? await loadChartFromApi(yesTokenId, selectedTimeRange.value) : []
rawChartData.value = points
data.value = points
ensureChartSeries()
setChartData(data.value)
} finally {
chartYesNoLoading.value = false
}
}
function selectTimeRange(range: string) {
selectedTimeRange.value = range
}
watch(selectedTimeRange, () => updateChartData())
// CLOB当有 market 且存在 clobTokenIds 时连接(使用 Yes/No token ID
const clobTokenIds = computed(() => {
const m = currentMarket.value
if (!m?.clobTokenIds?.length) return []
const yes = getClobTokenId(m, 0)
const no = getClobTokenId(m, 1)
return [yes, no].filter((id): id is string => !!id)
})
watch(
clobTokenIds,
(tokenIds) => {
if (tokenIds.length > 0) {
const payload = tradeMarketPayload.value
if (payload?.yesPrice != null) {
clobLastPriceByToken.value = {
...clobLastPriceByToken.value,
0: Math.round(payload.yesPrice * 100),
}
}
connectClob(tokenIds)
} else {
disconnectClob()
}
},
{ immediate: true },
)
const handleResize = () => {
if (!chartInstance || !chartContainerRef.value) return
const el = chartContainerRef.value
chartInstance.resize(el.clientWidth, el.clientHeight || 320)
}
watch(
() => route.params.id,
() => {
rulesDescriptionExpanded.value = false
loadEventDetail()
},
{ immediate: false },
)
// 内容显示时初始化图表chart 容器在 v-else 内loading 时不存在)
watch(
() => [detailLoading.value, eventDetail.value] as const,
([loading, detail]) => {
if (loading) {
chartInstance?.remove()
chartInstance = null
chartSeries = null
return
}
if (detail) {
nextTick(() => {
initChart()
updateChartData()
})
}
},
{ immediate: true },
)
// 监听语言切换,语言变化时重新加载数据
watch(
() => localeStore.currentLocale,
() => {
loadEventDetail()
loadMarketPositions()
},
)
// 订阅 position_update收到当前市场的推送时刷新持仓
const unsubscribePositionUpdate = userStore.onPositionUpdate((data) => {
const marketID = data.marketID ?? (data as Record<string, unknown>).market_id
if (marketID && String(marketID) === String(currentMarketId.value)) {
loadMarketPositions()
}
})
onMounted(() => {
loadEventDetail()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
unsubscribePositionUpdate()
cryptoWsUnsubscribe?.()
cryptoWsUnsubscribe = null
if (cryptoScrollToRtRaf) {
cancelAnimationFrame(cryptoScrollToRtRaf)
cryptoScrollToRtRaf = 0
}
window.removeEventListener('resize', handleResize)
chartInstance?.remove()
chartInstance = null
chartSeries = null
inPanePriceScale = null
disconnectClob()
})
</script>
<style scoped lang="scss">
.trade-detail-container {
padding: 24px;
min-height: 100vh;
box-sizing: border-box;
max-width: 1440px;
margin: 0 auto;
margin-top: $header-height;
}
/* 底部固定展示结算结果时,为内容留出空间(高度与 .trade-settled-outcome-bar 一致) */
.trade-detail-container.trade-detail-container--settled-bar {
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
}
.trade-settled-outcome-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 101;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
min-height: 52px;
padding: 12px 20px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
background: #ffffff;
border-top: 1px solid #e7e7e7;
font-size: 15px;
font-weight: 600;
color: #111827;
line-height: 1.35;
box-shadow: 0 -6px 16px rgba(0, 0, 0, 0.06);
box-sizing: border-box;
}
.trade-detail-pull-refresh {
width: 100%;
min-height: 100%;
}
.trade-detail-pull-refresh-inner {
min-height: 100%;
}
.trade-detail-loading-card {
padding: 48px;
text-align: center;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.trade-detail-loading-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.trade-detail-loading-placeholder p {
margin: 0;
color: #6b7280;
font-size: 14px;
}
.back-btn {
margin-bottom: 16px;
}
.trade-detail-card {
padding: 32px;
background-color: #ffffff;
border-radius: 12px;
border: 1px solid #e7e7e7;
}
.trade-title {
font-size: 1.5rem;
font-weight: 600;
color: #000000;
margin-bottom: 24px;
text-align: center;
}
.market-image-container {
display: flex;
justify-content: center;
margin-bottom: 32px;
}
.market-image {
border: 2px solid #e7e7e7;
}
.chance-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32px;
}
.progress-bar {
margin-bottom: 8px;
}
.chance-value {
font-size: 24px;
font-weight: 700;
color: #000000;
}
.chance-label {
font-size: 16px;
font-weight: normal;
color: #808080;
margin-top: 8px;
}
.options-section {
display: flex;
gap: 16px;
margin-bottom: 32px;
}
.option-yes {
flex: 1;
background-color: #b8e0b8;
height: 56px;
font-size: 18px;
}
.option-text-yes {
font-size: 18px;
font-weight: 600;
color: #006600;
}
.option-no {
flex: 1;
background-color: #f0b8b8;
height: 56px;
font-size: 18px;
}
.option-text-no {
font-size: 18px;
font-weight: 600;
color: #cc0000;
}
.market-info-section {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 32px;
padding: 24px;
background-color: #f9f9f9;
border-radius: 8px;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-label {
font-size: 14px;
font-weight: normal;
color: #808080;
}
.info-value {
font-size: 14px;
font-weight: 500;
color: #000000;
}
.description-section {
margin-bottom: 32px;
}
.description-title {
font-size: 16px;
font-weight: 600;
color: #000000;
margin-bottom: 12px;
}
.description-text {
font-size: 14px;
font-weight: normal;
color: #333333;
line-height: 1.6;
}
.action-buttons {
margin-top: 16px;
}
.action-btn {
height: 56px;
font-size: 16px;
font-weight: 600;
}
/* Polymarket 样式分时图卡片(扁平化) */
.chart-card.polymarket-chart {
margin-top: 0;
padding: 20px 24px 16px;
background-color: #ffffff;
border: 1px solid #e7e7e7;
border-radius: 12px;
box-shadow: none;
}
.chart-header {
margin-bottom: 16px;
}
.chart-title {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
margin: 0 0 12px 0;
line-height: 1.3;
}
.chart-error {
font-size: 0.875rem;
color: #dc2626;
margin: 0 0 8px 0;
}
.chart-controls-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.date-pill {
background-color: #111827 !important;
color: #fff !important;
font-size: 12px;
font-weight: 500;
text-transform: none;
}
.chart-chance {
font-size: 1.5rem;
font-weight: 700;
color: #2563eb;
}
.chart-wrapper {
position: relative;
width: 100%;
margin-bottom: 12px;
}
.chart-loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.6);
z-index: 2;
}
.chart-mode-toggle {
height: 26px;
margin-left: auto;
}
.chart-mode-toggle .v-btn.active {
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
}
.chart-mode-crypto-btn {
min-width: 44px;
padding: 0 10px;
letter-spacing: 0.02em;
}
.chart-crypto-ticker-label {
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.chart-container {
width: 100%;
height: 320px;
min-height: 260px;
}
.chart-footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
.chart-footer-left {
font-size: 12px;
color: #6b7280;
}
.chart-expires {
margin-left: 4px;
}
.chart-time-ranges {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.time-range-btn {
font-size: 12px;
text-transform: none;
min-width: 36px;
color: #6b7280;
}
.time-range-btn.active {
color: #111827;
font-weight: 600;
}
.time-range-btn:hover {
color: #111827;
}
/* Order Book Card Styles扁平化 */
.positions-orders-card {
margin-top: 16px;
border: 1px solid #e5e7eb;
overflow: hidden;
box-shadow: none;
}
.positions-orders-tabs {
border-bottom: 1px solid #e5e7eb;
}
.positions-orders-tabs :deep(.v-tab) {
text-transform: none;
font-size: 14px;
}
.positions-orders-window {
overflow: visible;
}
.positions-orders-card .detail-pane {
padding: 16px;
}
.order-book-card {
margin-top: 16px;
padding: 0;
background-color: #ffffff;
border: none;
box-shadow: none;
}
/* 左右布局左侧弹性右侧固定no-gutters 移除 v-col 默认 gutter由容器 padding 统一控制左右边距 */
.trade-detail-row {
display: flex;
flex-wrap: wrap;
}
.chart-col {
flex: 1 1 0%;
min-width: 0;
}
.trade-col {
margin-top: 32px;
}
.trade-sidebar {
position: sticky;
top: 24px;
width: 400px;
max-width: 100%;
flex-shrink: 0;
}
.chart-closed-banner {
margin: 8px 0 0;
font-size: 13px;
color: #92400e;
background: #fffbeb;
border-radius: 8px;
padding: 8px 12px;
line-height: 1.4;
}
.market-closed-pane {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 28px 20px;
border: 1px solid #e7e7e7;
border-radius: 12px;
background: #fafafa;
}
.market-closed-pane--mobile {
padding: 22px 18px;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.market-closed-pane-title {
margin: 12px 0 8px;
font-size: 18px;
font-weight: 600;
color: #111827;
}
.market-closed-pane-desc {
margin: 0;
font-size: 14px;
color: #6b7280;
line-height: 1.5;
}
.market-closed-mobile-card {
margin-top: 24px;
margin-bottom: 16px;
border: 1px solid #e7e7e7;
box-shadow: none !important;
overflow: hidden;
background-color: #f9fafb !important;
}
@media (min-width: 960px) {
.trade-detail-row {
flex-wrap: nowrap;
gap: 24px;
}
.chart-col {
flex: 1 1 0% !important;
min-width: 0;
max-width: none !important;
}
.trade-col {
flex: 0 0 400px !important;
max-width: 400px !important;
margin-top: 32px;
}
.trade-sidebar {
width: 400px;
}
}
@media (max-width: 959px) {
.trade-sidebar {
position: static;
width: 100%;
}
/* 移动端显示底部栏时,预留底部空间避免遮挡列表 */
.trade-detail-container {
padding-bottom: 100px;
}
.trade-detail-container.trade-detail-container--settled-bar {
padding-bottom: calc(68px + env(safe-area-inset-bottom, 0px));
}
}
/* Responsive adjustments */
@media (max-width: 599px) {
.trade-detail-container {
padding: 16px;
padding-left: 16px;
padding-right: 16px;
/* 底部预留空间,避免固定 Yes/No 栏遮挡列表内容 */
padding-bottom: 100px;
}
.trade-detail-container.trade-detail-container--settled-bar {
padding-bottom: calc(68px + env(safe-area-inset-bottom, 0px));
}
.market-closed-mobile-card {
margin-top: 28px;
}
.trade-detail-card {
padding: 24px;
}
.trade-title {
font-size: 1.25rem;
}
.market-image-container {
margin-bottom: 24px;
}
.chance-section {
margin-bottom: 24px;
}
.options-section {
margin-bottom: 24px;
}
.market-info-section {
margin-bottom: 24px;
padding: 16px;
}
.description-section {
margin-bottom: 24px;
}
.chart-container {
height: 260px;
}
.chart-card.polymarket-chart {
padding: 16px;
}
.chart-title {
font-size: 1.125rem;
}
}
/* Responsive order book adjustments */
@media (max-width: 600px) {
.order-book-card {
margin-top: 16px;
}
}
/* Comments / Top Holders / Activity扁平化 */
.activity-card {
margin-top: 16px;
border: 1px solid #e5e7eb;
overflow: hidden;
box-shadow: none;
}
.detail-tabs {
border-bottom: 1px solid #e5e7eb;
}
.detail-tabs :deep(.v-tab) {
text-transform: none;
font-size: 14px;
}
.detail-window {
overflow: visible;
}
.detail-pane {
padding: 16px;
}
.placeholder-pane {
color: #6b7280;
font-size: 14px;
padding: 24px 0;
}
.placeholder-pane--loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
.rules-pane {
padding-top: 16px;
padding-bottom: 16px;
padding-left: 16px;
padding-right: 16px;
}
.rules-section {
display: flex;
flex-direction: column;
align-items: stretch;
margin-bottom: 16px;
}
.rules-section:last-child {
margin-bottom: 0;
}
.rules-title {
font-size: 13px;
font-weight: 600;
color: #6b7280;
margin: 0 0 8px 0;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.rules-text {
font-size: 14px;
color: #374151;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.rules-text--multi-collapsed {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
overflow: hidden;
}
.rules-description-toggle {
margin-top: 4px;
padding: 0 4px !important;
min-width: auto !important;
text-transform: none;
font-weight: 600;
}
.rules-link {
font-size: 14px;
color: #2563eb;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.rules-link:hover {
text-decoration: underline;
}
/* 持仓 / 限价订单列表 */
.positions-list,
.orders-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.position-row-item,
.order-row-item {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.position-row-item:last-child,
.order-row-item:last-child {
border-bottom: none;
}
.position-row-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.position-row-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-weight: 700;
font-size: 16px;
color: #fff;
}
.position-row-icon.pill-yes {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
}
.position-row-icon.pill-down {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
}
.position-row-icon-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.position-row-icon-char {
line-height: 1;
}
.position-row-title {
font-size: 14px;
font-weight: 500;
color: #111827;
line-height: 1.3;
flex: 1;
min-width: 0;
}
.position-row-main,
.order-row-main {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.position-sell-btn {
margin-left: auto;
}
.position-lock-badge {
font-size: 11px;
color: #b45309;
background: #fef3c7;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
}
.position-outcome-pill,
.order-side-pill {
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
}
.position-outcome-pill.pill-yes,
.order-side-pill.side-yes {
background: #dcfce7;
color: #166534;
}
.position-outcome-pill.pill-down,
.order-side-pill.side-no {
background: #fee2e2;
color: #991b1b;
}
.position-shares,
.position-value,
.order-price,
.order-filled,
.order-total {
font-size: 14px;
color: #374151;
}
.position-row-meta {
font-size: 13px;
color: #6b7280;
margin-top: 4px;
}
.order-row-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.order-row-actions {
flex-shrink: 0;
}
.activity-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
}
.min-amount-select {
max-width: 140px;
font-size: 14px;
}
.min-amount-select :deep(.v-field) {
font-size: 14px;
}
.live-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #6b7280;
}
.live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #dc2626;
animation: live-pulse 1.5s ease-in-out infinite;
}
@keyframes live-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.activity-list {
display: flex;
flex-direction: column;
gap: 0;
}
.activity-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
font-size: 14px;
}
.activity-item:last-child {
border-bottom: none;
}
.activity-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
flex-shrink: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-gradient-1 {
background: linear-gradient(135deg, #10b981 0%, #8b5cf6 100%);
}
.avatar-gradient-2 {
background: linear-gradient(135deg, #10b981 0%, #3b82f6 100%);
}
.avatar-gradient-3 {
background: linear-gradient(135deg, #f59e0b 0%, #eab308 100%);
}
.avatar-gradient-4 {
background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
}
.activity-body {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.activity-user {
font-weight: 500;
color: #111827;
}
.activity-action {
color: #6b7280;
margin-left: 2px;
}
.activity-amount {
font-weight: 600;
margin-left: 2px;
}
.amount-yes {
color: #059669;
}
.amount-no {
color: #dc2626;
}
.activity-price {
color: #6b7280;
margin-left: 2px;
}
.activity-total {
color: #6b7280;
margin-left: 2px;
}
.activity-meta {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.activity-time {
font-size: 13px;
color: #9ca3af;
}
.activity-link {
color: #9ca3af;
display: inline-flex;
line-height: 1;
}
.activity-link:hover {
color: #6b7280;
}
/* 移动端底部交易栏(与 EventMarkets 一致) */
.mobile-trade-bar-spacer {
/* 预留空间避免底部栏遮挡列表内容,需覆盖 bar 高度 + safe-area */
height: 100px;
min-height: 100px;
}
.mobile-trade-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
display: flex;
align-items: stretch;
gap: 10px;
width: 100%;
padding: 12px 16px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #eee;
}
.mobile-bar-btn {
border: none;
border-radius: 6px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
text-transform: none;
}
.mobile-bar-yes {
flex: 1;
min-width: 0;
background: $yes-bg;
color: $yes-text;
height: 46px;
}
.mobile-bar-no {
flex: 1;
min-width: 0;
background: $no-bg;
color: $no-text;
height: 46px;
}
.mobile-bar-more-btn {
flex-shrink: 0;
width: 46px;
height: 46px;
min-width: 46px;
min-height: 46px;
padding: 0 !important;
border-radius: 50%;
background: #e5e7eb !important;
color: #6b7280;
align-self: center;
}
</style>