1118 lines
30 KiB
Vue
1118 lines
30 KiB
Vue
<template>
|
||
<v-container class="event-markets-container">
|
||
<v-card v-if="detailLoading && !eventDetail" class="loading-card" elevation="0" rounded="lg">
|
||
<div class="loading-placeholder">
|
||
<v-progress-circular indeterminate color="primary" size="48" />
|
||
<p>{{ t('common.loading') }}</p>
|
||
</div>
|
||
</v-card>
|
||
|
||
<template v-else-if="detailError">
|
||
<v-card class="error-card" elevation="0" rounded="lg">
|
||
<p class="error-text">{{ detailError }}</p>
|
||
</v-card>
|
||
</template>
|
||
|
||
<template v-else-if="eventDetail">
|
||
<v-row align="stretch" class="event-markets-row">
|
||
<!-- 左侧:分时图 + 市场列表 -->
|
||
<v-col cols="12" class="chart-col">
|
||
<!-- 分时图卡片(多市场多条线) -->
|
||
<v-card
|
||
v-if="markets.length > 0"
|
||
class="chart-card polymarket-chart"
|
||
elevation="0"
|
||
rounded="lg"
|
||
>
|
||
<div class="chart-header">
|
||
<h1 class="chart-title">{{ eventDetail?.title || t('eventMarkets.allMarkets') }}</h1>
|
||
<p v-if="eventDetail.series?.length || eventDetail.tags?.length" class="chart-meta">
|
||
{{ categoryText }}
|
||
</p>
|
||
<div class="chart-controls-row">
|
||
<v-btn variant="text" size="small" class="past-btn">{{ t('eventMarkets.past') }}</v-btn>
|
||
<v-btn class="date-pill" size="small" rounded="pill">{{ resolutionDate }}</v-btn>
|
||
</div>
|
||
<p class="chart-legend-hint">{{ t('eventMarkets.marketsCount', { n: markets.length }) }}</p>
|
||
</div>
|
||
<div class="chart-wrapper">
|
||
<div v-if="chartLoading" 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">{{ eventVolume || t('eventMarkets.volumeZero') }}</span>
|
||
<span v-if="marketExpiresAt" class="chart-expires">| {{ marketExpiresAt }}</span>
|
||
</div>
|
||
<div class="chart-time-ranges">
|
||
<v-btn
|
||
v-for="r in timeRanges"
|
||
:key="r.value"
|
||
:class="['time-range-btn', { active: selectedTimeRange === r.value }]"
|
||
variant="text"
|
||
size="small"
|
||
@click="selectTimeRange(r.value)"
|
||
>
|
||
{{ r.label }}
|
||
</v-btn>
|
||
</div>
|
||
</div>
|
||
</v-card>
|
||
|
||
<!-- 规则说明(描述 + 裁决来源) -->
|
||
<v-card
|
||
v-if="eventDetail?.description || eventDetail?.resolutionSource"
|
||
class="rules-card"
|
||
elevation="0"
|
||
rounded="lg"
|
||
>
|
||
<div class="rules-pane">
|
||
<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>
|
||
</div>
|
||
</v-card>
|
||
|
||
<!-- 市场列表 -->
|
||
<v-card class="markets-list-card" elevation="0" rounded="lg">
|
||
<div class="markets-list">
|
||
<div
|
||
v-for="(market, index) in markets"
|
||
:key="market.ID ?? index"
|
||
class="market-row"
|
||
:class="{ selected: selectedMarketIndex === index }"
|
||
@click="goToTradeDetail(market)"
|
||
>
|
||
<div class="market-row-main">
|
||
<span class="market-question">{{ market.question || t('eventMarkets.marketPlaceholder') }}</span>
|
||
<span class="market-chance">{{ marketChance(market) }}%</span>
|
||
</div>
|
||
<div class="market-row-vol">{{ formatVolume(market.volume) }}</div>
|
||
<div class="market-row-actions" @click.stop>
|
||
<v-btn
|
||
class="buy-yes-btn"
|
||
variant="flat"
|
||
size="small"
|
||
rounded="sm"
|
||
@click="openTrade(market, index, 'yes')"
|
||
>
|
||
{{ yesLabel(market) }} {{ yesPrice(market) }}
|
||
</v-btn>
|
||
<v-btn
|
||
class="buy-no-btn"
|
||
variant="flat"
|
||
size="small"
|
||
rounded="sm"
|
||
@click="openTrade(market, index, 'no')"
|
||
>
|
||
{{ noLabel(market) }} {{ noPrice(market) }}
|
||
</v-btn>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</v-card>
|
||
</v-col>
|
||
|
||
<!-- 右侧:购买组件(桌面端显示;移动端用底部栏+弹窗) -->
|
||
<v-col v-if="!isMobile" cols="12" class="trade-col">
|
||
<div v-if="markets.length > 0" class="trade-sidebar">
|
||
<TradeComponent
|
||
:market="tradeMarketPayload"
|
||
:initial-option="tradeInitialOption"
|
||
@submit="onTradeSubmit"
|
||
/>
|
||
</div>
|
||
</v-col>
|
||
</v-row>
|
||
|
||
<!-- 移动端:单 market 时显示固定底部 Yes/No 栏 + 三点菜单(Merge/Split) -->
|
||
<template v-if="isMobile && markets.length === 1">
|
||
<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')"
|
||
>
|
||
{{ barMarket ? yesLabel(barMarket) : t('eventMarkets.yes') }} {{ barMarket ? yesPrice(barMarket) : t('eventMarkets.priceZero') }}
|
||
</v-btn>
|
||
<v-btn
|
||
class="mobile-bar-btn mobile-bar-no"
|
||
variant="flat"
|
||
rounded="sm"
|
||
@click="openSheetWithOption('no')"
|
||
>
|
||
{{ barMarket ? noLabel(barMarket) : t('eventMarkets.no') }} {{ barMarket ? noPrice(barMarket) : t('eventMarkets.priceZero') }}
|
||
</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="t('eventMarkets.moreActions')"
|
||
>
|
||
<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>
|
||
</template>
|
||
<!-- 移动端交易弹窗:单 market 与多 market 均需(多 market 时通过列表 Buy Yes/No 打开) -->
|
||
<template v-if="isMobile && markets.length > 0">
|
||
<v-bottom-sheet v-model="tradeSheetOpen" content-class="event-markets-trade-sheet">
|
||
<TradeComponent
|
||
v-if="tradeSheetRenderContent"
|
||
ref="tradeComponentRef"
|
||
:key="`trade-${selectedMarketIndex}-${tradeInitialOption}`"
|
||
:market="tradeMarketPayload"
|
||
:initial-option="tradeInitialOption"
|
||
embedded-in-sheet
|
||
@submit="onTradeSubmit"
|
||
@order-success="onOrderSuccess"
|
||
/>
|
||
</v-bottom-sheet>
|
||
</template>
|
||
</template>
|
||
</v-container>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
defineOptions({ name: 'EventMarkets' })
|
||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { useDisplay } from 'vuetify'
|
||
import { createChart, LineSeries, LineType, LastPriceAnimationMode, type IChartApi, type ISeriesApi } from 'lightweight-charts'
|
||
import { toLwcData } from '../composables/useLightweightChart'
|
||
import TradeComponent from '../components/TradeComponent.vue'
|
||
import {
|
||
findPmEvent,
|
||
getMarketId,
|
||
getClobTokenId,
|
||
type FindPmEventParams,
|
||
type PmEventListItem,
|
||
type PmEventMarketItem,
|
||
} from '../api/event'
|
||
import { getPmPriceHistoryPublic, priceHistoryToChartData, getTimeRangeSeconds } from '../api/priceHistory'
|
||
import { getMockEventById } from '../api/mockData'
|
||
import { USE_MOCK_EVENT } from '../config/mock'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useUserStore } from '../stores/user'
|
||
import { useToastStore } from '../stores/toast'
|
||
import { useLocaleStore } from '../stores/locale'
|
||
|
||
const route = useRoute()
|
||
const { t } = useI18n()
|
||
const router = useRouter()
|
||
const userStore = useUserStore()
|
||
const localeStore = useLocaleStore()
|
||
const { mobile } = useDisplay()
|
||
const isMobile = computed(() => mobile.value)
|
||
|
||
const eventDetail = ref<PmEventListItem | null>(null)
|
||
const detailLoading = ref(false)
|
||
const detailError = ref<string | null>(null)
|
||
|
||
const selectedMarketIndex = ref(0)
|
||
/** 点击 Buy Yes/No 时传给购买组件的初始方向,不点击则为 undefined 使用组件默认 */
|
||
const tradeInitialOption = ref<'yes' | 'no' | undefined>(undefined)
|
||
/** 移动端交易弹窗开关 */
|
||
const tradeSheetOpen = ref(false)
|
||
/** 控制底部栏内 TradeComponent 的渲染,延迟挂载以避免 slot 竞态警告 */
|
||
const tradeSheetRenderContent = ref(false)
|
||
/** 从三点菜单点击 Merge/Split 时待打开的弹窗,等 TradeComponent 挂载后执行 */
|
||
const pendingMergeSplitDialog = ref<'merge' | 'split' | null>(null)
|
||
/** 移动端底部栏三点菜单开关 */
|
||
const mobileMenuOpen = ref(false)
|
||
/** TradeComponent 引用,用于从底部栏触发 Merge/Split */
|
||
const tradeComponentRef = ref<InstanceType<typeof TradeComponent> | null>(null)
|
||
const markets = computed(() => {
|
||
const list = eventDetail.value?.markets ?? []
|
||
return list.length > 0 ? list : []
|
||
})
|
||
const selectedMarket = computed(() => markets.value[selectedMarketIndex.value] ?? null)
|
||
/** 移动端底部栏显示的市场(选中项或首个),仅在 markets.length > 0 时使用 */
|
||
const barMarket = computed(() => selectedMarket.value ?? markets.value[0])
|
||
/** 传给购买组件的市场数据(当前选中的市场) */
|
||
const tradeMarketPayload = computed(() => {
|
||
const m = selectedMarket.value
|
||
if (!m) return undefined
|
||
const yesRaw = m.outcomePrices?.[0]
|
||
const noRaw = m.outcomePrices?.[1]
|
||
const yesPrice = yesRaw != null && Number.isFinite(Number(yesRaw)) ? Number(yesRaw) : 0.5
|
||
const noPrice = noRaw != null && Number.isFinite(Number(noRaw)) ? Number(noRaw) : 0.5
|
||
return {
|
||
marketId: getMarketId(m),
|
||
yesPrice,
|
||
noPrice,
|
||
title: m.question,
|
||
clobTokenIds: m.clobTokenIds,
|
||
outcomes: m.outcomes,
|
||
}
|
||
})
|
||
|
||
const categoryText = computed(() => {
|
||
const s = eventDetail.value?.series?.[0]?.title
|
||
const t = eventDetail.value?.tags?.[0]?.label
|
||
return [s, t].filter(Boolean).join(' · ') || ''
|
||
})
|
||
|
||
function formatVolume(volume: number | undefined): string {
|
||
if (volume == null || !Number.isFinite(volume)) return t('eventMarkets.volumeZero')
|
||
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
|
||
}
|
||
}
|
||
|
||
const eventVolume = computed(() => {
|
||
const v = eventDetail.value?.volume
|
||
return v != null ? formatVolume(v) : ''
|
||
})
|
||
|
||
const currentChance = computed(() => {
|
||
const m = selectedMarket.value
|
||
if (!m) return 0
|
||
return marketChance(m)
|
||
})
|
||
|
||
const selectedMarketVolume = computed(() => formatVolume(selectedMarket.value?.volume))
|
||
const marketExpiresAt = computed(() => {
|
||
const endDate = eventDetail.value?.endDate
|
||
return endDate ? formatExpiresAt(endDate) : ''
|
||
})
|
||
const resolutionDate = computed(() => {
|
||
const s = marketExpiresAt.value
|
||
return s ? s.replace(/,?\s*\d{4}$/, '').trim() || '' : ''
|
||
})
|
||
|
||
const isResolutionSourceUrl = computed(() => {
|
||
const src = eventDetail.value?.resolutionSource
|
||
return typeof src === 'string' && (src.startsWith('http://') || src.startsWith('https://'))
|
||
})
|
||
|
||
const timeRanges = [
|
||
{ 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 selectedTimeRange = ref('1D')
|
||
const chartContainerRef = ref<HTMLElement | null>(null)
|
||
|
||
type ChartSeriesItem = { name: string; data: [number, number][] }
|
||
const chartData = ref<ChartSeriesItem[]>([])
|
||
const chartLoading = ref(false)
|
||
let chartInstance: IChartApi | null = null
|
||
const chartSeriesList = ref<ISeriesApi<'Line'>[]>([])
|
||
|
||
const LINE_COLORS = [
|
||
'#2563eb',
|
||
'#dc2626',
|
||
'#16a34a',
|
||
'#ca8a04',
|
||
'#9333ea',
|
||
'#0891b2',
|
||
'#ea580c',
|
||
'#4f46e5',
|
||
]
|
||
const MOBILE_BREAKPOINT = 600
|
||
|
||
/** 按市场依次请求 getPmPriceHistoryPublic,market 传 clobTokenIds[0](YES token) */
|
||
async function loadChartFromApi(): Promise<ChartSeriesItem[]> {
|
||
const list = markets.value
|
||
const range = selectedTimeRange.value
|
||
// 增加每次请求的 points 数量,避免图表在较长时间粒度下出现“最新数据提前被截断”
|
||
const pageSizeByRange: Record<string, number> = {
|
||
'1H': 200,
|
||
'6H': 600,
|
||
'1D': 800,
|
||
'1W': 1200,
|
||
'1M': 1500,
|
||
ALL: 2000,
|
||
}
|
||
const pageSize = pageSizeByRange[range] ?? 500
|
||
const results: ChartSeriesItem[] = []
|
||
for (let i = 0; i < list.length; i++) {
|
||
const market = list[i]
|
||
if (!market) continue
|
||
const yesTokenId = getClobTokenId(market, 0)
|
||
const base = (market.question || t('eventMarkets.marketPlaceholder')).slice(0, 32)
|
||
const baseName = base + (base.length >= 32 ? '…' : '')
|
||
const name = list.length > 1 ? `${baseName} (${i + 1}/${list.length})` : baseName
|
||
if (!yesTokenId) {
|
||
results.push({ name, data: [] })
|
||
continue
|
||
}
|
||
try {
|
||
const ev = eventDetail.value
|
||
const timeRange = getTimeRangeSeconds(range, ev ? { startDate: ev.startDate, endDate: ev.endDate } : undefined)
|
||
const res = await getPmPriceHistoryPublic({
|
||
market: yesTokenId,
|
||
page: 1,
|
||
pageSize,
|
||
...(timeRange && { startTs: timeRange.startTs, endTs: timeRange.endTs }),
|
||
})
|
||
const points = priceHistoryToChartData(res.data?.list ?? [])
|
||
results.push({ name, data: points })
|
||
} catch {
|
||
results.push({ name, data: [] })
|
||
}
|
||
}
|
||
return results
|
||
}
|
||
|
||
function setChartSeries(seriesArr: ChartSeriesItem[]) {
|
||
if (!chartInstance) return
|
||
chartSeriesList.value.forEach((s) => chartInstance!.removeSeries(s))
|
||
chartSeriesList.value = []
|
||
seriesArr.forEach((s, i) => {
|
||
const color = LINE_COLORS[i % LINE_COLORS.length]
|
||
const series = chartInstance!.addSeries(LineSeries, {
|
||
color,
|
||
lineWidth: 2,
|
||
lineType: LineType.Curved,
|
||
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
|
||
crosshairMarkerVisible: true,
|
||
lastValueVisible: true,
|
||
priceFormat: { type: 'percent', precision: 1 },
|
||
})
|
||
series.setData(toLwcData(s.data))
|
||
chartSeriesList.value.push(series)
|
||
})
|
||
}
|
||
|
||
async function initChart() {
|
||
if (!chartContainerRef.value || markets.value.length === 0) return
|
||
chartLoading.value = true
|
||
try {
|
||
chartData.value = await loadChartFromApi()
|
||
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 },
|
||
handleScroll: false,
|
||
handleScale: false,
|
||
crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } },
|
||
})
|
||
setChartSeries(chartData.value)
|
||
} finally {
|
||
chartLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function updateChartData() {
|
||
chartLoading.value = true
|
||
try {
|
||
chartData.value = await loadChartFromApi()
|
||
setChartSeries(chartData.value)
|
||
} finally {
|
||
chartLoading.value = false
|
||
}
|
||
}
|
||
|
||
function selectTimeRange(range: string) {
|
||
selectedTimeRange.value = range
|
||
updateChartData()
|
||
}
|
||
|
||
const handleResize = () => {
|
||
if (!chartInstance || !chartContainerRef.value) return
|
||
chartInstance.resize(chartContainerRef.value.clientWidth, 320)
|
||
}
|
||
|
||
function selectMarket(index: number) {
|
||
selectedMarketIndex.value = index
|
||
}
|
||
|
||
/** 点击 Buy Yes/No:选中该市场并把数据和方向传给购买组件;移动端直接弹出交易弹窗 */
|
||
function openTrade(market: PmEventMarketItem, index: number, side: 'yes' | 'no') {
|
||
selectedMarketIndex.value = index
|
||
tradeInitialOption.value = side
|
||
if (isMobile.value) {
|
||
tradeSheetOpen.value = true
|
||
}
|
||
}
|
||
|
||
/** 移动端底部栏:点击 Yes/No 时打开交易弹窗 */
|
||
function openSheetWithOption(side: 'yes' | 'no') {
|
||
tradeInitialOption.value = side
|
||
tradeSheetOpen.value = true
|
||
}
|
||
|
||
/** 从底部栏三点菜单打开 Merge 弹窗 */
|
||
function openMergeFromBar() {
|
||
mobileMenuOpen.value = false
|
||
pendingMergeSplitDialog.value = 'merge'
|
||
tradeSheetOpen.value = true
|
||
}
|
||
|
||
/** 从底部栏三点菜单打开 Split 弹窗 */
|
||
function openSplitFromBar() {
|
||
mobileMenuOpen.value = false
|
||
pendingMergeSplitDialog.value = 'split'
|
||
tradeSheetOpen.value = true
|
||
}
|
||
|
||
function onTradeSubmit(payload: {
|
||
side: 'buy' | 'sell'
|
||
option: 'yes' | 'no'
|
||
limitPrice: number
|
||
shares: number
|
||
expirationEnabled: boolean
|
||
expirationTime: string
|
||
marketId?: string
|
||
}) {
|
||
// 可在此调用下单 API,payload 含 marketId(当前市场)
|
||
}
|
||
|
||
const toastStore = useToastStore()
|
||
function onOrderSuccess() {
|
||
tradeSheetOpen.value = false
|
||
toastStore.show(t('toast.orderSuccess'))
|
||
}
|
||
|
||
function marketChance(market: PmEventMarketItem): number {
|
||
const raw = market?.outcomePrices?.[0]
|
||
if (raw == null) return 0
|
||
const yesPrice = parseFloat(String(raw))
|
||
if (!Number.isFinite(yesPrice)) return 0
|
||
return Math.min(100, Math.max(0, Math.round(yesPrice * 100)))
|
||
}
|
||
|
||
function yesLabel(market: PmEventMarketItem): string {
|
||
return market?.outcomes?.[0] ?? 'Yes'
|
||
}
|
||
|
||
function noLabel(market: PmEventMarketItem): string {
|
||
return market?.outcomes?.[1] ?? 'No'
|
||
}
|
||
|
||
function yesPrice(market: PmEventMarketItem): string {
|
||
const raw = market?.outcomePrices?.[0]
|
||
if (raw == null) return '0¢'
|
||
const p = parseFloat(String(raw))
|
||
if (!Number.isFinite(p)) return '0¢'
|
||
return `${Math.round(p * 100)}¢`
|
||
}
|
||
|
||
function noPrice(market: PmEventMarketItem): string {
|
||
const raw = market?.outcomePrices?.[1]
|
||
if (raw == null) return '0¢'
|
||
const p = parseFloat(String(raw))
|
||
if (!Number.isFinite(p)) return '0¢'
|
||
return `${Math.round(p * 100)}¢`
|
||
}
|
||
|
||
function goToTradeDetail(market: PmEventMarketItem, side?: 'yes' | 'no') {
|
||
const eventId = route.params.id
|
||
const marketId = market.ID != null ? String(market.ID) : undefined
|
||
router.push({
|
||
path: `/trade-detail/${eventId}`,
|
||
query: {
|
||
title: market.question ?? eventDetail.value?.title,
|
||
marketId,
|
||
marketInfo: formatVolume(market.volume),
|
||
chance: String(marketChance(market)),
|
||
...(side && { side }),
|
||
...(eventDetail.value?.slug && { slug: eventDetail.value.slug }),
|
||
},
|
||
})
|
||
}
|
||
|
||
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
|
||
detailError.value = null
|
||
} else {
|
||
const fallback = USE_MOCK_EVENT && isNumericId ? getMockEventById(numId) : null
|
||
if (fallback) {
|
||
eventDetail.value = fallback
|
||
detailError.value = null
|
||
} else {
|
||
detailError.value = res.msg || t('error.loadFailed')
|
||
eventDetail.value = null
|
||
}
|
||
}
|
||
} catch (e) {
|
||
const fallback = USE_MOCK_EVENT && isNumericId ? getMockEventById(numId) : null
|
||
if (fallback) {
|
||
eventDetail.value = fallback
|
||
detailError.value = null
|
||
} else {
|
||
detailError.value = e instanceof Error ? e.message : t('error.loadFailed')
|
||
eventDetail.value = null
|
||
}
|
||
} finally {
|
||
detailLoading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadEventDetail()
|
||
window.addEventListener('resize', handleResize)
|
||
})
|
||
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
|
||
tradeComponentRef.value?.openMergeDialog?.()
|
||
} else if (pending === 'split') {
|
||
pendingMergeSplitDialog.value = null
|
||
tradeComponentRef.value?.openSplitDialog?.()
|
||
}
|
||
})
|
||
}, 50)
|
||
} else {
|
||
pendingMergeSplitDialog.value = null
|
||
if (tradeSheetMountTimer) {
|
||
clearTimeout(tradeSheetMountTimer)
|
||
tradeSheetMountTimer = undefined
|
||
}
|
||
tradeSheetUnmountTimer = setTimeout(() => {
|
||
tradeSheetRenderContent.value = false
|
||
tradeSheetUnmountTimer = undefined
|
||
}, 350)
|
||
}
|
||
}, { immediate: true })
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('resize', handleResize)
|
||
chartInstance?.remove()
|
||
chartInstance = null
|
||
chartSeriesList.value = []
|
||
if (tradeSheetUnmountTimer) clearTimeout(tradeSheetUnmountTimer)
|
||
if (tradeSheetMountTimer) clearTimeout(tradeSheetMountTimer)
|
||
})
|
||
|
||
watch(
|
||
() => markets.value.length,
|
||
(len) => {
|
||
if (len > 0) {
|
||
nextTick(() => initChart())
|
||
}
|
||
},
|
||
)
|
||
watch(
|
||
() => route.params.id,
|
||
() => loadEventDetail(),
|
||
)
|
||
|
||
// 监听语言切换,语言变化时重新加载数据
|
||
watch(
|
||
() => localeStore.currentLocale,
|
||
() => {
|
||
loadEventDetail()
|
||
},
|
||
)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.event-markets-container {
|
||
padding: 24px;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
.event-markets-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) {
|
||
.event-markets-row {
|
||
flex-wrap: nowrap;
|
||
}
|
||
.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%;
|
||
}
|
||
}
|
||
|
||
/* 与列表页、单 market 页保持一致的左右间距 */
|
||
@media (max-width: 599px) {
|
||
.event-markets-container {
|
||
padding: 16px;
|
||
padding-left: 16px;
|
||
padding-right: 16px;
|
||
}
|
||
|
||
.chart-card.polymarket-chart {
|
||
padding: 16px;
|
||
}
|
||
}
|
||
|
||
/* 分时图卡片(扁平化) */
|
||
.chart-card.polymarket-chart {
|
||
margin-bottom: 24px;
|
||
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 4px 0;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.chart-meta {
|
||
font-size: 14px;
|
||
color: #6b7280;
|
||
margin: 0 0 8px 0;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.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-legend-hint {
|
||
font-size: 0.875rem;
|
||
color: #6b7280;
|
||
margin: 0 0 8px 0;
|
||
}
|
||
|
||
.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.7);
|
||
z-index: 1;
|
||
}
|
||
|
||
.chart-container {
|
||
width: 100%;
|
||
height: 320px;
|
||
min-height: 260px;
|
||
}
|
||
|
||
.chart-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid #f3f4f6;
|
||
}
|
||
|
||
.chart-footer-left {
|
||
font-size: 12px;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.chart-expires {
|
||
margin-left: 4px;
|
||
}
|
||
|
||
.chart-time-ranges {
|
||
display: flex;
|
||
gap: 4px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.time-range-btn {
|
||
font-size: 12px;
|
||
text-transform: none;
|
||
min-width: 36px;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.time-range-btn.active {
|
||
color: #111827;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.time-range-btn:hover {
|
||
color: #111827;
|
||
}
|
||
|
||
.loading-card,
|
||
.error-card {
|
||
padding: 48px;
|
||
text-align: center;
|
||
}
|
||
|
||
.loading-placeholder p,
|
||
.error-text {
|
||
margin-top: 16px;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.error-text {
|
||
color: #dc2626;
|
||
}
|
||
|
||
.rules-card {
|
||
margin-top: 16px;
|
||
border: 1px solid #e7e7e7;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.rules-card .rules-pane {
|
||
padding: 16px 20px;
|
||
}
|
||
|
||
.rules-card .rules-section {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.rules-card .rules-section:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.rules-card .rules-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #6b7280;
|
||
margin: 0 0 8px 0;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.rules-card .rules-text {
|
||
font-size: 14px;
|
||
color: #374151;
|
||
line-height: 1.5;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.rules-card .rules-link {
|
||
font-size: 14px;
|
||
color: #2563eb;
|
||
text-decoration: none;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.rules-card .rules-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.markets-list-card {
|
||
margin-top: 16px;
|
||
border: 1px solid #e7e7e7;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.markets-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.market-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
cursor: pointer;
|
||
transition: background-color 0.15s ease;
|
||
}
|
||
|
||
.market-row:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.market-row:hover {
|
||
background-color: #f9fafb;
|
||
}
|
||
|
||
.market-row.selected {
|
||
background-color: #eff6ff;
|
||
}
|
||
|
||
.market-row-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.market-question {
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
color: #111827;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.market-chance {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: #111827;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.market-row-vol {
|
||
font-size: 14px;
|
||
color: #6b7280;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.market-row-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex: 1 1 100%;
|
||
min-width: 0;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
/* 宽屏时按钮与问题同行,节省垂直空间 */
|
||
@media (min-width: 768px) {
|
||
.market-row-actions {
|
||
flex: 0 0 auto;
|
||
margin-top: 0;
|
||
margin-left: auto;
|
||
}
|
||
}
|
||
|
||
.buy-yes-btn,
|
||
.buy-no-btn {
|
||
flex: 1;
|
||
min-width: 72px;
|
||
max-width: 140px;
|
||
white-space: nowrap;
|
||
text-transform: none;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.buy-yes-btn {
|
||
background-color: #b8e0b8 !important;
|
||
color: #006600 !important;
|
||
}
|
||
|
||
.buy-no-btn {
|
||
background-color: #f0b8b8 !important;
|
||
color: #cc0000 !important;
|
||
}
|
||
|
||
/* 移动端底部交易栏 */
|
||
.mobile-trade-bar-spacer {
|
||
height: 72px;
|
||
}
|
||
|
||
.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;
|
||
padding: 14px 16px;
|
||
}
|
||
|
||
.mobile-bar-no {
|
||
flex: 1;
|
||
min-width: 0;
|
||
background: #f0b8b8 !important;
|
||
color: #cc0000 !important;
|
||
padding: 14px 16px;
|
||
}
|
||
|
||
.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>
|