From fef3d86fc2f2da86fb2baba1d74e80f36bd569e9 Mon Sep 17 00:00:00 2001
From: ivan
Date: Tue, 31 Mar 2026 21:31:38 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E6=8A=98=E7=BA=BF?=
=?UTF-8?q?=E5=9B=BE=E7=9A=84=E6=98=BE=E7=A4=BA=E5=AE=BD=E5=BA=A6=E4=BC=98?=
=?UTF-8?q?=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/api/cryptoChart.ts | 37 +-
src/composables/inPanePriceScalePrimitive.ts | 160 ++++
src/views/EventMarkets.vue | 108 ++-
src/views/TradeDetail.vue | 766 +++++++++++--------
src/views/Wallet.vue | 55 +-
5 files changed, 742 insertions(+), 384 deletions(-)
create mode 100644 src/composables/inPanePriceScalePrimitive.ts
diff --git a/src/api/cryptoChart.ts b/src/api/cryptoChart.ts
index efd3967..21cdabb 100644
--- a/src/api/cryptoChart.ts
+++ b/src/api/cryptoChart.ts
@@ -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 | 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()
diff --git a/src/composables/inPanePriceScalePrimitive.ts b/src/composables/inPanePriceScalePrimitive.ts
new file mode 100644
index 0000000..9d159fb
--- /dev/null
+++ b/src/composables/inPanePriceScalePrimitive.ts
@@ -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
- {{ t('eventMarkets.past') }}
+ {{
+ t('eventMarkets.past')
+ }}
{{ resolutionDate }}
- {{ t('eventMarkets.marketsCount', { n: markets.length }) }}
+
+ {{ t('eventMarkets.marketsCount', { n: markets.length }) }}
+
@@ -101,7 +105,9 @@
@click="goToTradeDetail(market)"
>
- {{ market.question || t('eventMarkets.marketPlaceholder') }}
+ {{
+ market.question || t('eventMarkets.marketPlaceholder')
+ }}
{{ marketChance(market) }}%
{{ formatVolume(market.volume) }}
@@ -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') }}
- {{ barMarket ? noLabel(barMarket) : t('eventMarkets.no') }} {{ barMarket ? noPrice(barMarket) : t('eventMarkets.priceZero') }}
+ {{ barMarket ? noLabel(barMarket) : t('eventMarkets.no') }}
+ {{ barMarket ? noPrice(barMarket) : t('eventMarkets.priceZero') }}
{
}
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 | undefined
let tradeSheetMountTimer: ReturnType | 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)
diff --git a/src/views/TradeDetail.vue b/src/views/TradeDetail.vue
index 1942551..32845f0 100644
--- a/src/views/TradeDetail.vue
+++ b/src/views/TradeDetail.vue
@@ -3,7 +3,12 @@
-
+
{{ t('common.loading') }}
@@ -11,325 +16,342 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ t('activity.myPositions') }}
- {{ t('activity.openOrders') }}
-
-
-
-
-
-
{{ t('common.loading') }}
+
+
+
+
+
+
-
- {{ t('activity.noPositionsInMarket') }}
+
+
+
-
-
-
- {{ t('wallet.sellDialogTitle', { outcome: sellPositionItem.sellOutcome || t('wallet.position') }) }}
+ {{
+ t('wallet.sellDialogTitle', {
+ outcome: sellPositionItem.sellOutcome || t('wallet.position'),
+ })
+ }}
{{ sellPositionItem.market }}
@@ -456,7 +460,9 @@
{{ t('wallet.sellDialogRedeem') }}
-
{{ t('wallet.sellDialogEditOrder') }}
+
{{
+ t('wallet.sellDialogEditOrder')
+ }}
@@ -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 },
})