优化:将折线图换成tradingview

This commit is contained in:
ivan 2026-03-16 17:34:02 +08:00
parent e7a33c9638
commit 748544c883
14 changed files with 556 additions and 564 deletions

View File

@ -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 数据生效 |

View File

@ -11,7 +11,7 @@
- `isCryptoEvent`:判断事件是否为加密货币类型(通过 tags/series slug、ticker
- `inferCryptoSymbol`从事件信息推断币种符号btc、eth 等)
- `fetchCryptoChart`:从 Binance 获取 1 分钟 K 线历史(优先),不支持时回退 CoinGecko
- `subscribeCryptoRealtime`:订阅 Binance K 线 WebSocket约每 250ms 推送,实现走势图实时更新
- `subscribeCryptoRealtime`:订阅 Binance **归集交易**aggTradeWebSocket实时推送每笔成交比 K 线(约 1s更细内部 80ms 节流 + RAF 批处理,避免频繁 setOption 卡顿;支持 PING/PONG 保活
## 币种映射
@ -29,7 +29,13 @@ if (isCryptoEvent(eventDetail)) {
}
```
## 30 秒走势(仅加密货币)
- 30S 选项仅在加密货币走势图显示YES/NO 分时与 EventMarkets 不包含
- 30S 数据:拉取 1H60 分钟)走势,过滤保留最近 30 秒内的点展示
## 扩展方式
- 新增币种:在 `COINGECKO_COIN_IDS` 中追加映射
- 更换数据源:修改 `fetchCryptoChart` 内部实现,或通过后端代理 CoinGecko 以规避 CORS
- 实时流参考:[Binance WebSocket 归集交易](https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/web-socket-streams#归集交易)

View File

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

View File

@ -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` 等配置

View File

@ -22,7 +22,7 @@
- Vue 3、Vue Router 5、Pinia、Vuetify 4
- ethers、siwe钱包登录
- echarts图表
- lightweight-chartsTradingView 金融图表)
- @mdi/font(图标)
## 扩展方式

View File

@ -9,7 +9,7 @@
## 核心能力
- 分时图:ECharts 渲染,支持 Past、时间粒度切换1H/6H/1D/1W/1M/ALL**Yes/No 模式**数据来自 **GET /pmPriceHistory/getPmPriceHistoryPublic**market 传 clobTokenIds[0]),接口返回 `time`Unix 秒)、`price`01转成 `[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`01转成 `[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` 消息,实时追加成交记录

View File

@ -10,7 +10,7 @@
## 核心能力
- Portfolio 卡片余额、Deposit/Withdraw 按钮
- Profit/Loss 卡片时间范围切换1D/1W/1M/ALLECharts 资产变化折线图;数据格式为 `[timestamp_ms, pnl][]`**暂无接口时全部显示为 0**(真实时间轴 + 数值 0有接口后在此处对接
- Profit/Loss 卡片时间范围切换1D/1W/1M/ALLLightweight Charts 资产变化面积图;数据格式为 `[timestamp_ms, pnl][]`**暂无接口时全部显示为 0**(真实时间轴 + 数值 0有接口后在此处对接
- TabPositions、Open orders、**History**(历史记录来自 **GET /hr/getHistoryRecordListClient**`src/api/historyRecord.ts`需鉴权、按当前用户分页、Withdrawals提现记录
- **可结算/领取**未结算项unsettledItems由持仓中有 `marketID``tokenID`**所属 market.closed=true** 的项组成,用于「领取结算」按钮;不再使用 needClaim 判断
- Withdrawals分页列表状态筛选全部/审核中/提现成功/审核不通过/提现失败),对接 GET /pmset/getPmSettlementRequestsListClient

48
package-lock.json generated
View File

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

View File

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

View File

@ -225,21 +225,34 @@ async function fetchCoinGeckoChart(
*
* Binance1 退 CoinGecko
*/
const THIRTY_SEC_MS = 30 * 1000
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)
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 btceth
* @param onPoint [timestamp, price]
* @returns
* Binance WebSocketaggTrade
* 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<typeof setTimeout> | 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()
}
}

View File

@ -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 },
})
}

View File

@ -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<HTMLElement | null>(null)
type ChartSeriesItem = { name: string; data: [number, number][] }
const chartData = ref<ChartSeriesItem[]>([])
const chartLoading = ref(false)
let chartInstance: ECharts | null = null
let chartInstance: IChartApi | null = null
const chartSeriesList = ref<ISeriesApi<'Line'>[]>([])
const LINE_COLORS = [
'#2563eb',
@ -395,98 +396,24 @@ async function loadChartFromApi(): Promise<ChartSeriesItem[]> {
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<string, unknown> = {
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 },
})
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}%`
series.setData(toLwcData(s.data))
chartSeriesList.value.push(series)
})
return [dateStr, ...lines].join('<br/>')
},
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)
})

View File

@ -45,9 +45,7 @@
<template v-if="chartMode === 'crypto' && cryptoSymbol">
${{ currentCryptoPrice }} {{ cryptoSymbol.toUpperCase() }}
</template>
<template v-else>
{{ currentChance }}% {{ t('common.chance') }}
</template>
<template v-else> {{ currentChance }}% {{ t('common.chance') }} </template>
</div>
</div>
@ -93,22 +91,29 @@
{{ t('activity.noPositionsInMarket') }}
</div>
<div v-else class="positions-list">
<div
v-for="pos in marketPositionsFiltered"
:key="pos.id"
class="position-row-item"
>
<div v-for="pos in marketPositionsFiltered" :key="pos.id" class="position-row-item">
<div class="position-row-header">
<div class="position-row-icon" :class="pos.iconClass">
<img v-if="pos.imageUrl" :src="pos.imageUrl" alt="" class="position-row-icon-img" />
<img
v-if="pos.imageUrl"
:src="pos.imageUrl"
alt=""
class="position-row-icon-img"
/>
<span v-else class="position-row-icon-char">{{ pos.iconChar || '•' }}</span>
</div>
<span class="position-row-title">{{ pos.market }}</span>
</div>
<div class="position-row-main">
<span :class="['position-outcome-pill', pos.outcomePillClass]">{{ pos.outcomeTag }}</span>
<span :class="['position-outcome-pill', pos.outcomePillClass]">{{
pos.outcomeTag
}}</span>
<span v-if="pos.locked" class="position-lock-badge">
{{ 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')
}}
</span>
<span class="position-shares">{{ pos.shares }}</span>
<span class="position-value">{{ pos.value }}</span>
@ -133,11 +138,7 @@
{{ t('activity.noOpenOrdersInMarket') }}
</div>
<div v-else class="orders-list">
<div
v-for="ord in marketOpenOrders"
:key="ord.id"
class="order-row-item"
>
<div v-for="ord in marketOpenOrders" :key="ord.id" class="order-row-item">
<div class="order-row-main">
<span :class="['order-side-pill', ord.side === 'Yes' ? 'side-yes' : 'side-no']">
{{ ord.actionLabel || `Buy ${ord.outcome}` }}
@ -191,7 +192,10 @@
<v-window v-model="detailTab" class="detail-window">
<v-window-item value="rules" class="detail-pane">
<div class="rules-pane">
<div v-if="!eventDetail?.description && !eventDetail?.resolutionSource" class="placeholder-pane">
<div
v-if="!eventDetail?.description && !eventDetail?.resolutionSource"
class="placeholder-pane"
>
{{ t('activity.rulesEmpty') }}
</div>
<template v-else>
@ -247,7 +251,9 @@
</div>
<div class="activity-body">
<span class="activity-user">{{ item.user }}</span>
<span class="activity-action">{{ t(item.action === 'bought' ? 'activity.bought' : 'activity.sold') }}</span>
<span class="activity-action">{{
t(item.action === 'bought' ? 'activity.bought' : 'activity.sold')
}}</span>
<span
:class="['activity-amount', item.side === 'Yes' ? 'amount-yes' : 'amount-no']"
>
@ -258,7 +264,12 @@
</div>
<div class="activity-meta">
<span class="activity-time">{{ formatTimeAgo(item.time) }}</span>
<a href="#" class="activity-link" :aria-label="t('activity.viewTransaction')" @click.prevent>
<a
href="#"
class="activity-link"
:aria-label="t('activity.viewTransaction')"
@click.prevent
>
<v-icon size="16">mdi-open-in-new</v-icon>
</a>
</div>
@ -374,8 +385,15 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
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, toLwcPoint } from '../composables/useLightweightChart'
import OrderBook from '../components/OrderBook.vue'
import TradeComponent, { type TradePositionItem } from '../components/TradeComponent.vue'
import {
@ -390,7 +408,11 @@ import { useUserStore } from '../stores/user'
import { useToastStore } from '../stores/toast'
import { useLocaleStore } from '../stores/locale'
import { useAuthError } from '../composables/useAuthError'
import { getPositionList, mapPositionToDisplayItem, type PositionDisplayItem } from '../api/position'
import {
getPositionList,
mapPositionToDisplayItem,
type PositionDisplayItem,
} from '../api/position'
import {
getOrderList,
mapOrderToOpenOrderItem,
@ -412,11 +434,7 @@ import {
} from '../api/cryptoChart'
const { t } = useI18n()
import {
ClobSdk,
type PriceSizePolyMsg,
type TradePolyMsg,
} from '../../sdk/clobSocket'
import { ClobSdk, type PriceSizePolyMsg, type TradePolyMsg } from '../../sdk/clobSocket'
/**
* 分时图服务端推送数据格式约定
@ -565,12 +583,19 @@ const currentCryptoPrice = computed(() => {
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 Number.isFinite(p)
? p.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '—'
}
return '—'
})
function setChartMode(mode: 'yesno' | 'crypto') {
if (mode === 'yesno' && selectedTimeRange.value === '30S') {
selectedTimeRange.value = '1D'
} else if (mode === 'crypto') {
selectedTimeRange.value = '30S'
}
chartMode.value = mode
updateChartData()
}
@ -703,8 +728,7 @@ function applyPriceSizeDelta(msg: PriceSizePolyMsg) {
else map.set(key, { price, shares, priceRaw })
})
}
return Array.from(map.values())
.sort((a, b) => (asc ? a.price - b.price : b.price - a.price))
return Array.from(map.values()).sort((a, b) => (asc ? a.price - b.price : b.price - a.price))
}
const prev = orderBookByToken.value[idx] ?? { asks: [], bids: [] }
const asks = mergeDelta(prev.asks, msg.s, true)
@ -853,10 +877,10 @@ const tradeComponentRef = ref<InstanceType<typeof TradeComponent> | null>(null)
const mobileTradeComponentRef = ref<InstanceType<typeof TradeComponent> | null>(null)
const yesPriceCents = computed(() =>
tradeMarketPayload.value ? Math.round(tradeMarketPayload.value.yesPrice * 100) : 0
tradeMarketPayload.value ? Math.round(tradeMarketPayload.value.yesPrice * 100) : 0,
)
const noPriceCents = computed(() =>
tradeMarketPayload.value ? Math.round(tradeMarketPayload.value.noPrice * 100) : 0
tradeMarketPayload.value ? Math.round(tradeMarketPayload.value.noPrice * 100) : 0,
)
const yesLabel = computed(() => currentMarket.value?.outcomes?.[0] ?? 'Yes')
const noLabel = computed(() => currentMarket.value?.outcomes?.[1] ?? 'No')
@ -922,7 +946,9 @@ function onSellOrderSuccess() {
// Sell dialog transition emitsOptions
let sellDialogUnmountTimer: ReturnType<typeof setTimeout> | undefined
watch(sellDialogOpen, (open) => {
watch(
sellDialogOpen,
(open) => {
if (open) {
if (sellDialogUnmountTimer) {
clearTimeout(sellDialogUnmountTimer)
@ -935,7 +961,9 @@ watch(sellDialogOpen, (open) => {
sellDialogUnmountTimer = undefined
}, 350)
}
}, { immediate: true })
},
{ immediate: true },
)
onUnmounted(() => {
if (sellDialogUnmountTimer) clearTimeout(sellDialogUnmountTimer)
if (tradeSheetUnmountTimer) clearTimeout(tradeSheetUnmountTimer)
@ -945,7 +973,9 @@ onUnmounted(() => {
// TradeComponent / transition slot
let tradeSheetUnmountTimer: ReturnType<typeof setTimeout> | undefined
let tradeSheetMountTimer: ReturnType<typeof setTimeout> | undefined
watch(tradeSheetOpen, (open) => {
watch(
tradeSheetOpen,
(open) => {
if (open) {
if (tradeSheetUnmountTimer) {
clearTimeout(tradeSheetUnmountTimer)
@ -977,7 +1007,9 @@ watch(tradeSheetOpen, (open) => {
tradeSheetUnmountTimer = undefined
}, 350)
}
}, { immediate: true })
},
{ immediate: true },
)
// marketID
const currentMarketId = computed(() => getMarketId(currentMarket.value))
@ -1028,10 +1060,7 @@ async function loadMarketPositions() {
}
positionLoading.value = true
try {
const res = await getPositionList(
{ page: 1, pageSize: 50, marketID, userID },
{ headers },
)
const res = await getPositionList({ page: 1, pageSize: 50, marketID, userID }, { headers })
if (res.code === 0 || res.code === 200) {
marketPositions.value = res.data?.list?.map(mapPositionToDisplayItem) ?? []
} else {
@ -1233,7 +1262,7 @@ function formatTimeAgo(ts: number): string {
//
const selectedTimeRange = ref('1D')
const timeRanges = [
const timeRangesYesno = [
{ label: '1H', value: '1H' },
{ label: '6H', value: '6H' },
{ label: '1D', value: '1D' },
@ -1241,6 +1270,10 @@ const timeRanges = [
{ label: '1M', value: '1M' },
{ label: 'ALL', value: 'ALL' },
]
const timeRangesCrypto = [{ label: '30S', value: '30S' }, ...timeRangesYesno]
const timeRanges = computed(() =>
chartMode.value === 'crypto' ? timeRangesCrypto : timeRangesYesno,
)
const chartContainerRef = ref<HTMLElement | null>(null)
const data = ref<[number, number][]>([])
@ -1248,7 +1281,8 @@ const data = ref<[number, number][]>([])
const rawChartData = ref<ChartDataPoint[]>([])
const cryptoChartLoading = ref(false)
const chartYesNoLoading = ref(false)
let chartInstance: ECharts | null = null
let chartInstance: IChartApi | null = null
let chartSeries: ISeriesApi<'Line'> | null = null
const currentChance = computed(() => {
const ev = eventDetail.value
@ -1272,232 +1306,58 @@ const lineColor = '#2563eb'
const MOBILE_BREAKPOINT = 600
function buildOption(chartData: [number, number][], containerWidth?: number) {
const lastIndex = chartData.length - 1
const width = containerWidth ?? chartContainerRef.value?.clientWidth ?? 400
const isMobile = width < MOBILE_BREAKPOINT
// X
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 minTime = Math.min(...times)
const maxTime = Math.max(...times)
const span = maxTime - minTime
// 4
xAxisConfig.axisLabel = {
...(xAxisConfig.axisLabel as object),
interval: Math.max(span / 4, 60 * 1000),
formatter: (value: number) => {
const d = new Date(value)
const h = d.getHours()
const m = d.getMinutes()
if (span >= 24 * 60 * 60 * 1000) {
return `${d.getMonth() + 1}/${d.getDate()}`
}
return `${h}:${m.toString().padStart(2, '0')}`
},
rotate: -25,
}
}
return {
animation: false,
tooltip: {
trigger: 'axis',
formatter: function (params: unknown) {
const p = (Array.isArray(params) ? params[0] : params) as {
name: string | number
value: unknown
}
const date = new Date(p.name as number)
const val = Array.isArray(p.value) ? p.value[1] : p.value
return (
date.getDate() +
'/' +
(date.getMonth() + 1) +
'/' +
date.getFullYear() +
' : ' +
val +
'%'
)
},
axisPointer: { animation: false },
},
grid: {
left: 8,
right: 48,
top: 16,
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: [
{
name: 'probability',
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 ensureChartSeries() {
if (!chartInstance || !chartContainerRef.value) return
if (chartSeries) {
chartInstance.removeSeries(chartSeries)
chartSeries = null
}
const isCrypto = chartMode.value === 'crypto'
chartSeries = chartInstance.addSeries(LineSeries, {
color: lineColor,
lineWidth: 2,
lineType: LineType.Curved,
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
crosshairMarkerVisible: true,
lastValueVisible: true,
priceFormat: isCrypto ? { type: 'price', precision: 2 } : { type: 'percent', precision: 1 },
})
}
/** 根据数据范围计算 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 setChartData(chartData: [number, number][]) {
if (!chartSeries) return
chartSeries.setData(toLwcData(chartData))
}
function initChart() {
if (!chartContainerRef.value) return
data.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))
}
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 } },
})
ensureChartSeries()
}
/** 从 GET /pmPriceHistory/getPmPriceHistoryPublic 拉取价格历史market 传 YES 对应的 clobTokenId */
async function loadChartFromApi(marketParam: string, range: string): Promise<ChartDataPoint[]> {
const ev = eventDetail.value
const timeRange = getTimeRangeSeconds(range, ev ? { startDate: ev.startDate, endDate: ev.endDate } : undefined)
const timeRange = getTimeRangeSeconds(
range,
ev ? { startDate: ev.startDate, endDate: ev.endDate } : undefined,
)
const res = await getPmPriceHistoryPublic({
market: marketParam,
page: 1,
@ -1514,27 +1374,47 @@ let cryptoWsUnsubscribe: (() => void) | null = null
function applyCryptoRealtimePoint(point: [number, number]) {
const list = [...data.value]
const [ts, price] = point
const range = selectedTimeRange.value
let useIncrementalUpdate = false
let updatePoint: [number, number] | null = null
if (range === '30S') {
const cutoff = Date.now() - 30 * 1000
list.push([ts, price])
const filtered = list.filter(([t]) => t >= cutoff).sort((a, b) => a[0] - b[0])
data.value = filtered
// update
useIncrementalUpdate = filtered.length === list.length
if (useIncrementalUpdate) updatePoint = [ts, price]
} else {
const last = list[list.length - 1]
const minuteStart = Math.floor(ts / MINUTE_MS) * MINUTE_MS
const sameMinute = last && Math.floor(last[0] / MINUTE_MS) === Math.floor(ts / MINUTE_MS)
const max =
{ '1H': 60, '6H': 360, '1D': 1440, '1W': 1680, '1M': 43200, ALL: 10080 }[range] ?? 60
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'] }
)
useIncrementalUpdate = true
updatePoint = [last![0], price]
} else {
list.push([minuteStart, price])
const sliced = list.slice(-max)
data.value = sliced
// update setData
useIncrementalUpdate = sliced.length === list.length
if (useIncrementalUpdate) updatePoint = [minuteStart, price]
}
}
if (chartSeries && useIncrementalUpdate && updatePoint) {
chartSeries.update(toLwcPoint(updatePoint))
} else {
setChartData(data.value)
}
}
async function updateChartData() {
const w = chartContainerRef.value?.clientWidth
if (chartMode.value === 'crypto') {
const sym = cryptoSymbol.value ?? 'btc'
cryptoWsUnsubscribe?.()
@ -1546,11 +1426,8 @@ async function updateChartData() {
range: selectedTimeRange.value,
})
data.value = (res.data ?? []) as [number, number][]
if (chartInstance)
chartInstance.setOption(
buildOptionForCrypto(data.value, sym, w),
{ replaceMerge: ['series'] }
)
ensureChartSeries()
setChartData(data.value)
cryptoWsUnsubscribe = subscribeCryptoRealtime(sym, applyCryptoRealtimePoint)
} finally {
cryptoChartLoading.value = false
@ -1560,13 +1437,12 @@ async function updateChartData() {
cryptoWsUnsubscribe = null
chartYesNoLoading.value = true
try {
// market clobTokenIds[0]YES token ID
const yesTokenId = clobTokenIds.value[0]
const points = yesTokenId ? await loadChartFromApi(yesTokenId, selectedTimeRange.value) : []
rawChartData.value = points
data.value = points
if (chartInstance)
chartInstance.setOption(buildOption(data.value, w), { replaceMerge: ['series'] })
ensureChartSeries()
setChartData(data.value)
} finally {
chartYesNoLoading.value = false
}
@ -1608,15 +1484,7 @@ watch(
const handleResize = () => {
if (!chartInstance || !chartContainerRef.value) return
chartInstance.resize()
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'] })
}
chartInstance.resize(chartContainerRef.value.clientWidth, 320)
}
watch(
@ -1653,8 +1521,9 @@ onUnmounted(() => {
cryptoWsUnsubscribe?.()
cryptoWsUnsubscribe = null
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
chartInstance?.remove()
chartInstance = null
chartSeries = null
disconnectClob()
})
</script>

View File

@ -728,8 +728,8 @@ import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
const { t } = useI18n()
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import { createChart, AreaSeries, LineType, LastPriceAnimationMode } from 'lightweight-charts'
import { toLwcData } from '../composables/useLightweightChart'
import DepositDialog from '../components/DepositDialog.vue'
import WithdrawDialog from '../components/WithdrawDialog.vue'
import { useUserStore } from '../stores/user'
@ -1349,7 +1349,8 @@ function shareHistory(id: string) {
}
const plChartRef = ref<HTMLElement | null>(null)
let plChartInstance: ECharts | null = null
let plChartInstance: ReturnType<typeof createChart> | null = null
let plChartSeries: ReturnType<ReturnType<typeof createChart>['addSeries']> | null = null
/**
* 资产变化折线图数据 [timestamp_ms, pnl]
@ -1386,68 +1387,13 @@ function getPlChartData(range: string): [number, number][] {
return data
}
function buildPlChartOption(chartData: [number, number][]) {
const lastPoint = chartData[chartData.length - 1]
const lastVal = lastPoint != null ? lastPoint[1] : 0
const lineColor = lastVal >= 0 ? '#059669' : '#dc2626'
return {
animation: false,
tooltip: {
trigger: 'axis',
formatter: (params: unknown) => {
const p = (Array.isArray(params) ? params[0] : params) as {
name: string | number
value: unknown
}
const date = new Date(p.name as number)
const val = Array.isArray(p.value) ? (p.value as number[])[1] : p.value
const sign = Number(val) >= 0 ? '+' : ''
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}<br/>${sign}$${Number(val).toFixed(2)}`
},
axisPointer: { animation: false },
},
grid: { left: 8, right: 8, top: 8, bottom: 24, containLabel: false },
xAxis: {
type: 'time',
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#9ca3af', fontSize: 10 },
splitLine: { show: false },
},
yAxis: {
type: 'value',
position: 'right',
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#9ca3af', fontSize: 10, formatter: (v: number) => `$${v}` },
splitLine: { lineStyle: { type: 'dashed', color: '#e5e7eb' } },
},
series: [
{
type: 'line',
data: chartData,
smooth: true,
showSymbol: false,
lineStyle: { width: 2, color: lineColor },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: lineColor + '40' },
{ offset: 1, color: lineColor + '08' },
]),
},
},
],
}
}
const plChartData = ref<[number, number][]>([])
function updatePlChart() {
plChartData.value = getPlChartData(plRange.value)
const last = plChartData.value[plChartData.value.length - 1]
if (last != null) profitLoss.value = last[1].toFixed(2)
if (plChartInstance)
plChartInstance.setOption(buildPlChartOption(plChartData.value), { replaceMerge: ['series'] })
if (plChartSeries) plChartSeries.setData(toLwcData(plChartData.value))
}
function initPlChart() {
@ -1455,11 +1401,40 @@ function initPlChart() {
plChartData.value = getPlChartData(plRange.value)
const last = plChartData.value[plChartData.value.length - 1]
if (last != null) profitLoss.value = last[1].toFixed(2)
plChartInstance = echarts.init(plChartRef.value)
plChartInstance.setOption(buildPlChartOption(plChartData.value))
const el = plChartRef.value
plChartInstance = createChart(el, {
width: el.clientWidth,
height: 160,
layout: {
background: { color: '#ffffff' },
textColor: '#9ca3af',
fontFamily: 'inherit',
fontSize: 9,
attributionLogo: false,
},
grid: { vertLines: { visible: false }, horzLines: { color: '#f3f4f6' } },
rightPriceScale: { borderVisible: false },
timeScale: { borderVisible: false },
crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } },
})
const lastVal = last != null ? last[1] : 0
const color = lastVal >= 0 ? '#059669' : '#dc2626'
plChartSeries = plChartInstance.addSeries(AreaSeries, {
topColor: color + '40',
bottomColor: color + '08',
lineColor: color,
lineWidth: 2,
lineType: LineType.Curved,
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
priceFormat: { type: 'price', precision: 2 },
})
plChartSeries.setData(toLwcData(plChartData.value))
}
const handleResize = () => plChartInstance?.resize()
const handleResize = () => {
if (plChartInstance && plChartRef.value)
plChartInstance.resize(plChartRef.value.clientWidth, 160)
}
watch(plRange, () => updatePlChart())
@ -1482,8 +1457,9 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
plChartInstance?.dispose()
plChartInstance?.remove()
plChartInstance = null
plChartSeries = null
})
function onWithdrawSuccess() {