1065 lines
28 KiB
Vue
1065 lines
28 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">
|
||
<div class="event-header">
|
||
<h1 class="event-title">{{ eventDetail.title }}</h1>
|
||
<p v-if="eventDetail.series?.length || eventDetail.tags?.length" class="event-meta">
|
||
{{ categoryText }}
|
||
</p>
|
||
<p v-if="eventVolume" class="event-volume">{{ eventVolume }}</p>
|
||
</div>
|
||
|
||
<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 || 'All markets' }}</h1>
|
||
<div class="chart-controls-row">
|
||
<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>
|
||
<p class="chart-legend-hint">{{ markets.length }} 个市场</p>
|
||
</div>
|
||
<div class="chart-wrapper">
|
||
<div ref="chartContainerRef" class="chart-container"></div>
|
||
</div>
|
||
<div class="chart-footer">
|
||
<div class="chart-footer-left">
|
||
<span class="chart-volume">{{ eventVolume || '$0 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="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 || 'Market' }}</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) : 'Yes' }} {{ barMarket ? yesPrice(barMarket) : '0¢' }}
|
||
</v-btn>
|
||
<v-btn
|
||
class="mobile-bar-btn mobile-bar-no"
|
||
variant="flat"
|
||
rounded="sm"
|
||
@click="openSheetWithOption('no')"
|
||
>
|
||
{{ barMarket ? noLabel(barMarket) : 'No' }} {{ barMarket ? noPrice(barMarket) : '0¢' }}
|
||
</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>
|
||
</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
|
||
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 * as echarts from 'echarts'
|
||
import type { ECharts } from 'echarts'
|
||
import TradeComponent from '../components/TradeComponent.vue'
|
||
import {
|
||
findPmEvent,
|
||
getMarketId,
|
||
type FindPmEventParams,
|
||
type PmEventListItem,
|
||
type PmEventMarketItem,
|
||
} from '../api/event'
|
||
import { MOCK_EVENT_LIST } from '../api/mockEventList'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useUserStore } from '../stores/user'
|
||
import { useToastStore } from '../stores/toast'
|
||
|
||
const route = useRoute()
|
||
const { t } = useI18n()
|
||
const router = useRouter()
|
||
const userStore = useUserStore()
|
||
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)
|
||
/** 移动端底部栏三点菜单开关 */
|
||
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 '$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
|
||
}
|
||
}
|
||
|
||
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 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('ALL')
|
||
const chartContainerRef = ref<HTMLElement | null>(null)
|
||
|
||
type ChartSeriesItem = { name: string; data: [number, number][] }
|
||
const chartData = ref<ChartSeriesItem[]>([])
|
||
let chartInstance: ECharts | null = null
|
||
let dynamicInterval: number | undefined
|
||
|
||
const LINE_COLORS = [
|
||
'#2563eb',
|
||
'#dc2626',
|
||
'#16a34a',
|
||
'#ca8a04',
|
||
'#9333ea',
|
||
'#0891b2',
|
||
'#ea580c',
|
||
'#4f46e5',
|
||
]
|
||
const MOBILE_BREAKPOINT = 600
|
||
|
||
function getStepAndCount(range: string): { stepMs: number; count: number } {
|
||
switch (range) {
|
||
case '1H':
|
||
return { stepMs: 60 * 1000, count: 60 }
|
||
case '6H':
|
||
return { stepMs: 10 * 60 * 1000, count: 36 }
|
||
case '1D':
|
||
return { stepMs: 60 * 60 * 1000, count: 24 }
|
||
case '1W':
|
||
return { stepMs: 24 * 60 * 60 * 1000, count: 7 }
|
||
case '1M':
|
||
case 'ALL':
|
||
return { stepMs: 24 * 60 * 60 * 1000, count: 30 }
|
||
default:
|
||
return { stepMs: 60 * 60 * 1000, count: 24 }
|
||
}
|
||
}
|
||
|
||
function generateDataForMarket(baseChance: number, range: string): [number, number][] {
|
||
const now = Date.now()
|
||
const data: [number, number][] = []
|
||
const { stepMs, count } = getStepAndCount(range)
|
||
let value = baseChance + (Math.random() - 0.5) * 10
|
||
for (let i = count; i >= 0; i--) {
|
||
const t = now - i * stepMs
|
||
value = Math.max(10, Math.min(90, value + (Math.random() - 0.5) * 6))
|
||
data.push([t, Math.round(value * 10) / 10])
|
||
}
|
||
return data
|
||
}
|
||
|
||
function generateAllData(): ChartSeriesItem[] {
|
||
const range = selectedTimeRange.value
|
||
return markets.value.map((market) => {
|
||
const chance = marketChance(market)
|
||
const name = (market.question || 'Market').slice(0, 32)
|
||
return {
|
||
name: name + (name.length >= 32 ? '…' : ''),
|
||
data: generateDataForMarket(chance || 20, range),
|
||
}
|
||
})
|
||
}
|
||
|
||
function buildOption(seriesArr: ChartSeriesItem[], containerWidth?: number) {
|
||
const width = containerWidth ?? chartContainerRef.value?.clientWidth ?? 400
|
||
const isMobile = width < MOBILE_BREAKPOINT
|
||
const hasData = seriesArr.some((s) => s.data.length >= 2)
|
||
const xAxisConfig: Record<string, unknown> = {
|
||
type: 'time',
|
||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||
axisLabel: { color: '#6b7280', fontSize: isMobile ? 10 : 11 },
|
||
axisTick: { show: false },
|
||
splitLine: { show: false },
|
||
}
|
||
if (isMobile && hasData) {
|
||
const times = seriesArr.flatMap((s) => s.data.map((d) => d[0]))
|
||
const span = times.length ? Math.max(...times) - Math.min(...times) : 0
|
||
xAxisConfig.axisLabel = {
|
||
...(xAxisConfig.axisLabel as object),
|
||
interval: Math.max(span / 4, 60 * 1000),
|
||
formatter: (value: number) => {
|
||
const d = new Date(value)
|
||
return `${d.getMonth() + 1}/${d.getDate()}`
|
||
},
|
||
rotate: -25,
|
||
}
|
||
}
|
||
|
||
const series = seriesArr.map((s, i) => {
|
||
const color = LINE_COLORS[i % LINE_COLORS.length]
|
||
const lastIndex = s.data.length - 1
|
||
return {
|
||
name: s.name,
|
||
type: 'line' as const,
|
||
showSymbol: true,
|
||
symbol: 'circle',
|
||
symbolSize: (_: unknown, params: { dataIndex?: number }) =>
|
||
params?.dataIndex === lastIndex ? 8 : 0,
|
||
data: s.data,
|
||
smooth: true,
|
||
lineStyle: { width: 2, color },
|
||
itemStyle: { color, borderColor: '#fff', borderWidth: 2 },
|
||
}
|
||
})
|
||
|
||
return {
|
||
animation: false,
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
formatter: (params: unknown) => {
|
||
const arr = Array.isArray(params) ? params : [params]
|
||
if (arr.length === 0) return ''
|
||
const p0 = arr[0] as { name: string | number; value: unknown }
|
||
const date = new Date(p0.name as number)
|
||
const dateStr = `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`
|
||
const lines = arr
|
||
.filter((x) => x != null && (x as { value?: unknown }).value != null)
|
||
.map((x) => {
|
||
const v = (x as { seriesName?: string; value: unknown }).value
|
||
const val = Array.isArray(v) ? v[1] : v
|
||
return `${(x as { seriesName?: string }).seriesName}: ${val}%`
|
||
})
|
||
return [dateStr, ...lines].join('<br/>')
|
||
},
|
||
axisPointer: { animation: false },
|
||
},
|
||
legend: {
|
||
type: 'scroll',
|
||
top: 0,
|
||
right: 0,
|
||
data: seriesArr.map((s) => s.name),
|
||
textStyle: { fontSize: 11, color: '#6b7280' },
|
||
itemWidth: 14,
|
||
itemHeight: 8,
|
||
itemGap: 12,
|
||
},
|
||
grid: {
|
||
left: 16,
|
||
right: 48,
|
||
top: 40,
|
||
bottom: isMobile ? 44 : 28,
|
||
containLabel: false,
|
||
},
|
||
xAxis: xAxisConfig,
|
||
yAxis: {
|
||
type: 'value',
|
||
position: 'right',
|
||
boundaryGap: [0, '100%'],
|
||
axisLine: { show: false },
|
||
axisTick: { show: false },
|
||
axisLabel: { color: '#6b7280', fontSize: 11, formatter: '{value}%' },
|
||
splitLine: { show: true, lineStyle: { type: 'dashed', color: '#e5e7eb' } },
|
||
},
|
||
series,
|
||
}
|
||
}
|
||
|
||
function initChart() {
|
||
if (!chartContainerRef.value || markets.value.length === 0) return
|
||
chartData.value = generateAllData()
|
||
chartInstance = echarts.init(chartContainerRef.value)
|
||
const w = chartContainerRef.value.clientWidth
|
||
chartInstance.setOption(buildOption(chartData.value, w))
|
||
}
|
||
|
||
function updateChartData() {
|
||
chartData.value = generateAllData()
|
||
const w = chartContainerRef.value?.clientWidth
|
||
if (chartInstance)
|
||
chartInstance.setOption(buildOption(chartData.value, w), { replaceMerge: ['series'] })
|
||
}
|
||
|
||
function selectTimeRange(range: string) {
|
||
selectedTimeRange.value = range
|
||
updateChartData()
|
||
}
|
||
|
||
function getMaxPoints(range: string): number {
|
||
return getStepAndCount(range).count + 1
|
||
}
|
||
|
||
function startDynamicUpdate() {
|
||
dynamicInterval = window.setInterval(() => {
|
||
const nextData = chartData.value.map((s) => {
|
||
const list = [...s.data]
|
||
const last = list[list.length - 1]
|
||
if (!last) return s
|
||
const nextVal = Math.max(10, Math.min(90, last[1] + (Math.random() - 0.5) * 4))
|
||
list.push([Date.now(), Math.round(nextVal * 10) / 10])
|
||
const max = getMaxPoints(selectedTimeRange.value)
|
||
return { name: s.name, data: list.slice(-max) }
|
||
})
|
||
chartData.value = nextData
|
||
const w = chartContainerRef.value?.clientWidth
|
||
if (chartInstance)
|
||
chartInstance.setOption(buildOption(chartData.value, w), { replaceMerge: ['series'] })
|
||
}, 3000)
|
||
}
|
||
|
||
function stopDynamicUpdate() {
|
||
if (dynamicInterval) {
|
||
clearInterval(dynamicInterval)
|
||
dynamicInterval = undefined
|
||
}
|
||
}
|
||
|
||
const handleResize = () => {
|
||
if (!chartInstance || !chartContainerRef.value) return
|
||
chartInstance.resize()
|
||
chartInstance.setOption(buildOption(chartData.value, chartContainerRef.value.clientWidth), {
|
||
replaceMerge: ['series'],
|
||
})
|
||
}
|
||
|
||
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
|
||
tradeSheetOpen.value = true
|
||
nextTick(() => {
|
||
tradeComponentRef.value?.openMergeDialog?.()
|
||
})
|
||
}
|
||
|
||
/** 从底部栏三点菜单打开 Split 弹窗 */
|
||
function openSplitFromBar() {
|
||
mobileMenuOpen.value = false
|
||
tradeSheetOpen.value = true
|
||
nextTick(() => {
|
||
tradeComponentRef.value?.openSplitDialog?.()
|
||
})
|
||
}
|
||
|
||
function onTradeSubmit(payload: {
|
||
side: 'buy' | 'sell'
|
||
option: 'yes' | 'no'
|
||
limitPrice: number
|
||
shares: number
|
||
expirationEnabled: boolean
|
||
expirationTime: string
|
||
marketId?: string
|
||
}) {
|
||
// 可在此调用下单 API,payload 含 marketId(当前市场)
|
||
console.log('Trade submit', payload)
|
||
}
|
||
|
||
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 = 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 = 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
|
||
}
|
||
}
|
||
|
||
function getMockEventById(id: number): PmEventListItem | null {
|
||
const item = MOCK_EVENT_LIST.find((e) => e.ID === id)
|
||
return item && (item.markets?.length ?? 0) > 1 ? item : null
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadEventDetail()
|
||
window.addEventListener('resize', handleResize)
|
||
})
|
||
onUnmounted(() => {
|
||
stopDynamicUpdate()
|
||
window.removeEventListener('resize', handleResize)
|
||
chartInstance?.dispose()
|
||
chartInstance = null
|
||
})
|
||
|
||
watch(
|
||
() => markets.value.length,
|
||
(len) => {
|
||
if (len > 0) {
|
||
nextTick(() => {
|
||
initChart()
|
||
if (dynamicInterval == null) startDynamicUpdate()
|
||
})
|
||
}
|
||
},
|
||
)
|
||
watch(
|
||
() => route.params.id,
|
||
() => 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%;
|
||
}
|
||
}
|
||
|
||
/* 分时图卡片(扁平化) */
|
||
.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 12px 0;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.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 {
|
||
width: 100%;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.event-header {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.event-title {
|
||
font-size: 1.5rem;
|
||
font-weight: 600;
|
||
color: #111827;
|
||
margin: 0 0 8px 0;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.event-meta {
|
||
font-size: 14px;
|
||
color: #6b7280;
|
||
margin: 0 0 4px 0;
|
||
}
|
||
|
||
.event-volume {
|
||
font-size: 14px;
|
||
color: #6b7280;
|
||
margin: 0;
|
||
}
|
||
|
||
.markets-list-card {
|
||
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>
|