xtraderClient/src/views/EventMarkets.vue
2026-02-26 16:10:20 +08:00

1061 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<template>
<v-container 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 { 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'
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
}) {
// 可在此调用下单 APIpayload 含 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 = 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)
})
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>