新增:增加虚拟货币的走势图
This commit is contained in:
parent
d571d1b9b0
commit
50e451e892
35
docs/api/cryptoChart.md
Normal file
35
docs/api/cryptoChart.md
Normal 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
|
||||
@ -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` 消息,实时追加成交记录
|
||||
|
||||
302
src/api/cryptoChart.ts
Normal file
302
src/api/cryptoChart.ts
Normal 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 判断(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<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])
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取加密货币价格历史
|
||||
* 优先 Binance(1 分钟粒度),不支持时回退 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 币种符号如 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()
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -14,6 +14,10 @@
|
||||
"user": "ユーザー",
|
||||
"chance": "確率"
|
||||
},
|
||||
"chart": {
|
||||
"yesnoTimeSeries": "YES/NO 時系列",
|
||||
"cryptoPrice": "暗号資産価格"
|
||||
},
|
||||
"toast": {
|
||||
"orderSuccess": "注文が完了しました",
|
||||
"splitSuccess": "スプリット成功",
|
||||
|
||||
@ -14,6 +14,10 @@
|
||||
"user": "사용자",
|
||||
"chance": "확률"
|
||||
},
|
||||
"chart": {
|
||||
"yesnoTimeSeries": "YES/NO 시계열",
|
||||
"cryptoPrice": "암호화폐 가격"
|
||||
},
|
||||
"toast": {
|
||||
"orderSuccess": "주문이 완료되었습니다",
|
||||
"splitSuccess": "분할 완료",
|
||||
|
||||
@ -14,6 +14,10 @@
|
||||
"user": "用户",
|
||||
"chance": "概率"
|
||||
},
|
||||
"chart": {
|
||||
"yesnoTimeSeries": "YES/NO 分时",
|
||||
"cryptoPrice": "加密货币价格"
|
||||
},
|
||||
"toast": {
|
||||
"orderSuccess": "下单成功",
|
||||
"splitSuccess": "拆分成功",
|
||||
|
||||
@ -14,6 +14,10 @@
|
||||
"user": "用戶",
|
||||
"chance": "機率"
|
||||
},
|
||||
"chart": {
|
||||
"yesnoTimeSeries": "YES/NO 分時",
|
||||
"cryptoPrice": "加密貨幣價格"
|
||||
},
|
||||
"toast": {
|
||||
"orderSuccess": "下單成功",
|
||||
"splitSuccess": "拆分成功",
|
||||
|
||||
@ -12,14 +12,50 @@
|
||||
</h1>
|
||||
<p v-if="detailError" class="chart-error">{{ detailError }}</p>
|
||||
<div class="chart-controls-row">
|
||||
<v-btn-group
|
||||
v-if="isCryptoEvent"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
divided
|
||||
class="chart-mode-toggle"
|
||||
>
|
||||
<v-btn
|
||||
:class="{ active: chartMode === 'yesno' }"
|
||||
size="small"
|
||||
icon
|
||||
:aria-label="t('chart.yesnoTimeSeries')"
|
||||
@click="setChartMode('yesno')"
|
||||
>
|
||||
<v-icon size="20">mdi-chart-timeline-variant</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
:class="{ active: chartMode === 'crypto' }"
|
||||
size="small"
|
||||
icon
|
||||
:aria-label="t('chart.cryptoPrice')"
|
||||
@click="setChartMode('crypto')"
|
||||
>
|
||||
<v-icon size="20">mdi-currency-btc</v-icon>
|
||||
</v-btn>
|
||||
</v-btn-group>
|
||||
<v-btn variant="text" size="small" class="past-btn">Past ▾</v-btn>
|
||||
<v-btn class="date-pill" size="small" rounded="pill">{{ resolutionDate }}</v-btn>
|
||||
</div>
|
||||
<div class="chart-chance">{{ 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 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>
|
||||
|
||||
@ -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<HTMLElement | null>(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<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() {
|
||||
if (!chartContainerRef.value) return
|
||||
data.value = generateData(selectedTimeRange.value)
|
||||
chartInstance = echarts.init(chartContainerRef.value)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/** 从接口拉取图表数据(接入时在 updateChartData 中调用并赋给 data.value) */
|
||||
@ -1359,12 +1541,61 @@ async function loadChartFromApi(marketId: string): Promise<ChartDataPoint[]> {
|
||||
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 && 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), {
|
||||
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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user