优化:将折线图换成tradingview
This commit is contained in:
parent
e7a33c9638
commit
748544c883
@ -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 数据生效 |
|
||||
|
||||
|
||||
@ -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#归集交易)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
36
docs/composables/useLightweightChart.md
Normal file
36
docs/composables/useLightweightChart.md
Normal 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` 等配置
|
||||
@ -22,7 +22,7 @@
|
||||
|
||||
- Vue 3、Vue Router 5、Pinia、Vuetify 4
|
||||
- ethers、siwe(钱包登录)
|
||||
- echarts(图表)
|
||||
- lightweight-charts(TradingView 金融图表)
|
||||
- @mdi/font(图标)
|
||||
|
||||
## 扩展方式
|
||||
|
||||
@ -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` 消息,实时追加成交记录
|
||||
|
||||
@ -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
|
||||
|
||||
48
package-lock.json
generated
48
package-lock.json
generated
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -225,21 +225,34 @@ async function fetchCoinGeckoChart(
|
||||
* 拉取加密货币价格历史
|
||||
* 优先 Binance(1 分钟粒度),不支持时回退 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 币种符号如 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<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()
|
||||
}
|
||||
}
|
||||
|
||||
131
src/composables/useLightweightChart.ts
Normal file
131
src/composables/useLightweightChart.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
@ -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 },
|
||||
})
|
||||
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('<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)
|
||||
})
|
||||
|
||||
@ -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,20 +946,24 @@ function onSellOrderSuccess() {
|
||||
|
||||
// 延迟卸载 Sell 弹窗内容,等 dialog transition 完成,避免 emitsOptions 竞态
|
||||
let sellDialogUnmountTimer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(sellDialogOpen, (open) => {
|
||||
if (open) {
|
||||
if (sellDialogUnmountTimer) {
|
||||
clearTimeout(sellDialogUnmountTimer)
|
||||
sellDialogUnmountTimer = undefined
|
||||
watch(
|
||||
sellDialogOpen,
|
||||
(open) => {
|
||||
if (open) {
|
||||
if (sellDialogUnmountTimer) {
|
||||
clearTimeout(sellDialogUnmountTimer)
|
||||
sellDialogUnmountTimer = undefined
|
||||
}
|
||||
sellDialogRenderContent.value = true
|
||||
} else {
|
||||
sellDialogUnmountTimer = setTimeout(() => {
|
||||
sellDialogRenderContent.value = false
|
||||
sellDialogUnmountTimer = undefined
|
||||
}, 350)
|
||||
}
|
||||
sellDialogRenderContent.value = true
|
||||
} else {
|
||||
sellDialogUnmountTimer = setTimeout(() => {
|
||||
sellDialogRenderContent.value = false
|
||||
sellDialogUnmountTimer = undefined
|
||||
}, 350)
|
||||
}
|
||||
}, { immediate: true })
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
onUnmounted(() => {
|
||||
if (sellDialogUnmountTimer) clearTimeout(sellDialogUnmountTimer)
|
||||
if (tradeSheetUnmountTimer) clearTimeout(tradeSheetUnmountTimer)
|
||||
@ -945,39 +973,43 @@ onUnmounted(() => {
|
||||
// 底部栏 TradeComponent 延迟挂载/卸载,避免 transition 期间 slot 竞态
|
||||
let tradeSheetUnmountTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let tradeSheetMountTimer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(tradeSheetOpen, (open) => {
|
||||
if (open) {
|
||||
if (tradeSheetUnmountTimer) {
|
||||
clearTimeout(tradeSheetUnmountTimer)
|
||||
tradeSheetUnmountTimer = undefined
|
||||
watch(
|
||||
tradeSheetOpen,
|
||||
(open) => {
|
||||
if (open) {
|
||||
if (tradeSheetUnmountTimer) {
|
||||
clearTimeout(tradeSheetUnmountTimer)
|
||||
tradeSheetUnmountTimer = undefined
|
||||
}
|
||||
if (tradeSheetMountTimer) clearTimeout(tradeSheetMountTimer)
|
||||
tradeSheetMountTimer = setTimeout(() => {
|
||||
tradeSheetRenderContent.value = true
|
||||
tradeSheetMountTimer = undefined
|
||||
nextTick(() => {
|
||||
const pending = pendingMergeSplitDialog.value
|
||||
if (pending === 'merge') {
|
||||
pendingMergeSplitDialog.value = null
|
||||
mobileTradeComponentRef.value?.openMergeDialog?.()
|
||||
} else if (pending === 'split') {
|
||||
pendingMergeSplitDialog.value = null
|
||||
mobileTradeComponentRef.value?.openSplitDialog?.()
|
||||
}
|
||||
})
|
||||
}, 50)
|
||||
} else {
|
||||
pendingMergeSplitDialog.value = null
|
||||
if (tradeSheetMountTimer) {
|
||||
clearTimeout(tradeSheetMountTimer)
|
||||
tradeSheetMountTimer = undefined
|
||||
}
|
||||
tradeSheetUnmountTimer = setTimeout(() => {
|
||||
tradeSheetRenderContent.value = false
|
||||
tradeSheetUnmountTimer = undefined
|
||||
}, 350)
|
||||
}
|
||||
if (tradeSheetMountTimer) clearTimeout(tradeSheetMountTimer)
|
||||
tradeSheetMountTimer = setTimeout(() => {
|
||||
tradeSheetRenderContent.value = true
|
||||
tradeSheetMountTimer = undefined
|
||||
nextTick(() => {
|
||||
const pending = pendingMergeSplitDialog.value
|
||||
if (pending === 'merge') {
|
||||
pendingMergeSplitDialog.value = null
|
||||
mobileTradeComponentRef.value?.openMergeDialog?.()
|
||||
} else if (pending === 'split') {
|
||||
pendingMergeSplitDialog.value = null
|
||||
mobileTradeComponentRef.value?.openSplitDialog?.()
|
||||
}
|
||||
})
|
||||
}, 50)
|
||||
} else {
|
||||
pendingMergeSplitDialog.value = null
|
||||
if (tradeSheetMountTimer) {
|
||||
clearTimeout(tradeSheetMountTimer)
|
||||
tradeSheetMountTimer = undefined
|
||||
}
|
||||
tradeSheetUnmountTimer = setTimeout(() => {
|
||||
tradeSheetRenderContent.value = false
|
||||
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 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 {
|
||||
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]
|
||||
data.value = list.slice(-max)
|
||||
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)
|
||||
}
|
||||
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'] }
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user