优化:折线图的显示宽度优化
This commit is contained in:
parent
062e370bea
commit
fef3d86fc2
@ -278,11 +278,21 @@ interface BinanceAggTradeMsg {
|
||||
|
||||
const CRYPTO_UPDATE_THROTTLE_MS = 80
|
||||
|
||||
/** 加密货币分时折线图 X 轴 / 补点统一时间步长(ms),与图表 timeScale 一致 */
|
||||
export const CRYPTO_CHART_TIME_STEP_MS = 100
|
||||
|
||||
/** 将毫秒时间戳对齐到 {@link CRYPTO_CHART_TIME_STEP_MS} 网格(向下取整,单调递增友好) */
|
||||
export function alignCryptoChartTimeMs(tsMs: number): number {
|
||||
const step = CRYPTO_CHART_TIME_STEP_MS
|
||||
return Math.floor(tsMs / step) * step
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅 Binance 归集交易 WebSocket(aggTrade),实现更丝滑的实时走势
|
||||
* 参考:https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/web-socket-streams#归集交易
|
||||
* - aggTrade 实时推送每笔成交,比 kline_1m(约 1s 一次)更细
|
||||
* - 内部节流避免频繁 setOption 造成卡顿
|
||||
* - 每 {@link CRYPTO_CHART_TIME_STEP_MS}ms 若本周期未收到真实 aggTrade,则用最新成交价补一点(时间戳对齐到步长网格)
|
||||
*/
|
||||
export function subscribeCryptoRealtime(
|
||||
symbol: string,
|
||||
@ -294,14 +304,23 @@ export function subscribeCryptoRealtime(
|
||||
const stream = `${binanceSymbol.toLowerCase()}@aggTrade`
|
||||
const ws = new WebSocket(`${BINANCE_WS}/${stream}`)
|
||||
|
||||
let disposed = false
|
||||
let lastUpdateTs = 0
|
||||
let pendingPoint: CryptoChartPoint | null = null
|
||||
let rafId: number | null = null
|
||||
let throttleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
/** 本 hold 周期内是否收到过真实 aggTrade(用于持价补点) */
|
||||
let hadAggTradeSinceLastHoldTick = false
|
||||
/** 最近一笔有效成交价,供无推送时重复输出 */
|
||||
let lastTradedPrice: number | null = null
|
||||
|
||||
const flush = () => {
|
||||
rafId = null
|
||||
throttleTimer = null
|
||||
if (disposed) {
|
||||
pendingPoint = null
|
||||
return
|
||||
}
|
||||
if (pendingPoint) {
|
||||
onPoint(pendingPoint)
|
||||
pendingPoint = null
|
||||
@ -323,7 +342,16 @@ export function subscribeCryptoRealtime(
|
||||
}
|
||||
}
|
||||
|
||||
const holdTimer = setInterval(() => {
|
||||
if (disposed) return
|
||||
if (!hadAggTradeSinceLastHoldTick && lastTradedPrice != null) {
|
||||
onPoint([alignCryptoChartTimeMs(Date.now()), lastTradedPrice])
|
||||
}
|
||||
hadAggTradeSinceLastHoldTick = false
|
||||
}, CRYPTO_CHART_TIME_STEP_MS)
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (disposed) return
|
||||
try {
|
||||
const msg = JSON.parse((event as MessageEvent).data) as BinanceAggTradeMsg & { e?: string }
|
||||
if (msg.e === 'ping') {
|
||||
@ -335,9 +363,11 @@ export function subscribeCryptoRealtime(
|
||||
if (p == null || T == null) return
|
||||
const price = parseFloat(p)
|
||||
if (!Number.isFinite(price)) return
|
||||
const ts = T < 1e12 ? T * 1000 : T
|
||||
const point: CryptoChartPoint = [ts, price]
|
||||
const rawTs = T < 1e12 ? T * 1000 : T
|
||||
const point: CryptoChartPoint = [alignCryptoChartTimeMs(rawTs), price]
|
||||
|
||||
hadAggTradeSinceLastHoldTick = true
|
||||
lastTradedPrice = price
|
||||
pendingPoint = point
|
||||
const now = Date.now()
|
||||
if (now - lastUpdateTs >= CRYPTO_UPDATE_THROTTLE_MS) {
|
||||
@ -352,6 +382,9 @@ export function subscribeCryptoRealtime(
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
pendingPoint = null
|
||||
clearInterval(holdTimer)
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
if (throttleTimer) clearTimeout(throttleTimer)
|
||||
ws.close()
|
||||
|
||||
160
src/composables/inPanePriceScalePrimitive.ts
Normal file
160
src/composables/inPanePriceScalePrimitive.ts
Normal file
@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 在主图区域内绘制 Y 轴刻度标签(不占用右侧独立价轴条带)。
|
||||
* 思路来自 lightweight-charts 官方 plugin-examples/overlay-price-scale。
|
||||
*/
|
||||
import type { CanvasRenderingTarget2D } from 'fancy-canvas'
|
||||
import type {
|
||||
BarPrice,
|
||||
IPrimitivePaneRenderer,
|
||||
IPrimitivePaneView,
|
||||
ISeriesPrimitive,
|
||||
IPriceFormatter,
|
||||
SeriesAttachedParameter,
|
||||
Time,
|
||||
} from 'lightweight-charts'
|
||||
|
||||
export interface InPanePriceScaleOptions {
|
||||
textColor: string
|
||||
side: 'left' | 'right'
|
||||
/** 相邻刻度最小间距(像素) */
|
||||
tickSpacingPx: number
|
||||
fontSizePx: number
|
||||
/** 与绘图区边缘的水平间距 */
|
||||
sideMarginPx: number
|
||||
}
|
||||
|
||||
const defaultOptions: InPanePriceScaleOptions = {
|
||||
textColor: 'rgba(107, 114, 128, 0.65)',
|
||||
side: 'right',
|
||||
tickSpacingPx: 44,
|
||||
fontSizePx: 9,
|
||||
sideMarginPx: 8,
|
||||
}
|
||||
|
||||
interface Label {
|
||||
label: string
|
||||
y: number
|
||||
}
|
||||
|
||||
interface RendererData {
|
||||
priceFormatter: IPriceFormatter
|
||||
coordinateToPrice: (y: number) => BarPrice | null
|
||||
options: InPanePriceScaleOptions
|
||||
}
|
||||
|
||||
function barPriceToNumber(p: BarPrice | null): number | null {
|
||||
if (p == null) return null
|
||||
const n = typeof p === 'number' ? p : Number(p)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
class InPanePriceScaleRenderer implements IPrimitivePaneRenderer {
|
||||
private _data: RendererData | null = null
|
||||
|
||||
update(data: RendererData) {
|
||||
this._data = data
|
||||
}
|
||||
|
||||
draw(target: CanvasRenderingTarget2D) {
|
||||
target.useMediaCoordinateSpace((scope) => {
|
||||
if (!this._data) return
|
||||
const { options } = this._data
|
||||
const labels = this._labelsForHeight(scope.mediaSize.height, this._data)
|
||||
if (labels.length === 0) return
|
||||
|
||||
const ctx = scope.context
|
||||
ctx.font = `${options.fontSizePx}px inherit`
|
||||
const isLeft = options.side === 'left'
|
||||
|
||||
ctx.textAlign = isLeft ? 'left' : 'right'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
for (const item of labels) {
|
||||
const textX = isLeft
|
||||
? options.sideMarginPx
|
||||
: scope.mediaSize.width - options.sideMarginPx
|
||||
ctx.fillStyle = options.textColor
|
||||
ctx.fillText(item.label, textX, item.y)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private _labelsForHeight(height: number, data: RendererData): Label[] {
|
||||
const { coordinateToPrice, priceFormatter, options } = data
|
||||
const spacing = options.tickSpacingPx
|
||||
const half = Math.max(4, Math.round(spacing / 4))
|
||||
const ys: number[] = []
|
||||
for (let y = half; y <= height - half; y += spacing) {
|
||||
ys.push(y)
|
||||
}
|
||||
const out: Label[] = []
|
||||
for (const y of ys) {
|
||||
const price = barPriceToNumber(coordinateToPrice(y))
|
||||
if (price == null) continue
|
||||
out.push({ label: priceFormatter.format(price), y })
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
class InPanePriceScalePaneView implements IPrimitivePaneView {
|
||||
private readonly _renderer: InPanePriceScaleRenderer
|
||||
|
||||
constructor() {
|
||||
this._renderer = new InPanePriceScaleRenderer()
|
||||
}
|
||||
|
||||
zOrder(): 'top' {
|
||||
return 'top'
|
||||
}
|
||||
|
||||
renderer(): IPrimitivePaneRenderer {
|
||||
return this._renderer
|
||||
}
|
||||
|
||||
update(data: RendererData) {
|
||||
this._renderer.update(data)
|
||||
}
|
||||
}
|
||||
|
||||
export class InPanePriceScalePrimitive implements ISeriesPrimitive<Time> {
|
||||
private readonly _paneViews: InPanePriceScalePaneView[]
|
||||
private _options: InPanePriceScaleOptions
|
||||
private _series: SeriesAttachedParameter<Time>['series'] | null = null
|
||||
private _requestUpdate: (() => void) | undefined
|
||||
|
||||
constructor(options?: Partial<InPanePriceScaleOptions>) {
|
||||
this._options = { ...defaultOptions, ...options }
|
||||
this._paneViews = [new InPanePriceScalePaneView()]
|
||||
}
|
||||
|
||||
applyOptions(options: Partial<InPanePriceScaleOptions>) {
|
||||
this._options = { ...this._options, ...options }
|
||||
this._requestUpdate?.()
|
||||
}
|
||||
|
||||
attached(param: SeriesAttachedParameter<Time>): void {
|
||||
this._series = param.series
|
||||
this._requestUpdate = param.requestUpdate
|
||||
}
|
||||
|
||||
detached(): void {
|
||||
this._series = null
|
||||
this._requestUpdate = undefined
|
||||
}
|
||||
|
||||
updateAllViews(): void {
|
||||
const s = this._series
|
||||
if (!s) return
|
||||
const data: RendererData = {
|
||||
priceFormatter: s.priceFormatter(),
|
||||
coordinateToPrice: (y) => s.coordinateToPrice(y),
|
||||
options: this._options,
|
||||
}
|
||||
this._paneViews[0]!.update(data)
|
||||
}
|
||||
|
||||
paneViews(): readonly IPrimitivePaneView[] {
|
||||
return this._paneViews
|
||||
}
|
||||
}
|
||||
@ -30,10 +30,14 @@
|
||||
{{ categoryText }}
|
||||
</p>
|
||||
<div class="chart-controls-row">
|
||||
<v-btn variant="text" size="small" class="past-btn">{{ t('eventMarkets.past') }}</v-btn>
|
||||
<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>
|
||||
<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">
|
||||
@ -101,7 +105,9 @@
|
||||
@click="goToTradeDetail(market)"
|
||||
>
|
||||
<div class="market-row-main">
|
||||
<span class="market-question">{{ market.question || t('eventMarkets.marketPlaceholder') }}</span>
|
||||
<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>
|
||||
@ -152,7 +158,8 @@
|
||||
rounded="sm"
|
||||
@click="openSheetWithOption('yes')"
|
||||
>
|
||||
{{ barMarket ? yesLabel(barMarket) : t('eventMarkets.yes') }} {{ barMarket ? yesPrice(barMarket) : t('eventMarkets.priceZero') }}
|
||||
{{ barMarket ? yesLabel(barMarket) : t('eventMarkets.yes') }}
|
||||
{{ barMarket ? yesPrice(barMarket) : t('eventMarkets.priceZero') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="mobile-bar-btn mobile-bar-no"
|
||||
@ -160,7 +167,8 @@
|
||||
rounded="sm"
|
||||
@click="openSheetWithOption('no')"
|
||||
>
|
||||
{{ barMarket ? noLabel(barMarket) : t('eventMarkets.no') }} {{ barMarket ? noPrice(barMarket) : t('eventMarkets.priceZero') }}
|
||||
{{ barMarket ? noLabel(barMarket) : t('eventMarkets.no') }}
|
||||
{{ barMarket ? noPrice(barMarket) : t('eventMarkets.priceZero') }}
|
||||
</v-btn>
|
||||
<v-menu
|
||||
v-model="mobileMenuOpen"
|
||||
@ -215,7 +223,14 @@ 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 {
|
||||
createChart,
|
||||
LineSeries,
|
||||
LineType,
|
||||
LastPriceAnimationMode,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
} from 'lightweight-charts'
|
||||
import { toLwcData } from '../composables/useLightweightChart'
|
||||
import TradeComponent from '../components/TradeComponent.vue'
|
||||
import {
|
||||
@ -226,7 +241,11 @@ import {
|
||||
type PmEventListItem,
|
||||
type PmEventMarketItem,
|
||||
} from '../api/event'
|
||||
import { getPmPriceHistoryPublic, priceHistoryToChartData, getTimeRangeSeconds } from '../api/priceHistory'
|
||||
import {
|
||||
getPmPriceHistoryPublic,
|
||||
priceHistoryToChartData,
|
||||
getTimeRangeSeconds,
|
||||
} from '../api/priceHistory'
|
||||
import { getMockEventById } from '../api/mockData'
|
||||
import { USE_MOCK_EVENT } from '../config/mock'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@ -391,7 +410,10 @@ async function loadChartFromApi(): Promise<ChartSeriesItem[]> {
|
||||
}
|
||||
try {
|
||||
const ev = eventDetail.value
|
||||
const timeRange = getTimeRangeSeconds(range, ev ? { startDate: ev.startDate, endDate: ev.endDate } : undefined)
|
||||
const timeRange = getTimeRangeSeconds(
|
||||
range,
|
||||
ev ? { startDate: ev.startDate, endDate: ev.endDate } : undefined,
|
||||
)
|
||||
const res = await getPmPriceHistoryPublic({
|
||||
market: yesTokenId,
|
||||
page: 1,
|
||||
@ -416,7 +438,7 @@ function setChartSeries(seriesArr: ChartSeriesItem[]) {
|
||||
const series = chartInstance!.addSeries(LineSeries, {
|
||||
color,
|
||||
lineWidth: 2,
|
||||
lineType: LineType.Simple,
|
||||
lineType: LineType.Curved,
|
||||
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
|
||||
crosshairMarkerVisible: true,
|
||||
lastValueVisible: true,
|
||||
@ -630,39 +652,43 @@ onMounted(() => {
|
||||
})
|
||||
let tradeSheetUnmountTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let tradeSheetMountTimer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(tradeSheetOpen, (open) => {
|
||||
if (open) {
|
||||
if (tradeSheetUnmountTimer) {
|
||||
clearTimeout(tradeSheetUnmountTimer)
|
||||
tradeSheetUnmountTimer = 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)
|
||||
}
|
||||
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 })
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
|
||||
@ -3,7 +3,12 @@
|
||||
<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">
|
||||
<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>
|
||||
@ -11,325 +16,342 @@
|
||||
</v-card>
|
||||
|
||||
<v-row v-else align="stretch" no-gutters class="trade-detail-row">
|
||||
<!-- 左侧:分时图 + 订单簿(宽度弹性) -->
|
||||
<v-col cols="12" class="chart-col">
|
||||
<!-- 分时图卡片(Polymarket 样式) -->
|
||||
<v-card class="chart-card polymarket-chart" elevation="0" rounded="lg">
|
||||
<!-- 顶部:标题、当前概率、Past / 日期 -->
|
||||
<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-group
|
||||
v-if="isCryptoEvent"
|
||||
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"
|
||||
icon
|
||||
:aria-label="t('chart.cryptoPrice')"
|
||||
@click="setChartMode('crypto')"
|
||||
>
|
||||
<v-icon size="20">mdi-currency-btc</v-icon>
|
||||
</v-btn>
|
||||
</v-btn-group>
|
||||
<v-btn variant="text" size="small" class="past-btn">Past ▾</v-btn>
|
||||
<v-btn class="date-pill" size="small" rounded="pill">{{ resolutionDate }}</v-btn>
|
||||
</div>
|
||||
<div class="chart-chance">
|
||||
<template v-if="chartMode === 'crypto' && cryptoSymbol">
|
||||
${{ currentCryptoPrice }} {{ cryptoSymbol.toUpperCase() }}
|
||||
</template>
|
||||
<template v-else> {{ currentChance }}% {{ t('common.chance') }} </template>
|
||||
</div>
|
||||
</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 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>
|
||||
<!-- 左侧:分时图 + 订单簿(宽度弹性) -->
|
||||
<v-col cols="12" class="chart-col">
|
||||
<!-- 分时图卡片(Polymarket 样式) -->
|
||||
<v-card class="chart-card polymarket-chart" elevation="0" rounded="lg">
|
||||
<!-- 顶部:标题、当前概率、Past / 日期 -->
|
||||
<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-group
|
||||
v-if="isCryptoEvent"
|
||||
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"
|
||||
icon
|
||||
:aria-label="t('chart.cryptoPrice')"
|
||||
@click="setChartMode('crypto')"
|
||||
>
|
||||
<v-icon size="20">mdi-currency-btc</v-icon>
|
||||
</v-btn>
|
||||
</v-btn-group>
|
||||
<v-btn variant="text" size="small" class="past-btn">Past ▾</v-btn>
|
||||
<v-btn class="date-pill" size="small" rounded="pill">{{ resolutionDate }}</v-btn>
|
||||
</div>
|
||||
<div class="chart-chance">
|
||||
<template v-if="chartMode === 'crypto' && cryptoSymbol">
|
||||
${{ currentCryptoPrice }} {{ cryptoSymbol.toUpperCase() }}
|
||||
</template>
|
||||
<template v-else> {{ currentChance }}% {{ t('common.chance') }} </template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="marketPositionsFiltered.length === 0" class="placeholder-pane">
|
||||
{{ t('activity.noPositionsInMarket') }}
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<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 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 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 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="
|
||||
!(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>
|
||||
<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="!(pos.availableSharesNum != null && pos.availableSharesNum > 0)"
|
||||
@click="openSellFromPosition(pos)"
|
||||
>
|
||||
{{ t('trade.sell') }}
|
||||
</v-btn>
|
||||
</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 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 v-else-if="marketOpenOrders.length === 0" class="placeholder-pane">
|
||||
{{ t('activity.noOpenOrdersInMarket') }}
|
||||
</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 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>
|
||||
</div>
|
||||
</div>
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</v-card>
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</v-card>
|
||||
|
||||
<!-- Order Book Section -->
|
||||
<v-card 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>
|
||||
<!-- Order Book Section -->
|
||||
<v-card 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">{{ 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"
|
||||
<!-- 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"
|
||||
>
|
||||
{{ eventDetail.resolutionSource }}
|
||||
<v-icon size="14">mdi-open-in-new</v-icon>
|
||||
</a>
|
||||
<div v-else class="rules-text">{{ eventDetail.resolutionSource }}</div>
|
||||
{{ 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">{{ 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>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- 右侧:交易组件(固定宽度),传入当前市场以便 Split 调用拆单接口;移动端隐藏,改用底部栏+弹窗 -->
|
||||
<v-col v-if="!isMobile" cols="12" class="trade-col">
|
||||
<div class="trade-sidebar">
|
||||
<TradeComponent
|
||||
ref="tradeComponentRef"
|
||||
:market="tradeMarketPayload"
|
||||
:initial-option="tradeInitialOption"
|
||||
:positions="tradePositionsForComponent"
|
||||
@merge-success="onMergeSuccess"
|
||||
@split-success="onSplitSuccess"
|
||||
/>
|
||||
</div>
|
||||
</v-col>
|
||||
<!-- 右侧:交易组件(固定宽度),传入当前市场以便 Split 调用拆单接口;移动端隐藏,改用底部栏+弹窗 -->
|
||||
<v-col v-if="!isMobile" cols="12" class="trade-col">
|
||||
<div class="trade-sidebar">
|
||||
<TradeComponent
|
||||
ref="tradeComponentRef"
|
||||
:market="tradeMarketPayload"
|
||||
:initial-option="tradeInitialOption"
|
||||
:positions="tradePositionsForComponent"
|
||||
@merge-success="onMergeSuccess"
|
||||
@split-success="onSplitSuccess"
|
||||
/>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<!-- 移动端:固定底部 Yes/No 栏 + 三点菜单(Merge/Split) -->
|
||||
<template v-if="isMobile && tradeMarketPayload">
|
||||
<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 }">
|
||||
<!-- 移动端:固定底部 Yes/No 栏 + 三点菜单(Merge/Split) -->
|
||||
<template v-if="isMobile && tradeMarketPayload">
|
||||
<div class="mobile-trade-bar-spacer" aria-hidden="true"></div>
|
||||
<div class="mobile-trade-bar">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
class="mobile-bar-more-btn"
|
||||
class="mobile-bar-btn mobile-bar-yes"
|
||||
variant="flat"
|
||||
icon
|
||||
rounded="pill"
|
||||
aria-label="更多操作"
|
||||
rounded="sm"
|
||||
@click="openSheetWithOption('yes')"
|
||||
>
|
||||
<v-icon size="20">mdi-dots-horizontal</v-icon>
|
||||
{{ yesLabel }} {{ yesPriceCents }}¢
|
||||
</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>
|
||||
<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>
|
||||
<!-- 从持仓点击 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>
|
||||
@ -346,10 +368,13 @@ import {
|
||||
LineSeries,
|
||||
LineType,
|
||||
LastPriceAnimationMode,
|
||||
TickMarkType,
|
||||
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 {
|
||||
@ -387,6 +412,8 @@ import {
|
||||
inferCryptoSymbol,
|
||||
fetchCryptoChart,
|
||||
subscribeCryptoRealtime,
|
||||
alignCryptoChartTimeMs,
|
||||
CRYPTO_CHART_TIME_STEP_MS,
|
||||
} from '../api/cryptoChart'
|
||||
|
||||
const { t } = useI18n()
|
||||
@ -502,11 +529,9 @@ async function loadEventDetail() {
|
||||
}
|
||||
|
||||
function onRefresh({ done }: { done: () => void }) {
|
||||
Promise.all([
|
||||
loadEventDetail(),
|
||||
loadMarketPositions(),
|
||||
loadMarketOpenOrders(),
|
||||
]).finally(() => done())
|
||||
Promise.all([loadEventDetail(), loadMarketPositions(), loadMarketOpenOrders()]).finally(() =>
|
||||
done(),
|
||||
)
|
||||
}
|
||||
|
||||
// 标题、成交量、到期日:优先接口详情,其次卡片 query,最后占位
|
||||
@ -1174,6 +1199,8 @@ 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
|
||||
@ -1195,11 +1222,18 @@ const currentChance = computed(() => {
|
||||
|
||||
const lineColor = '#2563eb'
|
||||
|
||||
const MOBILE_BREAKPOINT = 600
|
||||
/** 叠加价格轴 ID:曲线绑定在 overlay scale,主图占满宽;Y 轴数字由 InPanePriceScalePrimitive 画在图内 */
|
||||
const CHART_OVERLAY_PRICE_SCALE_ID = 'overlay-price'
|
||||
/** 右侧留白(px):fitContent 时压缩可用宽度,线头略离开物理右缘,lastPrice 水波能露出一半且不依赖超大 rightOffset(bars) */
|
||||
const CHART_RIPPLE_RIGHT_PAD_PX = 28
|
||||
|
||||
function ensureChartSeries() {
|
||||
if (!chartInstance || !chartContainerRef.value) return
|
||||
if (chartSeries) {
|
||||
if (inPanePriceScale) {
|
||||
chartSeries.detachPrimitive(inPanePriceScale)
|
||||
inPanePriceScale = null
|
||||
}
|
||||
chartInstance.removeSeries(chartSeries)
|
||||
chartSeries = null
|
||||
}
|
||||
@ -1207,12 +1241,26 @@ function ensureChartSeries() {
|
||||
chartSeries = chartInstance.addSeries(LineSeries, {
|
||||
color: lineColor,
|
||||
lineWidth: 2,
|
||||
lineType: LineType.Simple,
|
||||
lineType: LineType.Curved,
|
||||
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
|
||||
crosshairMarkerVisible: true,
|
||||
lastValueVisible: true,
|
||||
priceFormat: isCrypto ? { type: 'price', precision: 2 } : { 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][]) {
|
||||
@ -1226,7 +1274,7 @@ function initChart() {
|
||||
const el = chartContainerRef.value
|
||||
chartInstance = createChart(el, {
|
||||
width: el.clientWidth,
|
||||
height: 320,
|
||||
height: el.clientHeight || 320,
|
||||
layout: {
|
||||
background: { color: '#ffffff' },
|
||||
textColor: '#6b7280',
|
||||
@ -1235,8 +1283,19 @@ function initChart() {
|
||||
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 },
|
||||
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 } },
|
||||
@ -1273,17 +1332,72 @@ async function loadChartFromApi(marketParam: string, range: string): Promise<Cha
|
||||
|
||||
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]
|
||||
const [ts, price] = point
|
||||
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() - 30 * 1000
|
||||
list.push([ts, price])
|
||||
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 实现平滑动画
|
||||
@ -1293,7 +1407,8 @@ function applyCryptoRealtimePoint(point: [number, number]) {
|
||||
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 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) {
|
||||
@ -1316,11 +1431,13 @@ function applyCryptoRealtimePoint(point: [number, number]) {
|
||||
} else {
|
||||
setChartData(data.value)
|
||||
}
|
||||
scheduleCryptoScrollToRealtime()
|
||||
}
|
||||
|
||||
async function updateChartData() {
|
||||
if (chartMode.value === 'crypto') {
|
||||
const sym = cryptoSymbol.value ?? 'btc'
|
||||
const gen = ++cryptoLoadGeneration
|
||||
cryptoWsUnsubscribe?.()
|
||||
cryptoWsUnsubscribe = null
|
||||
cryptoChartLoading.value = true
|
||||
@ -1329,14 +1446,23 @@ async function updateChartData() {
|
||||
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?.()
|
||||
cryptoWsUnsubscribe = null
|
||||
cryptoWsUnsubscribe = subscribeCryptoRealtime(sym, applyCryptoRealtimePoint)
|
||||
} finally {
|
||||
cryptoChartLoading.value = false
|
||||
if (gen === cryptoLoadGeneration) {
|
||||
cryptoChartLoading.value = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cryptoLoadGeneration++
|
||||
cryptoWsUnsubscribe?.()
|
||||
cryptoWsUnsubscribe = null
|
||||
chartYesNoLoading.value = true
|
||||
@ -1388,7 +1514,8 @@ watch(
|
||||
|
||||
const handleResize = () => {
|
||||
if (!chartInstance || !chartContainerRef.value) return
|
||||
chartInstance.resize(chartContainerRef.value.clientWidth, 320)
|
||||
const el = chartContainerRef.value
|
||||
chartInstance.resize(el.clientWidth, el.clientHeight || 320)
|
||||
}
|
||||
|
||||
watch(
|
||||
@ -1443,10 +1570,15 @@ 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>
|
||||
|
||||
@ -97,7 +97,7 @@
|
||||
<span v-else class="position-icon-char">{{ pos.iconChar || '•' }}</span>
|
||||
</div>
|
||||
<div class="design-pos-title-col">
|
||||
<MarqueeTitle :text="pos.market" title-class="position-mobile-title" />
|
||||
<MarqueeTitle :text="pos.market" title-class="position-mobile-title" />
|
||||
<div class="design-pos-tags">
|
||||
<span
|
||||
v-if="pos.outcomeTag"
|
||||
@ -164,12 +164,7 @@
|
||||
>
|
||||
<div class="design-order-top">
|
||||
<div class="order-mobile-icon" :class="ord.iconClass">
|
||||
<img
|
||||
v-if="ord.imageUrl"
|
||||
:src="ord.imageUrl"
|
||||
alt=""
|
||||
class="position-icon-img"
|
||||
/>
|
||||
<img v-if="ord.imageUrl" :src="ord.imageUrl" alt="" class="position-icon-img" />
|
||||
<span v-else class="position-icon-char">{{ ord.iconChar || '•' }}</span>
|
||||
</div>
|
||||
<div class="order-mobile-main design-order-title-col">
|
||||
@ -182,9 +177,11 @@
|
||||
<span class="order-side-pill" :class="getOrderSideClass(ord)">{{
|
||||
getOrderActionLabel(ord)
|
||||
}}</span>
|
||||
<span class="order-outcome-pill" :class="ord.side === 'Yes' ? 'outcome-yes' : 'outcome-no'">{{
|
||||
ord.side
|
||||
}}</span>
|
||||
<span
|
||||
class="order-outcome-pill"
|
||||
:class="ord.side === 'Yes' ? 'outcome-yes' : 'outcome-no'"
|
||||
>{{ ord.side }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<v-btn
|
||||
@ -267,9 +264,8 @@
|
||||
{{ getHistoryTagLabel(h) }}
|
||||
</span>
|
||||
<span class="design-history-meta"
|
||||
>{{ t('wallet.priceLabel') }}: {{ h.avgPrice || '—' }} · {{ t('wallet.sharesLabel') }}: {{
|
||||
h.shares || '—'
|
||||
}}</span
|
||||
>{{ t('wallet.priceLabel') }}: {{ h.avgPrice || '—' }} ·
|
||||
{{ t('wallet.sharesLabel') }}: {{ h.shares || '—' }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
@ -280,7 +276,11 @@
|
||||
<span v-else class="position-icon-char">{{ getFundingIconText(h) }}</span>
|
||||
</div>
|
||||
<div class="history-mobile-main">
|
||||
<MarqueeTitle :text="getFundingTitle(h)" title-class="history-mobile-title" fast />
|
||||
<MarqueeTitle
|
||||
:text="getFundingTitle(h)"
|
||||
title-class="history-mobile-title"
|
||||
fast
|
||||
/>
|
||||
<div class="history-mobile-activity">{{ h.timeAgo || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -441,7 +441,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="sell-dialog-title">
|
||||
{{ t('wallet.sellDialogTitle', { outcome: sellPositionItem.sellOutcome || t('wallet.position') }) }}
|
||||
{{
|
||||
t('wallet.sellDialogTitle', {
|
||||
outcome: sellPositionItem.sellOutcome || t('wallet.position'),
|
||||
})
|
||||
}}
|
||||
</h3>
|
||||
<p class="sell-dialog-market">{{ sellPositionItem.market }}</p>
|
||||
<div class="sell-receive-box">
|
||||
@ -456,7 +460,9 @@
|
||||
<v-btn color="success" variant="flat" block class="sell-redeem-btn" @click="redeemSell">
|
||||
{{ t('wallet.sellDialogRedeem') }}
|
||||
</v-btn>
|
||||
<a href="#" class="sell-edit-link" @click.prevent="editSellOrder">{{ t('wallet.sellDialogEditOrder') }}</a>
|
||||
<a href="#" class="sell-edit-link" @click.prevent="editSellOrder">{{
|
||||
t('wallet.sellDialogEditOrder')
|
||||
}}</a>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@ -687,10 +693,7 @@ interface HistoryItem {
|
||||
detailMarketId?: string
|
||||
}
|
||||
|
||||
function canOpenTradeDetail(opts: {
|
||||
tradeEventId?: string
|
||||
tradeEventSlug?: string
|
||||
}): boolean {
|
||||
function canOpenTradeDetail(opts: { tradeEventId?: string; tradeEventSlug?: string }): boolean {
|
||||
return !!(opts.tradeEventId?.trim() || opts.tradeEventSlug?.trim())
|
||||
}
|
||||
|
||||
@ -792,7 +795,9 @@ function isFundingHistory(h: HistoryItem): boolean {
|
||||
|
||||
function getFundingTitle(h: HistoryItem): string {
|
||||
if (h.market?.trim()) return h.market
|
||||
return isWithdrawalHistory(h) ? t('wallet.btcWithdrawHistoryLabel') : t('wallet.usdtDepositHistoryLabel')
|
||||
return isWithdrawalHistory(h)
|
||||
? t('wallet.btcWithdrawHistoryLabel')
|
||||
: t('wallet.usdtDepositHistoryLabel')
|
||||
}
|
||||
|
||||
function getFundingIconText(h: HistoryItem): string {
|
||||
@ -1062,8 +1067,10 @@ function getWithdrawAmountText(w: SettlementRequestClientItem): string {
|
||||
function getWithdrawFeeText(w: SettlementRequestClientItem): string {
|
||||
const feeRaw = Number(
|
||||
(w as { fee?: string | number; gasFee?: string | number; serviceFee?: string | number }).fee ??
|
||||
(w as { fee?: string | number; gasFee?: string | number; serviceFee?: string | number }).gasFee ??
|
||||
(w as { fee?: string | number; gasFee?: string | number; serviceFee?: string | number }).serviceFee ??
|
||||
(w as { fee?: string | number; gasFee?: string | number; serviceFee?: string | number })
|
||||
.gasFee ??
|
||||
(w as { fee?: string | number; gasFee?: string | number; serviceFee?: string | number })
|
||||
.serviceFee ??
|
||||
0,
|
||||
)
|
||||
const feeText = Number.isFinite(feeRaw)
|
||||
@ -1375,7 +1382,7 @@ function initPlChart() {
|
||||
bottomColor: color + '08',
|
||||
lineColor: color,
|
||||
lineWidth: 2,
|
||||
lineType: LineType.Simple,
|
||||
lineType: LineType.Curved,
|
||||
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
|
||||
priceFormat: { type: 'price', precision: 2 },
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user