xtraderClient/src/views/EventMarkets.vue
2026-03-22 11:23:48 +08:00

1118 lines
30 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">
<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
/** 按市场依次请求 getPmPriceHistoryPublicmarket 传 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
}) {
// 可在此调用下单 APIpayload 含 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>