2533 lines
73 KiB
Vue
2533 lines
73 KiB
Vue
<template>
|
||
<v-container fluid class="trade-detail-container" :class="{
|
||
'trade-detail-container--settled-bar': showSettledOutcomeBar,
|
||
}">
|
||
<v-pull-to-refresh class="trade-detail-pull-refresh" @load="onRefresh">
|
||
<div class="trade-detail-pull-refresh-inner">
|
||
<!-- findPmEvent 请求中:仅显示 loading -->
|
||
<v-card v-if="detailLoading && !eventDetail" class="trade-detail-loading-card" elevation="0" rounded="lg">
|
||
<div class="trade-detail-loading-placeholder">
|
||
<v-progress-circular indeterminate color="primary" size="48" />
|
||
<p>{{ t('common.loading') }}</p>
|
||
</div>
|
||
</v-card>
|
||
|
||
<v-row v-else align="stretch" class="trade-detail-row" style="--v-col-gap-y: 0px;">
|
||
<!-- 左侧:分时图 + 订单簿(宽度弹性) -->
|
||
<v-col cols="12" class="chart-col">
|
||
<!-- 分时图卡片(Polymarket 样式) -->
|
||
<v-card class="chart-card polymarket-chart" elevation="0" rounded="lg">
|
||
<!-- 顶部:标题、当前概率、日期与图表模式 -->
|
||
<div class="chart-header">
|
||
<h1 class="chart-title">
|
||
{{ detailLoading && !eventDetail ? t('common.loading') : marketTitle }}
|
||
</h1>
|
||
<p v-if="detailError" class="chart-error">{{ detailError }}</p>
|
||
<div class="chart-controls-row">
|
||
<v-btn class="date-pill" size="small" rounded="pill">{{ resolutionDate }}</v-btn>
|
||
<v-btn-group v-if="cryptoSymbol" variant="outlined" density="compact" divided
|
||
class="chart-mode-toggle">
|
||
<v-btn :class="{ active: chartMode === 'yesno' }" size="small" icon
|
||
:aria-label="t('chart.yesnoTimeSeries')" @click="setChartMode('yesno')">
|
||
<v-icon size="20">mdi-chart-timeline-variant</v-icon>
|
||
</v-btn>
|
||
<v-btn :class="{ active: chartMode === 'crypto' }" size="small" class="chart-mode-crypto-btn"
|
||
:aria-label="t('chart.cryptoPrice')" @click="setChartMode('crypto')">
|
||
<span class="chart-crypto-ticker-label">{{ cryptoSymbol.toUpperCase() }}</span>
|
||
</v-btn>
|
||
</v-btn-group>
|
||
</div>
|
||
<div class="chart-chance">
|
||
<template v-if="chartMode === 'crypto' && cryptoSymbol">
|
||
${{ currentCryptoPrice }} {{ cryptoSymbol.toUpperCase() }}
|
||
</template>
|
||
<template v-else> {{ currentChance }}% {{ t('common.chance') }} </template>
|
||
</div>
|
||
<p v-if="currentMarketClosed" class="chart-closed-banner">
|
||
{{ t('trade.marketClosedBanner') }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 图表区域 -->
|
||
<div class="chart-wrapper">
|
||
<div v-if="cryptoChartLoading || chartYesNoLoading" class="chart-loading-overlay">
|
||
<v-progress-circular indeterminate color="primary" size="32" />
|
||
</div>
|
||
<div ref="chartContainerRef" class="chart-container"></div>
|
||
</div>
|
||
|
||
<!-- 底部:成交量/到期日 | 时间粒度 -->
|
||
<div class="chart-footer">
|
||
<div class="chart-footer-left">
|
||
<span class="chart-volume">{{ marketVolume }} Vol.</span>
|
||
<span v-if="marketExpiresAt" class="chart-expires">| {{ marketExpiresAt }}</span>
|
||
</div>
|
||
<div class="chart-time-ranges">
|
||
<v-btn v-for="r in timeRanges" :key="r.value"
|
||
:class="['time-range-btn', { active: selectedTimeRange === r.value }]" variant="text" size="small"
|
||
@click="selectTimeRange(r.value)">
|
||
{{ r.label }}
|
||
</v-btn>
|
||
</div>
|
||
</div>
|
||
</v-card>
|
||
|
||
<!-- 移动端:无右侧交易栏时展示已关闭说明 -->
|
||
<v-card v-if="isMobile && currentMarketClosed" class="market-closed-mobile-card" elevation="0" rounded="lg">
|
||
<div class="market-closed-pane market-closed-pane--mobile">
|
||
<v-icon size="40" color="grey-darken-1">mdi-lock-outline</v-icon>
|
||
<h2 class="market-closed-pane-title">{{ t('trade.marketClosedTitle') }}</h2>
|
||
<p class="market-closed-pane-desc">{{ t('trade.marketClosedDesc') }}</p>
|
||
</div>
|
||
</v-card>
|
||
|
||
<!-- 持仓 / 限价(订单簿上方) -->
|
||
<v-card class="positions-orders-card" elevation="0" rounded="lg">
|
||
<v-tabs v-model="positionsOrdersTab" class="positions-orders-tabs" density="comfortable">
|
||
<v-tab value="positions">{{ t('activity.myPositions') }}</v-tab>
|
||
<v-tab value="orders">{{ t('activity.openOrders') }}</v-tab>
|
||
</v-tabs>
|
||
<v-window v-model="positionsOrdersTab" class="positions-orders-window">
|
||
<v-window-item value="positions" class="detail-pane">
|
||
<div v-if="positionLoading" class="placeholder-pane placeholder-pane--loading">
|
||
<v-progress-circular indeterminate size="36" width="2" />
|
||
<span>{{ t('common.loading') }}</span>
|
||
</div>
|
||
<div v-else-if="marketPositionsFiltered.length === 0" class="placeholder-pane">
|
||
{{ t('activity.noPositionsInMarket') }}
|
||
</div>
|
||
<div v-else class="positions-list">
|
||
<div v-for="pos in marketPositionsFiltered" :key="pos.id" class="position-row-item">
|
||
<div class="position-row-header">
|
||
<div class="position-row-icon" :class="pos.iconClass">
|
||
<img v-if="pos.imageUrl" :src="pos.imageUrl" alt="" class="position-row-icon-img" />
|
||
<span v-else class="position-row-icon-char">{{
|
||
pos.iconChar || '•'
|
||
}}</span>
|
||
</div>
|
||
<span class="position-row-title">{{ pos.market }}</span>
|
||
</div>
|
||
<div class="position-row-main">
|
||
<span :class="['position-outcome-pill', pos.outcomePillClass]">{{
|
||
pos.outcomeTag
|
||
}}</span>
|
||
<span v-if="pos.locked" class="position-lock-badge">
|
||
{{
|
||
pos.lockedSharesNum != null && Number.isFinite(pos.lockedSharesNum)
|
||
? t('activity.positionLockedWithAmount', { n: pos.lockedSharesNum })
|
||
: t('activity.positionLocked')
|
||
}}
|
||
</span>
|
||
<span class="position-shares">{{ pos.shares }}</span>
|
||
<span class="position-value">{{ pos.value }}</span>
|
||
<v-btn variant="outlined" size="small" color="primary" class="position-sell-btn" :disabled="currentMarketClosed ||
|
||
!(pos.availableSharesNum != null && pos.availableSharesNum > 0)
|
||
" @click="openSellFromPosition(pos)">
|
||
{{ t('trade.sell') }}
|
||
</v-btn>
|
||
</div>
|
||
<div class="position-row-meta">{{ pos.bet }} → {{ pos.toWin }}</div>
|
||
</div>
|
||
</div>
|
||
</v-window-item>
|
||
<v-window-item value="orders" class="detail-pane">
|
||
<div v-if="openOrderLoading" class="placeholder-pane placeholder-pane--loading">
|
||
<v-progress-circular indeterminate size="36" width="2" />
|
||
<span>{{ t('common.loading') }}</span>
|
||
</div>
|
||
<div v-else-if="marketOpenOrders.length === 0" class="placeholder-pane">
|
||
{{ t('activity.noOpenOrdersInMarket') }}
|
||
</div>
|
||
<div v-else class="orders-list">
|
||
<div v-for="ord in marketOpenOrders" :key="ord.id" class="order-row-item">
|
||
<div class="order-row-main">
|
||
<span :class="['order-side-pill', ord.side === 'Yes' ? 'side-yes' : 'side-no']">
|
||
{{ ord.actionLabel || `Buy ${ord.outcome}` }}
|
||
</span>
|
||
<span class="order-price">{{ ord.price }}</span>
|
||
<span class="order-filled">{{ ord.filled }}</span>
|
||
<span class="order-total">{{ ord.total }}</span>
|
||
</div>
|
||
<div class="order-row-actions">
|
||
<v-btn variant="text" size="small" color="error"
|
||
:disabled="cancelOrderLoading || ord.fullyFilled" @click="cancelMarketOrder(ord)">
|
||
{{ t('activity.cancelOrder') }}
|
||
</v-btn>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</v-window-item>
|
||
</v-window>
|
||
</v-card>
|
||
|
||
<!-- Order Book Section -->
|
||
<v-card v-if="!currentMarketClosed" class="order-book-card" elevation="0" rounded="lg">
|
||
<OrderBook :asks-yes="orderBookAsksYes" :bids-yes="orderBookBidsYes" :asks-no="orderBookAsksNo"
|
||
:bids-no="orderBookBidsNo" :anchor-best-bid-yes="orderBookBestBidYesCents"
|
||
:anchor-lowest-ask-yes="orderBookLowestAskYesCents" :anchor-best-bid-no="orderBookBestBidNoCents"
|
||
:anchor-lowest-ask-no="orderBookLowestAskNoCents"
|
||
:outcome-price-anchor-yes-cents="orderBookOutcomeAnchorYesCents"
|
||
:outcome-price-anchor-no-cents="orderBookOutcomeAnchorNoCents" :last-price-yes="clobLastPriceYes"
|
||
:last-price-no="clobLastPriceNo" :spread-yes="clobSpreadYes" :spread-no="clobSpreadNo"
|
||
:loading="clobLoading" :connected="clobConnected" :yes-label="yesLabel" :no-label="noLabel" />
|
||
</v-card>
|
||
|
||
<!-- Comments / Top Holders / Activity(与左侧图表、订单簿同宽) -->
|
||
<v-card class="activity-card" elevation="0" rounded="lg">
|
||
<div class="rules-pane">
|
||
<div v-if="!eventDetail?.description && !eventDetail?.resolutionSource" class="placeholder-pane">
|
||
{{ t('activity.rulesEmpty') }}
|
||
</div>
|
||
<template v-else>
|
||
<div v-if="eventDetail?.description" class="rules-section">
|
||
<h3 class="rules-title">{{ t('activity.rulesDescription') }}</h3>
|
||
<div class="rules-text" :class="{
|
||
'rules-text--multi-collapsed':
|
||
rulesDescriptionCollapsible && !rulesDescriptionExpanded,
|
||
}">
|
||
{{ eventDetail.description }}
|
||
</div>
|
||
<v-btn v-if="rulesDescriptionCollapsible" variant="text" size="small" density="compact"
|
||
class="rules-description-toggle" @click="rulesDescriptionExpanded = !rulesDescriptionExpanded">
|
||
{{
|
||
rulesDescriptionExpanded
|
||
? t('eventMarkets.collapseDescription')
|
||
: t('eventMarkets.expandDescription')
|
||
}}
|
||
</v-btn>
|
||
</div>
|
||
<div v-if="eventDetail?.resolutionSource" class="rules-section">
|
||
<h3 class="rules-title">{{ t('activity.rulesSource') }}</h3>
|
||
<a v-if="isResolutionSourceUrl" :href="eventDetail.resolutionSource" target="_blank"
|
||
rel="noopener noreferrer" class="rules-link">
|
||
{{ eventDetail.resolutionSource }}
|
||
<v-icon size="14">mdi-open-in-new</v-icon>
|
||
</a>
|
||
<div v-else class="rules-text">{{ eventDetail.resolutionSource }}</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</v-card>
|
||
</v-col>
|
||
|
||
<!-- 右侧:交易组件(固定宽度),传入当前市场以便 Split 调用拆单接口;移动端隐藏,改用底部栏+弹窗 -->
|
||
<v-col v-if="!isMobile" cols="12" class="trade-col">
|
||
<div v-if="currentMarketClosed" class="trade-sidebar market-closed-pane">
|
||
<v-icon size="44" color="grey-darken-1">mdi-lock-outline</v-icon>
|
||
<h2 class="market-closed-pane-title">{{ t('trade.marketClosedTitle') }}</h2>
|
||
<p class="market-closed-pane-desc">{{ t('trade.marketClosedDesc') }}</p>
|
||
</div>
|
||
<div v-else-if="tradeMarketPayload" class="trade-sidebar">
|
||
<TradeComponent ref="tradeComponentRef" :market="tradeMarketPayload"
|
||
:positions="tradePositionsForComponent" @merge-success="onMergeSuccess"
|
||
@split-success="onSplitSuccess" />
|
||
</div>
|
||
</v-col>
|
||
|
||
<!-- 移动端:固定底部 Yes/No 栏 + 三点菜单(Merge/Split) -->
|
||
<template v-if="isMobile && tradeMarketPayload && !currentMarketClosed">
|
||
<div class="mobile-trade-bar-spacer" aria-hidden="true"></div>
|
||
<div class="mobile-trade-bar">
|
||
<v-btn class="mobile-bar-btn mobile-bar-yes" variant="flat" rounded="sm"
|
||
@click="openSheetWithOption('yes')">
|
||
{{ yesLabel }} {{ yesPriceCents }}¢
|
||
</v-btn>
|
||
<v-btn class="mobile-bar-btn mobile-bar-no" variant="flat" rounded="sm"
|
||
@click="openSheetWithOption('no')">
|
||
{{ noLabel }} {{ noPriceCents }}¢
|
||
</v-btn>
|
||
<v-menu v-model="mobileMenuOpen" :close-on-content-click="true" location="top"
|
||
transition="scale-transition">
|
||
<template #activator="{ props: menuProps }">
|
||
<v-btn v-bind="menuProps" class="mobile-bar-more-btn" variant="flat" icon rounded="pill"
|
||
aria-label="更多操作">
|
||
<v-icon size="20">mdi-dots-horizontal</v-icon>
|
||
</v-btn>
|
||
</template>
|
||
<v-list density="compact">
|
||
<v-list-item @click="openMergeFromBar">
|
||
<v-list-item-title>{{ t('trade.merge') }}</v-list-item-title>
|
||
</v-list-item>
|
||
<v-list-item @click="openSplitFromBar">
|
||
<v-list-item-title>{{ t('trade.split') }}</v-list-item-title>
|
||
</v-list-item>
|
||
</v-list>
|
||
</v-menu>
|
||
</div>
|
||
<v-bottom-sheet v-model="tradeSheetOpen" content-class="trade-detail-trade-sheet">
|
||
<TradeComponent v-if="tradeSheetRenderContent" ref="mobileTradeComponentRef" :market="tradeMarketPayload"
|
||
:initial-option="tradeInitialOptionFromBar" :initial-tab="tradeInitialTabFromBar"
|
||
:positions="tradePositionsForComponent" embedded-in-sheet @order-success="onOrderSuccess"
|
||
@merge-success="onMergeSuccess" @split-success="onSplitSuccess" />
|
||
</v-bottom-sheet>
|
||
</template>
|
||
|
||
<!-- 从持仓点击 Sell 弹出的交易组件(桌面/移动端通用) -->
|
||
<v-dialog v-model="sellDialogOpen" max-width="420" content-class="trade-detail-sell-dialog"
|
||
transition="dialog-transition">
|
||
<TradeComponent v-if="sellDialogRenderContent" :market="tradeMarketPayload"
|
||
:initial-option="sellInitialOption" :initial-tab="'sell'" :positions="tradePositionsForComponent"
|
||
@order-success="onSellOrderSuccess" @merge-success="onMergeSuccess" @split-success="onSplitSuccess" />
|
||
</v-dialog>
|
||
</v-row>
|
||
</div>
|
||
</v-pull-to-refresh>
|
||
|
||
<!-- 已结算:结算结果固定在视口底部(滚动时仍可见) -->
|
||
<div v-if="showSettledOutcomeBar" class="trade-settled-outcome-bar" role="status">
|
||
{{ t('trade.marketClosedOutcome', { outcome: settledOutcomeLabel }) }}
|
||
</div>
|
||
|
||
<AppFooter />
|
||
</v-container>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useDisplay } from 'vuetify'
|
||
import {
|
||
createChart,
|
||
LineSeries,
|
||
LineType,
|
||
LastPriceAnimationMode,
|
||
TickMarkType,
|
||
type BarPrice,
|
||
type IChartApi,
|
||
type ISeriesApi,
|
||
type Time,
|
||
} from 'lightweight-charts'
|
||
import { toLwcData, toLwcPoint } from '../composables/useLightweightChart'
|
||
import { InPanePriceScalePrimitive } from '../composables/inPanePriceScalePrimitive'
|
||
import OrderBook from '../components/OrderBook.vue'
|
||
import TradeComponent, { type TradePositionItem } from '../components/TradeComponent.vue'
|
||
import {
|
||
findPmEvent,
|
||
getMarketId,
|
||
getClobTokenId,
|
||
getPmMarketSettledWinner,
|
||
isPmMarketClosed,
|
||
type FindPmEventParams,
|
||
type PmEventListItem,
|
||
} from '../api/event'
|
||
import { getClobWsUrl } from '../api/request'
|
||
import { useUserStore } from '../stores/user'
|
||
import { useToastStore } from '../stores/toast'
|
||
import { useLocaleStore } from '../stores/locale'
|
||
import { useAuthError } from '../composables/useAuthError'
|
||
import {
|
||
getPositionList,
|
||
mapPositionToDisplayItem,
|
||
type PositionDisplayItem,
|
||
} from '../api/position'
|
||
import {
|
||
getOrderList,
|
||
mapOrderToOpenOrderItem,
|
||
OrderStatus,
|
||
type OpenOrderDisplayItem,
|
||
} from '../api/order'
|
||
import { cancelOrder as apiCancelOrder } from '../api/order'
|
||
import type { ChartDataPoint } from '../api/chart'
|
||
import {
|
||
getPmPriceHistoryPublic,
|
||
getTimeRangeSeconds,
|
||
priceHistoryToChartData,
|
||
} from '../api/priceHistory'
|
||
import {
|
||
parseCryptoSymbolFromTicker,
|
||
formatCryptoChartPrice,
|
||
fetchCryptoChart,
|
||
subscribeCryptoRealtime,
|
||
alignCryptoChartTimeMs,
|
||
CRYPTO_CHART_TIME_STEP_MS,
|
||
CRYPTO_30S_RETENTION_MS,
|
||
} from '../api/cryptoChart'
|
||
|
||
const { t } = useI18n()
|
||
import { ClobSdk, type PriceSizePolyMsg, type TradePolyMsg } from '../../sdk/clobSocket'
|
||
|
||
/**
|
||
* 分时图服务端推送数据格式约定
|
||
*
|
||
* 图表所需单点格式(与 ECharts 时间轴一致):
|
||
* [timestamp, value]
|
||
* - timestamp: number,Unix 毫秒时间戳
|
||
* - value: number,当前概率/价格(如 0–100 的百分比)
|
||
*
|
||
* 服务端可选用两种推送方式:
|
||
*
|
||
* 1) 全量快照(切换时间粒度或首次加载时):
|
||
* { range?: string, data: [number, number][] }
|
||
* - range: 可选,'1H' | '6H' | '1D' | '1W' | '1M' | 'ALL'
|
||
* - data: 按时间升序的 [timestamp, value] 数组
|
||
*
|
||
* 2) 增量点(实时推送最新点):
|
||
* { point: [number, number] }
|
||
* - point: 单点 [timestamp, value],前端会追加并按当前 range 保留最近 N 个点
|
||
*
|
||
* 示例(WebSocket 消息体):
|
||
* 全量: { "data": [[1707206400000, 25.5], [1707210000000, 26.1], ...] }
|
||
* 增量: { "point": [1707213600000, 27.2] }
|
||
*/
|
||
export type ChartPoint = [number, number]
|
||
export type ChartSnapshot = { range?: string; data: ChartPoint[] }
|
||
export type ChartIncrement = { point: ChartPoint }
|
||
|
||
const route = useRoute()
|
||
const userStore = useUserStore()
|
||
const { formatAuthError } = useAuthError()
|
||
const localeStore = useLocaleStore()
|
||
const { mobile } = useDisplay()
|
||
const isMobile = computed(() => mobile.value)
|
||
|
||
// 详情接口 GET /PmEvent/findPmEvent 返回的数据
|
||
const eventDetail = ref<PmEventListItem | null>(null)
|
||
const detailLoading = ref(false)
|
||
const detailError = ref<string | null>(null)
|
||
|
||
function formatVolume(volume: number | undefined): string {
|
||
if (volume == null || !Number.isFinite(volume)) return '$0 Vol.'
|
||
if (volume >= 1000) return `$${(volume / 1000).toFixed(1)}k Vol.`
|
||
return `$${Math.round(volume)} Vol.`
|
||
}
|
||
|
||
function formatExpiresAt(endDate: string | undefined): string {
|
||
if (!endDate) return ''
|
||
try {
|
||
const d = new Date(endDate)
|
||
if (Number.isNaN(d.getTime())) return endDate
|
||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||
} catch {
|
||
return endDate
|
||
}
|
||
}
|
||
|
||
async function loadEventDetail() {
|
||
const idRaw = route.params.id
|
||
const idStr = String(idRaw ?? '').trim()
|
||
if (!idStr) {
|
||
detailError.value = t('error.invalidId')
|
||
eventDetail.value = null
|
||
return
|
||
}
|
||
const numId = parseInt(idStr, 10)
|
||
const isNumericId = Number.isFinite(numId) && String(numId) === idStr && numId >= 1
|
||
const params: FindPmEventParams = {
|
||
id: isNumericId ? numId : undefined,
|
||
slug: isNumericId ? undefined : idStr,
|
||
}
|
||
|
||
detailError.value = null
|
||
detailLoading.value = true
|
||
try {
|
||
const res = await findPmEvent(params, {
|
||
headers: userStore.getAuthHeaders(),
|
||
})
|
||
if (res.code === 0 || res.code === 200) {
|
||
eventDetail.value = res.data ?? null
|
||
// 单个市场详情数据打印,便于分析
|
||
const ev = eventDetail.value
|
||
console.log('[TradeDetail] 单个市场详情:', {
|
||
id: ev?.ID ?? ev?.id,
|
||
title: ev?.title,
|
||
slug: ev?.slug,
|
||
volume: ev?.volume,
|
||
endDate: ev?.endDate,
|
||
marketsCount: ev?.markets?.length ?? 0,
|
||
firstMarket: ev?.markets?.[0]
|
||
? {
|
||
question: ev.markets[0].question,
|
||
outcomePrices: ev.markets[0].outcomePrices,
|
||
marketId: ev.markets[0].marketId,
|
||
}
|
||
: null,
|
||
})
|
||
} else {
|
||
detailError.value = res.msg || t('error.loadFailed')
|
||
eventDetail.value = null
|
||
}
|
||
} catch (e) {
|
||
detailError.value = formatAuthError(e, t('error.loadFailed'))
|
||
eventDetail.value = null
|
||
} finally {
|
||
detailLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onRefresh({ done }: { done: () => void }) {
|
||
Promise.all([loadEventDetail(), loadMarketPositions(), loadMarketOpenOrders()]).finally(() =>
|
||
done(),
|
||
)
|
||
}
|
||
|
||
// 标题、成交量、到期日:仅使用接口详情(路径只带 slug/id,无 query)
|
||
const marketTitle = computed(() => eventDetail.value?.title ?? '')
|
||
const marketVolume = computed(() => {
|
||
if (eventDetail.value?.volume != null) return formatVolume(eventDetail.value.volume)
|
||
return formatVolume(undefined)
|
||
})
|
||
const marketExpiresAt = computed(() => {
|
||
if (eventDetail.value?.endDate) return formatExpiresAt(eventDetail.value.endDate)
|
||
return ''
|
||
})
|
||
const resolutionDate = computed(() => {
|
||
const s = marketExpiresAt.value
|
||
return s ? s.replace(/,?\s*\d{4}$/, '').trim() || 'Mar 31' : 'Mar 31'
|
||
})
|
||
|
||
const isResolutionSourceUrl = computed(() => {
|
||
const src = eventDetail.value?.resolutionSource
|
||
return typeof src === 'string' && (src.startsWith('http://') || src.startsWith('https://'))
|
||
})
|
||
|
||
/** 是否为加密货币类型事件(可切换 YES/NO 分时 vs 加密货币价格图) */
|
||
/** 从 event.ticker 首段解析的加密货币符号(如 bnb-updown-… → bnb);无映射则不展示切换器 */
|
||
const cryptoSymbol = computed(() => parseCryptoSymbolFromTicker(eventDetail.value?.ticker))
|
||
|
||
watch(cryptoSymbol, (sym) => {
|
||
if (!sym && chartMode.value === 'crypto') {
|
||
chartMode.value = 'yesno'
|
||
if (selectedTimeRange.value === '30S') selectedTimeRange.value = '1D'
|
||
void updateChartData()
|
||
}
|
||
})
|
||
|
||
/** 图表模式:yesno=YES/NO 分时,crypto=加密货币价格 */
|
||
const chartMode = ref<'yesno' | 'crypto'>('yesno')
|
||
|
||
/** 加密货币模式下当前价格(最新数据点) */
|
||
const currentCryptoPrice = computed(() => {
|
||
const d = data.value
|
||
const last = d.length > 0 ? d[d.length - 1] : undefined
|
||
if (last != null && chartMode.value === 'crypto') {
|
||
return formatCryptoChartPrice(last[1])
|
||
}
|
||
return '—'
|
||
})
|
||
|
||
function setChartMode(mode: 'yesno' | 'crypto') {
|
||
if (mode === 'yesno' && selectedTimeRange.value === '30S') {
|
||
selectedTimeRange.value = '1D'
|
||
} else if (mode === 'crypto') {
|
||
selectedTimeRange.value = '30S'
|
||
}
|
||
chartMode.value = mode
|
||
updateChartData()
|
||
}
|
||
|
||
/** 当前市场(用于交易组件与 Split 拆单):取接口返回的第一个市场 */
|
||
const currentMarket = computed(() => eventDetail.value?.markets?.[0] ?? null)
|
||
|
||
const currentMarketClosed = computed(() => isPmMarketClosed(currentMarket.value))
|
||
|
||
const settledOutcomeLabel = computed(() => {
|
||
const m = currentMarket.value
|
||
if (!m) return ''
|
||
const w = getPmMarketSettledWinner(m)
|
||
return w?.label ?? ''
|
||
})
|
||
|
||
/** 底部固定栏:仅在市场已关闭且能解析出结算结果文案时展示 */
|
||
const showSettledOutcomeBar = computed(
|
||
() => currentMarketClosed.value && Boolean((settledOutcomeLabel.value ?? '').trim()),
|
||
)
|
||
|
||
/** 详情页接口若含多个子市场,描述区首屏折叠 */
|
||
const isEventMultiMarket = computed(() => (eventDetail.value?.markets?.length ?? 0) > 1)
|
||
const RULES_DESC_COLLAPSE_MIN_LEN = 80
|
||
const rulesDescriptionExpanded = ref(false)
|
||
const rulesDescriptionCollapsible = computed(() => {
|
||
if (!isEventMultiMarket.value) return false
|
||
const d = eventDetail.value?.description?.trim() ?? ''
|
||
return d.length > RULES_DESC_COLLAPSE_MIN_LEN
|
||
})
|
||
|
||
// --- CLOB WebSocket 订单簿与成交 ---
|
||
// 按 token 索引区分:0 = Yes,1 = No
|
||
type OrderBookRows = { price: number; shares: number; priceRaw?: number }[]
|
||
const clobSdkRef = ref<ClobSdk | null>(null)
|
||
const orderBookByToken = ref<Record<number, { asks: OrderBookRows; bids: OrderBookRows }>>({
|
||
0: { asks: [], bids: [] },
|
||
1: { asks: [], bids: [] },
|
||
})
|
||
const clobLastPriceByToken = ref<Record<number, number | undefined>>({ 0: undefined, 1: undefined })
|
||
const clobSpreadByToken = ref<Record<number, number | undefined>>({ 0: undefined, 1: undefined })
|
||
const clobConnected = ref(false)
|
||
const clobLoading = ref(false)
|
||
/** 当前订阅的 tokenIds,用于根据 msg.m 匹配 Yes(0)/No(1) */
|
||
const clobTokenIdsRef = ref<string[]>([])
|
||
|
||
const orderBookAsksYes = computed(() => orderBookByToken.value[0]?.asks ?? [])
|
||
const orderBookBidsYes = computed(() => orderBookByToken.value[0]?.bids ?? [])
|
||
const orderBookAsksNo = computed(() => orderBookByToken.value[1]?.asks ?? [])
|
||
const orderBookBidsNo = computed(() => orderBookByToken.value[1]?.bids ?? [])
|
||
|
||
/** 订单簿 Yes 卖单最低价(分),无数据时为 0 */
|
||
const orderBookLowestAskYesCents = computed(() => {
|
||
const asks = orderBookAsksYes.value
|
||
if (!asks.length) return 0
|
||
return Math.min(...asks.map((a) => a.price))
|
||
})
|
||
/** 订单簿 No 卖单最低价(分),无数据时为 0 */
|
||
const orderBookLowestAskNoCents = computed(() => {
|
||
const asks = orderBookAsksNo.value
|
||
if (!asks.length) return 0
|
||
return Math.min(...asks.map((a) => a.price))
|
||
})
|
||
/** 订单簿 Yes 买单最高价(分),无数据时为 0;市价卖出时用于计算将收到金额 */
|
||
const orderBookBestBidYesCents = computed(() => {
|
||
const bids = orderBookBidsYes.value
|
||
if (!bids.length) return 0
|
||
return Math.max(...bids.map((b) => b.price))
|
||
})
|
||
/** 订单簿 No 买单最高价(分),无数据时为 0;市价卖出时用于计算将收到金额 */
|
||
const orderBookBestBidNoCents = computed(() => {
|
||
const bids = orderBookBidsNo.value
|
||
if (!bids.length) return 0
|
||
return Math.max(...bids.map((b) => b.price))
|
||
})
|
||
|
||
/** outcomePrices 单项 → 订单簿锚价(美分):概率 0–1 或已是美分 1–100 */
|
||
function outcomePriceToAnchorCents(raw: string | number | undefined): number {
|
||
if (raw == null || (typeof raw === 'string' && raw.trim() === '')) return 0
|
||
const p = parseFloat(String(raw))
|
||
if (!Number.isFinite(p)) return 0
|
||
if (p > 1 && p <= 100) return Math.min(99, Math.max(1, Math.round(p)))
|
||
return Math.min(99, Math.max(0, Math.round(p * 100)))
|
||
}
|
||
|
||
/** 无真实盘口时模拟 Yes 簿:锚定 outcomePrices[0] 为最优卖一 */
|
||
const orderBookOutcomeAnchorYesCents = computed(() =>
|
||
outcomePriceToAnchorCents(currentMarket.value?.outcomePrices?.[0]),
|
||
)
|
||
|
||
/** 无真实盘口时模拟 No 簿:优先 outcomePrices[1],否则与 Yes 互补(100−Yes¢) */
|
||
const orderBookOutcomeAnchorNoCents = computed(() => {
|
||
const m = currentMarket.value
|
||
const raw1 = m?.outcomePrices?.[1]
|
||
if (raw1 != null && String(raw1).trim() !== '') {
|
||
return outcomePriceToAnchorCents(raw1)
|
||
}
|
||
const y = orderBookOutcomeAnchorYesCents.value
|
||
if (y > 0) return Math.min(99, Math.max(2, 100 - y))
|
||
return outcomePriceToAnchorCents(m?.outcomePrices?.[0])
|
||
})
|
||
const clobLastPriceYes = computed(() => clobLastPriceByToken.value[0])
|
||
const clobLastPriceNo = computed(() => clobLastPriceByToken.value[1])
|
||
const clobSpreadYes = computed(() => clobSpreadByToken.value[0])
|
||
const clobSpreadNo = computed(() => clobSpreadByToken.value[1])
|
||
|
||
/** 订单簿份额接口按 6 位小数传(1_000_000 = 1 share),需除以该系数转为展示值 */
|
||
const ORDER_BOOK_SIZE_SCALE = 1_000_000
|
||
|
||
/** 接口 key p 为价格(与 price 美分同源,除以 100 为美分);priceRaw 为美元单价保留原始精度供订单簿算总价 */
|
||
function priceSizeToRows(record: Record<string, number> | undefined): OrderBookRows {
|
||
if (!record) return []
|
||
return Object.entries(record)
|
||
.filter(([, rawShares]) => rawShares > 0)
|
||
.map(([p, rawShares]) => {
|
||
const numP = parseFloat(p)
|
||
const priceCents = Math.round(numP / 100)
|
||
const priceRawDollars = Number.isFinite(numP) ? numP / 10000 : priceCents / 100
|
||
return {
|
||
price: priceCents,
|
||
shares: rawShares / ORDER_BOOK_SIZE_SCALE,
|
||
priceRaw: priceRawDollars,
|
||
}
|
||
})
|
||
}
|
||
|
||
function getTokenIndex(msg: PriceSizePolyMsg): number {
|
||
const ids = clobTokenIdsRef.value
|
||
const m = msg.m
|
||
if (m != null && ids.length >= 2) {
|
||
const idx = ids.findIndex((id) => String(id) === String(m))
|
||
if (idx === 0 || idx === 1) return idx
|
||
}
|
||
if (typeof msg.i === 'number' && (msg.i === 0 || msg.i === 1)) return msg.i
|
||
return 0
|
||
}
|
||
|
||
function applyPriceSizeAll(msg: PriceSizePolyMsg) {
|
||
const idx = getTokenIndex(msg)
|
||
const asks = priceSizeToRows(msg.s).sort((a, b) => a.price - b.price)
|
||
const bids = priceSizeToRows(msg.b).sort((a, b) => b.price - a.price)
|
||
orderBookByToken.value = {
|
||
...orderBookByToken.value,
|
||
[idx]: { asks, bids },
|
||
}
|
||
updateSpreadForToken(idx)
|
||
}
|
||
|
||
function applyPriceSizeDelta(msg: PriceSizePolyMsg) {
|
||
const idx = getTokenIndex(msg)
|
||
const mergeDelta = (
|
||
current: OrderBookRows,
|
||
delta: Record<string, number> | undefined,
|
||
asc: boolean,
|
||
) => {
|
||
const map = new Map<string, { price: number; shares: number; priceRaw: number }>()
|
||
const keyOf = (price: number, priceRaw: number) => priceRaw.toFixed(8)
|
||
current.forEach((r) => {
|
||
const pr = r.priceRaw ?? r.price / 100
|
||
map.set(keyOf(r.price, pr), { price: r.price, shares: r.shares, priceRaw: pr })
|
||
})
|
||
if (delta) {
|
||
Object.entries(delta).forEach(([p, rawShares]) => {
|
||
const numP = parseFloat(p)
|
||
const price = Math.round(numP / 100)
|
||
const priceRaw = Number.isFinite(numP) ? numP / 10000 : price / 100
|
||
const shares = rawShares / ORDER_BOOK_SIZE_SCALE
|
||
const key = keyOf(price, priceRaw)
|
||
if (shares <= 0) map.delete(key)
|
||
else map.set(key, { price, shares, priceRaw })
|
||
})
|
||
}
|
||
return Array.from(map.values()).sort((a, b) => (asc ? a.price - b.price : b.price - a.price))
|
||
}
|
||
const prev = orderBookByToken.value[idx] ?? { asks: [], bids: [] }
|
||
const asks = mergeDelta(prev.asks, msg.s, true)
|
||
const bids = mergeDelta(prev.bids, msg.b, false)
|
||
orderBookByToken.value = {
|
||
...orderBookByToken.value,
|
||
[idx]: { asks, bids },
|
||
}
|
||
updateSpreadForToken(idx)
|
||
}
|
||
|
||
function updateSpreadForToken(idx: number) {
|
||
const book = orderBookByToken.value[idx]
|
||
if (!book) return
|
||
const bestAsk = book.asks[0]?.price
|
||
const bestBid = book.bids[0]?.price
|
||
if (bestAsk != null && bestBid != null) {
|
||
clobSpreadByToken.value = { ...clobSpreadByToken.value, [idx]: bestAsk - bestBid }
|
||
}
|
||
}
|
||
|
||
function connectClob(tokenIds: string[]) {
|
||
clobSdkRef.value?.disconnect()
|
||
clobSdkRef.value = null
|
||
clobLoading.value = true
|
||
clobConnected.value = false
|
||
clobTokenIdsRef.value = tokenIds
|
||
orderBookByToken.value = { 0: { asks: [], bids: [] }, 1: { asks: [], bids: [] } }
|
||
clobLastPriceByToken.value = { 0: undefined, 1: undefined }
|
||
clobSpreadByToken.value = { 0: undefined, 1: undefined }
|
||
|
||
const options = {
|
||
url: getClobWsUrl(),
|
||
autoReconnect: true,
|
||
reconnectInterval: 2000,
|
||
}
|
||
const sdk = new ClobSdk(tokenIds, options)
|
||
|
||
sdk.onConnect(() => {
|
||
clobConnected.value = true
|
||
clobLoading.value = false
|
||
})
|
||
sdk.onDisconnect(() => {
|
||
clobConnected.value = false
|
||
})
|
||
sdk.onError(() => {
|
||
clobLoading.value = false
|
||
})
|
||
sdk.onPriceSizeAll(applyPriceSizeAll)
|
||
sdk.onPriceSizeDelta(applyPriceSizeDelta)
|
||
sdk.onTrade((msg: TradePolyMsg) => {
|
||
const priceNum = parseFloat(msg.p)
|
||
if (Number.isFinite(priceNum)) {
|
||
const priceCents = Math.round(priceNum * 100)
|
||
const side = msg.side?.toLowerCase()
|
||
const tokenIdx = side === 'buy' ? 0 : 1
|
||
clobLastPriceByToken.value = { ...clobLastPriceByToken.value, [tokenIdx]: priceCents }
|
||
}
|
||
})
|
||
|
||
clobSdkRef.value = sdk
|
||
sdk.connect()
|
||
}
|
||
|
||
function disconnectClob() {
|
||
clobSdkRef.value?.disconnect()
|
||
clobSdkRef.value = null
|
||
clobConnected.value = false
|
||
clobLoading.value = false
|
||
}
|
||
|
||
/**
|
||
* 传给 TradeComponent:yesPrice/noPrice 优先取对应 token 订单簿 **卖单最低价**(美元 0–1),
|
||
* 取不到再用当前市场的 outcomePrices。
|
||
*/
|
||
const tradeMarketPayload = computed(() => {
|
||
const m = currentMarket.value
|
||
const yC = orderBookLowestAskYesCents.value
|
||
const nC = orderBookLowestAskNoCents.value
|
||
let yesPrice = yC > 0 ? yC / 100 : 0
|
||
let noPrice = nC > 0 ? nC / 100 : 0
|
||
const bestBidYesCents = orderBookBestBidYesCents.value
|
||
const bestBidNoCents = orderBookBestBidNoCents.value
|
||
|
||
if (m) {
|
||
if (yesPrice <= 0) {
|
||
const c = outcomePriceToAnchorCents(m.outcomePrices?.[0])
|
||
if (c > 0) yesPrice = c / 100
|
||
}
|
||
if (noPrice <= 0) {
|
||
const raw1 = m.outcomePrices?.[1]
|
||
if (raw1 != null && String(raw1).trim() !== '') {
|
||
const c = outcomePriceToAnchorCents(raw1)
|
||
if (c > 0) noPrice = c / 100
|
||
} else if (yesPrice > 0) {
|
||
noPrice = Math.min(1, Math.max(0, 1 - yesPrice))
|
||
} else {
|
||
const c = outcomePriceToAnchorCents(m.outcomePrices?.[0])
|
||
if (c > 0) noPrice = c / 100
|
||
}
|
||
}
|
||
return {
|
||
marketId: getMarketId(m),
|
||
yesPrice,
|
||
noPrice,
|
||
title: m.question,
|
||
clobTokenIds: m.clobTokenIds,
|
||
outcomes: m.outcomes,
|
||
outcomePrices: m.outcomePrices,
|
||
bestBidYesCents,
|
||
bestBidNoCents,
|
||
}
|
||
}
|
||
return undefined
|
||
})
|
||
|
||
/** 移动端底部栏点击 Yes/No 时传给弹窗内 TradeComponent 的初始选项 */
|
||
const tradeInitialOptionFromBar = ref<'yes' | 'no' | undefined>(undefined)
|
||
/** 移动端弹窗初始 Tab:从持仓 Sell 打开时为 'sell',从底部栏 Yes/No 打开时为 undefined(默认 Buy) */
|
||
const tradeInitialTabFromBar = ref<'buy' | 'sell' | undefined>(undefined)
|
||
/** 移动端交易弹窗开关 */
|
||
const tradeSheetOpen = ref(false)
|
||
/** 控制底部栏内 TradeComponent 的渲染,延迟挂载以避免 slot 竞态警告 */
|
||
const tradeSheetRenderContent = ref(false)
|
||
/** 从三点菜单点击 Merge/Split 时待打开的弹窗,等 TradeComponent 挂载后执行 */
|
||
const pendingMergeSplitDialog = ref<'merge' | 'split' | null>(null)
|
||
/** 从持仓 Sell 打开的弹窗 */
|
||
const sellDialogOpen = ref(false)
|
||
/** 控制 Sell 弹窗内 TradeComponent 的渲染,延迟卸载以避免 emitsOptions 竞态 */
|
||
const sellDialogRenderContent = ref(false)
|
||
/** 从持仓 Sell 时预选的 Yes/No */
|
||
const sellInitialOption = ref<'yes' | 'no'>('yes')
|
||
/** 移动端三点菜单开关 */
|
||
const mobileMenuOpen = ref(false)
|
||
/** 桌面端 TradeComponent 引用(Merge/Split) */
|
||
const tradeComponentRef = ref<InstanceType<typeof TradeComponent> | null>(null)
|
||
/** 移动端弹窗内 TradeComponent 引用 */
|
||
const mobileTradeComponentRef = ref<InstanceType<typeof TradeComponent> | null>(null)
|
||
|
||
const yesPriceCents = computed(() =>
|
||
tradeMarketPayload.value ? Math.round(tradeMarketPayload.value.yesPrice * 100) : 0,
|
||
)
|
||
const noPriceCents = computed(() =>
|
||
tradeMarketPayload.value ? Math.round(tradeMarketPayload.value.noPrice * 100) : 0,
|
||
)
|
||
const yesLabel = computed(() => currentMarket.value?.outcomes?.[0] ?? 'Yes')
|
||
const noLabel = computed(() => currentMarket.value?.outcomes?.[1] ?? 'No')
|
||
|
||
function openSheetWithOption(side: 'yes' | 'no') {
|
||
tradeInitialOptionFromBar.value = side
|
||
tradeInitialTabFromBar.value = undefined
|
||
tradeSheetOpen.value = true
|
||
}
|
||
|
||
function openMergeFromBar() {
|
||
mobileMenuOpen.value = false
|
||
pendingMergeSplitDialog.value = 'merge'
|
||
tradeSheetOpen.value = true
|
||
}
|
||
|
||
function openSplitFromBar() {
|
||
mobileMenuOpen.value = false
|
||
pendingMergeSplitDialog.value = 'split'
|
||
tradeSheetOpen.value = true
|
||
}
|
||
|
||
const toastStore = useToastStore()
|
||
function onOrderSuccess() {
|
||
tradeSheetOpen.value = false
|
||
toastStore.show(t('toast.orderSuccess'))
|
||
loadMarketPositions()
|
||
loadMarketOpenOrders()
|
||
}
|
||
|
||
/** 合并成功后刷新持仓(根据 tokenId 更新本地持仓数据) */
|
||
function onMergeSuccess() {
|
||
toastStore.show(t('toast.mergeSuccess'))
|
||
loadMarketPositions()
|
||
}
|
||
|
||
/** 拆分成功后刷新持仓 */
|
||
function onSplitSuccess() {
|
||
loadMarketPositions()
|
||
}
|
||
|
||
/** 从持仓项点击 Sell:弹出交易组件并切到 Sell、对应 Yes/No。持仓类型用 outcome 字段与 noLabel 匹配。 */
|
||
function openSellFromPosition(pos: PositionDisplayItem) {
|
||
if (pos.availableSharesNum == null || pos.availableSharesNum <= 0) {
|
||
toastStore.show(t('activity.noAvailableSharesToSell'), 'warning')
|
||
return
|
||
}
|
||
const option = pos.outcome === noLabel.value ? 'no' : 'yes'
|
||
if (isMobile.value) {
|
||
tradeInitialOptionFromBar.value = option
|
||
tradeInitialTabFromBar.value = 'sell'
|
||
tradeSheetOpen.value = true
|
||
} else {
|
||
sellInitialOption.value = option
|
||
sellDialogOpen.value = true
|
||
}
|
||
}
|
||
|
||
function onSellOrderSuccess() {
|
||
sellDialogOpen.value = false
|
||
onOrderSuccess()
|
||
}
|
||
|
||
// 延迟卸载 Sell 弹窗内容,等 dialog transition 完成,避免 emitsOptions 竞态
|
||
let sellDialogUnmountTimer: ReturnType<typeof setTimeout> | undefined
|
||
watch(
|
||
sellDialogOpen,
|
||
(open) => {
|
||
if (open) {
|
||
if (sellDialogUnmountTimer) {
|
||
clearTimeout(sellDialogUnmountTimer)
|
||
sellDialogUnmountTimer = undefined
|
||
}
|
||
sellDialogRenderContent.value = true
|
||
} else {
|
||
sellDialogUnmountTimer = setTimeout(() => {
|
||
sellDialogRenderContent.value = false
|
||
sellDialogUnmountTimer = undefined
|
||
}, 350)
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
onUnmounted(() => {
|
||
if (sellDialogUnmountTimer) clearTimeout(sellDialogUnmountTimer)
|
||
if (tradeSheetUnmountTimer) clearTimeout(tradeSheetUnmountTimer)
|
||
if (tradeSheetMountTimer) clearTimeout(tradeSheetMountTimer)
|
||
})
|
||
|
||
// 底部栏 TradeComponent 延迟挂载/卸载,避免 transition 期间 slot 竞态
|
||
let tradeSheetUnmountTimer: ReturnType<typeof setTimeout> | undefined
|
||
let tradeSheetMountTimer: ReturnType<typeof setTimeout> | undefined
|
||
watch(
|
||
tradeSheetOpen,
|
||
(open) => {
|
||
if (open) {
|
||
if (tradeSheetUnmountTimer) {
|
||
clearTimeout(tradeSheetUnmountTimer)
|
||
tradeSheetUnmountTimer = undefined
|
||
}
|
||
if (tradeSheetMountTimer) clearTimeout(tradeSheetMountTimer)
|
||
tradeSheetMountTimer = setTimeout(() => {
|
||
tradeSheetRenderContent.value = true
|
||
tradeSheetMountTimer = undefined
|
||
nextTick(() => {
|
||
const pending = pendingMergeSplitDialog.value
|
||
if (pending === 'merge') {
|
||
pendingMergeSplitDialog.value = null
|
||
mobileTradeComponentRef.value?.openMergeDialog?.()
|
||
} else if (pending === 'split') {
|
||
pendingMergeSplitDialog.value = null
|
||
mobileTradeComponentRef.value?.openSplitDialog?.()
|
||
}
|
||
})
|
||
}, 50)
|
||
} else {
|
||
pendingMergeSplitDialog.value = null
|
||
if (tradeSheetMountTimer) {
|
||
clearTimeout(tradeSheetMountTimer)
|
||
tradeSheetMountTimer = undefined
|
||
}
|
||
tradeSheetUnmountTimer = setTimeout(() => {
|
||
tradeSheetRenderContent.value = false
|
||
tradeSheetUnmountTimer = undefined
|
||
}, 350)
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
// 当前市场的 marketID,用于筛选持仓和订单
|
||
const currentMarketId = computed(() => getMarketId(currentMarket.value))
|
||
|
||
// 持仓列表(仅当前市场)
|
||
const marketPositions = ref<PositionDisplayItem[]>([])
|
||
const positionLoading = ref(false)
|
||
|
||
/** 过滤掉份额为 0 的持仓项 */
|
||
const marketPositionsFiltered = computed(() =>
|
||
marketPositions.value.filter((p) => {
|
||
const n = parseFloat(p.shares?.replace(/[^0-9.]/g, '') ?? '')
|
||
return Number.isFinite(n) && n > 0
|
||
}),
|
||
)
|
||
|
||
/** 转为 TradeComponent 所需的 TradePositionItem[];sharesNum 用 available(最大可卖份额),available > 0 表示有订单可卖;outcome 与 noLabel 匹配则为 No */
|
||
const tradePositionsForComponent = computed<TradePositionItem[]>(() => {
|
||
const no = noLabel.value
|
||
return marketPositionsFiltered.value.map((p) => ({
|
||
id: p.id,
|
||
outcomeWord: (p.outcome === no ? 'No' : 'Yes') as 'Yes' | 'No',
|
||
shares: p.shares,
|
||
sharesNum:
|
||
p.availableSharesNum != null && Number.isFinite(p.availableSharesNum)
|
||
? p.availableSharesNum
|
||
: parseFloat(p.shares?.replace(/[^0-9.]/g, '')) || undefined,
|
||
locked: p.locked,
|
||
}))
|
||
})
|
||
|
||
async function loadMarketPositions() {
|
||
const marketID = currentMarketId.value
|
||
if (!marketID) {
|
||
marketPositions.value = []
|
||
return
|
||
}
|
||
const headers = userStore.getAuthHeaders()
|
||
if (!headers) {
|
||
marketPositions.value = []
|
||
return
|
||
}
|
||
const uid = userStore.user?.id ?? userStore.user?.ID
|
||
const userID = uid != null ? Number(uid) : undefined
|
||
if (!userID || !Number.isFinite(userID)) {
|
||
marketPositions.value = []
|
||
return
|
||
}
|
||
positionLoading.value = true
|
||
try {
|
||
const res = await getPositionList({ page: 1, pageSize: 50, marketID, userID }, { headers })
|
||
if (res.code === 0 || res.code === 200) {
|
||
marketPositions.value = res.data?.list?.map(mapPositionToDisplayItem) ?? []
|
||
} else {
|
||
marketPositions.value = []
|
||
}
|
||
} catch {
|
||
marketPositions.value = []
|
||
} finally {
|
||
positionLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 限价未成交订单(仅当前市场)
|
||
const marketOpenOrders = ref<OpenOrderDisplayItem[]>([])
|
||
const openOrderLoading = ref(false)
|
||
|
||
async function loadMarketOpenOrders() {
|
||
const marketID = currentMarketId.value
|
||
if (!marketID) {
|
||
marketOpenOrders.value = []
|
||
return
|
||
}
|
||
const headers = userStore.getAuthHeaders()
|
||
if (!headers) {
|
||
marketOpenOrders.value = []
|
||
return
|
||
}
|
||
const uid = userStore.user?.id ?? userStore.user?.ID
|
||
const userID = uid != null ? Number(uid) : undefined
|
||
if (!userID || !Number.isFinite(userID)) {
|
||
marketOpenOrders.value = []
|
||
return
|
||
}
|
||
openOrderLoading.value = true
|
||
try {
|
||
const res = await getOrderList(
|
||
{ page: 1, pageSize: 50, marketID, userID, status: OrderStatus.Live },
|
||
{ headers },
|
||
)
|
||
if (res.code === 0 || res.code === 200) {
|
||
const list = res.data?.list ?? []
|
||
const liveOnly = list.filter((o) => (o.status ?? 1) === OrderStatus.Live)
|
||
marketOpenOrders.value = liveOnly.map(mapOrderToOpenOrderItem)
|
||
} else {
|
||
marketOpenOrders.value = []
|
||
}
|
||
} catch {
|
||
marketOpenOrders.value = []
|
||
} finally {
|
||
openOrderLoading.value = false
|
||
}
|
||
}
|
||
|
||
const cancelOrderLoading = ref(false)
|
||
|
||
async function cancelMarketOrder(ord: OpenOrderDisplayItem) {
|
||
if (ord.fullyFilled) return
|
||
const orderID = ord.orderID ?? 0
|
||
const tokenID = ord.tokenID ?? ''
|
||
const uid = userStore.user?.id ?? userStore.user?.ID
|
||
const userID = uid != null ? Number(uid) : 0
|
||
if (!Number.isFinite(userID) || userID <= 0 || !tokenID) return
|
||
const headers = userStore.getAuthHeaders()
|
||
if (!headers) return
|
||
cancelOrderLoading.value = true
|
||
try {
|
||
const res = await apiCancelOrder({ orderID, tokenID, userID }, { headers })
|
||
if (res.code === 0 || res.code === 200) {
|
||
marketOpenOrders.value = marketOpenOrders.value.filter((o) => o.id !== ord.id)
|
||
userStore.fetchUsdcBalance()
|
||
}
|
||
} finally {
|
||
cancelOrderLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 持仓/限价 tab(订单簿上方)
|
||
const positionsOrdersTab = ref<'positions' | 'orders'>('positions')
|
||
|
||
// 切换持仓/限价 tab 或市场变化时加载
|
||
watch(
|
||
[() => positionsOrdersTab.value, currentMarketId],
|
||
([tab]) => {
|
||
if (tab === 'positions') loadMarketPositions()
|
||
else if (tab === 'orders') loadMarketOpenOrders()
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
// Rules(仅保留 Rules 面板)
|
||
|
||
// 时间粒度
|
||
const selectedTimeRange = ref('1D')
|
||
const timeRangesYesno = [
|
||
{ label: '1H', value: '1H' },
|
||
{ label: '6H', value: '6H' },
|
||
{ label: '1D', value: '1D' },
|
||
{ label: '1W', value: '1W' },
|
||
{ label: '1M', value: '1M' },
|
||
{ label: 'ALL', value: 'ALL' },
|
||
]
|
||
const timeRangesCrypto = [{ label: '30S', value: '30S' }, ...timeRangesYesno]
|
||
const timeRanges = computed(() =>
|
||
chartMode.value === 'crypto' ? timeRangesCrypto : timeRangesYesno,
|
||
)
|
||
|
||
const chartContainerRef = ref<HTMLElement | null>(null)
|
||
const data = ref<[number, number][]>([])
|
||
/** Yes/No 折线图:接口返回的完整数据(time 已转 ms),用于分时筛选 */
|
||
const rawChartData = ref<ChartDataPoint[]>([])
|
||
const cryptoChartLoading = ref(false)
|
||
const chartYesNoLoading = ref(false)
|
||
let chartInstance: IChartApi | null = null
|
||
let chartSeries: ISeriesApi<'Line'> | null = null
|
||
/** 主图内自绘 Y 轴刻度(不占用右侧价轴条带) */
|
||
let inPanePriceScale: InPanePriceScalePrimitive | null = null
|
||
|
||
const currentChance = computed(() => {
|
||
const ev = eventDetail.value
|
||
const market = ev?.markets?.[0]
|
||
if (market?.outcomePrices?.[0] != null) {
|
||
const p = parseFloat(String(market.outcomePrices[0]))
|
||
if (Number.isFinite(p)) return Math.min(100, Math.max(0, Math.round(p * 100)))
|
||
}
|
||
const d = data.value
|
||
const last = d.length > 0 ? d[d.length - 1] : undefined
|
||
if (last != null) return last[1]
|
||
return 0
|
||
})
|
||
|
||
const lineColor = '#2563eb'
|
||
|
||
/** 叠加价格轴 ID:曲线绑定在 overlay scale,主图占满宽;Y 轴数字由 InPanePriceScalePrimitive 画在图内 */
|
||
const CHART_OVERLAY_PRICE_SCALE_ID = 'overlay-price'
|
||
/** 右侧留白(px):fitContent 时压缩可用宽度,线头略离开物理右缘,lastPrice 水波能露出一半且不依赖超大 rightOffset(bars) */
|
||
const CHART_RIPPLE_RIGHT_PAD_PX = 0
|
||
|
||
function ensureChartSeries() {
|
||
if (!chartInstance || !chartContainerRef.value) return
|
||
if (chartSeries) {
|
||
if (inPanePriceScale) {
|
||
chartSeries.detachPrimitive(inPanePriceScale)
|
||
inPanePriceScale = null
|
||
}
|
||
chartInstance.removeSeries(chartSeries)
|
||
chartSeries = null
|
||
}
|
||
const isCrypto = chartMode.value === 'crypto'
|
||
chartSeries = chartInstance.addSeries(LineSeries, {
|
||
color: lineColor,
|
||
lineWidth: 2,
|
||
lineType: LineType.Curved,
|
||
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
|
||
crosshairMarkerVisible: true,
|
||
lastValueVisible: true,
|
||
priceFormat: isCrypto
|
||
? {
|
||
type: 'custom',
|
||
minMove: 1e-10,
|
||
formatter: (priceValue: BarPrice) => formatCryptoChartPrice(priceValue as number),
|
||
}
|
||
: { type: 'percent', precision: 1 },
|
||
priceScaleId: CHART_OVERLAY_PRICE_SCALE_ID,
|
||
})
|
||
chartInstance.priceScale(CHART_OVERLAY_PRICE_SCALE_ID).applyOptions({
|
||
borderVisible: false,
|
||
scaleMargins: { top: 0.1, bottom: 0.1 },
|
||
})
|
||
inPanePriceScale = new InPanePriceScalePrimitive({
|
||
textColor: 'rgba(107, 114, 128, 0.6)',
|
||
side: 'right',
|
||
tickSpacingPx: isCrypto ? 40 : 48,
|
||
fontSizePx: 9,
|
||
sideMarginPx: 6,
|
||
})
|
||
chartSeries.attachPrimitive(inPanePriceScale)
|
||
applyCryptoChartTimeScaleOptions()
|
||
}
|
||
|
||
function setChartData(chartData: [number, number][]) {
|
||
if (!chartSeries) return
|
||
chartSeries.setData(toLwcData(chartData))
|
||
}
|
||
|
||
function initChart() {
|
||
if (!chartContainerRef.value) return
|
||
data.value = []
|
||
const el = chartContainerRef.value
|
||
chartInstance = createChart(el, {
|
||
width: el.clientWidth,
|
||
height: el.clientHeight || 320,
|
||
layout: {
|
||
background: { color: '#ffffff' },
|
||
textColor: '#6b7280',
|
||
fontFamily: 'inherit',
|
||
fontSize: 9,
|
||
attributionLogo: false,
|
||
},
|
||
grid: { vertLines: { color: '#f3f4f6' }, horzLines: { color: '#f3f4f6' } },
|
||
leftPriceScale: { visible: false },
|
||
// 关闭右侧价轴条带;Y 轴数值由 InPanePriceScalePrimitive 绘在主图内
|
||
rightPriceScale: { visible: false },
|
||
overlayPriceScales: {
|
||
borderVisible: false,
|
||
scaleMargins: { top: 0.1, bottom: 0.1 },
|
||
},
|
||
timeScale: {
|
||
borderColor: '#e5e7eb',
|
||
timeVisible: true,
|
||
secondsVisible: false,
|
||
rightOffsetPixels: CHART_RIPPLE_RIGHT_PAD_PX,
|
||
},
|
||
handleScroll: false,
|
||
handleScale: false,
|
||
crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } },
|
||
})
|
||
ensureChartSeries()
|
||
}
|
||
|
||
/** 从 GET /pmPriceHistory/getPmPriceHistoryPublic 拉取价格历史,market 传 YES 对应的 clobTokenId */
|
||
async function loadChartFromApi(marketParam: string, range: string): Promise<ChartDataPoint[]> {
|
||
const ev = eventDetail.value
|
||
const timeRange = getTimeRangeSeconds(
|
||
range,
|
||
ev ? { startDate: ev.startDate, endDate: ev.endDate } : undefined,
|
||
)
|
||
// 增加每次请求的 points 数量,避免图表在较长时间粒度下出现“最新数据提前被截断”
|
||
const pageSizeByRange: Record<string, number> = {
|
||
'1H': 200,
|
||
'6H': 600,
|
||
'1D': 1200,
|
||
'1W': 2000,
|
||
'1M': 2000,
|
||
ALL: 2500,
|
||
}
|
||
const pageSize = pageSizeByRange[range] ?? 500
|
||
const res = await getPmPriceHistoryPublic({
|
||
market: marketParam,
|
||
page: 1,
|
||
pageSize,
|
||
...(timeRange && { startTs: timeRange.startTs, endTs: timeRange.endTs }),
|
||
})
|
||
const list = res.data?.list ?? []
|
||
return priceHistoryToChartData(list)
|
||
}
|
||
|
||
const MINUTE_MS = 60 * 1000
|
||
let cryptoWsUnsubscribe: (() => void) | null = null
|
||
/** 防止重叠的 updateChartData:旧请求若在晚请求已 subscribe 之后才结束,会再挂一层 WS/hold 定时器,插值越来越快 */
|
||
let cryptoLoadGeneration = 0
|
||
let cryptoScrollToRtRaf = 0
|
||
|
||
/** 每帧最多跟一次盘,避免插值过密时线头跑出视口(shiftVisibleRangeOnNewBar 对 update 新点偶发不跟) */
|
||
function scheduleCryptoScrollToRealtime() {
|
||
if (!chartInstance || chartMode.value !== 'crypto') return
|
||
if (cryptoScrollToRtRaf) return
|
||
cryptoScrollToRtRaf = requestAnimationFrame(() => {
|
||
cryptoScrollToRtRaf = 0
|
||
chartInstance?.timeScale().scrollToRealTime()
|
||
})
|
||
}
|
||
|
||
/** 加密货币细粒度推送:显示到秒+毫秒;新点推入时视窗右移并每帧 scrollToRealTime 咬住线头 */
|
||
function applyCryptoChartTimeScaleOptions() {
|
||
if (!chartInstance) return
|
||
if (chartMode.value === 'crypto') {
|
||
chartInstance.applyOptions({
|
||
timeScale: {
|
||
rightOffsetPixels: CHART_RIPPLE_RIGHT_PAD_PX,
|
||
secondsVisible: true,
|
||
shiftVisibleRangeOnNewBar: true,
|
||
tickMarkFormatter: (time: Time, tickMarkType: TickMarkType) => {
|
||
if (typeof time !== 'number') return null
|
||
if (tickMarkType !== TickMarkType.TimeWithSeconds) return null
|
||
const d = new Date(time * 1000)
|
||
const pad2 = (n: number) => String(n).padStart(2, '0')
|
||
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}`
|
||
},
|
||
},
|
||
})
|
||
} else {
|
||
chartInstance.applyOptions({
|
||
timeScale: {
|
||
rightOffsetPixels: CHART_RIPPLE_RIGHT_PAD_PX,
|
||
secondsVisible: false,
|
||
shiftVisibleRangeOnNewBar: true,
|
||
tickMarkFormatter: () => null,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
function applyCryptoRealtimePoint(point: [number, number]) {
|
||
if (chartMode.value !== 'crypto') return
|
||
|
||
const list = [...data.value]
|
||
let ts = alignCryptoChartTimeMs(point[0])
|
||
const price = point[1]
|
||
const prevLastT = list.length > 0 ? list[list.length - 1]![0] : null
|
||
// 本地补点用 now,成交用 Binance T;T 可能略早于上一根 → update() 报 Cannot update oldest data
|
||
if (prevLastT != null && ts < prevLastT) {
|
||
ts = alignCryptoChartTimeMs(prevLastT + CRYPTO_CHART_TIME_STEP_MS)
|
||
}
|
||
const range = selectedTimeRange.value
|
||
let useIncrementalUpdate = false
|
||
let updatePoint: [number, number] | null = null
|
||
|
||
if (range === '30S') {
|
||
const cutoff = Date.now() - CRYPTO_30S_RETENTION_MS
|
||
if (prevLastT != null && ts === prevLastT) {
|
||
list[list.length - 1] = [ts, price]
|
||
} else {
|
||
list.push([ts, price])
|
||
}
|
||
const filtered = list.filter(([t]) => t >= cutoff).sort((a, b) => a[0] - b[0])
|
||
data.value = filtered
|
||
// 仅当未过滤掉任何点时可用 update 实现平滑动画
|
||
useIncrementalUpdate = filtered.length === list.length
|
||
if (useIncrementalUpdate) updatePoint = [ts, price]
|
||
} else {
|
||
const last = list[list.length - 1]
|
||
const minuteStart = Math.floor(ts / MINUTE_MS) * MINUTE_MS
|
||
const sameMinute = last && Math.floor(last[0] / MINUTE_MS) === Math.floor(ts / MINUTE_MS)
|
||
const baseMax =
|
||
{ '1H': 60, '6H': 360, '1D': 1440, '1W': 1680, '1M': 43200, ALL: 10080 }[range] ?? 60
|
||
const extra = Math.min(200, Math.round(baseMax * 0.1))
|
||
const max = baseMax + extra
|
||
if (sameMinute) {
|
||
list[list.length - 1] = [last![0], price]
|
||
data.value = list.slice(-max)
|
||
useIncrementalUpdate = true
|
||
updatePoint = [last![0], price]
|
||
} else {
|
||
list.push([minuteStart, price])
|
||
const sliced = list.slice(-max)
|
||
data.value = sliced
|
||
// 仅当未从头部裁剪时可用 update,否则需全量 setData
|
||
useIncrementalUpdate = sliced.length === list.length
|
||
if (useIncrementalUpdate) updatePoint = [minuteStart, price]
|
||
}
|
||
}
|
||
|
||
if (chartSeries && useIncrementalUpdate && updatePoint) {
|
||
chartSeries.update(toLwcPoint(updatePoint))
|
||
} else {
|
||
setChartData(data.value)
|
||
}
|
||
scheduleCryptoScrollToRealtime()
|
||
}
|
||
|
||
async function updateChartData() {
|
||
if (chartMode.value === 'crypto') {
|
||
const sym = cryptoSymbol.value
|
||
if (sym) {
|
||
const gen = ++cryptoLoadGeneration
|
||
cryptoWsUnsubscribe?.()
|
||
cryptoWsUnsubscribe = null
|
||
cryptoChartLoading.value = true
|
||
try {
|
||
const res = await fetchCryptoChart({
|
||
symbol: sym,
|
||
range: selectedTimeRange.value,
|
||
})
|
||
if (gen !== cryptoLoadGeneration || chartMode.value !== 'crypto') {
|
||
return
|
||
}
|
||
data.value = (res.data ?? []) as [number, number][]
|
||
ensureChartSeries()
|
||
setChartData(data.value)
|
||
nextTick(() => scheduleCryptoScrollToRealtime())
|
||
cryptoWsUnsubscribe = subscribeCryptoRealtime(sym, applyCryptoRealtimePoint)
|
||
} finally {
|
||
if (gen === cryptoLoadGeneration) {
|
||
cryptoChartLoading.value = false
|
||
}
|
||
}
|
||
return
|
||
}
|
||
chartMode.value = 'yesno'
|
||
if (selectedTimeRange.value === '30S') selectedTimeRange.value = '1D'
|
||
}
|
||
cryptoLoadGeneration++
|
||
cryptoWsUnsubscribe?.()
|
||
cryptoWsUnsubscribe = null
|
||
chartYesNoLoading.value = true
|
||
try {
|
||
const yesTokenId = clobTokenIds.value[0]
|
||
const points = yesTokenId ? await loadChartFromApi(yesTokenId, selectedTimeRange.value) : []
|
||
rawChartData.value = points
|
||
data.value = points
|
||
ensureChartSeries()
|
||
setChartData(data.value)
|
||
} finally {
|
||
chartYesNoLoading.value = false
|
||
}
|
||
}
|
||
|
||
function selectTimeRange(range: string) {
|
||
selectedTimeRange.value = range
|
||
}
|
||
|
||
watch(selectedTimeRange, () => updateChartData())
|
||
|
||
// CLOB:当有 market 且存在 clobTokenIds 时连接(使用 Yes/No token ID)
|
||
const clobTokenIds = computed(() => {
|
||
const m = currentMarket.value
|
||
if (!m?.clobTokenIds?.length) return []
|
||
const yes = getClobTokenId(m, 0)
|
||
const no = getClobTokenId(m, 1)
|
||
return [yes, no].filter((id): id is string => !!id)
|
||
})
|
||
watch(
|
||
clobTokenIds,
|
||
(tokenIds) => {
|
||
if (tokenIds.length > 0) {
|
||
const payload = tradeMarketPayload.value
|
||
if (payload?.yesPrice != null) {
|
||
clobLastPriceByToken.value = {
|
||
...clobLastPriceByToken.value,
|
||
0: Math.round(payload.yesPrice * 100),
|
||
}
|
||
}
|
||
connectClob(tokenIds)
|
||
} else {
|
||
disconnectClob()
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
const handleResize = () => {
|
||
if (!chartInstance || !chartContainerRef.value) return
|
||
const el = chartContainerRef.value
|
||
chartInstance.resize(el.clientWidth, el.clientHeight || 320)
|
||
}
|
||
|
||
watch(
|
||
() => route.params.id,
|
||
() => {
|
||
rulesDescriptionExpanded.value = false
|
||
loadEventDetail()
|
||
},
|
||
{ immediate: false },
|
||
)
|
||
|
||
// 内容显示时初始化图表(chart 容器在 v-else 内,loading 时不存在)
|
||
watch(
|
||
() => [detailLoading.value, eventDetail.value] as const,
|
||
([loading, detail]) => {
|
||
if (loading) {
|
||
chartInstance?.remove()
|
||
chartInstance = null
|
||
chartSeries = null
|
||
return
|
||
}
|
||
if (detail) {
|
||
nextTick(() => {
|
||
initChart()
|
||
updateChartData()
|
||
})
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
// 监听语言切换,语言变化时重新加载数据
|
||
watch(
|
||
() => localeStore.currentLocale,
|
||
() => {
|
||
loadEventDetail()
|
||
loadMarketPositions()
|
||
},
|
||
)
|
||
|
||
// 订阅 position_update,收到当前市场的推送时刷新持仓
|
||
const unsubscribePositionUpdate = userStore.onPositionUpdate((data) => {
|
||
const marketID = data.marketID ?? (data as Record<string, unknown>).market_id
|
||
if (marketID && String(marketID) === String(currentMarketId.value)) {
|
||
loadMarketPositions()
|
||
}
|
||
})
|
||
|
||
onMounted(() => {
|
||
loadEventDetail()
|
||
window.addEventListener('resize', handleResize)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
unsubscribePositionUpdate()
|
||
cryptoWsUnsubscribe?.()
|
||
cryptoWsUnsubscribe = null
|
||
if (cryptoScrollToRtRaf) {
|
||
cancelAnimationFrame(cryptoScrollToRtRaf)
|
||
cryptoScrollToRtRaf = 0
|
||
}
|
||
window.removeEventListener('resize', handleResize)
|
||
chartInstance?.remove()
|
||
chartInstance = null
|
||
chartSeries = null
|
||
inPanePriceScale = null
|
||
disconnectClob()
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.trade-detail-container {
|
||
padding: 24px;
|
||
min-height: 100vh;
|
||
box-sizing: border-box;
|
||
max-width: 1440px;
|
||
margin: 0 auto;
|
||
margin-top: $header-height;
|
||
}
|
||
|
||
/* 底部固定展示结算结果时,为内容留出空间(高度与 .trade-settled-outcome-bar 一致) */
|
||
.trade-detail-container.trade-detail-container--settled-bar {
|
||
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||
}
|
||
|
||
.trade-settled-outcome-bar {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 101;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
text-align: center;
|
||
min-height: 52px;
|
||
padding: 12px 20px;
|
||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||
background: #ffffff;
|
||
border-top: 1px solid #e7e7e7;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #111827;
|
||
line-height: 1.35;
|
||
box-shadow: 0 -6px 16px rgba(0, 0, 0, 0.06);
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.trade-detail-pull-refresh {
|
||
width: 100%;
|
||
min-height: 100%;
|
||
}
|
||
|
||
.trade-detail-pull-refresh-inner {
|
||
min-height: 100%;
|
||
}
|
||
|
||
.trade-detail-loading-card {
|
||
padding: 48px;
|
||
text-align: center;
|
||
min-height: 200px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.trade-detail-loading-placeholder {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.trade-detail-loading-placeholder p {
|
||
margin: 0;
|
||
color: #6b7280;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.back-btn {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.trade-detail-card {
|
||
padding: 32px;
|
||
background-color: #ffffff;
|
||
border-radius: 12px;
|
||
border: 1px solid #e7e7e7;
|
||
}
|
||
|
||
.trade-title {
|
||
font-size: 1.5rem;
|
||
font-weight: 600;
|
||
color: #000000;
|
||
margin-bottom: 24px;
|
||
text-align: center;
|
||
}
|
||
|
||
.market-image-container {
|
||
display: flex;
|
||
justify-content: center;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.market-image {
|
||
border: 2px solid #e7e7e7;
|
||
}
|
||
|
||
.chance-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.progress-bar {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.chance-value {
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
color: #000000;
|
||
}
|
||
|
||
.chance-label {
|
||
font-size: 16px;
|
||
font-weight: normal;
|
||
color: #808080;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.options-section {
|
||
display: flex;
|
||
gap: 16px;
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.option-yes {
|
||
flex: 1;
|
||
background-color: #b8e0b8;
|
||
height: 56px;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.option-text-yes {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #006600;
|
||
}
|
||
|
||
.option-no {
|
||
flex: 1;
|
||
background-color: #f0b8b8;
|
||
height: 56px;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.option-text-no {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #cc0000;
|
||
}
|
||
|
||
.market-info-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
margin-bottom: 32px;
|
||
padding: 24px;
|
||
background-color: #f9f9f9;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.info-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.info-label {
|
||
font-size: 14px;
|
||
font-weight: normal;
|
||
color: #808080;
|
||
}
|
||
|
||
.info-value {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #000000;
|
||
}
|
||
|
||
.description-section {
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
.description-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #000000;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.description-text {
|
||
font-size: 14px;
|
||
font-weight: normal;
|
||
color: #333333;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.action-buttons {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.action-btn {
|
||
height: 56px;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Polymarket 样式分时图卡片(扁平化) */
|
||
.chart-card.polymarket-chart {
|
||
margin-top: 0;
|
||
padding: 20px 24px 16px;
|
||
background-color: #ffffff;
|
||
border: 1px solid #e7e7e7;
|
||
border-radius: 12px;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.chart-header {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.chart-title {
|
||
font-size: 1.25rem;
|
||
font-weight: 600;
|
||
color: #111827;
|
||
margin: 0 0 12px 0;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.chart-error {
|
||
font-size: 0.875rem;
|
||
color: #dc2626;
|
||
margin: 0 0 8px 0;
|
||
}
|
||
|
||
.chart-controls-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.date-pill {
|
||
background-color: #111827 !important;
|
||
color: #fff !important;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
text-transform: none;
|
||
}
|
||
|
||
.chart-chance {
|
||
font-size: 1.5rem;
|
||
font-weight: 700;
|
||
color: #2563eb;
|
||
}
|
||
|
||
.chart-wrapper {
|
||
position: relative;
|
||
width: 100%;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.chart-loading-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(255, 255, 255, 0.6);
|
||
z-index: 2;
|
||
}
|
||
|
||
.chart-mode-toggle {
|
||
height: 26px;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.chart-mode-toggle .v-btn.active {
|
||
background: rgb(var(--v-theme-primary));
|
||
color: rgb(var(--v-theme-on-primary));
|
||
}
|
||
|
||
.chart-mode-crypto-btn {
|
||
min-width: 44px;
|
||
padding: 0 10px;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.chart-crypto-ticker-label {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
line-height: 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;
|
||
}
|
||
|
||
/* Order Book Card Styles(扁平化) */
|
||
.positions-orders-card {
|
||
margin-top: 16px;
|
||
border: 1px solid #e5e7eb;
|
||
overflow: hidden;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.positions-orders-tabs {
|
||
border-bottom: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.positions-orders-tabs :deep(.v-tab) {
|
||
text-transform: none;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.positions-orders-window {
|
||
overflow: visible;
|
||
}
|
||
|
||
.positions-orders-card .detail-pane {
|
||
padding: 16px;
|
||
}
|
||
|
||
.order-book-card {
|
||
margin-top: 16px;
|
||
padding: 0;
|
||
background-color: #ffffff;
|
||
border: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
/* 左右布局:左侧弹性,右侧固定;no-gutters 移除 v-col 默认 gutter,由容器 padding 统一控制左右边距 */
|
||
.trade-detail-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.chart-col {
|
||
flex: 1 1 0%;
|
||
min-width: 0;
|
||
}
|
||
|
||
.trade-col {
|
||
margin-top: 32px;
|
||
}
|
||
|
||
.trade-sidebar {
|
||
position: sticky;
|
||
top: 24px;
|
||
width: 400px;
|
||
max-width: 100%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.chart-closed-banner {
|
||
margin: 8px 0 0;
|
||
font-size: 13px;
|
||
color: #92400e;
|
||
background: #fffbeb;
|
||
border-radius: 8px;
|
||
padding: 8px 12px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.market-closed-pane {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
text-align: center;
|
||
padding: 28px 20px;
|
||
border: 1px solid #e7e7e7;
|
||
border-radius: 12px;
|
||
background: #fafafa;
|
||
}
|
||
|
||
.market-closed-pane--mobile {
|
||
padding: 22px 18px;
|
||
border: none;
|
||
border-radius: 0;
|
||
background: transparent;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.market-closed-pane-title {
|
||
margin: 12px 0 8px;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #111827;
|
||
}
|
||
|
||
.market-closed-pane-desc {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
color: #6b7280;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.market-closed-mobile-card {
|
||
margin-top: 24px;
|
||
margin-bottom: 16px;
|
||
border: 1px solid #e7e7e7;
|
||
box-shadow: none !important;
|
||
overflow: hidden;
|
||
background-color: #f9fafb !important;
|
||
}
|
||
|
||
@media (min-width: 960px) {
|
||
.trade-detail-row {
|
||
flex-wrap: nowrap;
|
||
gap: 24px;
|
||
}
|
||
|
||
.chart-col {
|
||
flex: 1 1 0% !important;
|
||
min-width: 0;
|
||
max-width: none !important;
|
||
}
|
||
|
||
.trade-col {
|
||
flex: 0 0 400px !important;
|
||
max-width: 400px !important;
|
||
margin-top: 32px;
|
||
}
|
||
|
||
.trade-sidebar {
|
||
width: 400px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 959px) {
|
||
.trade-sidebar {
|
||
position: static;
|
||
width: 100%;
|
||
}
|
||
|
||
/* 移动端显示底部栏时,预留底部空间避免遮挡列表 */
|
||
.trade-detail-container {
|
||
padding-bottom: 100px;
|
||
}
|
||
|
||
.trade-detail-container.trade-detail-container--settled-bar {
|
||
padding-bottom: calc(68px + env(safe-area-inset-bottom, 0px));
|
||
}
|
||
}
|
||
|
||
/* Responsive adjustments */
|
||
@media (max-width: 599px) {
|
||
.trade-detail-container {
|
||
padding: 16px;
|
||
padding-left: 16px;
|
||
padding-right: 16px;
|
||
/* 底部预留空间,避免固定 Yes/No 栏遮挡列表内容 */
|
||
padding-bottom: 100px;
|
||
}
|
||
|
||
.trade-detail-container.trade-detail-container--settled-bar {
|
||
padding-bottom: calc(68px + env(safe-area-inset-bottom, 0px));
|
||
}
|
||
|
||
.market-closed-mobile-card {
|
||
margin-top: 28px;
|
||
}
|
||
|
||
.trade-detail-card {
|
||
padding: 24px;
|
||
}
|
||
|
||
.trade-title {
|
||
font-size: 1.25rem;
|
||
}
|
||
|
||
.market-image-container {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.chance-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.options-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.market-info-section {
|
||
margin-bottom: 24px;
|
||
padding: 16px;
|
||
}
|
||
|
||
.description-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.chart-container {
|
||
height: 260px;
|
||
}
|
||
|
||
.chart-card.polymarket-chart {
|
||
padding: 16px;
|
||
}
|
||
|
||
.chart-title {
|
||
font-size: 1.125rem;
|
||
}
|
||
}
|
||
|
||
/* Responsive order book adjustments */
|
||
@media (max-width: 600px) {
|
||
.order-book-card {
|
||
margin-top: 16px;
|
||
}
|
||
}
|
||
|
||
/* Comments / Top Holders / Activity(扁平化) */
|
||
.activity-card {
|
||
margin-top: 16px;
|
||
border: 1px solid #e5e7eb;
|
||
overflow: hidden;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.detail-tabs {
|
||
border-bottom: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.detail-tabs :deep(.v-tab) {
|
||
text-transform: none;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.detail-window {
|
||
overflow: visible;
|
||
}
|
||
|
||
.detail-pane {
|
||
padding: 16px;
|
||
}
|
||
|
||
.placeholder-pane {
|
||
color: #6b7280;
|
||
font-size: 14px;
|
||
padding: 24px 0;
|
||
}
|
||
|
||
.placeholder-pane--loading {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.rules-pane {
|
||
padding-top: 16px;
|
||
padding-bottom: 16px;
|
||
padding-left: 16px;
|
||
padding-right: 16px;
|
||
}
|
||
|
||
.rules-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.rules-section:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.rules-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #6b7280;
|
||
margin: 0 0 8px 0;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.rules-text {
|
||
font-size: 14px;
|
||
color: #374151;
|
||
line-height: 1.5;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.rules-text--multi-collapsed {
|
||
display: -webkit-box;
|
||
-webkit-box-orient: vertical;
|
||
-webkit-line-clamp: 4;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.rules-description-toggle {
|
||
margin-top: 4px;
|
||
padding: 0 4px !important;
|
||
min-width: auto !important;
|
||
text-transform: none;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.rules-link {
|
||
font-size: 14px;
|
||
color: #2563eb;
|
||
text-decoration: none;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.rules-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
/* 持仓 / 限价订单列表 */
|
||
.positions-list,
|
||
.orders-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.position-row-item,
|
||
.order-row-item {
|
||
padding: 12px 0;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.position-row-item:last-child,
|
||
.order-row-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.position-row-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.position-row-icon {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
font-weight: 700;
|
||
font-size: 16px;
|
||
color: #fff;
|
||
}
|
||
|
||
.position-row-icon.pill-yes {
|
||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||
}
|
||
|
||
.position-row-icon.pill-down {
|
||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||
}
|
||
|
||
.position-row-icon-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
border-radius: inherit;
|
||
}
|
||
|
||
.position-row-icon-char {
|
||
line-height: 1;
|
||
}
|
||
|
||
.position-row-title {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #111827;
|
||
line-height: 1.3;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.position-row-main,
|
||
.order-row-main {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.position-sell-btn {
|
||
margin-left: auto;
|
||
}
|
||
|
||
.position-lock-badge {
|
||
font-size: 11px;
|
||
color: #b45309;
|
||
background: #fef3c7;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.position-outcome-pill,
|
||
.order-side-pill {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.position-outcome-pill.pill-yes,
|
||
.order-side-pill.side-yes {
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
}
|
||
|
||
.position-outcome-pill.pill-down,
|
||
.order-side-pill.side-no {
|
||
background: #fee2e2;
|
||
color: #991b1b;
|
||
}
|
||
|
||
.position-shares,
|
||
.position-value,
|
||
.order-price,
|
||
.order-filled,
|
||
.order-total {
|
||
font-size: 14px;
|
||
color: #374151;
|
||
}
|
||
|
||
.position-row-meta {
|
||
font-size: 13px;
|
||
color: #6b7280;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.order-row-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.order-row-actions {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.activity-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 16px;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.min-amount-select {
|
||
max-width: 140px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.min-amount-select :deep(.v-field) {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.live-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.live-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background-color: #dc2626;
|
||
animation: live-pulse 1.5s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes live-pulse {
|
||
|
||
0%,
|
||
100% {
|
||
opacity: 1;
|
||
}
|
||
|
||
50% {
|
||
opacity: 0.5;
|
||
}
|
||
}
|
||
|
||
.activity-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
}
|
||
|
||
.activity-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 0;
|
||
border-bottom: 1px solid #f3f4f6;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.activity-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.activity-avatar {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
overflow: hidden;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.avatar-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.avatar-gradient-1 {
|
||
background: linear-gradient(135deg, #10b981 0%, #8b5cf6 100%);
|
||
}
|
||
|
||
.avatar-gradient-2 {
|
||
background: linear-gradient(135deg, #10b981 0%, #3b82f6 100%);
|
||
}
|
||
|
||
.avatar-gradient-3 {
|
||
background: linear-gradient(135deg, #f59e0b 0%, #eab308 100%);
|
||
}
|
||
|
||
.avatar-gradient-4 {
|
||
background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
|
||
}
|
||
|
||
.activity-body {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
}
|
||
|
||
.activity-user {
|
||
font-weight: 500;
|
||
color: #111827;
|
||
}
|
||
|
||
.activity-action {
|
||
color: #6b7280;
|
||
margin-left: 2px;
|
||
}
|
||
|
||
.activity-amount {
|
||
font-weight: 600;
|
||
margin-left: 2px;
|
||
}
|
||
|
||
.amount-yes {
|
||
color: #059669;
|
||
}
|
||
|
||
.amount-no {
|
||
color: #dc2626;
|
||
}
|
||
|
||
.activity-price {
|
||
color: #6b7280;
|
||
margin-left: 2px;
|
||
}
|
||
|
||
.activity-total {
|
||
color: #6b7280;
|
||
margin-left: 2px;
|
||
}
|
||
|
||
.activity-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.activity-time {
|
||
font-size: 13px;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
.activity-link {
|
||
color: #9ca3af;
|
||
display: inline-flex;
|
||
line-height: 1;
|
||
}
|
||
|
||
.activity-link:hover {
|
||
color: #6b7280;
|
||
}
|
||
|
||
/* 移动端底部交易栏(与 EventMarkets 一致) */
|
||
.mobile-trade-bar-spacer {
|
||
/* 预留空间避免底部栏遮挡列表内容,需覆盖 bar 高度 + safe-area */
|
||
height: 100px;
|
||
min-height: 100px;
|
||
}
|
||
|
||
.mobile-trade-bar {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 100;
|
||
display: flex;
|
||
align-items: stretch;
|
||
gap: 10px;
|
||
width: 100%;
|
||
padding: 12px 16px;
|
||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||
background: #fff;
|
||
border-top: 1px solid #eee;
|
||
}
|
||
|
||
.mobile-bar-btn {
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
text-transform: none;
|
||
}
|
||
|
||
.mobile-bar-yes {
|
||
flex: 1;
|
||
min-width: 0;
|
||
background: $yes-bg;
|
||
color: $yes-text;
|
||
height: 46px;
|
||
}
|
||
|
||
.mobile-bar-no {
|
||
flex: 1;
|
||
min-width: 0;
|
||
background: $no-bg;
|
||
color: $no-text;
|
||
height: 46px;
|
||
}
|
||
|
||
.mobile-bar-more-btn {
|
||
flex-shrink: 0;
|
||
width: 46px;
|
||
height: 46px;
|
||
min-width: 46px;
|
||
min-height: 46px;
|
||
padding: 0 !important;
|
||
border-radius: 50%;
|
||
background: #e5e7eb !important;
|
||
color: #6b7280;
|
||
align-self: center;
|
||
}
|
||
</style>
|