新增:增加虚拟货币的走势图

This commit is contained in:
ivan 2026-03-03 19:02:03 +08:00
parent d571d1b9b0
commit 50e451e892
9 changed files with 624 additions and 12 deletions

35
docs/api/cryptoChart.md Normal file
View File

@ -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

View File

@ -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` 转为展示值 - 订单簿:`OrderBook` 组件,通过 **ClobSdk** 对接 CLOB WebSocket 实时数据(全量快照、增量更新、成交推送);份额接口按 6 位小数传1_000_000 = 1 share`priceSizeToRows``mergeDelta` 会将 raw 值除以 `ORDER_BOOK_SIZE_SCALE` 转为展示值
- 交易:`TradeComponent`,传入 `market``initialOption``positions`(持仓数据) - 交易:`TradeComponent`,传入 `market``initialOption``positions`(持仓数据)
- 持仓列表:通过 `getPositionList` 获取当前市场持仓,传递给 `TradeComponent` 用于计算可合并份额 - 持仓列表:通过 `getPositionList` 获取当前市场持仓,传递给 `TradeComponent` 用于计算可合并份额
@ -36,7 +36,7 @@
## 扩展方式 ## 扩展方式
1. **订单簿**:已通过 `sdk/clobSocket.ts` 的 ClobSdk 对接 CLOB WebSocket使用 **Yes/No token ID** 订阅 `price_size_all``price_size_delta``trade` 消息 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 3. **Comments**:对接评论接口,替换 placeholder
4. **Top Holders**:对接持仓接口 4. **Top Holders**:对接持仓接口
5. **Activity**:已对接 CLOB `trade` 消息,实时追加成交记录 5. **Activity**:已对接 CLOB `trade` 消息,实时追加成交记录

302
src/api/cryptoChart.ts Normal file
View File

@ -0,0 +1,302 @@
/**
* API
* 使 Binance REST + WebSocket 1
*
*/
/** 图表数据点:[时间戳(ms), 价格(USD)] */
export type CryptoChartPoint = [number, number]
/** Binance 交易对映射symbol -> BTCUSDT 等) */
export const BINANCE_SYMBOLS: Record<string, string> = {
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<string, string> = {
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<string, number> = {
'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<string, number> = {
'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 cryptobtceth
*/
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<CryptoChartPoint[]> {
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<CryptoChartPoint[]> {
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])
}
/**
*
* Binance1 退 CoinGecko
*/
export async function fetchCryptoChart(
params: FetchCryptoChartParams
): Promise<FetchCryptoChartResponse> {
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 btceth
* @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()
}
}

View File

@ -14,6 +14,10 @@
"user": "User", "user": "User",
"chance": "chance" "chance": "chance"
}, },
"chart": {
"yesnoTimeSeries": "YES/NO Time Series",
"cryptoPrice": "Crypto Price"
},
"toast": { "toast": {
"orderSuccess": "Order placed successfully", "orderSuccess": "Order placed successfully",
"splitSuccess": "Split successful", "splitSuccess": "Split successful",

View File

@ -14,6 +14,10 @@
"user": "ユーザー", "user": "ユーザー",
"chance": "確率" "chance": "確率"
}, },
"chart": {
"yesnoTimeSeries": "YES/NO 時系列",
"cryptoPrice": "暗号資産価格"
},
"toast": { "toast": {
"orderSuccess": "注文が完了しました", "orderSuccess": "注文が完了しました",
"splitSuccess": "スプリット成功", "splitSuccess": "スプリット成功",

View File

@ -14,6 +14,10 @@
"user": "사용자", "user": "사용자",
"chance": "확률" "chance": "확률"
}, },
"chart": {
"yesnoTimeSeries": "YES/NO 시계열",
"cryptoPrice": "암호화폐 가격"
},
"toast": { "toast": {
"orderSuccess": "주문이 완료되었습니다", "orderSuccess": "주문이 완료되었습니다",
"splitSuccess": "분할 완료", "splitSuccess": "분할 완료",

View File

@ -14,6 +14,10 @@
"user": "用户", "user": "用户",
"chance": "概率" "chance": "概率"
}, },
"chart": {
"yesnoTimeSeries": "YES/NO 分时",
"cryptoPrice": "加密货币价格"
},
"toast": { "toast": {
"orderSuccess": "下单成功", "orderSuccess": "下单成功",
"splitSuccess": "拆分成功", "splitSuccess": "拆分成功",

View File

@ -14,6 +14,10 @@
"user": "用戶", "user": "用戶",
"chance": "機率" "chance": "機率"
}, },
"chart": {
"yesnoTimeSeries": "YES/NO 分時",
"cryptoPrice": "加密貨幣價格"
},
"toast": { "toast": {
"orderSuccess": "下單成功", "orderSuccess": "下單成功",
"splitSuccess": "拆分成功", "splitSuccess": "拆分成功",

View File

@ -12,14 +12,50 @@
</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-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 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">{{ currentChance }}% {{ t('common.chance') }}</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>
<!-- 图表区域 --> <!-- 图表区域 -->
<div class="chart-wrapper"> <div class="chart-wrapper">
<div v-if="cryptoChartLoading" class="chart-loading-overlay">
<v-progress-circular indeterminate color="primary" size="32" />
</div>
<div ref="chartContainerRef" class="chart-container"></div> <div ref="chartContainerRef" class="chart-container"></div>
</div> </div>
@ -368,6 +404,12 @@ import {
type ChartDataPoint, type ChartDataPoint,
type ChartTimeRange, type ChartTimeRange,
} from '../api/chart' } from '../api/chart'
import {
isCryptoEvent as checkIsCryptoEvent,
inferCryptoSymbol,
fetchCryptoChart,
subscribeCryptoRealtime,
} from '../api/cryptoChart'
const { t } = useI18n() const { t } = useI18n()
import { import {
@ -508,6 +550,31 @@ const isResolutionSourceUrl = computed(() => {
return typeof src === 'string' && (src.startsWith('http://') || src.startsWith('https://')) 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 匹配或取第一个 */ /** 当前市场(用于交易组件与 Split 拆单query.marketId 匹配或取第一个 */
const currentMarket = computed(() => { const currentMarket = computed(() => {
const list = eventDetail.value?.markets ?? [] const list = eventDetail.value?.markets ?? []
@ -1218,6 +1285,7 @@ function generateData(range: string): ChartDataPoint[] {
const chartContainerRef = ref<HTMLElement | null>(null) const chartContainerRef = ref<HTMLElement | null>(null)
const data = ref<[number, number][]>([]) const data = ref<[number, number][]>([])
const cryptoChartLoading = ref(false)
let chartInstance: ECharts | null = null let chartInstance: ECharts | null = null
let dynamicInterval: number | undefined let dynamicInterval: number | undefined
@ -1343,13 +1411,127 @@ 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<string, unknown> = {
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() { function initChart() {
if (!chartContainerRef.value) return if (!chartContainerRef.value) return
data.value = generateData(selectedTimeRange.value) data.value = generateData(selectedTimeRange.value)
chartInstance = echarts.init(chartContainerRef.value) chartInstance = echarts.init(chartContainerRef.value)
const w = chartContainerRef.value.clientWidth const w = chartContainerRef.value.clientWidth
if (chartMode.value === 'crypto') {
chartInstance.setOption(buildOptionForCrypto(data.value, cryptoSymbol.value ?? 'btc', w))
} else {
chartInstance.setOption(buildOption(data.value, w)) chartInstance.setOption(buildOption(data.value, w))
} }
}
/** 从接口拉取图表数据(接入时在 updateChartData 中调用并赋给 data.value */ /** 从接口拉取图表数据(接入时在 updateChartData 中调用并赋给 data.value */
async function loadChartFromApi(marketId: string): Promise<ChartDataPoint[]> { async function loadChartFromApi(marketId: string): Promise<ChartDataPoint[]> {
@ -1359,13 +1541,62 @@ async function loadChartFromApi(marketId: string): Promise<ChartDataPoint[]> {
return normalizeChartData(res.data ?? []) return normalizeChartData(res.data ?? [])
} }
// 使 updateChartData await loadChartFromApi(marketId) setOption generateData const MINUTE_MS = 60 * 1000
function updateChartData() { let cryptoWsUnsubscribe: (() => void) | null = null
data.value = generateData(selectedTimeRange.value)
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 const w = chartContainerRef.value?.clientWidth
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) if (chartInstance)
chartInstance.setOption(buildOption(data.value, w), { replaceMerge: ['series'] }) chartInstance.setOption(buildOption(data.value, w), { replaceMerge: ['series'] })
} }
}
function selectTimeRange(range: string) { function selectTimeRange(range: string) {
selectedTimeRange.value = range selectedTimeRange.value = range
@ -1392,6 +1623,7 @@ function getMaxPoints(range: string): number {
function startDynamicUpdate() { function startDynamicUpdate() {
dynamicInterval = window.setInterval(() => { dynamicInterval = window.setInterval(() => {
if (chartMode.value === 'crypto') return
const list = [...data.value] const list = [...data.value]
const last = list[list.length - 1] const last = list[list.length - 1]
if (!last) return if (!last) return
@ -1445,9 +1677,14 @@ watch(
const handleResize = () => { const handleResize = () => {
if (!chartInstance || !chartContainerRef.value) return if (!chartInstance || !chartContainerRef.value) return
chartInstance.resize() chartInstance.resize()
chartInstance.setOption(buildOption(data.value, chartContainerRef.value.clientWidth), { const w = chartContainerRef.value.clientWidth
if (chartMode.value === 'crypto') {
chartInstance.setOption(buildOptionForCrypto(data.value, cryptoSymbol.value ?? 'btc', w), {
replaceMerge: ['series'], replaceMerge: ['series'],
}) })
} else {
chartInstance.setOption(buildOption(data.value, w), { replaceMerge: ['series'] })
}
} }
watch( watch(
@ -1483,6 +1720,8 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
unsubscribePositionUpdate() unsubscribePositionUpdate()
stopDynamicUpdate() stopDynamicUpdate()
cryptoWsUnsubscribe?.()
cryptoWsUnsubscribe = null
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
chartInstance?.dispose() chartInstance?.dispose()
chartInstance = null chartInstance = null
@ -1696,10 +1935,26 @@ onUnmounted(() => {
} }
.chart-wrapper { .chart-wrapper {
position: relative;
width: 100%; width: 100%;
margin-bottom: 12px; 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 { .chart-container {
width: 100%; width: 100%;
height: 320px; height: 320px;