diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 00af240..58dca9b 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -32,7 +32,7 @@ | 功能 | 说明 | |------|------| | 事件详情 | `findPmEvent` 按 id/slug 获取,需鉴权 | -| 分时图 | ECharts 渲染,时间粒度 1H/6H/1D/1W/1M/ALL,**当前为本地生成 mock 数据** | +| 分时图 | Lightweight Charts 渲染,时间粒度 1H/6H/1D/1W/1M/ALL,**当前为本地生成 mock 数据** | | 交易组件 | Buy/Sell、Market 模式、Merge、Split | | 下单 | `pmOrderPlace` 调用 CLOB 下单接口 | | Split | `pmMarketSplit` 用 USDC 兑换 Yes+No | @@ -47,7 +47,7 @@ | Portfolio 卡片 | 展示 userStore.balance | | Deposit/Withdraw 弹窗 | UI 完整,Deposit 展示固定地址与二维码;Withdraw 支持金额、网络、目标地址选择 | | 取消订单 | `pmCancelOrder` 调用真实接口,**但 Positions/Open orders/History 数据为 mock** | -| Profit/Loss 图表 | ECharts 展示,**数据为 mock** | +| Profit/Loss 图表 | Lightweight Charts 展示,**数据为 mock** | | Positions/Orders/History Tab | 表格与移动端列表,**数据为 mock** | | 搜索、分页 | 对 mock 数据生效 | diff --git a/docs/api/cryptoChart.md b/docs/api/cryptoChart.md index 9ae2acb..ef1a4e2 100644 --- a/docs/api/cryptoChart.md +++ b/docs/api/cryptoChart.md @@ -11,7 +11,7 @@ - `isCryptoEvent`:判断事件是否为加密货币类型(通过 tags/series slug、ticker) - `inferCryptoSymbol`:从事件信息推断币种符号(btc、eth 等) - `fetchCryptoChart`:从 Binance 获取 1 分钟 K 线历史(优先),不支持时回退 CoinGecko -- `subscribeCryptoRealtime`:订阅 Binance K 线 WebSocket,约每 250ms 推送,实现走势图实时更新 +- `subscribeCryptoRealtime`:订阅 Binance **归集交易**(aggTrade)WebSocket,实时推送每笔成交,比 K 线(约 1s)更细;内部 80ms 节流 + RAF 批处理,避免频繁 setOption 卡顿;支持 PING/PONG 保活 ## 币种映射 @@ -29,7 +29,13 @@ if (isCryptoEvent(eventDetail)) { } ``` +## 30 秒走势(仅加密货币) + +- 30S 选项仅在加密货币走势图显示,YES/NO 分时与 EventMarkets 不包含 +- 30S 数据:拉取 1H(60 分钟)走势,过滤保留最近 30 秒内的点展示 + ## 扩展方式 - 新增币种:在 `COINGECKO_COIN_IDS` 中追加映射 - 更换数据源:修改 `fetchCryptoChart` 内部实现,或通过后端代理 CoinGecko 以规避 CORS +- 实时流参考:[Binance WebSocket 归集交易](https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/web-socket-streams#归集交易) diff --git a/docs/api/priceHistory.md b/docs/api/priceHistory.md index f9b5aeb..40dfffa 100644 --- a/docs/api/priceHistory.md +++ b/docs/api/priceHistory.md @@ -10,7 +10,7 @@ - `getPmPriceHistoryPublic`:按市场 ID 分页获取价格历史 - `getTimeRangeSeconds`:根据分时范围计算 `startTs`、`endTs`(Unix 秒);`endTs` 始终为当前时间;1H/6H/1D/1W/1M 的 `startTs` 为当前时间往前对应时长;ALL 的 `startTs` 为事件开始时间、`endTs` 为当前时间 -- `priceHistoryToChartData`:将接口返回的 `list` 转为 ECharts 使用的 `[timestamp_ms, value_0_100][]` +- `priceHistoryToChartData`:将接口返回的 `list` 转为图表使用的 `[timestamp_ms, value_0_100][]`(Lightweight Charts 需经 `toLwcData` 转为 `{ time, value }`) ## GET /pmPriceHistory/getPmPriceHistoryPublic diff --git a/docs/composables/useLightweightChart.md b/docs/composables/useLightweightChart.md new file mode 100644 index 0000000..4032210 --- /dev/null +++ b/docs/composables/useLightweightChart.md @@ -0,0 +1,36 @@ +# useLightweightChart + +**路径**:`src/composables/useLightweightChart.ts` + +## 功能用途 + +[TradingView Lightweight Charts](https://tradingview.github.io/lightweight-charts/) 工具函数,用于将项目内 `[timestamp_ms, value][]` 格式转为图表所需格式,并提供创建折线/面积图的便捷方法。 + +## 核心能力 + +- `toLwcData`:将 `[timestamp_ms, value][]` 转为 `{ time: UTCTimestamp, value: number }[]`,输入为毫秒(>=1e12)时自动除以 1000 转为秒,按时间升序排序并去重(同时间戳保留最新价格),时间轴显示用户当地时间 +- `toLwcPoint`:将单点 `[timestamp_ms, value]` 转为 `{ time, value }`,用于 `series.update()` 增量更新 +- `createLineChart`:创建基础折线图实例(`attributionLogo: false` 隐藏 TradingView 图标) +- `addLineSeries`:添加折线系列(支持 percent/price 格式) +- `addAreaSeries`:添加面积系列(带渐变) + +## 使用方式 + +```typescript +import { toLwcData, toLwcPoint, createLineChart, addLineSeries } from '@/composables/useLightweightChart' + +const points: [number, number][] = [[Date.now() - 3600000, 50], [Date.now(), 55]] +const lwcData = toLwcData(points) + +const chart = createLineChart(containerEl, { width: 400, height: 320 }) +const series = addLineSeries(chart, { color: '#2563eb', priceFormat: { type: 'percent', precision: 1 } }) +series.setData(lwcData) + +// 实时追加/更新单点时,使用 update() 替代 setData() 以获得过渡动画 +series.update(toLwcPoint([Date.now(), 52])) +``` + +## 扩展方式 + +- 新增图表类型:参考 `addAreaSeries` 使用 `AreaSeries`、`CandlestickSeries` 等 +- 自定义主题:修改 `createLineChart` 的 `layout`、`grid`、`rightPriceScale` 等配置 diff --git a/docs/config/package.md b/docs/config/package.md index 4d12817..f518b11 100644 --- a/docs/config/package.md +++ b/docs/config/package.md @@ -22,7 +22,7 @@ - Vue 3、Vue Router 5、Pinia、Vuetify 4 - ethers、siwe(钱包登录) -- echarts(图表) +- lightweight-charts(TradingView 金融图表) - @mdi/font(图标) ## 扩展方式 diff --git a/docs/views/TradeDetail.md b/docs/views/TradeDetail.md index 94b6cb0..204b286 100644 --- a/docs/views/TradeDetail.md +++ b/docs/views/TradeDetail.md @@ -9,7 +9,7 @@ ## 核心能力 -- 分时图:ECharts 渲染,支持 Past、时间粒度切换(1H/6H/1D/1W/1M/ALL);**Yes/No 模式**数据来自 **GET /pmPriceHistory/getPmPriceHistoryPublic**(market 传 clobTokenIds[0]),接口返回 `time`(Unix 秒)、`price`(0–1)转成 `[timestamp_ms, value_0_100][]` 后缓存在 `rawChartData`,**分时**为前端按当前选中范围过滤:1H=最近 1 小时、6H=6 小时、1D=1 天、1W=7 天、1M=30 天、ALL=全部,切换时间范围不重复请求;**加密货币事件**可切换 YES/NO 分时图与加密货币价格走势图(CoinGecko 实时数据) +- 分时图:TradingView Lightweight Charts 渲染,支持 Past、时间粒度切换(1H/6H/1D/1W/1M/ALL);**Yes/No 模式**数据来自 **GET /pmPriceHistory/getPmPriceHistoryPublic**(market 传 clobTokenIds[0]),接口返回 `time`(Unix 秒)、`price`(0–1)转成 `[timestamp_ms, value_0_100][]` 后缓存在 `rawChartData`,**分时**为前端按当前选中范围过滤:1H=最近 1 小时、6H=6 小时、1D=1 天、1W=7 天、1M=30 天、ALL=全部,切换时间范围不重复请求;**加密货币事件**可切换 YES/NO 分时图与加密货币价格走势图(CoinGecko 实时数据),**加密货币模式默认显示 30S 分时走势图** - 订单簿:`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. **分时图**:Yes/NO 折线图仅使用真实接口 `getPmPriceHistoryPublic`,无模拟数据与定时器;事件详情加载完成后自动请求并展示,无 marketId 或接口无数据时展示空图;加密货币事件已支持 YES/NO 分时与加密货币价格图切换(`src/api/cryptoChart.ts`) +2. **分时图**:Yes/NO 折线图仅使用真实接口 `getPmPriceHistoryPublic`,无模拟数据与定时器;事件详情加载完成后自动请求并展示,无 marketId 或接口无数据时展示空图;加密货币事件已支持 YES/NO 分时与加密货币价格图切换(`src/api/cryptoChart.ts`);加密货币实时推送时使用 `series.update()` 增量更新单点,配合 `LastPriceAnimationMode.OnDataUpdate` 实现新数据加入的过渡动画;30S 范围在未过滤掉旧点时同样使用 `update()` 实现平滑动画 3. **Comments**:对接评论接口,替换 placeholder 4. **Top Holders**:对接持仓接口 5. **Activity**:已对接 CLOB `trade` 消息,实时追加成交记录 diff --git a/docs/views/Wallet.md b/docs/views/Wallet.md index d300b5d..5eb7d87 100644 --- a/docs/views/Wallet.md +++ b/docs/views/Wallet.md @@ -10,7 +10,7 @@ ## 核心能力 - Portfolio 卡片:余额、Deposit/Withdraw 按钮 -- Profit/Loss 卡片:时间范围切换(1D/1W/1M/ALL)、ECharts 资产变化折线图;数据格式为 `[timestamp_ms, pnl][]`,**暂无接口时全部显示为 0**(真实时间轴 + 数值 0),有接口后在此处对接 +- Profit/Loss 卡片:时间范围切换(1D/1W/1M/ALL)、Lightweight Charts 资产变化面积图;数据格式为 `[timestamp_ms, pnl][]`,**暂无接口时全部显示为 0**(真实时间轴 + 数值 0),有接口后在此处对接 - Tab:Positions、Open orders、**History**(历史记录来自 **GET /hr/getHistoryRecordListClient**,`src/api/historyRecord.ts`,需鉴权、按当前用户分页)、Withdrawals(提现记录) - **可结算/领取**:未结算项(unsettledItems)由持仓中有 `marketID`、`tokenID` 且 **所属 market.closed=true** 的项组成,用于「领取结算」按钮;不再使用 needClaim 判断 - Withdrawals:分页列表,状态筛选(全部/审核中/提现成功/审核不通过/提现失败),对接 GET /pmset/getPmSettlementRequestsListClient diff --git a/package-lock.json b/package-lock.json index 014a42c..f6ae80f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,8 @@ "dependencies": { "@mdi/font": "^7.4.47", "buffer": "^6.0.3", - "echarts": "^6.0.0", "ethers": "^6.16.0", + "lightweight-charts": "^5.1.0", "pinia": "^3.0.4", "siwe": "^3.0.0", "vue": "^3.5.27", @@ -4243,22 +4243,6 @@ "dev": true, "license": "MIT" }, - "node_modules/echarts": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.0.0.tgz", - "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "2.3.0", - "zrender": "6.0.0" - } - }, - "node_modules/echarts/node_modules/tslib": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", - "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", - "license": "0BSD" - }, "node_modules/editorconfig": { "version": "1.0.4", "resolved": "https://registry.npmmirror.com/editorconfig/-/editorconfig-1.0.4.tgz", @@ -4894,6 +4878,12 @@ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "license": "MIT" }, + "node_modules/fancy-canvas": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fancy-canvas/-/fancy-canvas-2.1.0.tgz", + "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5925,6 +5915,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lightweight-charts": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/lightweight-charts/-/lightweight-charts-5.1.0.tgz", + "integrity": "sha512-jEAYR4ODYeyNZcWUigsoLTl52rbPmgXnvd5FLIv/ZoA/2sSDw63YKnef8n4yhzum7W926yHeFwlm7ididKb7YQ==", + "license": "Apache-2.0", + "dependencies": { + "fancy-canvas": "2.1.0" + } + }, "node_modules/local-pkg": { "version": "1.1.2", "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.1.2.tgz", @@ -9251,21 +9250,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zrender": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz", - "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", - "license": "BSD-3-Clause", - "dependencies": { - "tslib": "2.3.0" - } - }, - "node_modules/zrender/node_modules/tslib": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", - "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", - "license": "0BSD" } } } diff --git a/package.json b/package.json index 9054f2a..c34ff0c 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,8 @@ "dependencies": { "@mdi/font": "^7.4.47", "buffer": "^6.0.3", - "echarts": "^6.0.0", "ethers": "^6.16.0", + "lightweight-charts": "^5.1.0", "pinia": "^3.0.4", "siwe": "^3.0.0", "vue": "^3.5.27", diff --git a/src/api/cryptoChart.ts b/src/api/cryptoChart.ts index c91550f..b3a7688 100644 --- a/src/api/cryptoChart.ts +++ b/src/api/cryptoChart.ts @@ -225,21 +225,34 @@ async function fetchCoinGeckoChart( * 拉取加密货币价格历史 * 优先 Binance(1 分钟粒度),不支持时回退 CoinGecko */ +const THIRTY_SEC_MS = 30 * 1000 + 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) + const range = params.range + const is30S = range === '30S' + const fetchRange = is30S ? '1H' : range + const limit = Math.min(RANGE_TO_LIMIT[fetchRange] ?? 60, 1000) try { if (binanceSymbol) { - const points = await fetchBinanceKlines(binanceSymbol, limit) + let points = await fetchBinanceKlines(binanceSymbol, limit) + if (is30S) { + const cutoff = Date.now() - THIRTY_SEC_MS + points = points.filter(([ts]) => ts >= cutoff) + } 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) + const days = RANGE_TO_DAYS[fetchRange] ?? 7 + let points = await fetchCoinGeckoChart(coinId, days) + if (is30S) { + const cutoff = Date.now() - THIRTY_SEC_MS + points = points.filter(([ts]) => ts >= cutoff) + } return { code: 0, data: points, msg: 'ok' } } catch (e) { console.error('[fetchCryptoChart]', e) @@ -250,26 +263,22 @@ export async function fetchCryptoChart( } } -/** Binance K 线 WebSocket 消息格式 */ -interface BinanceKlineMsg { +/** Binance 归集交易 WebSocket 消息格式(实时推送每笔成交,比 K 线更细) */ +interface BinanceAggTradeMsg { e?: string E?: number s?: string - k?: { - t: number - o: string - h: string - l: string - c: string - x: boolean - } + p?: string + T?: number } +const CRYPTO_UPDATE_THROTTLE_MS = 80 + /** - * 订阅 Binance 1 分钟 K 线 WebSocket,实现实时走势(约每 250ms 推送) - * @param symbol 币种符号如 btc、eth - * @param onPoint 收到新价格点时回调 [timestamp, price] - * @returns 取消订阅函数 + * 订阅 Binance 归集交易 WebSocket(aggTrade),实现更丝滑的实时走势 + * 参考:https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/web-socket-streams#归集交易 + * - aggTrade 实时推送每笔成交,比 kline_1m(约 1s 一次)更细 + * - 内部节流避免频繁 setOption 造成卡顿 */ export function subscribeCryptoRealtime( symbol: string, @@ -278,18 +287,60 @@ export function subscribeCryptoRealtime( const binanceSymbol = BINANCE_SYMBOLS[(symbol ?? 'btc').toLowerCase()] if (!binanceSymbol) return () => {} - const stream = `${binanceSymbol.toLowerCase()}@kline_1m` + const stream = `${binanceSymbol.toLowerCase()}@aggTrade` const ws = new WebSocket(`${BINANCE_WS}/${stream}`) + let lastUpdateTs = 0 + let pendingPoint: CryptoChartPoint | null = null + let rafId: number | null = null + let throttleTimer: ReturnType | null = null + + const flush = () => { + rafId = null + throttleTimer = null + if (pendingPoint) { + onPoint(pendingPoint) + pendingPoint = null + } + } + + const scheduleFlush = () => { + if (rafId) cancelAnimationFrame(rafId) + if (throttleTimer) clearTimeout(throttleTimer) + const elapsed = Date.now() - lastUpdateTs + if (elapsed >= CRYPTO_UPDATE_THROTTLE_MS) { + lastUpdateTs = Date.now() + rafId = requestAnimationFrame(flush) + } else { + throttleTimer = setTimeout(() => { + lastUpdateTs = Date.now() + flush() + }, CRYPTO_UPDATE_THROTTLE_MS - elapsed) + } + } + 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]) + const msg = JSON.parse((event as MessageEvent).data) as BinanceAggTradeMsg & { e?: string } + if (msg.e === 'ping') { + ws.send(JSON.stringify({ e: 'pong' })) + return + } + const p = msg.p + const T = msg.T + 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] + + pendingPoint = point + const now = Date.now() + if (now - lastUpdateTs >= CRYPTO_UPDATE_THROTTLE_MS) { + lastUpdateTs = now + rafId = requestAnimationFrame(flush) + } else if (rafId == null && throttleTimer == null) { + scheduleFlush() } } catch { // ignore @@ -297,6 +348,8 @@ export function subscribeCryptoRealtime( } return () => { + if (rafId) cancelAnimationFrame(rafId) + if (throttleTimer) clearTimeout(throttleTimer) ws.close() } } diff --git a/src/composables/useLightweightChart.ts b/src/composables/useLightweightChart.ts new file mode 100644 index 0000000..fa28a31 --- /dev/null +++ b/src/composables/useLightweightChart.ts @@ -0,0 +1,131 @@ +/** + * TradingView Lightweight Charts 工具 + * 数据格式:[timestamp_ms, value][] -> { time: UTCTimestamp, value: number } + * @see https://tradingview.github.io/lightweight-charts/ + */ + +import { + createChart, + LineSeries, + AreaSeries, + type IChartApi, + type ISeriesApi, + type UTCTimestamp, +} from 'lightweight-charts' + +/** 将 UTC 秒时间戳转为「本地时间当作 UTC」的秒时间戳,使图表时间轴显示用户当地时间 */ +function timeToLocal(utcSeconds: number): UTCTimestamp { + const d = new Date(utcSeconds * 1000) + return (Date.UTC( + d.getFullYear(), + d.getMonth(), + d.getDate(), + d.getHours(), + d.getMinutes(), + d.getSeconds(), + d.getMilliseconds(), + ) / 1000) as UTCTimestamp +} + +/** 将 [timestamp_ms, value][] 转为 Lightweight Charts 所需格式 + * UTCTimestamp 为秒,输入为毫秒时(>=1e12)需除以 1000 + * 按时间升序排序并去重(同时间戳保留最新价格),满足 lightweight-charts 的严格升序要求 */ +export function toLwcData(points: [number, number][]): { time: UTCTimestamp; value: number }[] { + const sorted = [...points] + .filter(([t, v]) => Number.isFinite(t) && Number.isFinite(v)) + .sort((a, b) => a[0] - b[0]) + + const result: { time: UTCTimestamp; value: number }[] = [] + for (const [t, v] of sorted) { + const utcSec = t >= 1e12 ? t / 1000 : t + const time = timeToLocal(utcSec) + if (result.length > 0 && result[result.length - 1].time === time) { + result[result.length - 1].value = v + } else { + result.push({ time, value: v }) + } + } + return result +} + +/** 将单点 [timestamp_ms, value] 转为 Lightweight Charts 所需格式,用于 update() 增量更新 */ +export function toLwcPoint(point: [number, number]): { time: UTCTimestamp; value: number } { + const [t, v] = point + const utcSec = t >= 1e12 ? t / 1000 : t + return { + time: timeToLocal(utcSec), + value: v, + } +} + +/** 创建折线图实例 */ +export function createLineChart( + container: HTMLElement, + options?: { width?: number; height?: number } +): IChartApi { + const { width = container.clientWidth, height = 320 } = options ?? {} + return createChart(container, { + width, + height, + layout: { + background: { color: '#ffffff' }, + textColor: '#6b7280', + fontFamily: 'inherit', + fontSize: 9, + attributionLogo: false, + }, + grid: { + vertLines: { color: '#f3f4f6' }, + horzLines: { color: '#f3f4f6' }, + }, + rightPriceScale: { + borderColor: '#e5e7eb', + scaleMargins: { top: 0.1, bottom: 0.1 }, + }, + timeScale: { + borderColor: '#e5e7eb', + timeVisible: true, + secondsVisible: false, + }, + crosshair: { + vertLine: { labelVisible: false }, + horzLine: { labelVisible: true }, + }, + }) +} + +/** 添加折线系列 */ +export function addLineSeries( + chart: IChartApi, + options?: { color?: string; priceFormat?: { type: 'percent' | 'price'; precision?: number } } +): ISeriesApi<'Line'> { + return chart.addSeries(LineSeries, { + color: options?.color ?? '#2563eb', + lineWidth: 2, + crosshairMarkerVisible: true, + lastValueVisible: true, + priceFormat: + options?.priceFormat?.type === 'percent' + ? { type: 'percent', precision: options.priceFormat.precision ?? 1 } + : { type: 'price', precision: 2 }, + }) +} + +/** 添加面积系列(带渐变) */ +export function addAreaSeries( + chart: IChartApi, + options?: { color?: string; topColor?: string; bottomColor?: string } +): ISeriesApi<'Area'> { + const color = options?.color ?? '#2563eb' + const topColor = options?.topColor ?? color + '40' + const bottomColor = options?.bottomColor ?? color + '08' + return chart.addSeries(AreaSeries, { + topColor, + bottomColor, + lineColor: color, + lineWidth: 2, + crosshairMarkerVisible: true, + lastValueVisible: true, + priceFormat: { type: 'price', precision: 2 }, + }) +} diff --git a/src/views/EventMarkets.vue b/src/views/EventMarkets.vue index 4c0f912..6047983 100644 --- a/src/views/EventMarkets.vue +++ b/src/views/EventMarkets.vue @@ -215,8 +215,8 @@ defineOptions({ name: 'EventMarkets' }) import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useDisplay } from 'vuetify' -import * as echarts from 'echarts' -import type { ECharts } from 'echarts' +import { createChart, LineSeries, LineType, LastPriceAnimationMode, type IChartApi, type ISeriesApi } from 'lightweight-charts' +import { toLwcData } from '../composables/useLightweightChart' import TradeComponent from '../components/TradeComponent.vue' import { findPmEvent, @@ -347,7 +347,8 @@ const chartContainerRef = ref(null) type ChartSeriesItem = { name: string; data: [number, number][] } const chartData = ref([]) const chartLoading = ref(false) -let chartInstance: ECharts | null = null +let chartInstance: IChartApi | null = null +const chartSeriesList = ref[]>([]) const LINE_COLORS = [ '#2563eb', @@ -395,98 +396,24 @@ async function loadChartFromApi(): Promise { return results } -function buildOption(seriesArr: ChartSeriesItem[], containerWidth?: number) { - const width = containerWidth ?? chartContainerRef.value?.clientWidth ?? 400 - const isMobile = width < MOBILE_BREAKPOINT - const hasData = seriesArr.some((s) => s.data.length >= 2) - const xAxisConfig: Record = { - type: 'time', - axisLine: { lineStyle: { color: '#e5e7eb' } }, - axisLabel: { color: '#6b7280', fontSize: isMobile ? 10 : 11 }, - axisTick: { show: false }, - splitLine: { show: false }, - } - if (isMobile && hasData) { - const times = seriesArr.flatMap((s) => s.data.map((d) => d[0])) - const span = times.length ? Math.max(...times) - Math.min(...times) : 0 - xAxisConfig.axisLabel = { - ...(xAxisConfig.axisLabel as object), - interval: Math.max(span / 4, 60 * 1000), - formatter: (value: number) => { - const d = new Date(value) - return `${d.getMonth() + 1}/${d.getDate()}` - }, - rotate: -25, - } - } - - const series = seriesArr.map((s, i) => { +function setChartSeries(seriesArr: ChartSeriesItem[]) { + if (!chartInstance) return + chartSeriesList.value.forEach((s) => chartInstance!.removeSeries(s)) + chartSeriesList.value = [] + seriesArr.forEach((s, i) => { const color = LINE_COLORS[i % LINE_COLORS.length] - const lastIndex = s.data.length - 1 - return { - name: s.name, - type: 'line' as const, - showSymbol: true, - symbol: 'circle', - symbolSize: (_: unknown, params: { dataIndex?: number }) => - params?.dataIndex === lastIndex ? 8 : 0, - data: s.data, - smooth: true, - lineStyle: { width: 2, color }, - itemStyle: { color, borderColor: '#fff', borderWidth: 2 }, - } + const series = chartInstance!.addSeries(LineSeries, { + color, + lineWidth: 2, + lineType: LineType.Curved, + lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate, + crosshairMarkerVisible: true, + lastValueVisible: true, + priceFormat: { type: 'percent', precision: 1 }, + }) + series.setData(toLwcData(s.data)) + chartSeriesList.value.push(series) }) - - return { - animation: false, - tooltip: { - trigger: 'axis', - formatter: (params: unknown) => { - const arr = Array.isArray(params) ? params : [params] - if (arr.length === 0) return '' - const p0 = arr[0] as { name: string | number; value: unknown } - const date = new Date(p0.name as number) - const dateStr = `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}` - const lines = arr - .filter((x) => x != null && (x as { value?: unknown }).value != null) - .map((x) => { - const v = (x as { seriesName?: string; value: unknown }).value - const val = Array.isArray(v) ? v[1] : v - return `${(x as { seriesName?: string }).seriesName}: ${val}%` - }) - return [dateStr, ...lines].join('
') - }, - axisPointer: { animation: false }, - }, - legend: { - type: 'scroll', - top: 0, - right: 0, - data: seriesArr.map((s) => s.name), - textStyle: { fontSize: 11, color: '#6b7280' }, - itemWidth: 14, - itemHeight: 8, - itemGap: 12, - }, - grid: { - left: 8, - right: 48, - top: 40, - bottom: isMobile ? 44 : 28, - containLabel: false, - }, - xAxis: xAxisConfig, - yAxis: { - type: 'value', - position: 'right', - boundaryGap: [0, '100%'], - axisLine: { show: false }, - axisTick: { show: false }, - axisLabel: { color: '#6b7280', fontSize: 11, formatter: '{value}%' }, - splitLine: { show: true, lineStyle: { type: 'dashed', color: '#e5e7eb' } }, - }, - series, - } } async function initChart() { @@ -494,9 +421,23 @@ async function initChart() { chartLoading.value = true try { chartData.value = await loadChartFromApi() - chartInstance = echarts.init(chartContainerRef.value) - const w = chartContainerRef.value.clientWidth - chartInstance.setOption(buildOption(chartData.value, w)) + const el = chartContainerRef.value + chartInstance = createChart(el, { + width: el.clientWidth, + height: 320, + layout: { + background: { color: '#ffffff' }, + textColor: '#6b7280', + fontFamily: 'inherit', + fontSize: 9, + attributionLogo: false, + }, + grid: { vertLines: { color: '#f3f4f6' }, horzLines: { color: '#f3f4f6' } }, + rightPriceScale: { borderColor: '#e5e7eb', scaleMargins: { top: 0.1, bottom: 0.1 } }, + timeScale: { borderColor: '#e5e7eb', timeVisible: true, secondsVisible: false }, + crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } }, + }) + setChartSeries(chartData.value) } finally { chartLoading.value = false } @@ -506,9 +447,7 @@ async function updateChartData() { chartLoading.value = true try { chartData.value = await loadChartFromApi() - const w = chartContainerRef.value?.clientWidth - if (chartInstance) - chartInstance.setOption(buildOption(chartData.value, w), { replaceMerge: ['series'] }) + setChartSeries(chartData.value) } finally { chartLoading.value = false } @@ -521,10 +460,7 @@ function selectTimeRange(range: string) { const handleResize = () => { if (!chartInstance || !chartContainerRef.value) return - chartInstance.resize() - chartInstance.setOption(buildOption(chartData.value, chartContainerRef.value.clientWidth), { - replaceMerge: ['series'], - }) + chartInstance.resize(chartContainerRef.value.clientWidth, 320) } function selectMarket(index: number) { @@ -717,8 +653,9 @@ watch(tradeSheetOpen, (open) => { onUnmounted(() => { window.removeEventListener('resize', handleResize) - chartInstance?.dispose() + chartInstance?.remove() chartInstance = null + chartSeriesList.value = [] if (tradeSheetUnmountTimer) clearTimeout(tradeSheetUnmountTimer) if (tradeSheetMountTimer) clearTimeout(tradeSheetMountTimer) }) diff --git a/src/views/TradeDetail.vue b/src/views/TradeDetail.vue index 2e3370d..6a9797e 100644 --- a/src/views/TradeDetail.vue +++ b/src/views/TradeDetail.vue @@ -45,9 +45,7 @@ - + @@ -93,22 +91,29 @@ {{ t('activity.noPositionsInMarket') }}
-
+
- + {{ pos.iconChar || '•' }}
{{ pos.market }}
- {{ pos.outcomeTag }} + {{ + pos.outcomeTag + }} - {{ pos.lockedSharesNum != null && Number.isFinite(pos.lockedSharesNum) ? t('activity.positionLockedWithAmount', { n: pos.lockedSharesNum }) : t('activity.positionLocked') }} + {{ + pos.lockedSharesNum != null && Number.isFinite(pos.lockedSharesNum) + ? t('activity.positionLockedWithAmount', { n: pos.lockedSharesNum }) + : t('activity.positionLocked') + }} {{ pos.shares }} {{ pos.value }} @@ -133,11 +138,7 @@ {{ t('activity.noOpenOrdersInMarket') }}
-
+
{{ ord.actionLabel || `Buy ${ord.outcome}` }} @@ -191,7 +192,10 @@
-
+
{{ t('activity.rulesEmpty') }}