From 50e451e8921f76f55e6415ce5d4779924ba9d0e0 Mon Sep 17 00:00:00 2001 From: ivan Date: Tue, 3 Mar 2026 19:02:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=99=9A=E6=8B=9F=E8=B4=A7=E5=B8=81=E7=9A=84=E8=B5=B0=E5=8A=BF?= =?UTF-8?q?=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api/cryptoChart.md | 35 +++++ docs/views/TradeDetail.md | 4 +- src/api/cryptoChart.ts | 302 ++++++++++++++++++++++++++++++++++++++ src/locales/en.json | 4 + src/locales/ja.json | 4 + src/locales/ko.json | 4 + src/locales/zh-CN.json | 4 + src/locales/zh-TW.json | 4 + src/views/TradeDetail.vue | 275 ++++++++++++++++++++++++++++++++-- 9 files changed, 624 insertions(+), 12 deletions(-) create mode 100644 docs/api/cryptoChart.md create mode 100644 src/api/cryptoChart.ts diff --git a/docs/api/cryptoChart.md b/docs/api/cryptoChart.md new file mode 100644 index 0000000..9ae2acb --- /dev/null +++ b/docs/api/cryptoChart.md @@ -0,0 +1,35 @@ +# cryptoChart.ts + +**路径**:`src/api/cryptoChart.ts` + +## 功能用途 + +加密货币价格走势图 API,使用 **Binance REST + WebSocket** 实现细粒度(1 分钟)实时走势。当事件类型为加密货币时,详情页可切换显示 YES/NO 分时走势图与加密货币实时价格走势图。 + +## 核心能力 + +- `isCryptoEvent`:判断事件是否为加密货币类型(通过 tags/series slug、ticker) +- `inferCryptoSymbol`:从事件信息推断币种符号(btc、eth 等) +- `fetchCryptoChart`:从 Binance 获取 1 分钟 K 线历史(优先),不支持时回退 CoinGecko +- `subscribeCryptoRealtime`:订阅 Binance K 线 WebSocket,约每 250ms 推送,实现走势图实时更新 + +## 币种映射 + +`COINGECKO_COIN_IDS` 支持:btc、eth、sol、bnb、xrp、doge、ada、avax、matic、dot、link、uni、atom、ltc、near、apt、arb、op、inj、sui、pepe、wif、shib 等。 + +## 使用方式 + +```typescript +import { isCryptoEvent, inferCryptoSymbol, fetchCryptoChart } from '@/api/cryptoChart' + +if (isCryptoEvent(eventDetail)) { + const symbol = inferCryptoSymbol(eventDetail) ?? 'btc' + const res = await fetchCryptoChart({ symbol, range: '1D' }) + const points = res.data ?? [] +} +``` + +## 扩展方式 + +- 新增币种:在 `COINGECKO_COIN_IDS` 中追加映射 +- 更换数据源:修改 `fetchCryptoChart` 内部实现,或通过后端代理 CoinGecko 以规避 CORS diff --git a/docs/views/TradeDetail.md b/docs/views/TradeDetail.md index c954867..73ec996 100644 --- a/docs/views/TradeDetail.md +++ b/docs/views/TradeDetail.md @@ -9,7 +9,7 @@ ## 核心能力 -- 分时图:ECharts 渲染,支持 Past、时间粒度切换 +- 分时图:ECharts 渲染,支持 Past、时间粒度切换;**加密货币事件**可切换 YES/NO 分时图与加密货币价格走势图(CoinGecko 实时数据) - 订单簿:`OrderBook` 组件,通过 **ClobSdk** 对接 CLOB WebSocket 实时数据(全量快照、增量更新、成交推送);份额接口按 6 位小数传(1_000_000 = 1 share),`priceSizeToRows` 与 `mergeDelta` 会将 raw 值除以 `ORDER_BOOK_SIZE_SCALE` 转为展示值 - 交易:`TradeComponent`,传入 `market`、`initialOption`、`positions`(持仓数据) - 持仓列表:通过 `getPositionList` 获取当前市场持仓,传递给 `TradeComponent` 用于计算可合并份额 @@ -36,7 +36,7 @@ ## 扩展方式 1. **订单簿**:已通过 `sdk/clobSocket.ts` 的 ClobSdk 对接 CLOB WebSocket,使用 **Yes/No token ID** 订阅 `price_size_all`、`price_size_delta`、`trade` 消息 -2. **分时图**:可接入 WebSocket 推送的图表数据 +2. **分时图**:可接入 WebSocket 推送的图表数据;加密货币事件已支持 YES/NO 分时与加密货币价格图切换(`src/api/cryptoChart.ts`) 3. **Comments**:对接评论接口,替换 placeholder 4. **Top Holders**:对接持仓接口 5. **Activity**:已对接 CLOB `trade` 消息,实时追加成交记录 diff --git a/src/api/cryptoChart.ts b/src/api/cryptoChart.ts new file mode 100644 index 0000000..c91550f --- /dev/null +++ b/src/api/cryptoChart.ts @@ -0,0 +1,302 @@ +/** + * 加密货币价格走势图 API + * 使用 Binance REST + WebSocket 实现细粒度(1 分钟)实时走势 + * 用于事件类型为加密货币时,详情页切换显示实时价格走势 + */ + +/** 图表数据点:[时间戳(ms), 价格(USD)] */ +export type CryptoChartPoint = [number, number] + +/** Binance 交易对映射(symbol -> BTCUSDT 等) */ +export const BINANCE_SYMBOLS: Record = { + btc: 'BTCUSDT', + bitcoin: 'BTCUSDT', + eth: 'ETHUSDT', + ethereum: 'ETHUSDT', + sol: 'SOLUSDT', + solana: 'SOLUSDT', + bnb: 'BNBUSDT', + binancecoin: 'BNBUSDT', + xrp: 'XRPUSDT', + ripple: 'XRPUSDT', + doge: 'DOGEUSDT', + dogecoin: 'DOGEUSDT', + ada: 'ADAUSDT', + cardano: 'ADAUSDT', + avax: 'AVAXUSDT', + avalanche: 'AVAXUSDT', + matic: 'MATICUSDT', + polygon: 'MATICUSDT', + dot: 'DOTUSDT', + polkadot: 'DOTUSDT', + link: 'LINKUSDT', + chainlink: 'LINKUSDT', + uni: 'UNIUSDT', + uniswap: 'UNIUSDT', + atom: 'ATOMUSDT', + cosmos: 'ATOMUSDT', + ltc: 'LTCUSDT', + litecoin: 'LTCUSDT', + near: 'NEARUSDT', + apt: 'APTUSDT', + aptos: 'APTUSDT', + arb: 'ARBUSDT', + arbitrum: 'ARBUSDT', + op: 'OPUSDT', + optimism: 'OPUSDT', + inj: 'INJUSDT', + injective: 'INJUSDT', + sui: 'SUIUSDT', + pepe: 'PEPEUSDT', + wif: 'WIFUSDT', + dogwifcoin: 'WIFUSDT', + shib: 'SHIBUSDT', + 'shiba-inu': 'SHIBUSDT', +} + +/** CoinGecko 支持的币种 ID 映射(用于 inferCryptoSymbol 等) */ +export const COINGECKO_COIN_IDS: Record = { + btc: 'bitcoin', + bitcoin: 'bitcoin', + eth: 'ethereum', + ethereum: 'ethereum', + sol: 'solana', + solana: 'solana', + bnb: 'binancecoin', + binancecoin: 'binancecoin', + xrp: 'ripple', + ripple: 'ripple', + doge: 'dogecoin', + dogecoin: 'dogecoin', + ada: 'cardano', + cardano: 'cardano', + avax: 'avalanche-2', + avalanche: 'avalanche-2', + matic: 'matic-network', + polygon: 'matic-network', + dot: 'polkadot', + polkadot: 'polkadot', + link: 'chainlink', + chainlink: 'chainlink', + uni: 'uniswap', + uniswap: 'uniswap', + atom: 'cosmos', + cosmos: 'cosmos', + ltc: 'litecoin', + litecoin: 'litecoin', + near: 'near', + apt: 'aptos', + aptos: 'aptos', + arb: 'arbitrum', + arbitrum: 'arbitrum', + op: 'optimism', + optimism: 'optimism', + inj: 'injective-protocol', + injective: 'injective-protocol', + sui: 'sui', + pepe: 'pepe', + wif: 'dogwifcoin', + shib: 'shiba-inu', +} + +/** 时间范围对应的 CoinGecko 天数(用于 market_chart) */ +export const RANGE_TO_DAYS: Record = { + '1H': 1, + '6H': 1, + '1D': 1, + '1W': 7, + '1M': 30, + ALL: 90, +} + +const MINUTE_MS = 60 * 1000 +const HOUR_MS = 60 * MINUTE_MS + +const BINANCE_REST = 'https://api.binance.com/api/v3' +const BINANCE_WS = 'wss://stream.binance.com:9443/ws' + +/** 时间范围对应的 1 分钟 K 线数量(Binance 单次最多 1000) */ +const RANGE_TO_LIMIT: Record = { + '1H': 60, + '6H': 360, + '1D': 1440, + '1W': 1000, + '1M': 1000, + ALL: 1000, +} + +/** + * 从事件信息推断加密货币符号 + * 优先:ticker -> tags.slug -> series.slug -> 市场 question 中的币种名 + */ +export function inferCryptoSymbol(event: { + ticker?: string + tags?: { slug?: string; label?: string }[] + series?: { slug?: string; ticker?: string }[] + markets?: { question?: string }[] +}): string | null { + const t = (event.ticker ?? '').toLowerCase().trim() + if (t && COINGECKO_COIN_IDS[t]) return t + + const tagSlug = event.tags?.[0]?.slug?.toLowerCase() + if (tagSlug && COINGECKO_COIN_IDS[tagSlug]) return tagSlug + + const seriesSlug = event.series?.[0]?.slug?.toLowerCase() + if (seriesSlug && COINGECKO_COIN_IDS[seriesSlug]) return seriesSlug + + const seriesTicker = event.series?.[0]?.ticker?.toLowerCase() + if (seriesTicker && COINGECKO_COIN_IDS[seriesTicker]) return seriesTicker + + const q = event.markets?.[0]?.question?.toLowerCase() ?? '' + const match = q.match(/\b(btc|eth|bitcoin|ethereum|sol|solana|bnb)\b/)?.[0] + if (match && COINGECKO_COIN_IDS[match]) return match + + return null +} + +/** + * 判断事件是否为加密货币类型 + * 通过 tags/series 的 slug 判断(crypto、btc、eth 等) + */ +export function isCryptoEvent(event: { + tags?: { slug?: string }[] + series?: { slug?: string }[] + ticker?: string +} | null): boolean { + if (!event) return false + const cryptoSlugs = ['crypto', 'btc', 'eth', 'bitcoin', 'ethereum', 'sol', 'solana'] + const check = (s: string | undefined) => s && cryptoSlugs.includes(s.toLowerCase()) + if (event.ticker && COINGECKO_COIN_IDS[event.ticker.toLowerCase()]) return true + if (event.tags?.some((t) => check(t.slug))) return true + if (event.series?.some((s) => check(s.slug))) return true + return false +} + +export interface FetchCryptoChartParams { + /** 币种符号,如 btc、eth */ + symbol: string + /** 时间范围 */ + range: string +} + +export interface FetchCryptoChartResponse { + code: number + data?: CryptoChartPoint[] + msg: string +} + +/** + * 从 Binance 获取 1 分钟 K 线历史(细粒度,适合实时走势) + * GET /api/v3/klines?symbol=X&interval=1m&limit=N + */ +async function fetchBinanceKlines( + binanceSymbol: string, + limit: number +): Promise { + const url = `${BINANCE_REST}/klines?symbol=${binanceSymbol}&interval=1m&limit=${limit}` + const res = await fetch(url) + if (!res.ok) throw new Error(`Binance API: ${res.status}`) + const raw = (await res.json()) as [number, string, string, string, string, number, ...unknown[]][] + return raw.map(([openTime, , , , close]) => [ + openTime, + parseFloat(close) || 0, + ]) +} + +/** + * 从 CoinGecko 获取价格历史(Binance 不支持时的回退) + */ +async function fetchCoinGeckoChart( + coinId: string, + days: number +): Promise { + const url = `https://api.coingecko.com/api/v3/coins/${coinId}/market_chart?vs_currency=usd&days=${days}` + const res = await fetch(url) + if (!res.ok) throw new Error(`CoinGecko API: ${res.status}`) + const json = (await res.json()) as { prices?: [number, number][] } + const raw = json.prices ?? [] + return raw.map(([t, p]) => { + const ts = t < 1e12 ? t * 1000 : t + return [ts, Number.isFinite(p) ? p : 0] as CryptoChartPoint + }).sort((a, b) => a[0] - b[0]) +} + +/** + * 拉取加密货币价格历史 + * 优先 Binance(1 分钟粒度),不支持时回退 CoinGecko + */ +export async function fetchCryptoChart( + params: FetchCryptoChartParams +): Promise { + const symbol = (params.symbol ?? 'btc').toLowerCase() + const binanceSymbol = BINANCE_SYMBOLS[symbol] + const limit = Math.min(RANGE_TO_LIMIT[params.range] ?? 60, 1000) + + try { + if (binanceSymbol) { + const points = await fetchBinanceKlines(binanceSymbol, limit) + return { code: 0, data: points, msg: 'ok' } + } + const coinId = COINGECKO_COIN_IDS[symbol] ?? 'bitcoin' + const days = RANGE_TO_DAYS[params.range] ?? 7 + const points = await fetchCoinGeckoChart(coinId, days) + return { code: 0, data: points, msg: 'ok' } + } catch (e) { + console.error('[fetchCryptoChart]', e) + return { + code: -1, + msg: (e as Error)?.message ?? 'Failed to fetch crypto price', + } + } +} + +/** Binance K 线 WebSocket 消息格式 */ +interface BinanceKlineMsg { + e?: string + E?: number + s?: string + k?: { + t: number + o: string + h: string + l: string + c: string + x: boolean + } +} + +/** + * 订阅 Binance 1 分钟 K 线 WebSocket,实现实时走势(约每 250ms 推送) + * @param symbol 币种符号如 btc、eth + * @param onPoint 收到新价格点时回调 [timestamp, price] + * @returns 取消订阅函数 + */ +export function subscribeCryptoRealtime( + symbol: string, + onPoint: (point: CryptoChartPoint) => void +): () => void { + const binanceSymbol = BINANCE_SYMBOLS[(symbol ?? 'btc').toLowerCase()] + if (!binanceSymbol) return () => {} + + const stream = `${binanceSymbol.toLowerCase()}@kline_1m` + const ws = new WebSocket(`${BINANCE_WS}/${stream}`) + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data) as BinanceKlineMsg + const k = msg.k + if (!k?.c) return + const ts = k.t + const price = parseFloat(k.c) + if (Number.isFinite(ts) && Number.isFinite(price)) { + onPoint([ts, price]) + } + } catch { + // ignore + } + } + + return () => { + ws.close() + } +} diff --git a/src/locales/en.json b/src/locales/en.json index 11ec5d2..fbf7378 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -14,6 +14,10 @@ "user": "User", "chance": "chance" }, + "chart": { + "yesnoTimeSeries": "YES/NO Time Series", + "cryptoPrice": "Crypto Price" + }, "toast": { "orderSuccess": "Order placed successfully", "splitSuccess": "Split successful", diff --git a/src/locales/ja.json b/src/locales/ja.json index f3f84f0..46eee99 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -14,6 +14,10 @@ "user": "ユーザー", "chance": "確率" }, + "chart": { + "yesnoTimeSeries": "YES/NO 時系列", + "cryptoPrice": "暗号資産価格" + }, "toast": { "orderSuccess": "注文が完了しました", "splitSuccess": "スプリット成功", diff --git a/src/locales/ko.json b/src/locales/ko.json index 12cc8cd..b69012d 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -14,6 +14,10 @@ "user": "사용자", "chance": "확률" }, + "chart": { + "yesnoTimeSeries": "YES/NO 시계열", + "cryptoPrice": "암호화폐 가격" + }, "toast": { "orderSuccess": "주문이 완료되었습니다", "splitSuccess": "분할 완료", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 8ee2f56..554a05a 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -14,6 +14,10 @@ "user": "用户", "chance": "概率" }, + "chart": { + "yesnoTimeSeries": "YES/NO 分时", + "cryptoPrice": "加密货币价格" + }, "toast": { "orderSuccess": "下单成功", "splitSuccess": "拆分成功", diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index 9ee5bbd..0d177c6 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -14,6 +14,10 @@ "user": "用戶", "chance": "機率" }, + "chart": { + "yesnoTimeSeries": "YES/NO 分時", + "cryptoPrice": "加密貨幣價格" + }, "toast": { "orderSuccess": "下單成功", "splitSuccess": "拆分成功", diff --git a/src/views/TradeDetail.vue b/src/views/TradeDetail.vue index bb0b2e0..ed8fdcc 100644 --- a/src/views/TradeDetail.vue +++ b/src/views/TradeDetail.vue @@ -12,14 +12,50 @@

{{ detailError }}

+ + + mdi-chart-timeline-variant + + + mdi-currency-btc + + Past ▾ {{ resolutionDate }}
-
{{ currentChance }}% {{ t('common.chance') }}
+
+ + +
+
+ +
@@ -368,6 +404,12 @@ import { type ChartDataPoint, type ChartTimeRange, } from '../api/chart' +import { + isCryptoEvent as checkIsCryptoEvent, + inferCryptoSymbol, + fetchCryptoChart, + subscribeCryptoRealtime, +} from '../api/cryptoChart' const { t } = useI18n() import { @@ -508,6 +550,31 @@ const isResolutionSourceUrl = computed(() => { return typeof src === 'string' && (src.startsWith('http://') || src.startsWith('https://')) }) +/** 是否为加密货币类型事件(可切换 YES/NO 分时 vs 加密货币价格图) */ +const isCryptoEvent = computed(() => checkIsCryptoEvent(eventDetail.value)) + +/** 从事件推断的加密货币符号,如 btc、eth */ +const cryptoSymbol = computed(() => inferCryptoSymbol(eventDetail.value ?? {}) ?? null) + +/** 图表模式:yesno=YES/NO 分时,crypto=加密货币价格 */ +const chartMode = ref<'yesno' | 'crypto'>('yesno') + +/** 加密货币模式下当前价格(最新数据点) */ +const currentCryptoPrice = computed(() => { + const d = data.value + const last = d.length > 0 ? d[d.length - 1] : undefined + if (last != null && chartMode.value === 'crypto') { + const p = last[1] + return Number.isFinite(p) ? p.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—' + } + return '—' +}) + +function setChartMode(mode: 'yesno' | 'crypto') { + chartMode.value = mode + updateChartData() +} + /** 当前市场(用于交易组件与 Split 拆单):query.marketId 匹配或取第一个 */ const currentMarket = computed(() => { const list = eventDetail.value?.markets ?? [] @@ -1218,6 +1285,7 @@ function generateData(range: string): ChartDataPoint[] { const chartContainerRef = ref(null) const data = ref<[number, number][]>([]) +const cryptoChartLoading = ref(false) let chartInstance: ECharts | null = null let dynamicInterval: number | undefined @@ -1343,12 +1411,126 @@ function buildOption(chartData: [number, number][], containerWidth?: number) { } } +/** 根据数据范围计算 Y 轴 min/max,留出适当边距便于观察起伏 */ +function computeCryptoYAxisRange(chartData: [number, number][]): { min: number; max: number } { + if (chartData.length === 0) return { min: 0, max: 100 } + const prices = chartData.map((d) => d[1]).filter(Number.isFinite) + if (prices.length === 0) return { min: 0, max: 100 } + const dataMin = Math.min(...prices) + const dataMax = Math.max(...prices) + const span = dataMax - dataMin || 1 + const padding = Math.max(span * 0.08, dataMin * 0.002) + return { + min: Math.max(0, dataMin - padding), + max: dataMax + padding, + } +} + +/** 加密货币价格图配置(Y 轴为美元价格,按数据范围动态缩放) */ +function buildOptionForCrypto( + chartData: [number, number][], + symbol: string, + containerWidth?: number +) { + const lastIndex = chartData.length - 1 + const width = containerWidth ?? chartContainerRef.value?.clientWidth ?? 400 + const isMobile = width < MOBILE_BREAKPOINT + const yRange = computeCryptoYAxisRange(chartData) + const xAxisConfig: Record = { + type: 'time', + axisLine: { lineStyle: { color: '#e5e7eb' } }, + axisLabel: { color: '#6b7280', fontSize: isMobile ? 10 : 11 }, + axisTick: { show: false }, + splitLine: { show: false }, + } + if (isMobile && chartData.length >= 2) { + const times = chartData.map((d) => d[0]) + const span = Math.max(...times) - Math.min(...times) + xAxisConfig.axisLabel = { + ...(xAxisConfig.axisLabel as object), + interval: Math.max(span / 4, 60 * 1000), + formatter: (value: number) => { + const d = new Date(value) + if (span >= 24 * 60 * 60 * 1000) return `${d.getMonth() + 1}/${d.getDate()}` + return `${d.getHours()}:${d.getMinutes().toString().padStart(2, '0')}` + }, + rotate: -25, + } + } + return { + animation: false, + tooltip: { + trigger: 'axis', + formatter: (params: unknown) => { + const p = (Array.isArray(params) ? params[0] : params) as { + data?: [number, number] + value?: unknown + } + const raw = p.data ?? (Array.isArray(p.value) ? p.value : null) + const timestamp = Array.isArray(raw) ? raw[0] : null + const val = Array.isArray(raw) ? raw[1] : p.value + const date = timestamp != null && Number.isFinite(timestamp) + ? new Date(timestamp) + : null + const price = Number.isFinite(Number(val)) + ? Number(val).toLocaleString(undefined, { minimumFractionDigits: 2 }) + : val + const dateStr = date && !Number.isNaN(date.getTime()) + ? date.toLocaleString() + : '—' + return `${dateStr} : $${price}` + }, + axisPointer: { animation: false }, + }, + grid: { + left: 8, + right: 48, + top: 16, + bottom: isMobile ? 44 : 28, + containLabel: false, + }, + xAxis: xAxisConfig, + yAxis: { + type: 'value', + position: 'right', + min: yRange.min, + max: yRange.max, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { + color: '#6b7280', + fontSize: 11, + formatter: (v: number) => `$${v >= 1000 ? (v / 1000).toFixed(1) + 'k' : v.toFixed(0)}`, + }, + splitLine: { lineStyle: { type: 'dashed', color: '#e5e7eb' } }, + }, + series: [ + { + name: symbol.toUpperCase(), + type: 'line', + showSymbol: true, + symbol: 'circle', + symbolSize: (_: unknown, params: { dataIndex?: number }) => + params?.dataIndex === lastIndex ? 8 : 0, + data: chartData, + smooth: true, + lineStyle: { width: 2, color: lineColor }, + itemStyle: { color: lineColor, borderColor: '#fff', borderWidth: 2 }, + }, + ], + } +} + function initChart() { if (!chartContainerRef.value) return data.value = generateData(selectedTimeRange.value) chartInstance = echarts.init(chartContainerRef.value) const w = chartContainerRef.value.clientWidth - chartInstance.setOption(buildOption(data.value, w)) + if (chartMode.value === 'crypto') { + chartInstance.setOption(buildOptionForCrypto(data.value, cryptoSymbol.value ?? 'btc', w)) + } else { + chartInstance.setOption(buildOption(data.value, w)) + } } /** 从接口拉取图表数据(接入时在 updateChartData 中调用并赋给 data.value) */ @@ -1359,12 +1541,61 @@ async function loadChartFromApi(marketId: string): Promise { return normalizeChartData(res.data ?? []) } -// 使用接口时:在 updateChartData 内先 await loadChartFromApi(marketId),再 setOption;暂无接口时用 generateData -function updateChartData() { - data.value = generateData(selectedTimeRange.value) +const MINUTE_MS = 60 * 1000 +let cryptoWsUnsubscribe: (() => void) | null = null + +function applyCryptoRealtimePoint(point: [number, number]) { + const list = [...data.value] + const [ts, price] = point + const last = list[list.length - 1] + const sameMinute = last && Math.floor(last[0] / MINUTE_MS) === Math.floor(ts / MINUTE_MS) + if (sameMinute) { + list[list.length - 1] = [last![0], price] + } else { + list.push([ts, price]) + } + const max = { '1H': 60, '6H': 360, '1D': 1440, '1W': 1680, '1M': 43200, ALL: 10080 }[ + selectedTimeRange.value + ] ?? 60 + data.value = list.slice(-max) const w = chartContainerRef.value?.clientWidth - if (chartInstance) - chartInstance.setOption(buildOption(data.value, w), { replaceMerge: ['series'] }) + if (chartInstance && chartMode.value === 'crypto') + chartInstance.setOption( + buildOptionForCrypto(data.value, cryptoSymbol.value ?? 'btc', w), + { replaceMerge: ['series'] } + ) +} + +// 使用接口时:在 updateChartData 内先 await loadChartFromApi(marketId),再 setOption;暂无接口时用 generateData +async function updateChartData() { + const w = chartContainerRef.value?.clientWidth + if (chartMode.value === 'crypto') { + const sym = cryptoSymbol.value ?? 'btc' + cryptoWsUnsubscribe?.() + cryptoWsUnsubscribe = null + cryptoChartLoading.value = true + try { + const res = await fetchCryptoChart({ + symbol: sym, + range: selectedTimeRange.value, + }) + data.value = (res.data ?? []) as [number, number][] + if (chartInstance) + chartInstance.setOption( + buildOptionForCrypto(data.value, sym, w), + { replaceMerge: ['series'] } + ) + cryptoWsUnsubscribe = subscribeCryptoRealtime(sym, applyCryptoRealtimePoint) + } finally { + cryptoChartLoading.value = false + } + } else { + cryptoWsUnsubscribe?.() + cryptoWsUnsubscribe = null + data.value = generateData(selectedTimeRange.value) + if (chartInstance) + chartInstance.setOption(buildOption(data.value, w), { replaceMerge: ['series'] }) + } } function selectTimeRange(range: string) { @@ -1392,6 +1623,7 @@ function getMaxPoints(range: string): number { function startDynamicUpdate() { dynamicInterval = window.setInterval(() => { + if (chartMode.value === 'crypto') return const list = [...data.value] const last = list[list.length - 1] if (!last) return @@ -1445,9 +1677,14 @@ watch( const handleResize = () => { if (!chartInstance || !chartContainerRef.value) return chartInstance.resize() - chartInstance.setOption(buildOption(data.value, chartContainerRef.value.clientWidth), { - replaceMerge: ['series'], - }) + const w = chartContainerRef.value.clientWidth + if (chartMode.value === 'crypto') { + chartInstance.setOption(buildOptionForCrypto(data.value, cryptoSymbol.value ?? 'btc', w), { + replaceMerge: ['series'], + }) + } else { + chartInstance.setOption(buildOption(data.value, w), { replaceMerge: ['series'] }) + } } watch( @@ -1483,6 +1720,8 @@ onMounted(() => { onUnmounted(() => { unsubscribePositionUpdate() stopDynamicUpdate() + cryptoWsUnsubscribe?.() + cryptoWsUnsubscribe = null window.removeEventListener('resize', handleResize) chartInstance?.dispose() chartInstance = null @@ -1696,10 +1935,26 @@ onUnmounted(() => { } .chart-wrapper { + position: relative; width: 100%; margin-bottom: 12px; } +.chart-loading-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.6); + z-index: 2; +} + +.chart-mode-toggle .v-btn.active { + background: rgb(var(--v-theme-primary)); + color: rgb(var(--v-theme-on-primary)); +} + .chart-container { width: 100%; height: 320px;