优化:折线图的显示宽度优化

This commit is contained in:
ivan 2026-03-31 21:31:38 +08:00
parent 062e370bea
commit fef3d86fc2
5 changed files with 742 additions and 384 deletions

View File

@ -278,11 +278,21 @@ interface BinanceAggTradeMsg {
const CRYPTO_UPDATE_THROTTLE_MS = 80 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 WebSocketaggTrade * Binance WebSocketaggTrade
* https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/web-socket-streams#归集交易 * https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/web-socket-streams#归集交易
* - aggTrade kline_1m 1s * - aggTrade kline_1m 1s
* - setOption * - setOption
* - {@link CRYPTO_CHART_TIME_STEP_MS}ms aggTrade
*/ */
export function subscribeCryptoRealtime( export function subscribeCryptoRealtime(
symbol: string, symbol: string,
@ -294,14 +304,23 @@ export function subscribeCryptoRealtime(
const stream = `${binanceSymbol.toLowerCase()}@aggTrade` const stream = `${binanceSymbol.toLowerCase()}@aggTrade`
const ws = new WebSocket(`${BINANCE_WS}/${stream}`) const ws = new WebSocket(`${BINANCE_WS}/${stream}`)
let disposed = false
let lastUpdateTs = 0 let lastUpdateTs = 0
let pendingPoint: CryptoChartPoint | null = null let pendingPoint: CryptoChartPoint | null = null
let rafId: number | null = null let rafId: number | null = null
let throttleTimer: ReturnType<typeof setTimeout> | null = null let throttleTimer: ReturnType<typeof setTimeout> | null = null
/** 本 hold 周期内是否收到过真实 aggTrade用于持价补点 */
let hadAggTradeSinceLastHoldTick = false
/** 最近一笔有效成交价,供无推送时重复输出 */
let lastTradedPrice: number | null = null
const flush = () => { const flush = () => {
rafId = null rafId = null
throttleTimer = null throttleTimer = null
if (disposed) {
pendingPoint = null
return
}
if (pendingPoint) { if (pendingPoint) {
onPoint(pendingPoint) onPoint(pendingPoint)
pendingPoint = null 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) => { ws.onmessage = (event) => {
if (disposed) return
try { try {
const msg = JSON.parse((event as MessageEvent).data) as BinanceAggTradeMsg & { e?: string } const msg = JSON.parse((event as MessageEvent).data) as BinanceAggTradeMsg & { e?: string }
if (msg.e === 'ping') { if (msg.e === 'ping') {
@ -335,9 +363,11 @@ export function subscribeCryptoRealtime(
if (p == null || T == null) return if (p == null || T == null) return
const price = parseFloat(p) const price = parseFloat(p)
if (!Number.isFinite(price)) return if (!Number.isFinite(price)) return
const ts = T < 1e12 ? T * 1000 : T const rawTs = T < 1e12 ? T * 1000 : T
const point: CryptoChartPoint = [ts, price] const point: CryptoChartPoint = [alignCryptoChartTimeMs(rawTs), price]
hadAggTradeSinceLastHoldTick = true
lastTradedPrice = price
pendingPoint = point pendingPoint = point
const now = Date.now() const now = Date.now()
if (now - lastUpdateTs >= CRYPTO_UPDATE_THROTTLE_MS) { if (now - lastUpdateTs >= CRYPTO_UPDATE_THROTTLE_MS) {
@ -352,6 +382,9 @@ export function subscribeCryptoRealtime(
} }
return () => { return () => {
disposed = true
pendingPoint = null
clearInterval(holdTimer)
if (rafId) cancelAnimationFrame(rafId) if (rafId) cancelAnimationFrame(rafId)
if (throttleTimer) clearTimeout(throttleTimer) if (throttleTimer) clearTimeout(throttleTimer)
ws.close() ws.close()

View 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
}
}

View File

@ -30,10 +30,14 @@
{{ categoryText }} {{ categoryText }}
</p> </p>
<div class="chart-controls-row"> <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> <v-btn class="date-pill" size="small" rounded="pill">{{ resolutionDate }}</v-btn>
</div> </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>
<div class="chart-wrapper"> <div class="chart-wrapper">
<div v-if="chartLoading" class="chart-loading-overlay"> <div v-if="chartLoading" class="chart-loading-overlay">
@ -101,7 +105,9 @@
@click="goToTradeDetail(market)" @click="goToTradeDetail(market)"
> >
<div class="market-row-main"> <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> <span class="market-chance">{{ marketChance(market) }}%</span>
</div> </div>
<div class="market-row-vol">{{ formatVolume(market.volume) }}</div> <div class="market-row-vol">{{ formatVolume(market.volume) }}</div>
@ -152,7 +158,8 @@
rounded="sm" rounded="sm"
@click="openSheetWithOption('yes')" @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>
<v-btn <v-btn
class="mobile-bar-btn mobile-bar-no" class="mobile-bar-btn mobile-bar-no"
@ -160,7 +167,8 @@
rounded="sm" rounded="sm"
@click="openSheetWithOption('no')" @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-btn>
<v-menu <v-menu
v-model="mobileMenuOpen" v-model="mobileMenuOpen"
@ -215,7 +223,14 @@ defineOptions({ name: 'EventMarkets' })
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useDisplay } from 'vuetify' 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 { toLwcData } from '../composables/useLightweightChart'
import TradeComponent from '../components/TradeComponent.vue' import TradeComponent from '../components/TradeComponent.vue'
import { import {
@ -226,7 +241,11 @@ import {
type PmEventListItem, type PmEventListItem,
type PmEventMarketItem, type PmEventMarketItem,
} from '../api/event' } from '../api/event'
import { getPmPriceHistoryPublic, priceHistoryToChartData, getTimeRangeSeconds } from '../api/priceHistory' import {
getPmPriceHistoryPublic,
priceHistoryToChartData,
getTimeRangeSeconds,
} from '../api/priceHistory'
import { getMockEventById } from '../api/mockData' import { getMockEventById } from '../api/mockData'
import { USE_MOCK_EVENT } from '../config/mock' import { USE_MOCK_EVENT } from '../config/mock'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@ -391,7 +410,10 @@ async function loadChartFromApi(): Promise<ChartSeriesItem[]> {
} }
try { try {
const ev = eventDetail.value 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({ const res = await getPmPriceHistoryPublic({
market: yesTokenId, market: yesTokenId,
page: 1, page: 1,
@ -416,7 +438,7 @@ function setChartSeries(seriesArr: ChartSeriesItem[]) {
const series = chartInstance!.addSeries(LineSeries, { const series = chartInstance!.addSeries(LineSeries, {
color, color,
lineWidth: 2, lineWidth: 2,
lineType: LineType.Simple, lineType: LineType.Curved,
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate, lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
crosshairMarkerVisible: true, crosshairMarkerVisible: true,
lastValueVisible: true, lastValueVisible: true,
@ -630,39 +652,43 @@ onMounted(() => {
}) })
let tradeSheetUnmountTimer: ReturnType<typeof setTimeout> | undefined let tradeSheetUnmountTimer: ReturnType<typeof setTimeout> | undefined
let tradeSheetMountTimer: ReturnType<typeof setTimeout> | undefined let tradeSheetMountTimer: ReturnType<typeof setTimeout> | undefined
watch(tradeSheetOpen, (open) => { watch(
if (open) { tradeSheetOpen,
if (tradeSheetUnmountTimer) { (open) => {
clearTimeout(tradeSheetUnmountTimer) if (open) {
tradeSheetUnmountTimer = undefined 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(() => { { immediate: true },
tradeSheetRenderContent.value = true )
tradeSheetMountTimer = undefined
nextTick(() => {
const pending = pendingMergeSplitDialog.value
if (pending === 'merge') {
pendingMergeSplitDialog.value = null
tradeComponentRef.value?.openMergeDialog?.()
} else if (pending === 'split') {
pendingMergeSplitDialog.value = null
tradeComponentRef.value?.openSplitDialog?.()
}
})
}, 50)
} else {
pendingMergeSplitDialog.value = null
if (tradeSheetMountTimer) {
clearTimeout(tradeSheetMountTimer)
tradeSheetMountTimer = undefined
}
tradeSheetUnmountTimer = setTimeout(() => {
tradeSheetRenderContent.value = false
tradeSheetUnmountTimer = undefined
}, 350)
}
}, { immediate: true })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)

View File

@ -3,7 +3,12 @@
<v-pull-to-refresh class="trade-detail-pull-refresh" @load="onRefresh"> <v-pull-to-refresh class="trade-detail-pull-refresh" @load="onRefresh">
<div class="trade-detail-pull-refresh-inner"> <div class="trade-detail-pull-refresh-inner">
<!-- findPmEvent 请求中仅显示 loading --> <!-- 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"> <div class="trade-detail-loading-placeholder">
<v-progress-circular indeterminate color="primary" size="48" /> <v-progress-circular indeterminate color="primary" size="48" />
<p>{{ t('common.loading') }}</p> <p>{{ t('common.loading') }}</p>
@ -11,325 +16,342 @@
</v-card> </v-card>
<v-row v-else align="stretch" no-gutters class="trade-detail-row"> <v-row v-else align="stretch" no-gutters class="trade-detail-row">
<!-- 左侧分时图 + 订单簿宽度弹性 --> <!-- 左侧分时图 + 订单簿宽度弹性 -->
<v-col cols="12" class="chart-col"> <v-col cols="12" class="chart-col">
<!-- 分时图卡片Polymarket 样式 --> <!-- 分时图卡片Polymarket 样式 -->
<v-card class="chart-card polymarket-chart" elevation="0" rounded="lg"> <v-card class="chart-card polymarket-chart" elevation="0" rounded="lg">
<!-- 顶部标题当前概率Past / 日期 --> <!-- 顶部标题当前概率Past / 日期 -->
<div class="chart-header"> <div class="chart-header">
<h1 class="chart-title"> <h1 class="chart-title">
{{ detailLoading && !eventDetail ? t('common.loading') : marketTitle }} {{ detailLoading && !eventDetail ? t('common.loading') : marketTitle }}
</h1> </h1>
<p v-if="detailError" class="chart-error">{{ detailError }}</p> <p v-if="detailError" class="chart-error">{{ detailError }}</p>
<div class="chart-controls-row"> <div class="chart-controls-row">
<v-btn-group <v-btn-group
v-if="isCryptoEvent" v-if="isCryptoEvent"
variant="outlined" variant="outlined"
density="compact" density="compact"
divided divided
class="chart-mode-toggle" class="chart-mode-toggle"
> >
<v-btn <v-btn
:class="{ active: chartMode === 'yesno' }" :class="{ active: chartMode === 'yesno' }"
size="small" size="small"
icon icon
:aria-label="t('chart.yesnoTimeSeries')" :aria-label="t('chart.yesnoTimeSeries')"
@click="setChartMode('yesno')" @click="setChartMode('yesno')"
> >
<v-icon size="20">mdi-chart-timeline-variant</v-icon> <v-icon size="20">mdi-chart-timeline-variant</v-icon>
</v-btn> </v-btn>
<v-btn <v-btn
:class="{ active: chartMode === 'crypto' }" :class="{ active: chartMode === 'crypto' }"
size="small" size="small"
icon icon
:aria-label="t('chart.cryptoPrice')" :aria-label="t('chart.cryptoPrice')"
@click="setChartMode('crypto')" @click="setChartMode('crypto')"
> >
<v-icon size="20">mdi-currency-btc</v-icon> <v-icon size="20">mdi-currency-btc</v-icon>
</v-btn> </v-btn>
</v-btn-group> </v-btn-group>
<v-btn variant="text" size="small" class="past-btn">Past </v-btn> <v-btn variant="text" size="small" class="past-btn">Past </v-btn>
<v-btn class="date-pill" size="small" rounded="pill">{{ resolutionDate }}</v-btn> <v-btn class="date-pill" size="small" rounded="pill">{{ resolutionDate }}</v-btn>
</div> </div>
<div class="chart-chance"> <div class="chart-chance">
<template v-if="chartMode === 'crypto' && cryptoSymbol"> <template v-if="chartMode === 'crypto' && cryptoSymbol">
${{ currentCryptoPrice }} {{ cryptoSymbol.toUpperCase() }} ${{ currentCryptoPrice }} {{ cryptoSymbol.toUpperCase() }}
</template> </template>
<template v-else> {{ currentChance }}% {{ t('common.chance') }} </template> <template v-else> {{ currentChance }}% {{ t('common.chance') }} </template>
</div> </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>
</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>
<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="chart-footer">
<div class="position-row-icon" :class="pos.iconClass"> <div class="chart-footer-left">
<img <span class="chart-volume">{{ marketVolume }} Vol.</span>
v-if="pos.imageUrl" <span v-if="marketExpiresAt" class="chart-expires">| {{ marketExpiresAt }}</span>
:src="pos.imageUrl" </div>
alt="" <div class="chart-time-ranges">
class="position-row-icon-img" <v-btn
/> v-for="r in timeRanges"
<span v-else class="position-row-icon-char">{{ pos.iconChar || '•' }}</span> :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> </div>
<span class="position-row-title">{{ pos.market }}</span>
</div> </div>
<div class="position-row-main"> </v-window-item>
<span :class="['position-outcome-pill', pos.outcomePillClass]">{{ <v-window-item value="orders" class="detail-pane">
pos.outcomeTag <div v-if="openOrderLoading" class="placeholder-pane placeholder-pane--loading">
}}</span> <v-progress-circular indeterminate size="36" width="2" />
<span v-if="pos.locked" class="position-lock-badge"> <span>{{ t('common.loading') }}</span>
{{
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>
<div class="position-row-meta">{{ pos.bet }} {{ pos.toWin }}</div> <div v-else-if="marketOpenOrders.length === 0" class="placeholder-pane">
</div> {{ t('activity.noOpenOrdersInMarket') }}
</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>
<div class="order-row-actions"> <div v-else class="orders-list">
<v-btn <div v-for="ord in marketOpenOrders" :key="ord.id" class="order-row-item">
variant="text" <div class="order-row-main">
size="small" <span
color="error" :class="['order-side-pill', ord.side === 'Yes' ? 'side-yes' : 'side-no']"
:disabled="cancelOrderLoading || ord.fullyFilled" >
@click="cancelMarketOrder(ord)" {{ ord.actionLabel || `Buy ${ord.outcome}` }}
> </span>
{{ t('activity.cancelOrder') }} <span class="order-price">{{ ord.price }}</span>
</v-btn> <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>
</div> </v-window>
</v-window-item> </v-card>
</v-window>
</v-card>
<!-- Order Book Section --> <!-- Order Book Section -->
<v-card class="order-book-card" elevation="0" rounded="lg"> <v-card class="order-book-card" elevation="0" rounded="lg">
<OrderBook <OrderBook
:asks-yes="orderBookAsksYes" :asks-yes="orderBookAsksYes"
:bids-yes="orderBookBidsYes" :bids-yes="orderBookBidsYes"
:asks-no="orderBookAsksNo" :asks-no="orderBookAsksNo"
:bids-no="orderBookBidsNo" :bids-no="orderBookBidsNo"
:anchor-best-bid-yes="orderBookBestBidYesCents" :anchor-best-bid-yes="orderBookBestBidYesCents"
:anchor-lowest-ask-yes="orderBookLowestAskYesCents" :anchor-lowest-ask-yes="orderBookLowestAskYesCents"
:anchor-best-bid-no="orderBookBestBidNoCents" :anchor-best-bid-no="orderBookBestBidNoCents"
:anchor-lowest-ask-no="orderBookLowestAskNoCents" :anchor-lowest-ask-no="orderBookLowestAskNoCents"
:outcome-price-anchor-yes-cents="orderBookOutcomeAnchorYesCents" :outcome-price-anchor-yes-cents="orderBookOutcomeAnchorYesCents"
:outcome-price-anchor-no-cents="orderBookOutcomeAnchorNoCents" :outcome-price-anchor-no-cents="orderBookOutcomeAnchorNoCents"
:last-price-yes="clobLastPriceYes" :last-price-yes="clobLastPriceYes"
:last-price-no="clobLastPriceNo" :last-price-no="clobLastPriceNo"
:spread-yes="clobSpreadYes" :spread-yes="clobSpreadYes"
:spread-no="clobSpreadNo" :spread-no="clobSpreadNo"
:loading="clobLoading" :loading="clobLoading"
:connected="clobConnected" :connected="clobConnected"
:yes-label="yesLabel" :yes-label="yesLabel"
:no-label="noLabel" :no-label="noLabel"
/> />
</v-card> </v-card>
<!-- Comments / Top Holders / Activity与左侧图表订单簿同宽 --> <!-- Comments / Top Holders / Activity与左侧图表订单簿同宽 -->
<v-card class="activity-card" elevation="0" rounded="lg"> <v-card class="activity-card" elevation="0" rounded="lg">
<div class="rules-pane"> <div class="rules-pane">
<div v-if="!eventDetail?.description && !eventDetail?.resolutionSource" class="placeholder-pane"> <div
{{ t('activity.rulesEmpty') }} v-if="!eventDetail?.description && !eventDetail?.resolutionSource"
</div> class="placeholder-pane"
<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 }} {{ t('activity.rulesEmpty') }}
<v-icon size="14">mdi-open-in-new</v-icon> </div>
</a> <template v-else>
<div v-else class="rules-text">{{ eventDetail.resolutionSource }}</div> <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> </div>
</template> </v-card>
</div> </v-col>
</v-card>
</v-col>
<!-- 右侧交易组件固定宽度传入当前市场以便 Split 调用拆单接口移动端隐藏改用底部栏+弹窗 --> <!-- 右侧交易组件固定宽度传入当前市场以便 Split 调用拆单接口移动端隐藏改用底部栏+弹窗 -->
<v-col v-if="!isMobile" cols="12" class="trade-col"> <v-col v-if="!isMobile" cols="12" class="trade-col">
<div class="trade-sidebar"> <div class="trade-sidebar">
<TradeComponent <TradeComponent
ref="tradeComponentRef" ref="tradeComponentRef"
:market="tradeMarketPayload" :market="tradeMarketPayload"
:initial-option="tradeInitialOption" :initial-option="tradeInitialOption"
:positions="tradePositionsForComponent" :positions="tradePositionsForComponent"
@merge-success="onMergeSuccess" @merge-success="onMergeSuccess"
@split-success="onSplitSuccess" @split-success="onSplitSuccess"
/> />
</div> </div>
</v-col> </v-col>
<!-- 移动端固定底部 Yes/No + 三点菜单Merge/Split --> <!-- 移动端固定底部 Yes/No + 三点菜单Merge/Split -->
<template v-if="isMobile && tradeMarketPayload"> <template v-if="isMobile && tradeMarketPayload">
<div class="mobile-trade-bar-spacer" aria-hidden="true"></div> <div class="mobile-trade-bar-spacer" aria-hidden="true"></div>
<div class="mobile-trade-bar"> <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-btn
v-bind="menuProps" class="mobile-bar-btn mobile-bar-yes"
class="mobile-bar-more-btn"
variant="flat" variant="flat"
icon rounded="sm"
rounded="pill" @click="openSheetWithOption('yes')"
aria-label="更多操作"
> >
<v-icon size="20">mdi-dots-horizontal</v-icon> {{ yesLabel }} {{ yesPriceCents }}¢
</v-btn> </v-btn>
</template> <v-btn
<v-list density="compact"> class="mobile-bar-btn mobile-bar-no"
<v-list-item @click="openMergeFromBar"> variant="flat"
<v-list-item-title>{{ t('trade.merge') }}</v-list-item-title> rounded="sm"
</v-list-item> @click="openSheetWithOption('no')"
<v-list-item @click="openSplitFromBar"> >
<v-list-item-title>{{ t('trade.split') }}</v-list-item-title> {{ noLabel }} {{ noPriceCents }}¢
</v-list-item> </v-btn>
</v-list> <v-menu
</v-menu> v-model="mobileMenuOpen"
</div> :close-on-content-click="true"
<v-bottom-sheet v-model="tradeSheetOpen" content-class="trade-detail-trade-sheet"> location="top"
<TradeComponent transition="scale-transition"
v-if="tradeSheetRenderContent" >
ref="mobileTradeComponentRef" <template #activator="{ props: menuProps }">
:market="tradeMarketPayload" <v-btn
:initial-option="tradeInitialOptionFromBar" v-bind="menuProps"
:initial-tab="tradeInitialTabFromBar" class="mobile-bar-more-btn"
:positions="tradePositionsForComponent" variant="flat"
embedded-in-sheet icon
@order-success="onOrderSuccess" rounded="pill"
@merge-success="onMergeSuccess" aria-label="更多操作"
@split-success="onSplitSuccess" >
/> <v-icon size="20">mdi-dots-horizontal</v-icon>
</v-bottom-sheet> </v-btn>
</template> </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 弹出的交易组件桌面/移动端通用 --> <!-- 从持仓点击 Sell 弹出的交易组件桌面/移动端通用 -->
<v-dialog <v-dialog
v-model="sellDialogOpen" v-model="sellDialogOpen"
max-width="420" max-width="420"
content-class="trade-detail-sell-dialog" content-class="trade-detail-sell-dialog"
transition="dialog-transition" transition="dialog-transition"
> >
<TradeComponent <TradeComponent
v-if="sellDialogRenderContent" v-if="sellDialogRenderContent"
:market="tradeMarketPayload" :market="tradeMarketPayload"
:initial-option="sellInitialOption" :initial-option="sellInitialOption"
:initial-tab="'sell'" :initial-tab="'sell'"
:positions="tradePositionsForComponent" :positions="tradePositionsForComponent"
@order-success="onSellOrderSuccess" @order-success="onSellOrderSuccess"
@merge-success="onMergeSuccess" @merge-success="onMergeSuccess"
@split-success="onSplitSuccess" @split-success="onSplitSuccess"
/> />
</v-dialog> </v-dialog>
</v-row> </v-row>
</div> </div>
</v-pull-to-refresh> </v-pull-to-refresh>
@ -346,10 +368,13 @@ import {
LineSeries, LineSeries,
LineType, LineType,
LastPriceAnimationMode, LastPriceAnimationMode,
TickMarkType,
type IChartApi, type IChartApi,
type ISeriesApi, type ISeriesApi,
type Time,
} from 'lightweight-charts' } from 'lightweight-charts'
import { toLwcData, toLwcPoint } from '../composables/useLightweightChart' import { toLwcData, toLwcPoint } from '../composables/useLightweightChart'
import { InPanePriceScalePrimitive } from '../composables/inPanePriceScalePrimitive'
import OrderBook from '../components/OrderBook.vue' import OrderBook from '../components/OrderBook.vue'
import TradeComponent, { type TradePositionItem } from '../components/TradeComponent.vue' import TradeComponent, { type TradePositionItem } from '../components/TradeComponent.vue'
import { import {
@ -387,6 +412,8 @@ import {
inferCryptoSymbol, inferCryptoSymbol,
fetchCryptoChart, fetchCryptoChart,
subscribeCryptoRealtime, subscribeCryptoRealtime,
alignCryptoChartTimeMs,
CRYPTO_CHART_TIME_STEP_MS,
} from '../api/cryptoChart' } from '../api/cryptoChart'
const { t } = useI18n() const { t } = useI18n()
@ -502,11 +529,9 @@ async function loadEventDetail() {
} }
function onRefresh({ done }: { done: () => void }) { function onRefresh({ done }: { done: () => void }) {
Promise.all([ Promise.all([loadEventDetail(), loadMarketPositions(), loadMarketOpenOrders()]).finally(() =>
loadEventDetail(), done(),
loadMarketPositions(), )
loadMarketOpenOrders(),
]).finally(() => done())
} }
// query // query
@ -1174,6 +1199,8 @@ const cryptoChartLoading = ref(false)
const chartYesNoLoading = ref(false) const chartYesNoLoading = ref(false)
let chartInstance: IChartApi | null = null let chartInstance: IChartApi | null = null
let chartSeries: ISeriesApi<'Line'> | null = null let chartSeries: ISeriesApi<'Line'> | null = null
/** 主图内自绘 Y 轴刻度(不占用右侧价轴条带) */
let inPanePriceScale: InPanePriceScalePrimitive | null = null
const currentChance = computed(() => { const currentChance = computed(() => {
const ev = eventDetail.value const ev = eventDetail.value
@ -1195,11 +1222,18 @@ const currentChance = computed(() => {
const lineColor = '#2563eb' const lineColor = '#2563eb'
const MOBILE_BREAKPOINT = 600 /** 叠加价格轴 ID曲线绑定在 overlay scale主图占满宽Y 轴数字由 InPanePriceScalePrimitive 画在图内 */
const CHART_OVERLAY_PRICE_SCALE_ID = 'overlay-price'
/** 右侧留白pxfitContent 时压缩可用宽度线头略离开物理右缘lastPrice 水波能露出一半且不依赖超大 rightOffsetbars */
const CHART_RIPPLE_RIGHT_PAD_PX = 28
function ensureChartSeries() { function ensureChartSeries() {
if (!chartInstance || !chartContainerRef.value) return if (!chartInstance || !chartContainerRef.value) return
if (chartSeries) { if (chartSeries) {
if (inPanePriceScale) {
chartSeries.detachPrimitive(inPanePriceScale)
inPanePriceScale = null
}
chartInstance.removeSeries(chartSeries) chartInstance.removeSeries(chartSeries)
chartSeries = null chartSeries = null
} }
@ -1207,12 +1241,26 @@ function ensureChartSeries() {
chartSeries = chartInstance.addSeries(LineSeries, { chartSeries = chartInstance.addSeries(LineSeries, {
color: lineColor, color: lineColor,
lineWidth: 2, lineWidth: 2,
lineType: LineType.Simple, lineType: LineType.Curved,
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate, lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
crosshairMarkerVisible: true, crosshairMarkerVisible: true,
lastValueVisible: true, lastValueVisible: true,
priceFormat: isCrypto ? { type: 'price', precision: 2 } : { type: 'percent', precision: 1 }, 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][]) { function setChartData(chartData: [number, number][]) {
@ -1226,7 +1274,7 @@ function initChart() {
const el = chartContainerRef.value const el = chartContainerRef.value
chartInstance = createChart(el, { chartInstance = createChart(el, {
width: el.clientWidth, width: el.clientWidth,
height: 320, height: el.clientHeight || 320,
layout: { layout: {
background: { color: '#ffffff' }, background: { color: '#ffffff' },
textColor: '#6b7280', textColor: '#6b7280',
@ -1235,8 +1283,19 @@ function initChart() {
attributionLogo: false, attributionLogo: false,
}, },
grid: { vertLines: { color: '#f3f4f6' }, horzLines: { color: '#f3f4f6' } }, grid: { vertLines: { color: '#f3f4f6' }, horzLines: { color: '#f3f4f6' } },
rightPriceScale: { borderColor: '#e5e7eb', scaleMargins: { top: 0.1, bottom: 0.1 } }, leftPriceScale: { visible: false },
timeScale: { borderColor: '#e5e7eb', timeVisible: true, secondsVisible: 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, handleScroll: false,
handleScale: false, handleScale: false,
crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } }, 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 const MINUTE_MS = 60 * 1000
let cryptoWsUnsubscribe: (() => void) | null = null 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]) { function applyCryptoRealtimePoint(point: [number, number]) {
if (chartMode.value !== 'crypto') return
const list = [...data.value] 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 TT update() Cannot update oldest data
if (prevLastT != null && ts < prevLastT) {
ts = alignCryptoChartTimeMs(prevLastT + CRYPTO_CHART_TIME_STEP_MS)
}
const range = selectedTimeRange.value const range = selectedTimeRange.value
let useIncrementalUpdate = false let useIncrementalUpdate = false
let updatePoint: [number, number] | null = null let updatePoint: [number, number] | null = null
if (range === '30S') { if (range === '30S') {
const cutoff = Date.now() - 30 * 1000 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]) const filtered = list.filter(([t]) => t >= cutoff).sort((a, b) => a[0] - b[0])
data.value = filtered data.value = filtered
// update // update
@ -1293,7 +1407,8 @@ function applyCryptoRealtimePoint(point: [number, number]) {
const last = list[list.length - 1] const last = list[list.length - 1]
const minuteStart = Math.floor(ts / MINUTE_MS) * MINUTE_MS const minuteStart = Math.floor(ts / MINUTE_MS) * MINUTE_MS
const sameMinute = last && Math.floor(last[0] / MINUTE_MS) === Math.floor(ts / 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 extra = Math.min(200, Math.round(baseMax * 0.1))
const max = baseMax + extra const max = baseMax + extra
if (sameMinute) { if (sameMinute) {
@ -1316,11 +1431,13 @@ function applyCryptoRealtimePoint(point: [number, number]) {
} else { } else {
setChartData(data.value) setChartData(data.value)
} }
scheduleCryptoScrollToRealtime()
} }
async function updateChartData() { async function updateChartData() {
if (chartMode.value === 'crypto') { if (chartMode.value === 'crypto') {
const sym = cryptoSymbol.value ?? 'btc' const sym = cryptoSymbol.value ?? 'btc'
const gen = ++cryptoLoadGeneration
cryptoWsUnsubscribe?.() cryptoWsUnsubscribe?.()
cryptoWsUnsubscribe = null cryptoWsUnsubscribe = null
cryptoChartLoading.value = true cryptoChartLoading.value = true
@ -1329,14 +1446,23 @@ async function updateChartData() {
symbol: sym, symbol: sym,
range: selectedTimeRange.value, range: selectedTimeRange.value,
}) })
if (gen !== cryptoLoadGeneration || chartMode.value !== 'crypto') {
return
}
data.value = (res.data ?? []) as [number, number][] data.value = (res.data ?? []) as [number, number][]
ensureChartSeries() ensureChartSeries()
setChartData(data.value) setChartData(data.value)
nextTick(() => scheduleCryptoScrollToRealtime())
cryptoWsUnsubscribe?.()
cryptoWsUnsubscribe = null
cryptoWsUnsubscribe = subscribeCryptoRealtime(sym, applyCryptoRealtimePoint) cryptoWsUnsubscribe = subscribeCryptoRealtime(sym, applyCryptoRealtimePoint)
} finally { } finally {
cryptoChartLoading.value = false if (gen === cryptoLoadGeneration) {
cryptoChartLoading.value = false
}
} }
} else { } else {
cryptoLoadGeneration++
cryptoWsUnsubscribe?.() cryptoWsUnsubscribe?.()
cryptoWsUnsubscribe = null cryptoWsUnsubscribe = null
chartYesNoLoading.value = true chartYesNoLoading.value = true
@ -1388,7 +1514,8 @@ watch(
const handleResize = () => { const handleResize = () => {
if (!chartInstance || !chartContainerRef.value) return if (!chartInstance || !chartContainerRef.value) return
chartInstance.resize(chartContainerRef.value.clientWidth, 320) const el = chartContainerRef.value
chartInstance.resize(el.clientWidth, el.clientHeight || 320)
} }
watch( watch(
@ -1443,10 +1570,15 @@ onUnmounted(() => {
unsubscribePositionUpdate() unsubscribePositionUpdate()
cryptoWsUnsubscribe?.() cryptoWsUnsubscribe?.()
cryptoWsUnsubscribe = null cryptoWsUnsubscribe = null
if (cryptoScrollToRtRaf) {
cancelAnimationFrame(cryptoScrollToRtRaf)
cryptoScrollToRtRaf = 0
}
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
chartInstance?.remove() chartInstance?.remove()
chartInstance = null chartInstance = null
chartSeries = null chartSeries = null
inPanePriceScale = null
disconnectClob() disconnectClob()
}) })
</script> </script>

View File

@ -97,7 +97,7 @@
<span v-else class="position-icon-char">{{ pos.iconChar || '•' }}</span> <span v-else class="position-icon-char">{{ pos.iconChar || '•' }}</span>
</div> </div>
<div class="design-pos-title-col"> <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"> <div class="design-pos-tags">
<span <span
v-if="pos.outcomeTag" v-if="pos.outcomeTag"
@ -164,12 +164,7 @@
> >
<div class="design-order-top"> <div class="design-order-top">
<div class="order-mobile-icon" :class="ord.iconClass"> <div class="order-mobile-icon" :class="ord.iconClass">
<img <img v-if="ord.imageUrl" :src="ord.imageUrl" alt="" class="position-icon-img" />
v-if="ord.imageUrl"
:src="ord.imageUrl"
alt=""
class="position-icon-img"
/>
<span v-else class="position-icon-char">{{ ord.iconChar || '•' }}</span> <span v-else class="position-icon-char">{{ ord.iconChar || '•' }}</span>
</div> </div>
<div class="order-mobile-main design-order-title-col"> <div class="order-mobile-main design-order-title-col">
@ -182,9 +177,11 @@
<span class="order-side-pill" :class="getOrderSideClass(ord)">{{ <span class="order-side-pill" :class="getOrderSideClass(ord)">{{
getOrderActionLabel(ord) getOrderActionLabel(ord)
}}</span> }}</span>
<span class="order-outcome-pill" :class="ord.side === 'Yes' ? 'outcome-yes' : 'outcome-no'">{{ <span
ord.side class="order-outcome-pill"
}}</span> :class="ord.side === 'Yes' ? 'outcome-yes' : 'outcome-no'"
>{{ ord.side }}</span
>
</div> </div>
</div> </div>
<v-btn <v-btn
@ -267,9 +264,8 @@
{{ getHistoryTagLabel(h) }} {{ getHistoryTagLabel(h) }}
</span> </span>
<span class="design-history-meta" <span class="design-history-meta"
>{{ t('wallet.priceLabel') }}: {{ h.avgPrice || '—' }} · {{ t('wallet.sharesLabel') }}: {{ >{{ t('wallet.priceLabel') }}: {{ h.avgPrice || '—' }} ·
h.shares || '—' {{ t('wallet.sharesLabel') }}: {{ h.shares || '—' }}</span
}}</span
> >
</div> </div>
</template> </template>
@ -280,7 +276,11 @@
<span v-else class="position-icon-char">{{ getFundingIconText(h) }}</span> <span v-else class="position-icon-char">{{ getFundingIconText(h) }}</span>
</div> </div>
<div class="history-mobile-main"> <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 class="history-mobile-activity">{{ h.timeAgo || '—' }}</div>
</div> </div>
</div> </div>
@ -441,7 +441,11 @@
</div> </div>
</div> </div>
<h3 class="sell-dialog-title"> <h3 class="sell-dialog-title">
{{ t('wallet.sellDialogTitle', { outcome: sellPositionItem.sellOutcome || t('wallet.position') }) }} {{
t('wallet.sellDialogTitle', {
outcome: sellPositionItem.sellOutcome || t('wallet.position'),
})
}}
</h3> </h3>
<p class="sell-dialog-market">{{ sellPositionItem.market }}</p> <p class="sell-dialog-market">{{ sellPositionItem.market }}</p>
<div class="sell-receive-box"> <div class="sell-receive-box">
@ -456,7 +460,9 @@
<v-btn color="success" variant="flat" block class="sell-redeem-btn" @click="redeemSell"> <v-btn color="success" variant="flat" block class="sell-redeem-btn" @click="redeemSell">
{{ t('wallet.sellDialogRedeem') }} {{ t('wallet.sellDialogRedeem') }}
</v-btn> </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-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
@ -687,10 +693,7 @@ interface HistoryItem {
detailMarketId?: string detailMarketId?: string
} }
function canOpenTradeDetail(opts: { function canOpenTradeDetail(opts: { tradeEventId?: string; tradeEventSlug?: string }): boolean {
tradeEventId?: string
tradeEventSlug?: string
}): boolean {
return !!(opts.tradeEventId?.trim() || opts.tradeEventSlug?.trim()) return !!(opts.tradeEventId?.trim() || opts.tradeEventSlug?.trim())
} }
@ -792,7 +795,9 @@ function isFundingHistory(h: HistoryItem): boolean {
function getFundingTitle(h: HistoryItem): string { function getFundingTitle(h: HistoryItem): string {
if (h.market?.trim()) return h.market 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 { function getFundingIconText(h: HistoryItem): string {
@ -1062,8 +1067,10 @@ function getWithdrawAmountText(w: SettlementRequestClientItem): string {
function getWithdrawFeeText(w: SettlementRequestClientItem): string { function getWithdrawFeeText(w: SettlementRequestClientItem): string {
const feeRaw = Number( 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 }).fee ??
(w as { fee?: string | number; gasFee?: string | number; serviceFee?: string | number }).gasFee ?? (w as { fee?: string | number; gasFee?: string | number; serviceFee?: string | number })
(w as { fee?: string | number; gasFee?: string | number; serviceFee?: string | number }).serviceFee ?? .gasFee ??
(w as { fee?: string | number; gasFee?: string | number; serviceFee?: string | number })
.serviceFee ??
0, 0,
) )
const feeText = Number.isFinite(feeRaw) const feeText = Number.isFinite(feeRaw)
@ -1375,7 +1382,7 @@ function initPlChart() {
bottomColor: color + '08', bottomColor: color + '08',
lineColor: color, lineColor: color,
lineWidth: 2, lineWidth: 2,
lineType: LineType.Simple, lineType: LineType.Curved,
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate, lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
priceFormat: { type: 'price', precision: 2 }, priceFormat: { type: 'price', precision: 2 },
}) })