2382 lines
66 KiB
Vue
2382 lines
66 KiB
Vue
<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: number,Unix 毫秒时间戳
|
||
* - value: number,当前概率/价格(如 0–100 的百分比)
|
||
*
|
||
* 服务端可选用两种推送方式:
|
||
*
|
||
* 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 = Yes,1 = 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/split;yesPrice/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: 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;
|
||
}
|
||
|
||
@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>
|