xtraderClient/src/views/TradeDetail.vue

2382 lines
66 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">
<v-row align="stretch" no-gutters class="trade-detail-row">
<!-- 左侧分时图 + 订单簿宽度弹性 -->
<v-col cols="12" class="chart-col">
<!-- 分时图卡片Polymarket 样式 -->
<v-card class="chart-card polymarket-chart" elevation="0" rounded="lg">
<!-- 顶部标题当前概率Past / 日期 -->
<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-group
v-if="isCryptoEvent"
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"
icon
:aria-label="t('chart.cryptoPrice')"
@click="setChartMode('crypto')"
>
<v-icon size="20">mdi-currency-btc</v-icon>
</v-btn>
</v-btn-group>
<v-btn variant="text" size="small" class="past-btn">Past </v-btn>
<v-btn class="date-pill" size="small" rounded="pill">{{ resolutionDate }}</v-btn>
</div>
<div class="chart-chance">
<template v-if="chartMode === 'crypto' && cryptoSymbol">
${{ currentCryptoPrice }} {{ cryptoSymbol.toUpperCase() }}
</template>
<template v-else> {{ currentChance }}% {{ t('common.chance') }} </template>
</div>
</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 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">{{ t('common.loading') }}</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="!(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">{{ t('common.loading') }}</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 class="order-book-card" elevation="0" rounded="lg">
<OrderBook
:asks-yes="orderBookAsksYes"
:bids-yes="orderBookBidsYes"
:asks-no="orderBookAsksNo"
:bids-no="orderBookBidsNo"
: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">
<v-tabs v-model="detailTab" class="detail-tabs" density="comfortable">
<v-tab value="rules">{{ t('activity.rules') }}</v-tab>
<v-tab value="holders">{{ t('activity.topHolders') }}</v-tab>
<v-tab value="activity">{{ t('activity.activity') }}</v-tab>
</v-tabs>
<v-window v-model="detailTab" class="detail-window">
<v-window-item value="rules" class="detail-pane">
<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">{{ eventDetail.description }}</div>
</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-window-item>
<v-window-item value="holders" class="detail-pane">
<div class="placeholder-pane">{{ t('activity.topHoldersPlaceholder') }}</div>
</v-window-item>
<v-window-item value="activity" class="detail-pane">
<div class="activity-toolbar">
<v-select
v-model="activityMinAmount"
:items="minAmountOptions"
density="compact"
hide-details
variant="outlined"
:label="t('activity.minAmount')"
class="min-amount-select"
/>
<span class="live-badge">
<span class="live-dot"></span>
{{ t('activity.live') }}
</span>
</div>
<div class="activity-list">
<div v-for="item in filteredActivity" :key="item.id" class="activity-item">
<div class="activity-avatar" :class="item.avatarClass">
<img
v-if="item.avatarUrl"
:src="item.avatarUrl"
:alt="item.user"
class="avatar-img"
/>
</div>
<div class="activity-body">
<span class="activity-user">{{ item.user }}</span>
<span class="activity-action">{{
t(item.action === 'bought' ? 'activity.bought' : 'activity.sold')
}}</span>
<span
:class="['activity-amount', item.side === 'Yes' ? 'amount-yes' : 'amount-no']"
>
{{ item.amount }} {{ item.side }}
</span>
<span class="activity-price">{{ t('activity.at') }} {{ item.price }}</span>
<span class="activity-total">({{ item.total }})</span>
</div>
<div class="activity-meta">
<span class="activity-time">{{ formatTimeAgo(item.time) }}</span>
<a
href="#"
class="activity-link"
:aria-label="t('activity.viewTransaction')"
@click.prevent
>
<v-icon size="16">mdi-open-in-new</v-icon>
</a>
</div>
</div>
</div>
</v-window-item>
</v-window>
</v-card>
</v-col>
<!-- 右侧交易组件固定宽度传入当前市场以便 Split 调用拆单接口移动端隐藏改用底部栏+弹窗 -->
<v-col v-if="!isMobile" cols="12" class="trade-col">
<div class="trade-sidebar">
<TradeComponent
ref="tradeComponentRef"
:market="tradeMarketPayload"
:initial-option="tradeInitialOption"
:positions="tradePositionsForComponent"
@merge-success="onMergeSuccess"
@split-success="onSplitSuccess"
/>
</div>
</v-col>
<!-- 移动端固定底部 Yes/No + 三点菜单Merge/Split -->
<template v-if="isMobile && tradeMarketPayload">
<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>
</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,
type IChartApi,
type ISeriesApi,
} from 'lightweight-charts'
import { toLwcData, toLwcPoint } from '../composables/useLightweightChart'
import OrderBook from '../components/OrderBook.vue'
import TradeComponent, { type TradePositionItem } from '../components/TradeComponent.vue'
import {
findPmEvent,
getMarketId,
getClobTokenId,
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, ChartTimeRange } from '../api/chart'
import {
getPmPriceHistoryPublic,
getTimeRangeSeconds,
priceHistoryToChartData,
} from '../api/priceHistory'
import {
isCryptoEvent as checkIsCryptoEvent,
inferCryptoSymbol,
fetchCryptoChart,
subscribeCryptoRealtime,
} 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 slugFromQuery = (route.query.slug as string)?.trim()
const params: FindPmEventParams = {
id: isNumericId ? numId : undefined,
slug: isNumericId ? slugFromQuery || 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
}
}
// 标题、成交量、到期日:优先接口详情,其次卡片 query最后占位
const marketTitle = computed(() => {
if (eventDetail.value?.title) return eventDetail.value.title
return (route.query.title as string) || 'U.S. anti-cartel ground operation in Mexico by March 31?'
})
const marketVolume = computed(() => {
if (eventDetail.value?.volume != null) return formatVolume(eventDetail.value.volume)
return (route.query.marketInfo as string) || '$398,719'
})
const marketExpiresAt = computed(() => {
if (eventDetail.value?.endDate) return formatExpiresAt(eventDetail.value.endDate)
return (route.query.expiresAt as string) || 'Mar 31, 2026'
})
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 加密货币价格图) */
const isCryptoEvent = computed(() => checkIsCryptoEvent(eventDetail.value))
/** 从事件推断的加密货币符号,如 btc、eth */
const cryptoSymbol = computed(() => inferCryptoSymbol(eventDetail.value ?? {}) ?? null)
/** 图表模式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') {
const p = last[1]
return Number.isFinite(p)
? p.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '—'
}
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 拆单query.marketId 匹配或取第一个 */
const currentMarket = computed(() => {
const list = eventDetail.value?.markets ?? []
if (list.length === 0) return null
const qId = route.query.marketId
if (qId != null && String(qId).trim() !== '') {
const qStr = String(qId).trim()
const found = list.find((m) => getMarketId(m) === qStr)
if (found) return found
}
return list[0]
})
// --- 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))
})
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 }
}
// 追加到 Activity 列表
const side = msg.side?.toLowerCase() === 'buy' ? 'Yes' : 'No'
const action = side === 'Yes' ? 'bought' : 'sold'
activityList.value.unshift({
id: `clob-${Date.now()}-${Math.random().toString(36).slice(2)}`,
user: '0x...',
avatarClass: 'avatar-gradient-1',
action: action as 'bought' | 'sold',
side: side as 'Yes' | 'No',
amount: Math.round(parseFloat(msg.s) || 0),
price: `${Math.round((priceNum || 0) * 100)}¢`,
total: `$${((parseFloat(msg.s) || 0) * (priceNum || 0)).toFixed(2)}`,
time: Date.now(),
})
})
clobSdkRef.value = sdk
sdk.connect()
}
function disconnectClob() {
clobSdkRef.value?.disconnect()
clobSdkRef.value = null
clobConnected.value = false
clobLoading.value = false
}
/** 传给 TradeComponent 的 market供 Split 调用 /PmMarket/splityesPrice/noPrice 取订单簿卖单最低价,无数据时为 0 */
const tradeMarketPayload = computed(() => {
const m = currentMarket.value
const yesPrice = orderBookLowestAskYesCents.value / 100
const noPrice = orderBookLowestAskNoCents.value / 100
const bestBidYesCents = orderBookBestBidYesCents.value
const bestBidNoCents = orderBookBestBidNoCents.value
if (m) {
return {
marketId: getMarketId(m),
yesPrice,
noPrice,
title: m.question,
clobTokenIds: m.clobTokenIds,
outcomes: m.outcomes,
bestBidYesCents,
bestBidNoCents,
}
}
const qId = route.query.marketId
if (qId != null && String(qId).trim() !== '') {
return {
marketId: String(qId).trim(),
yesPrice,
noPrice,
title: (route.query.title as string) || undefined,
bestBidYesCents,
bestBidNoCents,
}
}
return undefined
})
const tradeInitialOption = computed(() => {
const side = route.query.side
if (side === 'yes' || side === 'no') return side
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 },
)
// Comments / Top Holders / Activity
const detailTab = ref('rules')
const activityMinAmount = ref<string>('0')
const minAmountOptions = computed(() => [
{ title: t('activity.any'), value: '0' },
{ title: '$1', value: '1' },
{ title: '$10', value: '10' },
{ title: '$100', value: '100' },
{ title: '$500', value: '500' },
])
interface ActivityItem {
id: string
user: string
avatarClass: string
avatarUrl?: string
action: 'bought' | 'sold'
side: 'Yes' | 'No'
amount: number
price: string
total: string
time: number
}
const activityList = ref<ActivityItem[]>([
{
id: 'a1',
user: 'Scottp1887',
avatarClass: 'avatar-gradient-1',
action: 'bought',
side: 'Yes',
amount: 914,
price: '32.8¢',
total: '$300',
time: Date.now() - 10 * 60 * 1000,
},
{
id: 'a2',
user: 'Outrageous-Budd...',
avatarClass: 'avatar-gradient-2',
action: 'sold',
side: 'No',
amount: 20,
price: '70.0¢',
total: '$14',
time: Date.now() - 29 * 60 * 1000,
},
{
id: 'a3',
user: '0xbc7db42d5ea9a...',
avatarClass: 'avatar-gradient-3',
action: 'bought',
side: 'Yes',
amount: 5,
price: '68.0¢',
total: '$3.40',
time: Date.now() - 60 * 60 * 1000,
},
{
id: 'a4',
user: 'FINNISH-FEMBOY',
avatarClass: 'avatar-gradient-4',
action: 'sold',
side: 'No',
amount: 57,
price: '35.0¢',
total: '$20',
time: Date.now() - 2 * 60 * 60 * 1000,
},
{
id: 'a5',
user: 'CryptoWhale',
avatarClass: 'avatar-gradient-1',
action: 'bought',
side: 'No',
amount: 143,
price: '28.0¢',
total: '$40',
time: Date.now() - 3 * 60 * 60 * 1000,
},
{
id: 'a6',
user: 'PolyTrader',
avatarClass: 'avatar-gradient-2',
action: 'sold',
side: 'Yes',
amount: 30,
price: '72.0¢',
total: '$21.60',
time: Date.now() - 5 * 60 * 60 * 1000,
},
])
const filteredActivity = computed(() => {
const min = Number(activityMinAmount.value) || 0
return activityList.value.filter((item) => {
const totalNum = parseFloat(item.total.replace(/[$,]/g, ''))
return totalNum >= min
})
})
function formatTimeAgo(ts: number): string {
const sec = Math.floor((Date.now() - ts) / 1000)
if (sec < 60) return t('activity.justNow')
if (sec < 3600) return t('activity.minutesAgo', { n: Math.floor(sec / 60) })
if (sec < 86400) return t('activity.hoursAgo', { n: Math.floor(sec / 3600) })
if (sec < 604800) return t('activity.daysAgo', { n: Math.floor(sec / 86400) })
return t('activity.weeksAgo', { n: Math.floor(sec / 604800) })
}
// 时间粒度
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
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]
const q = route.query.chance
if (q != null) {
const n = Number(q)
if (Number.isFinite(n)) return Math.min(100, Math.max(0, Math.round(n)))
}
return 20
})
const lineColor = '#2563eb'
const MOBILE_BREAKPOINT = 600
function ensureChartSeries() {
if (!chartInstance || !chartContainerRef.value) return
if (chartSeries) {
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: 'price', precision: 2 } : { type: 'percent', precision: 1 },
})
}
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: 320,
layout: {
background: { color: '#ffffff' },
textColor: '#6b7280',
fontFamily: 'inherit',
fontSize: 9,
attributionLogo: false,
},
grid: { vertLines: { color: '#f3f4f6' }, horzLines: { color: '#f3f4f6' } },
rightPriceScale: { borderColor: '#e5e7eb', scaleMargins: { top: 0.1, bottom: 0.1 } },
timeScale: { borderColor: '#e5e7eb', timeVisible: true, secondsVisible: 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,
)
const res = await getPmPriceHistoryPublic({
market: marketParam,
page: 1,
pageSize: 500,
...(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
function applyCryptoRealtimePoint(point: [number, number]) {
const list = [...data.value]
const [ts, price] = point
const range = selectedTimeRange.value
let useIncrementalUpdate = false
let updatePoint: [number, number] | null = null
if (range === '30S') {
const cutoff = Date.now() - 30 * 1000
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 max =
{ '1H': 60, '6H': 360, '1D': 1440, '1W': 1680, '1M': 43200, ALL: 10080 }[range] ?? 60
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)
}
}
async function updateChartData() {
if (chartMode.value === 'crypto') {
const sym = cryptoSymbol.value ?? 'btc'
cryptoWsUnsubscribe?.()
cryptoWsUnsubscribe = null
cryptoChartLoading.value = true
try {
const res = await fetchCryptoChart({
symbol: sym,
range: selectedTimeRange.value,
})
data.value = (res.data ?? []) as [number, number][]
ensureChartSeries()
setChartData(data.value)
cryptoWsUnsubscribe = subscribeCryptoRealtime(sym, applyCryptoRealtimePoint)
} finally {
cryptoChartLoading.value = false
}
} else {
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
chartInstance.resize(chartContainerRef.value.clientWidth, 320)
}
watch(
() => route.params.id,
() => loadEventDetail(),
{ immediate: false },
)
// 监听语言切换,语言变化时重新加载数据
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().then(() => updateChartData())
initChart()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
unsubscribePositionUpdate()
cryptoWsUnsubscribe?.()
cryptoWsUnsubscribe = null
window.removeEventListener('resize', handleResize)
chartInstance?.remove()
chartInstance = null
chartSeries = null
disconnectClob()
})
</script>
<style scoped>
.trade-detail-container {
padding: 24px;
padding-left: 24px;
padding-right: 24px;
min-height: 100vh;
box-sizing: border-box;
}
.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;
}
.past-btn {
font-size: 13px;
color: #6b7280;
text-transform: none;
}
.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 .v-btn.active {
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
}
.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: 1px solid #e7e7e7;
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;
}
@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;
}
}
/* Responsive adjustments */
@media (max-width: 599px) {
.trade-detail-container {
padding: 16px;
padding-left: 16px;
padding-right: 16px;
/* 底部预留空间,避免固定 Yes/No 栏遮挡列表内容 */
padding-bottom: 100px;
}
.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;
}
.rules-pane {
padding: 4px 0;
}
.rules-section {
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-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: #b8e0b8 !important;
color: #006600 !important;
height: 46px;
}
.mobile-bar-no {
flex: 1;
min-width: 0;
background: #f0b8b8 !important;
color: #cc0000 !important;
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>