优化:将折线图换成tradingview
This commit is contained in:
parent
e7a33c9638
commit
748544c883
@ -32,7 +32,7 @@
|
|||||||
| 功能 | 说明 |
|
| 功能 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 事件详情 | `findPmEvent` 按 id/slug 获取,需鉴权 |
|
| 事件详情 | `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 |
|
| 交易组件 | Buy/Sell、Market 模式、Merge、Split |
|
||||||
| 下单 | `pmOrderPlace` 调用 CLOB 下单接口 |
|
| 下单 | `pmOrderPlace` 调用 CLOB 下单接口 |
|
||||||
| Split | `pmMarketSplit` 用 USDC 兑换 Yes+No |
|
| Split | `pmMarketSplit` 用 USDC 兑换 Yes+No |
|
||||||
@ -47,7 +47,7 @@
|
|||||||
| Portfolio 卡片 | 展示 userStore.balance |
|
| Portfolio 卡片 | 展示 userStore.balance |
|
||||||
| Deposit/Withdraw 弹窗 | UI 完整,Deposit 展示固定地址与二维码;Withdraw 支持金额、网络、目标地址选择 |
|
| Deposit/Withdraw 弹窗 | UI 完整,Deposit 展示固定地址与二维码;Withdraw 支持金额、网络、目标地址选择 |
|
||||||
| 取消订单 | `pmCancelOrder` 调用真实接口,**但 Positions/Open orders/History 数据为 mock** |
|
| 取消订单 | `pmCancelOrder` 调用真实接口,**但 Positions/Open orders/History 数据为 mock** |
|
||||||
| Profit/Loss 图表 | ECharts 展示,**数据为 mock** |
|
| Profit/Loss 图表 | Lightweight Charts 展示,**数据为 mock** |
|
||||||
| Positions/Orders/History Tab | 表格与移动端列表,**数据为 mock** |
|
| Positions/Orders/History Tab | 表格与移动端列表,**数据为 mock** |
|
||||||
| 搜索、分页 | 对 mock 数据生效 |
|
| 搜索、分页 | 对 mock 数据生效 |
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
- `isCryptoEvent`:判断事件是否为加密货币类型(通过 tags/series slug、ticker)
|
- `isCryptoEvent`:判断事件是否为加密货币类型(通过 tags/series slug、ticker)
|
||||||
- `inferCryptoSymbol`:从事件信息推断币种符号(btc、eth 等)
|
- `inferCryptoSymbol`:从事件信息推断币种符号(btc、eth 等)
|
||||||
- `fetchCryptoChart`:从 Binance 获取 1 分钟 K 线历史(优先),不支持时回退 CoinGecko
|
- `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` 中追加映射
|
- 新增币种:在 `COINGECKO_COIN_IDS` 中追加映射
|
||||||
- 更换数据源:修改 `fetchCryptoChart` 内部实现,或通过后端代理 CoinGecko 以规避 CORS
|
- 更换数据源:修改 `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 分页获取价格历史
|
- `getPmPriceHistoryPublic`:按市场 ID 分页获取价格历史
|
||||||
- `getTimeRangeSeconds`:根据分时范围计算 `startTs`、`endTs`(Unix 秒);`endTs` 始终为当前时间;1H/6H/1D/1W/1M 的 `startTs` 为当前时间往前对应时长;ALL 的 `startTs` 为事件开始时间、`endTs` 为当前时间
|
- `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
|
## 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
|
- Vue 3、Vue Router 5、Pinia、Vuetify 4
|
||||||
- ethers、siwe(钱包登录)
|
- ethers、siwe(钱包登录)
|
||||||
- echarts(图表)
|
- lightweight-charts(TradingView 金融图表)
|
||||||
- @mdi/font(图标)
|
- @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` 转为展示值
|
- 订单簿:`OrderBook` 组件,通过 **ClobSdk** 对接 CLOB WebSocket 实时数据(全量快照、增量更新、成交推送);份额接口按 6 位小数传(1_000_000 = 1 share),`priceSizeToRows` 与 `mergeDelta` 会将 raw 值除以 `ORDER_BOOK_SIZE_SCALE` 转为展示值
|
||||||
- 交易:`TradeComponent`,传入 `market`、`initialOption`、`positions`(持仓数据)
|
- 交易:`TradeComponent`,传入 `market`、`initialOption`、`positions`(持仓数据)
|
||||||
- 持仓列表:通过 `getPositionList` 获取当前市场持仓,传递给 `TradeComponent` 用于计算可合并份额
|
- 持仓列表:通过 `getPositionList` 获取当前市场持仓,传递给 `TradeComponent` 用于计算可合并份额
|
||||||
@ -36,7 +36,7 @@
|
|||||||
## 扩展方式
|
## 扩展方式
|
||||||
|
|
||||||
1. **订单簿**:已通过 `sdk/clobSocket.ts` 的 ClobSdk 对接 CLOB WebSocket,使用 **Yes/No token ID** 订阅 `price_size_all`、`price_size_delta`、`trade` 消息
|
1. **订单簿**:已通过 `sdk/clobSocket.ts` 的 ClobSdk 对接 CLOB WebSocket,使用 **Yes/No token ID** 订阅 `price_size_all`、`price_size_delta`、`trade` 消息
|
||||||
2. **分时图**: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
|
3. **Comments**:对接评论接口,替换 placeholder
|
||||||
4. **Top Holders**:对接持仓接口
|
4. **Top Holders**:对接持仓接口
|
||||||
5. **Activity**:已对接 CLOB `trade` 消息,实时追加成交记录
|
5. **Activity**:已对接 CLOB `trade` 消息,实时追加成交记录
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
## 核心能力
|
## 核心能力
|
||||||
|
|
||||||
- Portfolio 卡片:余额、Deposit/Withdraw 按钮
|
- 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(提现记录)
|
- Tab:Positions、Open orders、**History**(历史记录来自 **GET /hr/getHistoryRecordListClient**,`src/api/historyRecord.ts`,需鉴权、按当前用户分页)、Withdrawals(提现记录)
|
||||||
- **可结算/领取**:未结算项(unsettledItems)由持仓中有 `marketID`、`tokenID` 且 **所属 market.closed=true** 的项组成,用于「领取结算」按钮;不再使用 needClaim 判断
|
- **可结算/领取**:未结算项(unsettledItems)由持仓中有 `marketID`、`tokenID` 且 **所属 market.closed=true** 的项组成,用于「领取结算」按钮;不再使用 needClaim 判断
|
||||||
- Withdrawals:分页列表,状态筛选(全部/审核中/提现成功/审核不通过/提现失败),对接 GET /pmset/getPmSettlementRequestsListClient
|
- Withdrawals:分页列表,状态筛选(全部/审核中/提现成功/审核不通过/提现失败),对接 GET /pmset/getPmSettlementRequestsListClient
|
||||||
|
|||||||
48
package-lock.json
generated
48
package-lock.json
generated
@ -10,8 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/font": "^7.4.47",
|
"@mdi/font": "^7.4.47",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"echarts": "^6.0.0",
|
|
||||||
"ethers": "^6.16.0",
|
"ethers": "^6.16.0",
|
||||||
|
"lightweight-charts": "^5.1.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"siwe": "^3.0.0",
|
"siwe": "^3.0.0",
|
||||||
"vue": "^3.5.27",
|
"vue": "^3.5.27",
|
||||||
@ -4243,22 +4243,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/editorconfig": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmmirror.com/editorconfig/-/editorconfig-1.0.4.tgz",
|
"resolved": "https://registry.npmmirror.com/editorconfig/-/editorconfig-1.0.4.tgz",
|
||||||
@ -4894,6 +4878,12 @@
|
|||||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@ -5925,6 +5915,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/local-pkg": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.1.2.tgz",
|
||||||
@ -9251,21 +9250,6 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"dependencies": {
|
||||||
"@mdi/font": "^7.4.47",
|
"@mdi/font": "^7.4.47",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"echarts": "^6.0.0",
|
|
||||||
"ethers": "^6.16.0",
|
"ethers": "^6.16.0",
|
||||||
|
"lightweight-charts": "^5.1.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"siwe": "^3.0.0",
|
"siwe": "^3.0.0",
|
||||||
"vue": "^3.5.27",
|
"vue": "^3.5.27",
|
||||||
|
|||||||
@ -225,21 +225,34 @@ async function fetchCoinGeckoChart(
|
|||||||
* 拉取加密货币价格历史
|
* 拉取加密货币价格历史
|
||||||
* 优先 Binance(1 分钟粒度),不支持时回退 CoinGecko
|
* 优先 Binance(1 分钟粒度),不支持时回退 CoinGecko
|
||||||
*/
|
*/
|
||||||
|
const THIRTY_SEC_MS = 30 * 1000
|
||||||
|
|
||||||
export async function fetchCryptoChart(
|
export async function fetchCryptoChart(
|
||||||
params: FetchCryptoChartParams
|
params: FetchCryptoChartParams
|
||||||
): Promise<FetchCryptoChartResponse> {
|
): Promise<FetchCryptoChartResponse> {
|
||||||
const symbol = (params.symbol ?? 'btc').toLowerCase()
|
const symbol = (params.symbol ?? 'btc').toLowerCase()
|
||||||
const binanceSymbol = BINANCE_SYMBOLS[symbol]
|
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 {
|
try {
|
||||||
if (binanceSymbol) {
|
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' }
|
return { code: 0, data: points, msg: 'ok' }
|
||||||
}
|
}
|
||||||
const coinId = COINGECKO_COIN_IDS[symbol] ?? 'bitcoin'
|
const coinId = COINGECKO_COIN_IDS[symbol] ?? 'bitcoin'
|
||||||
const days = RANGE_TO_DAYS[params.range] ?? 7
|
const days = RANGE_TO_DAYS[fetchRange] ?? 7
|
||||||
const points = await fetchCoinGeckoChart(coinId, days)
|
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' }
|
return { code: 0, data: points, msg: 'ok' }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[fetchCryptoChart]', e)
|
console.error('[fetchCryptoChart]', e)
|
||||||
@ -250,26 +263,22 @@ export async function fetchCryptoChart(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Binance K 线 WebSocket 消息格式 */
|
/** Binance 归集交易 WebSocket 消息格式(实时推送每笔成交,比 K 线更细) */
|
||||||
interface BinanceKlineMsg {
|
interface BinanceAggTradeMsg {
|
||||||
e?: string
|
e?: string
|
||||||
E?: number
|
E?: number
|
||||||
s?: string
|
s?: string
|
||||||
k?: {
|
p?: string
|
||||||
t: number
|
T?: number
|
||||||
o: string
|
|
||||||
h: string
|
|
||||||
l: string
|
|
||||||
c: string
|
|
||||||
x: boolean
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CRYPTO_UPDATE_THROTTLE_MS = 80
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订阅 Binance 1 分钟 K 线 WebSocket,实现实时走势(约每 250ms 推送)
|
* 订阅 Binance 归集交易 WebSocket(aggTrade),实现更丝滑的实时走势
|
||||||
* @param symbol 币种符号如 btc、eth
|
* 参考:https://developers.binance.com/docs/zh-CN/binance-spot-api-docs/web-socket-streams#归集交易
|
||||||
* @param onPoint 收到新价格点时回调 [timestamp, price]
|
* - aggTrade 实时推送每笔成交,比 kline_1m(约 1s 一次)更细
|
||||||
* @returns 取消订阅函数
|
* - 内部节流避免频繁 setOption 造成卡顿
|
||||||
*/
|
*/
|
||||||
export function subscribeCryptoRealtime(
|
export function subscribeCryptoRealtime(
|
||||||
symbol: string,
|
symbol: string,
|
||||||
@ -278,18 +287,60 @@ export function subscribeCryptoRealtime(
|
|||||||
const binanceSymbol = BINANCE_SYMBOLS[(symbol ?? 'btc').toLowerCase()]
|
const binanceSymbol = BINANCE_SYMBOLS[(symbol ?? 'btc').toLowerCase()]
|
||||||
if (!binanceSymbol) return () => {}
|
if (!binanceSymbol) return () => {}
|
||||||
|
|
||||||
const stream = `${binanceSymbol.toLowerCase()}@kline_1m`
|
const stream = `${binanceSymbol.toLowerCase()}@aggTrade`
|
||||||
const ws = new WebSocket(`${BINANCE_WS}/${stream}`)
|
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) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data) as BinanceKlineMsg
|
const msg = JSON.parse((event as MessageEvent).data) as BinanceAggTradeMsg & { e?: string }
|
||||||
const k = msg.k
|
if (msg.e === 'ping') {
|
||||||
if (!k?.c) return
|
ws.send(JSON.stringify({ e: 'pong' }))
|
||||||
const ts = k.t
|
return
|
||||||
const price = parseFloat(k.c)
|
}
|
||||||
if (Number.isFinite(ts) && Number.isFinite(price)) {
|
const p = msg.p
|
||||||
onPoint([ts, price])
|
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 {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@ -297,6 +348,8 @@ export function subscribeCryptoRealtime(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (rafId) cancelAnimationFrame(rafId)
|
||||||
|
if (throttleTimer) clearTimeout(throttleTimer)
|
||||||
ws.close()
|
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 { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import * as echarts from 'echarts'
|
import { createChart, LineSeries, LineType, LastPriceAnimationMode, type IChartApi, type ISeriesApi } from 'lightweight-charts'
|
||||||
import type { ECharts } from 'echarts'
|
import { toLwcData } from '../composables/useLightweightChart'
|
||||||
import TradeComponent from '../components/TradeComponent.vue'
|
import TradeComponent from '../components/TradeComponent.vue'
|
||||||
import {
|
import {
|
||||||
findPmEvent,
|
findPmEvent,
|
||||||
@ -347,7 +347,8 @@ const chartContainerRef = ref<HTMLElement | null>(null)
|
|||||||
type ChartSeriesItem = { name: string; data: [number, number][] }
|
type ChartSeriesItem = { name: string; data: [number, number][] }
|
||||||
const chartData = ref<ChartSeriesItem[]>([])
|
const chartData = ref<ChartSeriesItem[]>([])
|
||||||
const chartLoading = ref(false)
|
const chartLoading = ref(false)
|
||||||
let chartInstance: ECharts | null = null
|
let chartInstance: IChartApi | null = null
|
||||||
|
const chartSeriesList = ref<ISeriesApi<'Line'>[]>([])
|
||||||
|
|
||||||
const LINE_COLORS = [
|
const LINE_COLORS = [
|
||||||
'#2563eb',
|
'#2563eb',
|
||||||
@ -395,98 +396,24 @@ async function loadChartFromApi(): Promise<ChartSeriesItem[]> {
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOption(seriesArr: ChartSeriesItem[], containerWidth?: number) {
|
function setChartSeries(seriesArr: ChartSeriesItem[]) {
|
||||||
const width = containerWidth ?? chartContainerRef.value?.clientWidth ?? 400
|
if (!chartInstance) return
|
||||||
const isMobile = width < MOBILE_BREAKPOINT
|
chartSeriesList.value.forEach((s) => chartInstance!.removeSeries(s))
|
||||||
const hasData = seriesArr.some((s) => s.data.length >= 2)
|
chartSeriesList.value = []
|
||||||
const xAxisConfig: Record<string, unknown> = {
|
seriesArr.forEach((s, i) => {
|
||||||
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) => {
|
|
||||||
const color = LINE_COLORS[i % LINE_COLORS.length]
|
const color = LINE_COLORS[i % LINE_COLORS.length]
|
||||||
const lastIndex = s.data.length - 1
|
const series = chartInstance!.addSeries(LineSeries, {
|
||||||
return {
|
color,
|
||||||
name: s.name,
|
lineWidth: 2,
|
||||||
type: 'line' as const,
|
lineType: LineType.Curved,
|
||||||
showSymbol: true,
|
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
|
||||||
symbol: 'circle',
|
crosshairMarkerVisible: true,
|
||||||
symbolSize: (_: unknown, params: { dataIndex?: number }) =>
|
lastValueVisible: true,
|
||||||
params?.dataIndex === lastIndex ? 8 : 0,
|
priceFormat: { type: 'percent', precision: 1 },
|
||||||
data: s.data,
|
|
||||||
smooth: true,
|
|
||||||
lineStyle: { width: 2, color },
|
|
||||||
itemStyle: { color, borderColor: '#fff', borderWidth: 2 },
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
series.setData(toLwcData(s.data))
|
||||||
return {
|
chartSeriesList.value.push(series)
|
||||||
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() {
|
async function initChart() {
|
||||||
@ -494,9 +421,23 @@ async function initChart() {
|
|||||||
chartLoading.value = true
|
chartLoading.value = true
|
||||||
try {
|
try {
|
||||||
chartData.value = await loadChartFromApi()
|
chartData.value = await loadChartFromApi()
|
||||||
chartInstance = echarts.init(chartContainerRef.value)
|
const el = chartContainerRef.value
|
||||||
const w = chartContainerRef.value.clientWidth
|
chartInstance = createChart(el, {
|
||||||
chartInstance.setOption(buildOption(chartData.value, w))
|
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 {
|
} finally {
|
||||||
chartLoading.value = false
|
chartLoading.value = false
|
||||||
}
|
}
|
||||||
@ -506,9 +447,7 @@ async function updateChartData() {
|
|||||||
chartLoading.value = true
|
chartLoading.value = true
|
||||||
try {
|
try {
|
||||||
chartData.value = await loadChartFromApi()
|
chartData.value = await loadChartFromApi()
|
||||||
const w = chartContainerRef.value?.clientWidth
|
setChartSeries(chartData.value)
|
||||||
if (chartInstance)
|
|
||||||
chartInstance.setOption(buildOption(chartData.value, w), { replaceMerge: ['series'] })
|
|
||||||
} finally {
|
} finally {
|
||||||
chartLoading.value = false
|
chartLoading.value = false
|
||||||
}
|
}
|
||||||
@ -521,10 +460,7 @@ function selectTimeRange(range: string) {
|
|||||||
|
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
if (!chartInstance || !chartContainerRef.value) return
|
if (!chartInstance || !chartContainerRef.value) return
|
||||||
chartInstance.resize()
|
chartInstance.resize(chartContainerRef.value.clientWidth, 320)
|
||||||
chartInstance.setOption(buildOption(chartData.value, chartContainerRef.value.clientWidth), {
|
|
||||||
replaceMerge: ['series'],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectMarket(index: number) {
|
function selectMarket(index: number) {
|
||||||
@ -717,8 +653,9 @@ watch(tradeSheetOpen, (open) => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
chartInstance?.dispose()
|
chartInstance?.remove()
|
||||||
chartInstance = null
|
chartInstance = null
|
||||||
|
chartSeriesList.value = []
|
||||||
if (tradeSheetUnmountTimer) clearTimeout(tradeSheetUnmountTimer)
|
if (tradeSheetUnmountTimer) clearTimeout(tradeSheetUnmountTimer)
|
||||||
if (tradeSheetMountTimer) clearTimeout(tradeSheetMountTimer)
|
if (tradeSheetMountTimer) clearTimeout(tradeSheetMountTimer)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -45,9 +45,7 @@
|
|||||||
<template v-if="chartMode === 'crypto' && cryptoSymbol">
|
<template v-if="chartMode === 'crypto' && cryptoSymbol">
|
||||||
${{ currentCryptoPrice }} {{ cryptoSymbol.toUpperCase() }}
|
${{ currentCryptoPrice }} {{ cryptoSymbol.toUpperCase() }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else> {{ currentChance }}% {{ t('common.chance') }} </template>
|
||||||
{{ currentChance }}% {{ t('common.chance') }}
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -93,22 +91,29 @@
|
|||||||
{{ t('activity.noPositionsInMarket') }}
|
{{ t('activity.noPositionsInMarket') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="positions-list">
|
<div v-else class="positions-list">
|
||||||
<div
|
<div v-for="pos in marketPositionsFiltered" :key="pos.id" class="position-row-item">
|
||||||
v-for="pos in marketPositionsFiltered"
|
|
||||||
:key="pos.id"
|
|
||||||
class="position-row-item"
|
|
||||||
>
|
|
||||||
<div class="position-row-header">
|
<div class="position-row-header">
|
||||||
<div class="position-row-icon" :class="pos.iconClass">
|
<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>
|
<span v-else class="position-row-icon-char">{{ pos.iconChar || '•' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="position-row-title">{{ pos.market }}</span>
|
<span class="position-row-title">{{ pos.market }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="position-row-main">
|
<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">
|
<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>
|
||||||
<span class="position-shares">{{ pos.shares }}</span>
|
<span class="position-shares">{{ pos.shares }}</span>
|
||||||
<span class="position-value">{{ pos.value }}</span>
|
<span class="position-value">{{ pos.value }}</span>
|
||||||
@ -133,11 +138,7 @@
|
|||||||
{{ t('activity.noOpenOrdersInMarket') }}
|
{{ t('activity.noOpenOrdersInMarket') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="orders-list">
|
<div v-else class="orders-list">
|
||||||
<div
|
<div v-for="ord in marketOpenOrders" :key="ord.id" class="order-row-item">
|
||||||
v-for="ord in marketOpenOrders"
|
|
||||||
:key="ord.id"
|
|
||||||
class="order-row-item"
|
|
||||||
>
|
|
||||||
<div class="order-row-main">
|
<div class="order-row-main">
|
||||||
<span :class="['order-side-pill', ord.side === 'Yes' ? 'side-yes' : 'side-no']">
|
<span :class="['order-side-pill', ord.side === 'Yes' ? 'side-yes' : 'side-no']">
|
||||||
{{ ord.actionLabel || `Buy ${ord.outcome}` }}
|
{{ ord.actionLabel || `Buy ${ord.outcome}` }}
|
||||||
@ -191,7 +192,10 @@
|
|||||||
<v-window v-model="detailTab" class="detail-window">
|
<v-window v-model="detailTab" class="detail-window">
|
||||||
<v-window-item value="rules" class="detail-pane">
|
<v-window-item value="rules" class="detail-pane">
|
||||||
<div class="rules-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') }}
|
{{ t('activity.rulesEmpty') }}
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@ -247,7 +251,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="activity-body">
|
<div class="activity-body">
|
||||||
<span class="activity-user">{{ item.user }}</span>
|
<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
|
<span
|
||||||
:class="['activity-amount', item.side === 'Yes' ? 'amount-yes' : 'amount-no']"
|
:class="['activity-amount', item.side === 'Yes' ? 'amount-yes' : 'amount-no']"
|
||||||
>
|
>
|
||||||
@ -258,7 +264,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="activity-meta">
|
<div class="activity-meta">
|
||||||
<span class="activity-time">{{ formatTimeAgo(item.time) }}</span>
|
<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>
|
<v-icon size="16">mdi-open-in-new</v-icon>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -374,8 +385,15 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import * as echarts from 'echarts'
|
import {
|
||||||
import type { ECharts } from 'echarts'
|
createChart,
|
||||||
|
LineSeries,
|
||||||
|
LineType,
|
||||||
|
LastPriceAnimationMode,
|
||||||
|
type IChartApi,
|
||||||
|
type ISeriesApi,
|
||||||
|
} from 'lightweight-charts'
|
||||||
|
import { toLwcData, toLwcPoint } from '../composables/useLightweightChart'
|
||||||
import OrderBook from '../components/OrderBook.vue'
|
import OrderBook from '../components/OrderBook.vue'
|
||||||
import TradeComponent, { type TradePositionItem } from '../components/TradeComponent.vue'
|
import TradeComponent, { type TradePositionItem } from '../components/TradeComponent.vue'
|
||||||
import {
|
import {
|
||||||
@ -390,7 +408,11 @@ import { useUserStore } from '../stores/user'
|
|||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
import { useLocaleStore } from '../stores/locale'
|
import { useLocaleStore } from '../stores/locale'
|
||||||
import { useAuthError } from '../composables/useAuthError'
|
import { useAuthError } from '../composables/useAuthError'
|
||||||
import { getPositionList, mapPositionToDisplayItem, type PositionDisplayItem } from '../api/position'
|
import {
|
||||||
|
getPositionList,
|
||||||
|
mapPositionToDisplayItem,
|
||||||
|
type PositionDisplayItem,
|
||||||
|
} from '../api/position'
|
||||||
import {
|
import {
|
||||||
getOrderList,
|
getOrderList,
|
||||||
mapOrderToOpenOrderItem,
|
mapOrderToOpenOrderItem,
|
||||||
@ -412,11 +434,7 @@ import {
|
|||||||
} from '../api/cryptoChart'
|
} from '../api/cryptoChart'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
import {
|
import { ClobSdk, type PriceSizePolyMsg, type TradePolyMsg } from '../../sdk/clobSocket'
|
||||||
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
|
const last = d.length > 0 ? d[d.length - 1] : undefined
|
||||||
if (last != null && chartMode.value === 'crypto') {
|
if (last != null && chartMode.value === 'crypto') {
|
||||||
const p = last[1]
|
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 '—'
|
return '—'
|
||||||
})
|
})
|
||||||
|
|
||||||
function setChartMode(mode: 'yesno' | 'crypto') {
|
function setChartMode(mode: 'yesno' | 'crypto') {
|
||||||
|
if (mode === 'yesno' && selectedTimeRange.value === '30S') {
|
||||||
|
selectedTimeRange.value = '1D'
|
||||||
|
} else if (mode === 'crypto') {
|
||||||
|
selectedTimeRange.value = '30S'
|
||||||
|
}
|
||||||
chartMode.value = mode
|
chartMode.value = mode
|
||||||
updateChartData()
|
updateChartData()
|
||||||
}
|
}
|
||||||
@ -703,8 +728,7 @@ function applyPriceSizeDelta(msg: PriceSizePolyMsg) {
|
|||||||
else map.set(key, { price, shares, priceRaw })
|
else map.set(key, { price, shares, priceRaw })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return Array.from(map.values())
|
return Array.from(map.values()).sort((a, b) => (asc ? a.price - b.price : b.price - a.price))
|
||||||
.sort((a, b) => (asc ? a.price - b.price : b.price - a.price))
|
|
||||||
}
|
}
|
||||||
const prev = orderBookByToken.value[idx] ?? { asks: [], bids: [] }
|
const prev = orderBookByToken.value[idx] ?? { asks: [], bids: [] }
|
||||||
const asks = mergeDelta(prev.asks, msg.s, true)
|
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 mobileTradeComponentRef = ref<InstanceType<typeof TradeComponent> | null>(null)
|
||||||
|
|
||||||
const yesPriceCents = computed(() =>
|
const yesPriceCents = computed(() =>
|
||||||
tradeMarketPayload.value ? Math.round(tradeMarketPayload.value.yesPrice * 100) : 0
|
tradeMarketPayload.value ? Math.round(tradeMarketPayload.value.yesPrice * 100) : 0,
|
||||||
)
|
)
|
||||||
const noPriceCents = computed(() =>
|
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 yesLabel = computed(() => currentMarket.value?.outcomes?.[0] ?? 'Yes')
|
||||||
const noLabel = computed(() => currentMarket.value?.outcomes?.[1] ?? 'No')
|
const noLabel = computed(() => currentMarket.value?.outcomes?.[1] ?? 'No')
|
||||||
@ -922,7 +946,9 @@ function onSellOrderSuccess() {
|
|||||||
|
|
||||||
// 延迟卸载 Sell 弹窗内容,等 dialog transition 完成,避免 emitsOptions 竞态
|
// 延迟卸载 Sell 弹窗内容,等 dialog transition 完成,避免 emitsOptions 竞态
|
||||||
let sellDialogUnmountTimer: ReturnType<typeof setTimeout> | undefined
|
let sellDialogUnmountTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
watch(sellDialogOpen, (open) => {
|
watch(
|
||||||
|
sellDialogOpen,
|
||||||
|
(open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
if (sellDialogUnmountTimer) {
|
if (sellDialogUnmountTimer) {
|
||||||
clearTimeout(sellDialogUnmountTimer)
|
clearTimeout(sellDialogUnmountTimer)
|
||||||
@ -935,7 +961,9 @@ watch(sellDialogOpen, (open) => {
|
|||||||
sellDialogUnmountTimer = undefined
|
sellDialogUnmountTimer = undefined
|
||||||
}, 350)
|
}, 350)
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (sellDialogUnmountTimer) clearTimeout(sellDialogUnmountTimer)
|
if (sellDialogUnmountTimer) clearTimeout(sellDialogUnmountTimer)
|
||||||
if (tradeSheetUnmountTimer) clearTimeout(tradeSheetUnmountTimer)
|
if (tradeSheetUnmountTimer) clearTimeout(tradeSheetUnmountTimer)
|
||||||
@ -945,7 +973,9 @@ onUnmounted(() => {
|
|||||||
// 底部栏 TradeComponent 延迟挂载/卸载,避免 transition 期间 slot 竞态
|
// 底部栏 TradeComponent 延迟挂载/卸载,避免 transition 期间 slot 竞态
|
||||||
let tradeSheetUnmountTimer: ReturnType<typeof setTimeout> | undefined
|
let tradeSheetUnmountTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
let tradeSheetMountTimer: ReturnType<typeof setTimeout> | undefined
|
let tradeSheetMountTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
watch(tradeSheetOpen, (open) => {
|
watch(
|
||||||
|
tradeSheetOpen,
|
||||||
|
(open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
if (tradeSheetUnmountTimer) {
|
if (tradeSheetUnmountTimer) {
|
||||||
clearTimeout(tradeSheetUnmountTimer)
|
clearTimeout(tradeSheetUnmountTimer)
|
||||||
@ -977,7 +1007,9 @@ watch(tradeSheetOpen, (open) => {
|
|||||||
tradeSheetUnmountTimer = undefined
|
tradeSheetUnmountTimer = undefined
|
||||||
}, 350)
|
}, 350)
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
// 当前市场的 marketID,用于筛选持仓和订单
|
// 当前市场的 marketID,用于筛选持仓和订单
|
||||||
const currentMarketId = computed(() => getMarketId(currentMarket.value))
|
const currentMarketId = computed(() => getMarketId(currentMarket.value))
|
||||||
@ -1028,10 +1060,7 @@ async function loadMarketPositions() {
|
|||||||
}
|
}
|
||||||
positionLoading.value = true
|
positionLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getPositionList(
|
const res = await getPositionList({ page: 1, pageSize: 50, marketID, userID }, { headers })
|
||||||
{ page: 1, pageSize: 50, marketID, userID },
|
|
||||||
{ headers },
|
|
||||||
)
|
|
||||||
if (res.code === 0 || res.code === 200) {
|
if (res.code === 0 || res.code === 200) {
|
||||||
marketPositions.value = res.data?.list?.map(mapPositionToDisplayItem) ?? []
|
marketPositions.value = res.data?.list?.map(mapPositionToDisplayItem) ?? []
|
||||||
} else {
|
} else {
|
||||||
@ -1233,7 +1262,7 @@ function formatTimeAgo(ts: number): string {
|
|||||||
|
|
||||||
// 时间粒度
|
// 时间粒度
|
||||||
const selectedTimeRange = ref('1D')
|
const selectedTimeRange = ref('1D')
|
||||||
const timeRanges = [
|
const timeRangesYesno = [
|
||||||
{ label: '1H', value: '1H' },
|
{ label: '1H', value: '1H' },
|
||||||
{ label: '6H', value: '6H' },
|
{ label: '6H', value: '6H' },
|
||||||
{ label: '1D', value: '1D' },
|
{ label: '1D', value: '1D' },
|
||||||
@ -1241,6 +1270,10 @@ const timeRanges = [
|
|||||||
{ label: '1M', value: '1M' },
|
{ label: '1M', value: '1M' },
|
||||||
{ label: 'ALL', value: 'ALL' },
|
{ 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 chartContainerRef = ref<HTMLElement | null>(null)
|
||||||
const data = ref<[number, number][]>([])
|
const data = ref<[number, number][]>([])
|
||||||
@ -1248,7 +1281,8 @@ const data = ref<[number, number][]>([])
|
|||||||
const rawChartData = ref<ChartDataPoint[]>([])
|
const rawChartData = ref<ChartDataPoint[]>([])
|
||||||
const cryptoChartLoading = ref(false)
|
const cryptoChartLoading = ref(false)
|
||||||
const chartYesNoLoading = 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 currentChance = computed(() => {
|
||||||
const ev = eventDetail.value
|
const ev = eventDetail.value
|
||||||
@ -1272,232 +1306,58 @@ const lineColor = '#2563eb'
|
|||||||
|
|
||||||
const MOBILE_BREAKPOINT = 600
|
const MOBILE_BREAKPOINT = 600
|
||||||
|
|
||||||
function buildOption(chartData: [number, number][], containerWidth?: number) {
|
function ensureChartSeries() {
|
||||||
const lastIndex = chartData.length - 1
|
if (!chartInstance || !chartContainerRef.value) return
|
||||||
const width = containerWidth ?? chartContainerRef.value?.clientWidth ?? 400
|
if (chartSeries) {
|
||||||
const isMobile = width < MOBILE_BREAKPOINT
|
chartInstance.removeSeries(chartSeries)
|
||||||
|
chartSeries = null
|
||||||
// 手机端:计算时间范围,减少 X 轴标签数量并缩短格式,避免重叠
|
}
|
||||||
const xAxisConfig: Record<string, unknown> = {
|
const isCrypto = chartMode.value === 'crypto'
|
||||||
type: 'time',
|
chartSeries = chartInstance.addSeries(LineSeries, {
|
||||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
color: lineColor,
|
||||||
axisLabel: {
|
lineWidth: 2,
|
||||||
color: '#6b7280',
|
lineType: LineType.Curved,
|
||||||
fontSize: isMobile ? 10 : 11,
|
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
|
||||||
},
|
crosshairMarkerVisible: true,
|
||||||
axisTick: { show: false },
|
lastValueVisible: true,
|
||||||
splitLine: { show: false },
|
priceFormat: isCrypto ? { type: 'price', precision: 2 } : { type: 'percent', precision: 1 },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMobile && chartData.length >= 2) {
|
function setChartData(chartData: [number, number][]) {
|
||||||
const times = chartData.map((d) => d[0])
|
if (!chartSeries) return
|
||||||
const minTime = Math.min(...times)
|
chartSeries.setData(toLwcData(chartData))
|
||||||
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 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 根据数据范围计算 Y 轴 min/max,留出适当边距便于观察起伏 */
|
|
||||||
function computeCryptoYAxisRange(chartData: [number, number][]): { min: number; max: number } {
|
|
||||||
if (chartData.length === 0) return { min: 0, max: 100 }
|
|
||||||
const prices = chartData.map((d) => d[1]).filter(Number.isFinite)
|
|
||||||
if (prices.length === 0) return { min: 0, max: 100 }
|
|
||||||
const dataMin = Math.min(...prices)
|
|
||||||
const dataMax = Math.max(...prices)
|
|
||||||
const span = dataMax - dataMin || 1
|
|
||||||
const padding = Math.max(span * 0.08, dataMin * 0.002)
|
|
||||||
return {
|
|
||||||
min: Math.max(0, dataMin - padding),
|
|
||||||
max: dataMax + padding,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 加密货币价格图配置(Y 轴为美元价格,按数据范围动态缩放) */
|
|
||||||
function buildOptionForCrypto(
|
|
||||||
chartData: [number, number][],
|
|
||||||
symbol: string,
|
|
||||||
containerWidth?: number
|
|
||||||
) {
|
|
||||||
const lastIndex = chartData.length - 1
|
|
||||||
const width = containerWidth ?? chartContainerRef.value?.clientWidth ?? 400
|
|
||||||
const isMobile = width < MOBILE_BREAKPOINT
|
|
||||||
const yRange = computeCryptoYAxisRange(chartData)
|
|
||||||
const xAxisConfig: Record<string, unknown> = {
|
|
||||||
type: 'time',
|
|
||||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
|
||||||
axisLabel: { color: '#6b7280', fontSize: isMobile ? 10 : 11 },
|
|
||||||
axisTick: { show: false },
|
|
||||||
splitLine: { show: false },
|
|
||||||
}
|
|
||||||
if (isMobile && chartData.length >= 2) {
|
|
||||||
const times = chartData.map((d) => d[0])
|
|
||||||
const span = Math.max(...times) - Math.min(...times)
|
|
||||||
xAxisConfig.axisLabel = {
|
|
||||||
...(xAxisConfig.axisLabel as object),
|
|
||||||
interval: Math.max(span / 4, 60 * 1000),
|
|
||||||
formatter: (value: number) => {
|
|
||||||
const d = new Date(value)
|
|
||||||
if (span >= 24 * 60 * 60 * 1000) return `${d.getMonth() + 1}/${d.getDate()}`
|
|
||||||
return `${d.getHours()}:${d.getMinutes().toString().padStart(2, '0')}`
|
|
||||||
},
|
|
||||||
rotate: -25,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
animation: false,
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
formatter: (params: unknown) => {
|
|
||||||
const p = (Array.isArray(params) ? params[0] : params) as {
|
|
||||||
data?: [number, number]
|
|
||||||
value?: unknown
|
|
||||||
}
|
|
||||||
const raw = p.data ?? (Array.isArray(p.value) ? p.value : null)
|
|
||||||
const timestamp = Array.isArray(raw) ? raw[0] : null
|
|
||||||
const val = Array.isArray(raw) ? raw[1] : p.value
|
|
||||||
const date = timestamp != null && Number.isFinite(timestamp)
|
|
||||||
? new Date(timestamp)
|
|
||||||
: null
|
|
||||||
const price = Number.isFinite(Number(val))
|
|
||||||
? Number(val).toLocaleString(undefined, { minimumFractionDigits: 2 })
|
|
||||||
: val
|
|
||||||
const dateStr = date && !Number.isNaN(date.getTime())
|
|
||||||
? date.toLocaleString()
|
|
||||||
: '—'
|
|
||||||
return `${dateStr} : $${price}`
|
|
||||||
},
|
|
||||||
axisPointer: { animation: false },
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
left: 8,
|
|
||||||
right: 48,
|
|
||||||
top: 16,
|
|
||||||
bottom: isMobile ? 44 : 28,
|
|
||||||
containLabel: false,
|
|
||||||
},
|
|
||||||
xAxis: xAxisConfig,
|
|
||||||
yAxis: {
|
|
||||||
type: 'value',
|
|
||||||
position: 'right',
|
|
||||||
min: yRange.min,
|
|
||||||
max: yRange.max,
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisTick: { show: false },
|
|
||||||
axisLabel: {
|
|
||||||
color: '#6b7280',
|
|
||||||
fontSize: 11,
|
|
||||||
formatter: (v: number) => `$${v >= 1000 ? (v / 1000).toFixed(1) + 'k' : v.toFixed(0)}`,
|
|
||||||
},
|
|
||||||
splitLine: { lineStyle: { type: 'dashed', color: '#e5e7eb' } },
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: symbol.toUpperCase(),
|
|
||||||
type: 'line',
|
|
||||||
showSymbol: true,
|
|
||||||
symbol: 'circle',
|
|
||||||
symbolSize: (_: unknown, params: { dataIndex?: number }) =>
|
|
||||||
params?.dataIndex === lastIndex ? 8 : 0,
|
|
||||||
data: chartData,
|
|
||||||
smooth: true,
|
|
||||||
lineStyle: { width: 2, color: lineColor },
|
|
||||||
itemStyle: { color: lineColor, borderColor: '#fff', borderWidth: 2 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initChart() {
|
function initChart() {
|
||||||
if (!chartContainerRef.value) return
|
if (!chartContainerRef.value) return
|
||||||
data.value = []
|
data.value = []
|
||||||
chartInstance = echarts.init(chartContainerRef.value)
|
const el = chartContainerRef.value
|
||||||
const w = chartContainerRef.value.clientWidth
|
chartInstance = createChart(el, {
|
||||||
if (chartMode.value === 'crypto') {
|
width: el.clientWidth,
|
||||||
chartInstance.setOption(buildOptionForCrypto(data.value, cryptoSymbol.value ?? 'btc', w))
|
height: 320,
|
||||||
} else {
|
layout: {
|
||||||
chartInstance.setOption(buildOption(data.value, w))
|
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 */
|
/** 从 GET /pmPriceHistory/getPmPriceHistoryPublic 拉取价格历史,market 传 YES 对应的 clobTokenId */
|
||||||
async function loadChartFromApi(marketParam: string, range: string): Promise<ChartDataPoint[]> {
|
async function loadChartFromApi(marketParam: string, range: string): Promise<ChartDataPoint[]> {
|
||||||
const ev = eventDetail.value
|
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({
|
const res = await getPmPriceHistoryPublic({
|
||||||
market: marketParam,
|
market: marketParam,
|
||||||
page: 1,
|
page: 1,
|
||||||
@ -1514,27 +1374,47 @@ let cryptoWsUnsubscribe: (() => void) | null = null
|
|||||||
function applyCryptoRealtimePoint(point: [number, number]) {
|
function applyCryptoRealtimePoint(point: [number, number]) {
|
||||||
const list = [...data.value]
|
const list = [...data.value]
|
||||||
const [ts, price] = point
|
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 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 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) {
|
if (sameMinute) {
|
||||||
list[list.length - 1] = [last![0], price]
|
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)
|
data.value = list.slice(-max)
|
||||||
const w = chartContainerRef.value?.clientWidth
|
useIncrementalUpdate = true
|
||||||
if (chartInstance && chartMode.value === 'crypto')
|
updatePoint = [last![0], price]
|
||||||
chartInstance.setOption(
|
} else {
|
||||||
buildOptionForCrypto(data.value, cryptoSymbol.value ?? 'btc', w),
|
list.push([minuteStart, price])
|
||||||
{ replaceMerge: ['series'] }
|
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() {
|
async function updateChartData() {
|
||||||
const w = chartContainerRef.value?.clientWidth
|
|
||||||
if (chartMode.value === 'crypto') {
|
if (chartMode.value === 'crypto') {
|
||||||
const sym = cryptoSymbol.value ?? 'btc'
|
const sym = cryptoSymbol.value ?? 'btc'
|
||||||
cryptoWsUnsubscribe?.()
|
cryptoWsUnsubscribe?.()
|
||||||
@ -1546,11 +1426,8 @@ async function updateChartData() {
|
|||||||
range: selectedTimeRange.value,
|
range: selectedTimeRange.value,
|
||||||
})
|
})
|
||||||
data.value = (res.data ?? []) as [number, number][]
|
data.value = (res.data ?? []) as [number, number][]
|
||||||
if (chartInstance)
|
ensureChartSeries()
|
||||||
chartInstance.setOption(
|
setChartData(data.value)
|
||||||
buildOptionForCrypto(data.value, sym, w),
|
|
||||||
{ replaceMerge: ['series'] }
|
|
||||||
)
|
|
||||||
cryptoWsUnsubscribe = subscribeCryptoRealtime(sym, applyCryptoRealtimePoint)
|
cryptoWsUnsubscribe = subscribeCryptoRealtime(sym, applyCryptoRealtimePoint)
|
||||||
} finally {
|
} finally {
|
||||||
cryptoChartLoading.value = false
|
cryptoChartLoading.value = false
|
||||||
@ -1560,13 +1437,12 @@ async function updateChartData() {
|
|||||||
cryptoWsUnsubscribe = null
|
cryptoWsUnsubscribe = null
|
||||||
chartYesNoLoading.value = true
|
chartYesNoLoading.value = true
|
||||||
try {
|
try {
|
||||||
// 价格历史接口的 market 传 clobTokenIds[0](YES 对应 token ID)
|
|
||||||
const yesTokenId = clobTokenIds.value[0]
|
const yesTokenId = clobTokenIds.value[0]
|
||||||
const points = yesTokenId ? await loadChartFromApi(yesTokenId, selectedTimeRange.value) : []
|
const points = yesTokenId ? await loadChartFromApi(yesTokenId, selectedTimeRange.value) : []
|
||||||
rawChartData.value = points
|
rawChartData.value = points
|
||||||
data.value = points
|
data.value = points
|
||||||
if (chartInstance)
|
ensureChartSeries()
|
||||||
chartInstance.setOption(buildOption(data.value, w), { replaceMerge: ['series'] })
|
setChartData(data.value)
|
||||||
} finally {
|
} finally {
|
||||||
chartYesNoLoading.value = false
|
chartYesNoLoading.value = false
|
||||||
}
|
}
|
||||||
@ -1608,15 +1484,7 @@ watch(
|
|||||||
|
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
if (!chartInstance || !chartContainerRef.value) return
|
if (!chartInstance || !chartContainerRef.value) return
|
||||||
chartInstance.resize()
|
chartInstance.resize(chartContainerRef.value.clientWidth, 320)
|
||||||
const w = chartContainerRef.value.clientWidth
|
|
||||||
if (chartMode.value === 'crypto') {
|
|
||||||
chartInstance.setOption(buildOptionForCrypto(data.value, cryptoSymbol.value ?? 'btc', w), {
|
|
||||||
replaceMerge: ['series'],
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
chartInstance.setOption(buildOption(data.value, w), { replaceMerge: ['series'] })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@ -1653,8 +1521,9 @@ onUnmounted(() => {
|
|||||||
cryptoWsUnsubscribe?.()
|
cryptoWsUnsubscribe?.()
|
||||||
cryptoWsUnsubscribe = null
|
cryptoWsUnsubscribe = null
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
chartInstance?.dispose()
|
chartInstance?.remove()
|
||||||
chartInstance = null
|
chartInstance = null
|
||||||
|
chartSeries = null
|
||||||
disconnectClob()
|
disconnectClob()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -728,8 +728,8 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
import * as echarts from 'echarts'
|
import { createChart, AreaSeries, LineType, LastPriceAnimationMode } from 'lightweight-charts'
|
||||||
import type { ECharts } from 'echarts'
|
import { toLwcData } from '../composables/useLightweightChart'
|
||||||
import DepositDialog from '../components/DepositDialog.vue'
|
import DepositDialog from '../components/DepositDialog.vue'
|
||||||
import WithdrawDialog from '../components/WithdrawDialog.vue'
|
import WithdrawDialog from '../components/WithdrawDialog.vue'
|
||||||
import { useUserStore } from '../stores/user'
|
import { useUserStore } from '../stores/user'
|
||||||
@ -1349,7 +1349,8 @@ function shareHistory(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const plChartRef = ref<HTMLElement | null>(null)
|
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]
|
* 资产变化折线图数据 [timestamp_ms, pnl]
|
||||||
@ -1386,68 +1387,13 @@ function getPlChartData(range: string): [number, number][] {
|
|||||||
return data
|
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][]>([])
|
const plChartData = ref<[number, number][]>([])
|
||||||
|
|
||||||
function updatePlChart() {
|
function updatePlChart() {
|
||||||
plChartData.value = getPlChartData(plRange.value)
|
plChartData.value = getPlChartData(plRange.value)
|
||||||
const last = plChartData.value[plChartData.value.length - 1]
|
const last = plChartData.value[plChartData.value.length - 1]
|
||||||
if (last != null) profitLoss.value = last[1].toFixed(2)
|
if (last != null) profitLoss.value = last[1].toFixed(2)
|
||||||
if (plChartInstance)
|
if (plChartSeries) plChartSeries.setData(toLwcData(plChartData.value))
|
||||||
plChartInstance.setOption(buildPlChartOption(plChartData.value), { replaceMerge: ['series'] })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initPlChart() {
|
function initPlChart() {
|
||||||
@ -1455,11 +1401,40 @@ function initPlChart() {
|
|||||||
plChartData.value = getPlChartData(plRange.value)
|
plChartData.value = getPlChartData(plRange.value)
|
||||||
const last = plChartData.value[plChartData.value.length - 1]
|
const last = plChartData.value[plChartData.value.length - 1]
|
||||||
if (last != null) profitLoss.value = last[1].toFixed(2)
|
if (last != null) profitLoss.value = last[1].toFixed(2)
|
||||||
plChartInstance = echarts.init(plChartRef.value)
|
const el = plChartRef.value
|
||||||
plChartInstance.setOption(buildPlChartOption(plChartData.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())
|
watch(plRange, () => updatePlChart())
|
||||||
|
|
||||||
@ -1482,8 +1457,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
plChartInstance?.dispose()
|
plChartInstance?.remove()
|
||||||
plChartInstance = null
|
plChartInstance = null
|
||||||
|
plChartSeries = null
|
||||||
})
|
})
|
||||||
|
|
||||||
function onWithdrawSuccess() {
|
function onWithdrawSuccess() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user