Compare commits

...

7 Commits

Author SHA1 Message Date
ivan
a084780180 优化:UI优化,接口对接优化 2026-02-28 20:17:59 +08:00
ivan
73cf147348 优化:分类数据优化 2026-02-28 00:03:37 +08:00
ivan
af22e1a91c 优化:最大份额显示 2026-02-27 23:34:58 +08:00
ivan
ef4b0c8922 新增:持仓数据对接 2026-02-27 22:25:35 +08:00
ivan
3e329c307e 新增:持仓数据接口对接 2026-02-27 15:15:17 +08:00
ivan
38d70e3e56 优化:列表分类请求 2026-02-27 10:04:04 +08:00
ivan
da9de8c772 优化:交易组件的价格显示单位与外面一致 2026-02-26 20:40:07 +08:00
33 changed files with 2927 additions and 404 deletions

View File

@ -0,0 +1,70 @@
---
description: Trae 开发规则,定义如何使用 Trae 进行项目开发和维护
globs: src/**/*
alwaysApply: true
---
# Trae 开发规则
**使用 Trae 进行开发时必须遵循的规则**,确保代码质量和开发效率。
## 开发流程
1. **需求分析**:理解用户需求,明确功能边界
2. **代码实现**:按照项目框架规范实现功能
3. **测试验证**:确保功能正常工作
4. **文档更新**:同步更新相关文档
5. **代码检查**:运行 lint 和类型检查
## 代码规范
### 1. 代码风格
- 遵循项目现有的代码风格Prettier + ESLint
- 使用 TypeScript 严格模式
- 避免使用 `any` 类型
- 函数和变量命名清晰,符合语义
### 2. 组件开发
- 使用 Vue 3 Composition API
- 组件文件使用 PascalCase 命名
- 样式使用 `<style scoped>`
- 组件 props 和 emits 必须有类型定义
### 3. API 开发
- 遵循 XTrader API 接入规范
- 使用统一的请求封装(`src/api/request.ts`
- 为每个接口定义完整的 TypeScript 类型
- 处理错误和边界情况
## 工具使用
### 1. 代码生成
- 使用 Trae 的代码生成功能创建组件、接口等
- 生成的代码必须符合项目规范
- 手动调整生成的代码,确保质量
### 2. 调试工具
- 使用 Trae 的调试功能排查问题
- 利用 Trae 的搜索功能快速定位代码
- 结合浏览器开发工具进行前端调试
## 最佳实践
1. **模块化开发**:将功能拆分为小的、可维护的模块
2. **代码复用**:提取公共组件和工具函数
3. **性能优化**:注意组件渲染性能,避免不必要的重渲染
4. **安全性**:注意处理用户输入,避免安全漏洞
5. **可访问性**:确保界面对所有用户可访问
## 检查清单
开发完成后,确保:
- [ ] 代码符合项目框架规范
- [ ] 功能正常工作
- [ ] 文档已同步更新
- [ ] 无 lint 和类型错误
- [ ] 代码风格一致
- [ ] 测试通过
遵循这些规则,使用 Trae 可以更高效、更规范地进行项目开发。

View File

@ -0,0 +1,154 @@
---
name: trae-development
version: 1.0.0
description: 提供 Trae 开发相关的功能,包括代码生成、调试、搜索等工具的使用指南。当用户需要使用 Trae 进行开发时使用此技能。
author: Trae Team
---
# Trae 开发技能
## 功能介绍
本技能提供 Trae 开发环境的使用指南,帮助开发者更高效地使用 Trae 进行项目开发和维护。
## 使用场景
- 代码生成:创建新组件、接口、页面等
- 代码搜索:快速定位代码和文件
- 代码分析:理解现有代码结构
- 调试工具:排查和解决问题
- 项目管理:项目结构和规范管理
## 核心功能
### 1. 代码生成
#### 组件生成
**使用方式**
- 在命令面板中输入 `Trae: Generate Component`
- 选择组件类型(如 Vue 组件、TypeScript 接口等)
- 输入组件名称和相关选项
- Trae 会自动生成符合项目规范的代码
**生成的组件结构**
- Vue 组件:包含 template、script setup 和 scoped style
- TypeScript 接口:包含完整的类型定义
- API 模块:包含请求函数和类型定义
### 2. 代码搜索
**使用方式**
- 在命令面板中输入 `Trae: Search Code`
- 输入搜索关键词
- Trae 会显示所有匹配的代码片段
- 点击结果可直接跳转到对应文件
**搜索范围**
- 项目代码库
- 依赖库
- 文档
### 3. 代码分析
**使用方式**
- 在命令面板中输入 `Trae: Analyze Code`
- 选择分析类型(如依赖分析、性能分析等)
- Trae 会生成详细的分析报告
**分析内容**
- 代码复杂度
- 依赖关系
- 性能瓶颈
- 代码质量
### 4. 调试工具
**使用方式**
- 在命令面板中输入 `Trae: Debug Code`
- 设置断点或选择调试模式
- 运行调试会话
- Trae 会提供详细的调试信息
**调试功能**
- 断点设置
- 变量监视
- 调用栈查看
- 表达式求值
### 5. 项目管理
**使用方式**
- 在命令面板中输入 `Trae: Project Management`
- 选择管理功能(如依赖管理、版本控制等)
- 执行相应的管理操作
**管理功能**
- 依赖安装和更新
- 版本控制操作
- 项目配置管理
- 构建和部署
## 最佳实践
1. **代码组织**
- 按照项目框架规范组织代码
- 使用清晰的命名和目录结构
- 遵循模块化开发原则
2. **代码质量**
- 定期运行 lint 和类型检查
- 编写单元测试
- 保持代码风格一致
3. **开发效率**
- 使用 Trae 的代码生成功能
- 利用搜索功能快速定位代码
- 结合调试工具排查问题
4. **团队协作**
- 遵循统一的代码规范
- 及时更新文档
- 提交有意义的代码注释
## 常见问题
### Q: 如何使用 Trae 生成组件?
**A**: 在命令面板中输入 `Trae: Generate Component`选择组件类型和配置选项Trae 会自动生成符合项目规范的代码。
### Q: 如何搜索项目中的代码?
**A**: 在命令面板中输入 `Trae: Search Code`输入搜索关键词Trae 会显示所有匹配的代码片段。
### Q: 如何调试代码?
**A**: 在命令面板中输入 `Trae: Debug Code`,设置断点或选择调试模式,运行调试会话查看详细信息。
### Q: 如何分析项目依赖?
**A**: 在命令面板中输入 `Trae: Analyze Code`选择依赖分析Trae 会生成详细的依赖关系报告。
## 配置选项
Trae 开发技能支持以下配置选项:
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| `trae.codeGeneration.style` | 代码生成风格 | `project` |
| `trae.search.scope` | 搜索范围 | `project` |
| `trae.analysis.level` | 分析深度 | `medium` |
| `trae.debug.enabled` | 启用调试 | `true` |
## 版本历史
- **1.0.0**:初始版本,包含代码生成、搜索、分析和调试功能
## 注意事项
- 使用 Trae 开发技能时,请确保项目已经正确初始化
- 生成的代码可能需要根据具体需求进行调整
- 调试功能需要在开发模式下使用
- 分析功能可能会消耗一定的系统资源
通过合理使用 Trae 开发技能,可以显著提高开发效率和代码质量,为项目开发提供有力支持。

View File

@ -16,19 +16,38 @@ description: Interprets the XTrader API from the Swagger 2.0 spec at https://api
### 强制动作序列(必须依次执行)
1. **收到对接请求** → 立即用 `mcp_web_fetch``curl` 获取 `https://api.xtrader.vip/swagger/doc.json`
2. **第一步** → 解析 `paths["<path>"]["<method>"]``definitions`**在对话中输出**
2. **检查接口是否存在** → 若 `paths["<path>"]``paths["<path>"]["<method>"]` **不存在**,则:
- ❌ **禁止**自行猜测或虚构数据结构并自动对接
- ✅ **必须**在对话中明确告知用户「该接口在 doc.json 中不存在」
- ✅ **必须**向用户询问:
- 是否仍要对接?若对接,请提供**请求参数**与**响应数据结构**(或示例 JSON
- 用户可选择**不对接**,则流程终止
- 仅在用户明确提供数据结构或确认对接后,才可进入第二步
3. **第一步**(接口存在时)→ 解析 `paths["<path>"]["<method>"]``definitions`**在对话中输出**
- 请求参数表Query/Body/鉴权)
- 响应参数表200 schema
- data 的 `$ref` 对应 definitions 的**完整字段表**
- 输出后**明确标注「第一步完成」**
3. **第二步** → 在 `src/api/` 中根据第一步表格定义 TypeScript 类型,**不得在第一步完成前执行**
4. **第三步** → 实现请求函数并集成到页面,**不得在第二步完成前执行**
4. **第二步** → 在 `src/api/` 中根据第一步表格(或用户提供的数据结构)定义 TypeScript 类型,**不得在第一步完成前执行**
5. **第三步** → 实现请求函数并集成到页面,**不得在第二步完成前执行**
### 禁止行为
- ❌ 在对话中输出第一步结果**之前**写任何 `src/api/``src/views/` 业务代码
- ❌ 跳过第一步直接定义类型或实现请求
- ❌ 合并步骤(如边输出边写代码)
- ❌ **接口文档不存在时**:自行猜测数据结构并自动对接;必须向用户询问后再决定是否对接
### 接口文档不存在时的处理流程
`paths["<path>"]` 在 doc.json 中**不存在**时:
1. **立即停止**:不写任何对接代码,不猜测数据结构
2. **明确告知**:在对话中说明「该接口在 Swagger doc.json 中不存在」
3. **向用户询问**
- 是否仍要对接?若对接,请提供请求参数与响应数据结构(或示例 JSON
- 用户可选择**不对接**,则流程终止
4. **仅在用户明确提供**后,才继续执行第二步、第三步
### 第一步输出模板(必须包含)
@ -163,6 +182,7 @@ Swagger UI 页面(如 [PmEvent findPmEvent](https://api.xtrader.vip/swagger/in
## 简要检查清单
- [ ] **接口存在性**:若 doc.json 中无该 path已向用户询问数据结构未擅自猜测对接
- [ ] **按规范顺序**:已先列出请求参数与响应参数,再建 Model最后集成到页面
- [ ] 规范 URL 使用 `https://api.xtrader.vip/swagger/doc.json`,或本地缓存与之一致
- [ ] 请求 path、method、query/body 与 `paths` 一致

View File

@ -17,7 +17,24 @@
| 类型 | 说明 |
|------|------|
| `PmTagMainItem` | 接口返回的 PmTag 结构,含 children |
| `CategoryTreeNode` | 前端使用的树节点,含 id、label、slug、icon、sectionTitle、children |
| `CategoryTreeNode` | 前端使用的树节点,含 id、label、slug、icon、sectionTitle、children、tagIds |
### CategoryTreeNode.tagIds
从后端 `PmTagCatalogItem.tagId` 字段提取的标签 ID 列表,用于事件筛选:
```typescript
interface CategoryTreeNode {
// ... 其他字段
/** 关联的标签 ID 列表,用于事件筛选 */
tagIds?: number[]
}
```
**提取逻辑**
- 如果 `tagId` 是数字数组,直接使用(`[1351, 1368]`
- 如果 `tagId` 是单个数字,包装为数组(`1351``[1351]`
- 如果 `tagId` 是对象或其他类型,忽略
## 使用方式

View File

@ -34,7 +34,7 @@ import {
} from '@/api/event'
// 获取列表
const res = await getPmEventPublic({ page: 1, pageSize: 10, tagSlug: 'crypto' })
const res = await getPmEventPublic({ page: 1, pageSize: 10, tagIds: [1, 2] })
const cards = res.data.list.map(mapEventItemToCard)
// 获取详情(需鉴权)
@ -49,3 +49,18 @@ clearEventListCache()
1. **新增筛选参数**:在 `GetPmEventListParams` 中增加字段,并在 `getPmEventPublic` 的 query 中传入
2. **缓存策略**:可改为 sessionStorage 或带 TTL 的缓存
3. **多选项展示**`mapEventItemToCard` 已支持 multi 类型,可扩展 `EventCardOutcome` 字段
## 参数传递方式
### tagIds 参数(数组)
`tagIds` 使用传统数组方式传递,不再是逗号分隔的字符串:
```typescript
// 正确方式 - 直接传递数组
const res = await getPmEventPublic({
page: 1,
pageSize: 10,
tagIds: [1, 2, 3] // 会作为多个同名参数传递:?tagIds=1&tagIds=2&tagIds=3
})
```

View File

@ -13,15 +13,38 @@
| market | object | 市场信息,含 marketId、clobTokenIds、outcomes、outcomePrices 等 |
| initialOption | 'yes' \| 'no' | 初始选中的选项 |
| embeddedInSheet | boolean | 是否嵌入底部弹窗(移动端) |
| positions | TradePositionItem[] | 当前市场持仓列表用于计算可合并份额Merge 功能) |
### TradePositionItem
```typescript
interface TradePositionItem {
id: string
outcomeWord: 'Yes' | 'No' // 持仓方向
shares: string // 份额展示文本,如 "5 shares"
sharesNum?: number // 份数数值(可选,优先使用)
}
```
**可合并份额计算逻辑**:取 Yes 和 No 持仓份数的最小值成对数量。例如Yes 持仓 10 份No 持仓 8 份,则可合并份额为 8。
## 核心能力
- Buy/Sell Tab 切换
- Market/Limit 类型、Merge/Split 菜单
- **Buy 模式 Amount 区**:无论余额是否充足,均显示 Amount 标签、Balance、金额输入、+$1/+$20/+$100/Max 快捷按钮(桌面端、嵌入弹窗、移动端弹窗一致)
- **Buy 模式 Amount 区**:无论余额是否充足,均显示 Amount 标签、Balance、**可编辑金额输入框**v-text-field带 $ 前缀variant="outlined")、+$1/+$20/+$100/Max 快捷按钮(桌面端、嵌入弹窗、移动端弹窗一致)
- 输入框支持直接输入金额(>= 0支持小数
- 事件处理:`onAmountInput``onAmountKeydown``onAmountPaste`
- 余额不足时 Buy 显示 Deposit 按钮
- 25%/50%/Max 快捷份额
- **Sell 模式 UI 优化**
- Shares 标签与 Max shares 提示同行显示(`max-shares-inline`
- 输入框独占一行(`shares-input-wrapper`
- 25%/50%/Max 按钮独立一行(`sell-shares-buttons`
- 整体布局更清晰:`Shares Max: 2``[输入框]``[25%][50%][Max]`
- 调用 market API 下单、Split、Merge
- **合并/拆分成功后触发事件**`mergeSuccess``splitSuccess`,父组件监听后可刷新持仓列表
- **401 权限错误提示**:通过 `useAuthError().formatAuthError` 统一处理,未登录显示「请先登录」,已登录显示「权限不足」
## 使用方式
@ -29,10 +52,30 @@
<TradeComponent
:market="tradeMarketPayload"
:initial-option="tradeInitialOption"
:positions="marketPositions"
embedded-in-sheet
/>
```
**positions 示例**
```typescript
const marketPositions = [
{ id: '1', outcomeWord: 'Yes', shares: '10 shares', sharesNum: 10 },
{ id: '2', outcomeWord: 'No', shares: '8 shares', sharesNum: 8 }
]
// 可合并份额 = min(10, 8) = 8
```
## Events
| 事件名 | 说明 |
|--------|------|
| `optionChange` | 选项切换yes/no |
| `orderSuccess` | 下单成功 |
| `mergeSuccess` | 合并份额成功,父组件应监听并刷新持仓 |
| `splitSuccess` | 拆分份额成功,父组件应监听并刷新持仓 |
| `submit` | 提交订单前的回调,携带订单 payload |
## 国际化
Merge/Split 弹窗文案均通过 `trade.*` 键国际化:

View File

@ -0,0 +1,36 @@
# useAuthError.ts
**路径**`src/composables/useAuthError.ts`
## 功能用途
统一处理 HTTP 401Unauthorized等权限相关错误根据登录状态返回用户友好的提示文案未登录时提示「请先登录」已登录时提示「权限不足」。
## 核心能力
- `formatAuthError(err, fallback)`:将异常转换为展示文案
- 若错误信息包含 `401``Unauthorized`:根据 `userStore.isLoggedIn` 返回 `error.pleaseLogin``error.insufficientPermission`
- 否则返回原始错误信息或 `fallback`
## 使用方式
```typescript
import { useAuthError } from '@/composables/useAuthError'
const { formatAuthError } = useAuthError()
try {
await someApiCall()
} catch (e) {
errorMessage.value = formatAuthError(e, t('error.requestFailed'))
}
```
## 国际化
依赖 `error.pleaseLogin``error.insufficientPermission`,在各 `locales/*.json``error` 下配置。
## 扩展方式
1. **扩展状态码**:在 `formatAuthError` 中增加对 403 等的判断
2. **统一拦截**:在 `request.ts` 层统一处理 401自动跳转登录或弹 toast

View File

@ -1,27 +1,69 @@
# Home.vue
**路径**`src/views/Home.vue`
**路由**`/`name: `home`
## 功能用途
首页,展示分类 Tab、搜索、事件卡片列表。支持三层分类、下拉刷新、无限滚动、搜索历史卡片支持单一/多选项展示
首页,展示分类导航栏(三层级)、事件卡片列表。支持分类筛选、搜索、下拉刷新、触底加载更多
## 核心能力
- 分类:第一层 Tab、第二层图标、第三层 Tab`getPmTagMain` 获取
- 搜索:展开浮层、历史记录、`useSearchHistory`
- 列表:`getPmEventPublic` 分页、`mapEventItemToCard` 映射、`MarketCard` 渲染
- 缓存:`eventListCache` 切换页面时复用,下拉刷新时清空
- Keep-alive`Home` 被 include切换回来时保留状态
- **分类导航**:三层级分类选择(一级 Tab、二级图标、三级 Tab
- **事件列表**:卡片式展示,支持下拉刷新、触底加载
- **搜索**:可按关键词搜索事件
- **分类筛选**:选中分类后,自动提取所有层级节点的 `tagIds` 进行事件筛选
## 数据流
```
PmTagCatalogItem.tagId = [1351]
↓ mapCatalogToTreeNode
CategoryTreeNode.tagIds = [1351]
↓ 用户选择分类(如:政治 → 特朗普)
activeTagIds = [1351, 1368] // 合并所有选中层级的 tagIds
↓ getPmEventPublic
?tagIds=1351&tagIds=1368
```
## 核心计算属性
### activeTagIds
收集所有选中层级节点的 `tagIds`(含父级),用于事件筛选:
```typescript
const activeTagIds = computed(() => {
const activeIds = layerActiveValues.value
const tagIdSet = new Set<number>()
// 遍历每一层选中的节点,收集所有 tagIds含父级
let currentNodes = filterVisible(categoryTree.value)
for (let i = 0; i < activeIds.length; i++) {
const selectedId = activeIds[i]
if (!selectedId) continue
const node = currentNodes.find((n) => n.id === selectedId)
if (node?.tagIds && node.tagIds.length > 0) {
node.tagIds.forEach((id) => tagIdSet.add(id))
}
currentNodes = filterVisible(node?.children)
}
return Array.from(tagIdSet) // 去重后的数组
})
```
**示例**
- 选中「政治」tagIds: [1351])→ activeTagIds = [1351]
- 选中「政治 → 特朗普」tagIds: [1351] + [1368])→ activeTagIds = [1351, 1368]
## 使用方式
- 访问 `/` 即可进入
- 分类切换、搜索、下拉刷新、滚动加载均自动工作
无需手动调用,路由 `/` 自动加载。
## 扩展方式
1. **新增筛选**:在搜索浮层旁增加筛选按钮,修改 `getPmEventPublic` 的 params
2. **骨架屏**:在 loading 时展示 `v-skeleton-loader`
3. **空状态**:列表为空时展示空状态插画与文案
1. **新增分类层级**:修改 `MAX_LAYER` 常量,调整模板渲染逻辑
2. **自定义筛选逻辑**:修改 `activeTagIds` 计算属性
3. **列表缓存策略**:调整 `getEventListCache` / `setEventListCache`

View File

@ -11,15 +11,26 @@
- 分时图ECharts 渲染,支持 Past、时间粒度切换
- 订单簿:`OrderBook` 组件,通过 **ClobSdk** 对接 CLOB WebSocket 实时数据(全量快照、增量更新、成交推送)
- 交易:`TradeComponent`,传入 `market``initialOption`
- 交易:`TradeComponent`,传入 `market``initialOption``positions`(持仓数据)
- 持仓列表:通过 `getPositionList` 获取当前市场持仓,传递给 `TradeComponent` 用于计算可合并份额
- 限价订单:通过 `getOrderList` 获取当前市场未成交限价单,支持撤单
- 移动端:底部栏 + `v-bottom-sheet` 嵌入 `TradeComponent`
- Merge/Split通过 `TradeComponent` 或底部菜单触发
- Merge/Split通过 `TradeComponent` 或底部菜单触发,成功后监听 `mergeSuccess`/`splitSuccess` 事件刷新持仓
- **401 权限错误**:加载详情失败时,通过 `useAuthError().formatAuthError` 统一提示「请先登录」或「权限不足」
## 使用方式
- 从首页卡片点击进入,或直接访问 `/trade-detail/123`
- 路由参数 `id` 为 Event ID用于 `findPmEvent`
## 持仓刷新机制
- **下单成功**`TradeComponent` 触发 `orderSuccess``onOrderSuccess()` 刷新持仓和未成交订单
- **合并成功**`TradeComponent` 触发 `mergeSuccess``onMergeSuccess()` 刷新持仓,显示 toast 提示
- **拆分成功**`TradeComponent` 触发 `splitSuccess``onSplitSuccess()` 刷新持仓
持仓刷新调用 `loadMarketPositions()`,通过 `/clob/position/getPositionList` 接口获取最新持仓数据,并根据 `tokenId` 匹配 Yes/No 方向。
## 扩展方式
1. **订单簿**:已通过 `sdk/clobSocket.ts` 的 ClobSdk 对接 CLOB WebSocket使用 **Yes/No token ID** 订阅 `price_size_all``price_size_delta``trade` 消息

View File

@ -13,6 +13,7 @@
- Profit/Loss 卡片时间范围切换、ECharts 图表
- TabPositions、Open orders、History
- DepositDialog、WithdrawDialog 组件
- **401 权限错误**:取消订单等接口失败时,通过 `useAuthError().formatAuthError` 统一提示「请先登录」或「权限不足」
## 使用方式

358
sdk/approve.ts Normal file
View File

@ -0,0 +1,358 @@
// 跨链USDT授权核心方法 - 修复版无需外部ethers库
// 适用于支持EIP-1193标准的钱包如MetaMask
interface ChainConfig {
chainId: string;
name: string;
usdtAddress: string;
rpcUrl: string;
explorer: string;
}
// 链配置
const chains: Record<string, ChainConfig> = {
eth: {
chainId: '0x1', // Ethereum Mainnet
name: 'Ethereum',
usdtAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
rpcUrl: 'https://mainnet.infura.io/v3/',
explorer: 'https://etherscan.io'
},
bnb: {
chainId: '0x38', // Binance Smart Chain Mainnet
name: 'Binance Smart Chain',
usdtAddress: '0x55d398326f99059fF775485246999027B3197955',
rpcUrl: 'https://bsc-dataseed.binance.org/',
explorer: 'https://bscscan.com'
},
polygon: {
chainId: '0x89', // Polygon Mainnet
name: 'Polygon',
usdtAddress: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F',
rpcUrl: 'https://polygon-rpc.com/',
explorer: 'https://polygonscan.com'
}
};
// USDT ABI
const USDT_ABI = [
"function approve(address spender, uint256 value) public returns (bool)",
"function allowance(address owner, address spender) view returns (uint256)",
"function balanceOf(address owner) view returns (uint256)"
];
// 将数字转换为十六进制字符串
function numberToHex(num: number | string): string {
return '0x' + BigInt(num).toString(16);
}
// 将十进制数字转换为带指定小数位的整数形式
function parseUnits(value: string, decimals: number): string {
const [integerPart = '0', decimalPart = ''] = value.split('.');
let result = integerPart.replace(/^0+/, '') || '0'; // 移除前导零
if (decimalPart.length > decimals) {
throw new Error(`小数位数超过限制: ${decimals}`);
}
const paddedDecimal = decimalPart.padEnd(decimals, '0');
result += paddedDecimal;
return result;
}
// 验证钱包连接
function validateWallet(): boolean {
if (typeof window === 'undefined' || typeof (window as any).ethereum === 'undefined') {
throw new Error('请在支持以太坊的钱包浏览器中运行此代码');
}
return true;
}
// 获取当前钱包账户
async function getAccount(): Promise<string> {
try {
const accounts = await (window as any).ethereum.request({
method: 'eth_requestAccounts'
});
return accounts[0];
} catch (error) {
console.error('用户拒绝授权:', error);
throw error;
}
}
// 获取当前网络ID
async function getCurrentNetworkId(): Promise<string> {
try {
const chainId = await (window as any).ethereum.request({
method: 'eth_chainId'
});
return chainId;
} catch (error) {
console.error('获取网络ID失败:', error);
throw error;
}
}
// 切换网络
async function switchNetwork(chainId: string): Promise<void> {
const chainName = Object.values(chains).find(c => c.chainId === chainId)?.name || 'Unknown Network';
try {
await (window as any).ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: chainId }],
});
console.log(`已切换到${chainName}`);
} catch (switchError: any) {
if (switchError.code === 4902) {
// 网络不存在,尝试添加网络
const config = Object.values(chains).find(c => c.chainId === chainId);
if (!config) {
throw new Error(`不支持的网络: ${chainId}`);
}
try {
await (window as any).ethereum.request({
method: 'wallet_addEthereumChain',
params: [{
chainId: chainId,
chainName: config.name,
nativeCurrency: {
name: chainId === '0x38' ? 'BNB' : chainId === '0x89' ? 'MATIC' : 'ETH',
symbol: chainId === '0x38' ? 'BNB' : chainId === '0x89' ? 'MATIC' : 'ETH',
decimals: 18,
},
rpcUrls: [config.rpcUrl],
blockExplorerUrls: [config.explorer]
}],
});
console.log(`已添加${chainName}`);
} catch (addError) {
console.error('添加网络失败:', addError);
throw addError;
}
} else {
console.error('切换网络失败:', switchError);
throw switchError;
}
}
}
// 编码函数调用数据
function encodeFunctionCall(abiFragment: string, args: any[]): string {
// 简化的函数签名计算
const funcSignature = abiFragment.substring(0, abiFragment.indexOf('('));
const params = abiFragment.substring(abiFragment.indexOf('(') + 1, abiFragment.lastIndexOf(')'));
// 为 approve(address,uint256) 函数构建调用数据
if (funcSignature === 'approve' && params === 'address,uint256') {
// 函数选择器: keccak256("approve(address,uint256)") 的前4字节
// 已知为 0x095ea7b3
let data = '0x095ea7b3';
// 地址参数补零到32字节
let addr = args[0].toLowerCase();
if (addr.startsWith('0x')) addr = addr.substring(2);
data += addr.padStart(64, '0');
// 数量参数补零到32字节
let amount = BigInt(args[1]).toString(16);
data += amount.padStart(64, '0');
return data;
}
// 为 allowance(address,address) 函数构建调用数据
if (funcSignature === 'allowance' && params === 'address,address') {
// 函数选择器: keccak256("allowance(address,address)") 的前4字节
// 已知为 0xdd62ed3e
let data = '0xdd62ed3e';
// 第一个地址参数
let owner = args[0].toLowerCase();
if (owner.startsWith('0x')) owner = owner.substring(2);
data += owner.padStart(64, '0');
// 第二个地址参数
let spender = args[1].toLowerCase();
if (spender.startsWith('0x')) spender = spender.substring(2);
data += spender.padStart(64, '0');
return data;
}
// 为 balanceOf(address) 函数构建调用数据
if (funcSignature === 'balanceOf' && params === 'address') {
// 函数选择器: keccak256("balanceOf(address)") 的前4字节
// 已知为 0x70a08231
let data = '0x70a08231';
// 地址参数补零到32字节
let addr = args[0].toLowerCase();
if (addr.startsWith('0x')) addr = addr.substring(2);
data += addr.padStart(64, '0');
return data;
}
throw new Error('不支持的ABI片段: ' + abiFragment);
}
// 授权USDT
async function authorizeUSDT(
chain: 'eth' | 'bnb' | 'polygon',
spenderAddress: string,
amount: string
): Promise<{ success: boolean; transactionHash?: string; error?: any }> {
try {
// 验证参数
if (!isValidAddress(spenderAddress)) {
throw new Error('无效的被授权地址');
}
if (isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
throw new Error('授权金额必须大于0');
}
// 获取钱包账户
const account = await getAccount();
// 获取当前网络
const currentNetworkId = await getCurrentNetworkId();
const targetChainConfig = chains[chain];
if (!targetChainConfig) throw new Error(`不支持的链: ${chain}`);
// 检查是否需要切换网络
if (currentNetworkId !== targetChainConfig.chainId) {
await switchNetwork(targetChainConfig.chainId);
}
// 将金额转换为带6位小数的单位USDT有6位小数
const parsedAmount = parseUnits(amount, 6);
// 构建交易数据
const data = encodeFunctionCall('approve(address,uint256)', [spenderAddress, parsedAmount]);
// 构建交易对象
const tx = {
from: account,
to: targetChainConfig.usdtAddress,
data: data
};
// 发送交易
const transactionHash = await (window as any).ethereum.request({
method: 'eth_sendTransaction',
params: [tx]
});
console.log(`正在${targetChainConfig.name}上发送授权交易...`);
console.log('交易哈希:', transactionHash);
// 等待交易确认
// 注意:这里我们只返回交易哈希,实际等待确认需要轮询或监听
return {
success: true,
transactionHash: transactionHash
};
} catch (error) {
console.error('授权失败:', error);
return {
success: false,
error: error
};
}
}
// 查询USDT余额
async function getUSDTBalance(chain: 'eth' | 'bnb' | 'polygon', account: string): Promise<string> {
try {
const targetChainConfig = chains[chain];
if (!targetChainConfig) throw new Error(`不支持的链: ${chain}`);
// 构建调用数据
const data = encodeFunctionCall('balanceOf(address)', [account]);
// 执行调用
const result = await (window as any).ethereum.request({
method: 'eth_call',
params: [{
to: targetChainConfig.usdtAddress,
data: data
}, 'latest']
});
// 解析结果(十六进制转十进制)
const balance = BigInt(result).toString();
// 转换为带6位小数的格式
if (balance.length <= 6) {
return '0.' + balance.padStart(6, '0');
} else {
const integerPart = balance.slice(0, -6);
const decimalPart = balance.slice(-6).replace(/0+$/, ''); // 移除末尾零
return decimalPart ? `${integerPart}.${decimalPart}` : integerPart;
}
} catch (error) {
console.error('查询余额失败:', error);
throw error;
}
}
// 查询当前授权额度
async function getAllowance(
chain: 'eth' | 'bnb' | 'polygon',
owner: string,
spender: string
): Promise<string> {
try {
const targetChainConfig = chains[chain];
if (!targetChainConfig) throw new Error(`不支持的链: ${chain}`);
// 构建调用数据
const data = encodeFunctionCall('allowance(address,address)', [owner, spender]);
// 执行调用
const result = await (window as any).ethereum.request({
method: 'eth_call',
params: [{
to: targetChainConfig.usdtAddress,
data: data
}, 'latest']
});
// 解析结果(十六进制转十进制)
const allowance = BigInt(result).toString();
// 转换为带6位小数的格式
if (allowance.length <= 6) {
return '0.' + allowance.padStart(6, '0');
} else {
const integerPart = allowance.slice(0, -6);
const decimalPart = allowance.slice(-6).replace(/0+$/, ''); // 移除末尾零
return decimalPart ? `${integerPart}.${decimalPart}` : integerPart;
}
} catch (error) {
console.error('查询授权额度失败:', error);
throw error;
}
}
// 验证地址格式
function isValidAddress(address: string): boolean {
return /^0x[a-fA-F0-9]{40}$/.test(address);
}
// 导出方法供UI调用
export const CrossChainUSDTAuth = {
authorizeUSDT,
getUSDTBalance,
getAllowance
};
// 使用示例:
// await CrossChainUSDTAuth.authorizeUSDT('eth', '0x1234...', '100')
// await CrossChainUSDTAuth.getUSDTBalance('eth', '0x1234...')
// await CrossChainUSDTAuth.getAllowance('eth', '0x1234...', '0x5678...')

View File

@ -126,7 +126,10 @@ export class ClobSdk {
console.log(`[ClobSdk] 已连接到 ${this.url}`);
this.reconnectAttempts = 0;
this.notifyConnect(event);
this.subscribe();
setTimeout(() => {
this.subscribe();
}, 1000);
// this.subscribe();
};
this.ws.onmessage = (event: any) => {

View File

@ -50,6 +50,8 @@ export interface CategoryTreeNode {
updatedAt?: string
/** 排序值,有则按从小到大排序 */
sort?: number
/** 关联的标签 ID 列表,用于事件筛选 */
tagIds?: number[]
children?: CategoryTreeNode[]
}
@ -204,6 +206,14 @@ function mapCatalogToTreeNode(item: PmTagCatalogItem): CategoryTreeNode {
? item.children.map(mapCatalogToTreeNode)
: undefined
const icon = item.icon ?? resolveCategoryIcon({ label, slug })
// 提取 tagIds优先使用数组形式的 tagId
const tagIds = Array.isArray(item.tagId)
? item.tagId.filter((v): v is number => typeof v === 'number')
: typeof item.tagId === 'number'
? [item.tagId]
: undefined
return {
id,
label,
@ -211,6 +221,7 @@ function mapCatalogToTreeNode(item: PmTagCatalogItem): CategoryTreeNode {
icon,
sectionTitle: item.sectionTitle,
sort: item.sort,
tagIds,
children: children?.length ? children : undefined,
}
}

View File

@ -113,42 +113,39 @@ export interface PmEventListResponse {
msg: string
}
/**
* GET /PmEvent/getPmEventPublic doc.json
*/
export interface GetPmEventListParams {
page?: number
pageSize?: number
keyword?: string
/** 创建时间范围,如 ['2025-01-01', '2025-12-31'] */
createdAtRange?: string[]
/** clobTokenIds 对应的值,用于按市场 token 筛选;可从 market.clobTokenIds 获取 */
tokenid?: string | string[]
/** 标签 ID按分类筛选 */
tagId?: number
/** 标签 slug按分类筛选 */
tagSlug?: string
/** 标签 ID 列表,按分类筛选,传统数组方式传递 */
tagIds?: number[]
}
/**
* Event
* GET /PmEvent/getPmEventPublic
*
* Query: page, pageSize, keyword, createdAtRange, tokenid, tagId, tagSlug
* Query: page, pageSize, keyword, createdAtRange, tagIds
* doc.json: paths["/PmEvent/getPmEventPublic"].get.parameters
*/
export async function getPmEventPublic(
params: GetPmEventListParams = {},
): Promise<PmEventListResponse> {
const { page = 1, pageSize = 10, keyword, createdAtRange, tokenid, tagId, tagSlug } = params
const query: Record<string, string | number | string[] | undefined> = {
const { page = 1, pageSize = 10, keyword, createdAtRange, tagIds } = params
const query: Record<string, string | number | number[] | string[] | undefined> = {
page,
pageSize,
}
if (keyword != null && keyword !== '') query.keyword = keyword
if (createdAtRange != null && createdAtRange.length) query.createdAtRange = createdAtRange
if (tokenid != null) {
query.tokenid = Array.isArray(tokenid) ? tokenid : [tokenid]
if (tagIds != null && tagIds.length > 0) {
query.tagIds = tagIds
}
// if (tagId != null && Number.isFinite(tagId)) query.tagId = tagId
// if (tagSlug != null && tagSlug !== '') query.tagSlug = tagSlug
return get<PmEventListResponse>('/PmEvent/getPmEventPublic', query)
}
@ -203,13 +200,13 @@ export interface EventCardOutcome {
title: string
/** 第一选项概率(来自 outcomePrices[0] */
chanceValue: number
/** 第一选项按钮文案(来自 outcomes[0],如 Yes / Up */
/** Yes 价格 01来自 outcomePrices[0],供交易组件使用 */
yesPrice?: number
/** No 价格 01来自 outcomePrices[1],供交易组件使用 */
noPrice?: number
yesLabel?: string
/** 第二选项按钮文案(来自 outcomes[1],如 No / Down */
noLabel?: string
/** 可选,用于交易时区分 market */
marketId?: string
/** 用于下单 tokenId与 outcomes 顺序一致 */
clobTokenIds?: string[]
}
@ -240,6 +237,10 @@ export interface EventCardItem {
marketId?: string
/** 用于下单 tokenId单 market 时取自 firstMarket.clobTokenIds */
clobTokenIds?: string[]
/** Yes 价格 01来自 outcomePrices[0],供交易组件使用 */
yesPrice?: number
/** No 价格 01来自 outcomePrices[1],供交易组件使用 */
noPrice?: number
}
/** 内存缓存:列表数据,切换页面时复用,下拉刷新时清空 */
@ -322,17 +323,38 @@ export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
const category = item.series?.[0]?.title ?? item.tags?.[0]?.label ?? ''
function parseOutcomePrices(m: PmEventMarketItem): { yesPrice: number; noPrice: number } {
const y = m?.outcomePrices?.[0]
const n = m?.outcomePrices?.[1]
const yesPrice =
y != null && Number.isFinite(parseFloat(String(y)))
? Math.min(1, Math.max(0, parseFloat(String(y))))
: 0.5
const noPrice =
n != null && Number.isFinite(parseFloat(String(n)))
? Math.min(1, Math.max(0, parseFloat(String(n))))
: 1 - yesPrice
return { yesPrice, noPrice }
}
const outcomes: EventCardOutcome[] | undefined = multi
? markets.map((m) => ({
title: m.question ?? '',
chanceValue: marketChance(m),
yesLabel: m.outcomes?.[0] ?? 'Yes',
noLabel: m.outcomes?.[1] ?? 'No',
marketId: getMarketId(m),
clobTokenIds: m.clobTokenIds,
}))
? markets.map((m) => {
const { yesPrice, noPrice } = parseOutcomePrices(m)
return {
title: m.question ?? '',
chanceValue: marketChance(m),
yesPrice,
noPrice,
yesLabel: m.outcomes?.[0] ?? 'Yes',
noLabel: m.outcomes?.[1] ?? 'No',
marketId: getMarketId(m),
clobTokenIds: m.clobTokenIds,
}
})
: undefined
const firstPrices = firstMarket ? parseOutcomePrices(firstMarket) : { yesPrice: 0.5, noPrice: 0.5 }
return {
id,
slug: item.slug ?? undefined,
@ -349,5 +371,7 @@ export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
isNew: item.new === true,
marketId: getMarketId(firstMarket),
clobTokenIds: firstMarket?.clobTokenIds,
yesPrice: firstPrices.yesPrice,
noPrice: firstPrices.noPrice,
}
}

View File

@ -24,6 +24,8 @@ export interface ClobSubmitOrderRequest {
taker: boolean
tokenID: string
userID: number
/** 市场 ID */
marketID: string
}
/**

230
src/api/order.ts Normal file
View File

@ -0,0 +1,230 @@
import { get, post } from './request'
/** 分页结果 */
export interface PageResult<T> {
list: T[]
page: number
pageSize: number
total: number
}
/**
* doc.json definitions["model.ClobOrder"]
* GET /clob/order/getOrderList
*/
export interface ClobOrderItem {
ID: number
assetID?: string
createdAt?: string
updatedAt?: string
expiration?: number
feeRateBps?: number
market?: string
orderType?: number
originalSize?: number
outcome?: string
price?: number
side?: number
sizeMatched?: number
status?: number
userID?: number
[key: string]: unknown
}
/** 订单列表响应 */
export interface OrderListResponse {
code: number
data: PageResult<ClobOrderItem>
msg: string
}
/** 订单状态1=未成交Live2=已成交0=已取消等 */
export const OrderStatus = {
Live: 1,
Matched: 2,
Cancelled: 0,
} as const
/**
* GET /clob/order/getOrderList
*/
export interface GetOrderListParams {
page?: number
pageSize?: number
/** 订单状态筛选1=未成交 */
status?: number
startCreatedAt?: string
endCreatedAt?: string
marketID?: string
tokenID?: string
userID?: number
}
/**
*
* GET /clob/order/getOrderList
* x-tokenx-user-id
*/
export async function getOrderList(
params: GetOrderListParams = {},
config?: { headers?: Record<string, string> },
): Promise<OrderListResponse> {
const { page = 1, pageSize = 10, status, startCreatedAt, endCreatedAt, marketID, tokenID, userID } =
params
const query: Record<string, string | number | undefined> = { page, pageSize }
if (status != null && Number.isFinite(status)) query.status = status
if (startCreatedAt != null && startCreatedAt !== '') query.startCreatedAt = startCreatedAt
if (endCreatedAt != null && endCreatedAt !== '') query.endCreatedAt = endCreatedAt
if (marketID != null && marketID !== '') query.marketID = marketID
if (tokenID != null && tokenID !== '') query.tokenID = tokenID
if (userID != null && Number.isFinite(userID)) query.userID = userID
return get<OrderListResponse>('/clob/order/getOrderList', query, config)
}
/** 取消订单请求体request.CancelOrderReq */
export interface CancelOrderReq {
orderID: number
tokenID: string
userID: number
}
/** 通用 API 响应 */
export interface ApiResponse {
code: number
data?: unknown
msg: string
}
/**
* POST /clob/order/cancelOrder
* x-token
*/
export async function cancelOrder(
data: CancelOrderReq,
config?: { headers?: Record<string, string> },
): Promise<ApiResponse> {
return post<ApiResponse>('/clob/order/cancelOrder', data, config)
}
/** 钱包 History 展示项(与 Wallet.vue HistoryItem 一致) */
export interface HistoryDisplayItem {
id: string
market: string
side: 'Yes' | 'No'
activity: string
value: string
activityDetail?: string
profitLoss?: string
profitLossNegative?: boolean
timeAgo?: string
avgPrice?: string
shares?: string
iconChar?: string
iconClass?: string
}
/** Side: Buy=1, Sell=2 */
const Side = { Buy: 1, Sell: 2 } as const
function formatTimeAgo(createdAt: string | undefined): string {
if (!createdAt) return ''
const d = new Date(createdAt)
const now = Date.now()
const diff = now - d.getTime()
if (diff < 60000) return 'Just now'
if (diff < 3600000) return `${Math.floor(diff / 60000)} minutes ago`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} hours ago`
if (diff < 604800000) return `${Math.floor(diff / 86400000)} days ago`
return d.toLocaleDateString()
}
/**
* ClobOrderItem History
* price 10000sizeMatched
*/
export function mapOrderToHistoryItem(order: ClobOrderItem): HistoryDisplayItem {
const id = String(order.ID ?? '')
const market = order.market ?? ''
const outcome = order.outcome ?? 'Yes'
const sideNum = order.side ?? Side.Buy
const sideLabel = sideNum === Side.Sell ? 'Sell' : 'Buy'
const activity = `${sideLabel} ${outcome}`
const priceBps = order.price ?? 0
const priceCents = Math.round(priceBps / 100)
const size = order.sizeMatched ?? order.originalSize ?? 0
const valueUsd = (priceBps / 10000) * size
const value = `$${valueUsd.toFixed(2)}`
const verb = sideNum === Side.Sell ? 'Sold' : 'Bought'
const activityDetail = `${verb} ${size} ${outcome} at ${priceCents}¢`
const avgPrice = `${priceCents}¢`
const timeAgo = formatTimeAgo(order.createdAt)
return {
id,
market,
side: outcome === 'No' ? 'No' : 'Yes',
activity,
value,
activityDetail,
profitLoss: value,
profitLossNegative: false,
timeAgo,
avgPrice,
shares: String(size),
}
}
/** 钱包 Open Orders 展示项(与 Wallet.vue OpenOrder 一致) */
export interface OpenOrderDisplayItem {
id: string
market: string
side: 'Yes' | 'No'
outcome: string
price: string
filled: string
total: string
expiration: string
actionLabel?: string
filledDisplay?: string
orderID?: number
tokenID?: string
}
/** OrderType GTC=0 表示 Until Cancelled */
const OrderType = { GTC: 0, GTD: 1 } as const
/**
* ClobOrderItem Open Orders
* price 10000
*/
export function mapOrderToOpenOrderItem(order: ClobOrderItem): OpenOrderDisplayItem {
const id = String(order.ID ?? '')
const market = order.market ?? ''
const sideNum = order.side ?? Side.Buy
const side = sideNum === Side.Sell ? 'No' : 'Yes'
const outcome = order.outcome || (side === 'Yes' ? 'Yes' : 'No')
const priceBps = order.price ?? 0
const priceCents = Math.round(priceBps / 100)
const price = `${priceCents}¢`
const originalSize = order.originalSize ?? 0
const sizeMatched = order.sizeMatched ?? 0
const filled = `${sizeMatched}/${originalSize}`
const totalUsd = (priceBps / 10000) * originalSize
const total = `$${totalUsd.toFixed(2)}`
const expiration =
order.orderType === OrderType.GTC ? 'Until Cancelled' : order.expiration?.toString() ?? ''
const actionLabel = sideNum === Side.Buy ? `Buy ${outcome}` : `Sell ${outcome}`
return {
id,
market,
side,
outcome,
price,
filled,
total,
expiration,
actionLabel,
filledDisplay: filled,
orderID: order.ID,
tokenID: order.assetID,
}
}

133
src/api/position.ts Normal file
View File

@ -0,0 +1,133 @@
import { get } from './request'
/** 分页结果 */
export interface PageResult<T> {
list: T[]
page: number
pageSize: number
total: number
}
/**
* /clob/position/getPositionList
* sizeavailablecost 6 1000000
*/
export interface ClobPositionItem {
ID?: number
userID?: number
marketID?: string
tokenId?: string
/** 份额字符串6 位小数 */
size?: string
/** 可用份额字符串6 位小数 */
available?: string
/** 成本字符串6 位小数 */
cost?: string
/** 方向Yes | No */
outcome?: string
version?: number
CreatedAt?: string
UpdatedAt?: string
[key: string]: unknown
}
/** 份额/金额 6 位小数 */
const SCALE = 1_000_000
function parsePosNum(s: string | number | undefined): number {
if (s == null) return 0
const n = typeof s === 'string' ? parseFloat(s) : Number(s)
return Number.isFinite(n) ? n : 0
}
/** 持仓列表响应 */
export interface PositionListResponse {
code: number
data: PageResult<ClobPositionItem>
msg: string
}
/**
* GET /clob/position/getPositionList
*/
export interface GetPositionListParams {
page?: number
pageSize?: number
startCreatedAt?: string
endCreatedAt?: string
marketID?: string
tokenID?: string
userID?: number
}
/**
*
* GET /clob/position/getPositionList
* x-tokenx-user-id
*/
export async function getPositionList(
params: GetPositionListParams = {},
config?: { headers?: Record<string, string> },
): Promise<PositionListResponse> {
const { page = 1, pageSize = 10, startCreatedAt, endCreatedAt, marketID, tokenID, userID } = params
const query: Record<string, string | number | undefined> = { page, pageSize }
if (startCreatedAt != null && startCreatedAt !== '') query.startCreatedAt = startCreatedAt
if (endCreatedAt != null && endCreatedAt !== '') query.endCreatedAt = endCreatedAt
if (marketID != null && marketID !== '') query.marketID = marketID
if (tokenID != null && tokenID !== '') query.tokenID = tokenID
if (userID != null && Number.isFinite(userID)) query.userID = userID
return get<PositionListResponse>('/clob/position/getPositionList', query, config)
}
/** 钱包 Positions 展示项(与 Wallet.vue Position 一致) */
export interface PositionDisplayItem {
id: string
market: string
shares: string
avgNow: string
bet: string
toWin: string
value: string
valueChange?: string
valueChangePct?: string
valueChangeLoss?: boolean
sellOutcome?: string
outcomeWord?: string
iconChar?: string
iconClass?: string
outcomeTag?: string
outcomePillClass?: string
}
/**
* ClobPositionItem Position
* sizeavailablecost 6
*/
export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplayItem {
const id = String(pos.ID ?? '')
const market = pos.marketID ?? ''
const sizeRaw = parsePosNum(pos.size ?? pos.available)
const costRaw = parsePosNum(pos.cost)
const size = sizeRaw / SCALE
const costUsd = costRaw / SCALE
const shares = `${size} shares`
const outcomeWord = pos.outcome === 'No' ? 'No' : 'Yes'
const pillClass = outcomeWord === 'No' ? 'pill-down' : 'pill-yes'
const value = `$${costUsd.toFixed(2)}`
const bet = value
const toWin = `$${size.toFixed(2)}`
const outcomeTag = `${outcomeWord}`
return {
id,
market,
shares,
avgNow: '—',
bet,
toWin,
value,
sellOutcome: outcomeWord,
outcomeWord,
outcomeTag,
outcomePillClass: pillClass,
}
}

View File

@ -35,7 +35,7 @@ export interface RequestConfig {
*/
export async function get<T = unknown>(
path: string,
params?: Record<string, string | number | string[] | undefined>,
params?: Record<string, string | number | string[] | number[] | undefined>,
config?: RequestConfig,
): Promise<T> {
const url = new URL(path, BASE_URL || window.location.origin)

View File

@ -3,11 +3,49 @@ import { get } from './request'
const USDC_DECIMALS = 1_000_000
/**
* getUserInfo datadefinitions system.SysUser
* doc.json definitions["system.SysUser"]
* getUserInfo data 2026
* data balanceorderspositionsuserInfo
*/
/** 余额项data.balance */
export interface UserInfoBalance {
ID?: number
CreatedAt?: string
UpdatedAt?: string
userID?: number
marketID?: string
tokenType?: string
tokenID?: string
amount?: string
available?: string
locked?: string
version?: number
}
/** 订单项data.orders[] */
export interface UserInfoOrder {
ID?: number
CreatedAt?: string
UpdatedAt?: string
userID?: number
market?: string
status?: number
assetID?: string
side?: number
price?: number
originalSize?: number
sizeMatched?: number
outcome?: string
expiration?: number
orderType?: number
feeRateBps?: number
}
/** 用户信息data.userInfo */
export interface UserInfoData {
ID?: number
CreatedAt?: string
UpdatedAt?: string
userName?: string
nickName?: string
headerImg?: string
@ -15,8 +53,6 @@ export interface UserInfoData {
authorityId?: number
authority?: unknown
authorities?: unknown[]
createdAt?: string
updatedAt?: string
email?: string
phone?: string
enable?: number
@ -26,12 +62,20 @@ export interface UserInfoData {
promotionCode?: string
puserId?: number
remark?: string
originSetting?: Record<string, unknown>
originSetting?: Record<string, unknown> | null
}
/** getUserInfo 完整 data */
export interface GetUserInfoData {
balance?: UserInfoBalance
orders?: UserInfoOrder[]
positions?: unknown[]
userInfo?: UserInfoData
}
export interface GetUserInfoResponse {
code: number
data?: UserInfoData
data?: GetUserInfoData
msg?: string
}

View File

@ -181,6 +181,8 @@ const emit = defineEmits<{
clobTokenIds?: string[]
yesLabel?: string
noLabel?: string
yesPrice?: number
noPrice?: number
},
]
}>()
@ -210,6 +212,10 @@ const props = withDefaults(
marketId?: string
/** 用于下单 tokenId单 market 时 */
clobTokenIds?: string[]
/** Yes 价格 01供交易组件使用 */
yesPrice?: number
/** No 价格 01供交易组件使用 */
noPrice?: number
}>(),
{
marketTitle: 'Mamdan opens city-owned grocery store b...',
@ -225,6 +231,8 @@ const props = withDefaults(
noLabel: 'No',
isNew: false,
marketId: undefined,
yesPrice: undefined,
noPrice: undefined,
},
)
@ -306,6 +314,8 @@ function openTradeSingle(side: 'yes' | 'no') {
clobTokenIds: props.clobTokenIds,
yesLabel: props.yesLabel,
noLabel: props.noLabel,
yesPrice: props.yesPrice,
noPrice: props.noPrice,
})
}
@ -318,6 +328,8 @@ function openTradeMulti(side: 'yes' | 'no', outcome: EventCardOutcome) {
clobTokenIds: outcome.clobTokenIds,
yesLabel: outcome.yesLabel,
noLabel: outcome.noLabel,
yesPrice: outcome.yesPrice,
noPrice: outcome.noPrice,
})
}
</script>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'
/**
* 401
*/
export function useAuthError() {
const { t } = useI18n()
const userStore = useUserStore()
function formatAuthError(err: unknown, fallback: string): string {
const msg = err instanceof Error ? err.message : String(err ?? fallback)
if (/401|Unauthorized/i.test(msg)) {
return userStore.isLoggedIn ? t('error.insufficientPermission') : t('error.pleaseLogin')
}
return msg || fallback
}
return { formatAuthError }
}

View File

@ -15,7 +15,9 @@
"chance": "chance"
},
"toast": {
"orderSuccess": "Order placed successfully"
"orderSuccess": "Order placed successfully",
"splitSuccess": "Split successful",
"mergeSuccess": "Merge successful"
},
"trade": {
"buy": "Buy",
@ -56,6 +58,7 @@
"avgPrice": "Avg. Price",
"max": "Max",
"balanceLabel": "Balance",
"maxShares": "Max shares",
"pleaseLogin": "Please log in first",
"pleaseSelectMarket": "Please select a market (with clobTokenIds)",
"userError": "User info error",
@ -82,12 +85,24 @@
"error": {
"requestFailed": "Request failed",
"loadFailed": "Load failed",
"invalidId": "Invalid ID or slug"
"invalidId": "Invalid ID or slug",
"pleaseLogin": "Please log in first",
"insufficientPermission": "Insufficient permission"
},
"activity": {
"comments": "Comments",
"topHolders": "Top Holders",
"activity": "Activity",
"rules": "Rules",
"rulesDescription": "Description",
"rulesSource": "Resolution source",
"rulesEmpty": "No rules available.",
"mine": "Mine",
"myPositions": "Positions",
"openOrders": "Orders",
"noPositionsInMarket": "No positions in this market.",
"noOpenOrdersInMarket": "No open orders in this market.",
"cancelOrder": "Cancel",
"noCommentsYet": "No comments yet.",
"topHoldersPlaceholder": "Top holders will appear here.",
"minAmount": "Min amount",
@ -108,6 +123,8 @@
"today": "Today",
"deposit": "Deposit",
"withdraw": "Withdraw",
"authorize": "Authorize",
"authorizeDesc": "Authorize USDC spending for trading. This allows the exchange contract to use your balance when placing orders.",
"profitLoss": "Profit/Loss",
"allTime": "All-Time",
"pl1D": "1D",

View File

@ -15,7 +15,9 @@
"chance": "確率"
},
"toast": {
"orderSuccess": "注文が完了しました"
"orderSuccess": "注文が完了しました",
"splitSuccess": "スプリット成功",
"mergeSuccess": "マージ成功"
},
"trade": {
"buy": "買う",
@ -56,6 +58,7 @@
"avgPrice": "平均価格",
"max": "最大",
"balanceLabel": "残高",
"maxShares": "最大シェア",
"pleaseLogin": "先にログインしてください",
"pleaseSelectMarket": "市場を選択してくださいclobTokenIds が必要)",
"userError": "ユーザー情報エラー",
@ -82,12 +85,24 @@
"error": {
"requestFailed": "リクエストに失敗しました",
"loadFailed": "読み込みに失敗しました",
"invalidId": "無効な ID または slug"
"invalidId": "無効な ID または slug",
"pleaseLogin": "先にログインしてください",
"insufficientPermission": "権限がありません"
},
"activity": {
"comments": "コメント",
"topHolders": "持倉トップ",
"activity": "アクティビティ",
"rules": "ルール",
"rulesDescription": "説明",
"rulesSource": "決裁ソース",
"rulesEmpty": "ルールはありません",
"mine": "マイ",
"myPositions": "ポジション",
"openOrders": "注文",
"noPositionsInMarket": "この市場にポジションはありません",
"noOpenOrdersInMarket": "この市場に未約定注文はありません",
"cancelOrder": "キャンセル",
"noCommentsYet": "コメントはまだありません",
"topHoldersPlaceholder": "持倉トップがここに表示されます",
"minAmount": "最小金額",
@ -108,6 +123,8 @@
"today": "今日",
"deposit": "入金",
"withdraw": "出金",
"authorize": "承認",
"authorizeDesc": "取引のため USDC の使用を承認します。注文時に取引所が残高を使用できるようになります。",
"profitLoss": "損益",
"allTime": "全期間",
"pl1D": "1日",

View File

@ -15,7 +15,9 @@
"chance": "확률"
},
"toast": {
"orderSuccess": "주문이 완료되었습니다"
"orderSuccess": "주문이 완료되었습니다",
"splitSuccess": "분할 완료",
"mergeSuccess": "병합 완료"
},
"trade": {
"buy": "매수",
@ -56,6 +58,7 @@
"avgPrice": "평균 가격",
"max": "최대",
"balanceLabel": "잔액",
"maxShares": "최대 주식",
"pleaseLogin": "먼저 로그인하세요",
"pleaseSelectMarket": "시장을 선택하세요 (clobTokenIds 필요)",
"userError": "사용자 정보 오류",
@ -82,12 +85,24 @@
"error": {
"requestFailed": "요청 실패",
"loadFailed": "로드 실패",
"invalidId": "잘못된 ID 또는 slug"
"invalidId": "잘못된 ID 또는 slug",
"pleaseLogin": "먼저 로그인하세요",
"insufficientPermission": "권한이 없습니다"
},
"activity": {
"comments": "댓글",
"topHolders": "보유자 순위",
"activity": "활동",
"rules": "규칙",
"rulesDescription": "설명",
"rulesSource": "결정 출처",
"rulesEmpty": "규칙이 없습니다",
"mine": "내 것",
"myPositions": "포지션",
"openOrders": "주문",
"noPositionsInMarket": "이 시장에 포지션이 없습니다",
"noOpenOrdersInMarket": "이 시장에 미체결 주문이 없습니다",
"cancelOrder": "취소",
"noCommentsYet": "아직 댓글이 없습니다",
"topHoldersPlaceholder": "보유자 순위가 여기에 표시됩니다",
"minAmount": "최소 금액",
@ -108,6 +123,8 @@
"today": "오늘",
"deposit": "입금",
"withdraw": "출금",
"authorize": "승인",
"authorizeDesc": "거래를 위해 USDC 사용을 승인합니다. 주문 시 거래소가 잔액을 사용할 수 있습니다.",
"profitLoss": "손익",
"allTime": "전체",
"pl1D": "1일",

View File

@ -15,7 +15,9 @@
"chance": "概率"
},
"toast": {
"orderSuccess": "下单成功"
"orderSuccess": "下单成功",
"splitSuccess": "拆分成功",
"mergeSuccess": "合并成功"
},
"trade": {
"buy": "买入",
@ -56,6 +58,7 @@
"avgPrice": "平均价",
"max": "最大",
"balanceLabel": "余额",
"maxShares": "最大份额",
"pleaseLogin": "请先登录",
"pleaseSelectMarket": "请先选择市场(需包含 clobTokenIds",
"userError": "用户信息异常",
@ -82,12 +85,24 @@
"error": {
"requestFailed": "请求失败",
"loadFailed": "加载失败",
"invalidId": "无效的 ID 或 slug"
"invalidId": "无效的 ID 或 slug",
"pleaseLogin": "请先登录",
"insufficientPermission": "权限不足"
},
"activity": {
"comments": "评论",
"topHolders": "持仓大户",
"activity": "动态",
"rules": "规则",
"rulesDescription": "描述",
"rulesSource": "裁决来源",
"rulesEmpty": "暂无规则说明",
"mine": "我的",
"myPositions": "持仓",
"openOrders": "限价",
"noPositionsInMarket": "本市场暂无持仓",
"noOpenOrdersInMarket": "本市场暂无未成交订单",
"cancelOrder": "撤单",
"noCommentsYet": "暂无评论",
"topHoldersPlaceholder": "持仓大户将在此显示",
"minAmount": "最小金额",
@ -108,6 +123,8 @@
"today": "今日",
"deposit": "入金",
"withdraw": "提现",
"authorize": "授权",
"authorizeDesc": "授权 USDC 用于交易。允许交易合约在下单时使用您的余额。",
"profitLoss": "盈亏",
"allTime": "全部",
"pl1D": "1天",

View File

@ -15,7 +15,9 @@
"chance": "機率"
},
"toast": {
"orderSuccess": "下單成功"
"orderSuccess": "下單成功",
"splitSuccess": "拆分成功",
"mergeSuccess": "合併成功"
},
"trade": {
"buy": "買入",
@ -56,6 +58,7 @@
"avgPrice": "平均價",
"max": "最大",
"balanceLabel": "餘額",
"maxShares": "最大份額",
"pleaseLogin": "請先登入",
"pleaseSelectMarket": "請先選擇市場(需包含 clobTokenIds",
"userError": "用戶資訊異常",
@ -82,12 +85,24 @@
"error": {
"requestFailed": "請求失敗",
"loadFailed": "載入失敗",
"invalidId": "無效的 ID 或 slug"
"invalidId": "無效的 ID 或 slug",
"pleaseLogin": "請先登入",
"insufficientPermission": "權限不足"
},
"activity": {
"comments": "評論",
"topHolders": "持倉大戶",
"activity": "動態",
"rules": "規則",
"rulesDescription": "描述",
"rulesSource": "裁決來源",
"rulesEmpty": "暫無規則說明",
"mine": "我的",
"myPositions": "持倉",
"openOrders": "限價",
"noPositionsInMarket": "本市場暫無持倉",
"noOpenOrdersInMarket": "本市場暫無未成交訂單",
"cancelOrder": "撤單",
"noCommentsYet": "暫無評論",
"topHoldersPlaceholder": "持倉大戶將在此顯示",
"minAmount": "最小金額",
@ -108,6 +123,8 @@
"today": "今日",
"deposit": "入金",
"withdraw": "提現",
"authorize": "授權",
"authorizeDesc": "授權 USDC 用於交易。允許交易合約在下單時使用您的餘額。",
"profitLoss": "盈虧",
"allTime": "全部",
"pl1D": "1天",

View File

@ -44,9 +44,14 @@ export const useToastStore = defineStore('toast', () => {
if (now - last >= DEDUP_MS) return false
const msg = normalizeMsg(item.message)
const inDisplayingIdx = displaying.value.findLastIndex(
(d) => normalizeMsg(d.message) === msg && d.type === item.type
)
let inDisplayingIdx = -1
for (let i = displaying.value.length - 1; i >= 0; i--) {
const d = displaying.value[i]
if (d && normalizeMsg(d.message) === msg && d.type === item.type) {
inDisplayingIdx = i
break
}
}
if (inDisplayingIdx >= 0) {
displaying.value = displaying.value.map((x, i) =>
i === inDisplayingIdx
@ -95,7 +100,7 @@ export const useToastStore = defineStore('toast', () => {
function remove(id: string) {
displaying.value = displaying.value.filter((d) => d.id !== id)
if (queue.value.length > 0) {
const next = queue.value[0]
const next = queue.value[0]!
queue.value = queue.value.slice(1)
displaying.value = [...displaying.value, next]
}

View File

@ -2,7 +2,7 @@ import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { getUsdcBalance, formatUsdcBalance, getUserInfo } from '@/api/user'
import { getUserWsUrl } from '@/api/request'
import { UserSdk, type BalanceData } from '../../sdk/userSocket'
import { UserSdk, type BalanceData, type PositionData } from '../../sdk/userSocket'
export interface UserInfo {
/** 用户 IDAPI 可能返回 id 或 ID */
@ -53,6 +53,7 @@ export const useUserStore = defineStore('user', () => {
const balance = ref<string>('0.00')
let userSdkRef: UserSdk | null = null
const positionUpdateCallbacks: ((data: PositionData & Record<string, unknown>) => void)[] = []
// 若从 storage 恢复登录态,自动连接 UserSocket
if (stored?.token && stored?.user) {
@ -70,11 +71,15 @@ export const useUserStore = defineStore('user', () => {
reconnectInterval: 2000,
})
sdk.onBalanceUpdate((data: BalanceData) => {
if ((data.tokenType ?? '').toUpperCase() !== 'USDC') return
const avail = data.available ?? data.amount
if (avail != null) {
balance.value = formatUsdcBalance(String(avail))
}
})
sdk.onPositionUpdate((data) => {
positionUpdateCallbacks.forEach((cb) => cb(data as PositionData & Record<string, unknown>))
})
sdk.onConnect(() => {})
sdk.onDisconnect(() => {})
sdk.onError((e) => {
@ -91,6 +96,15 @@ export const useUserStore = defineStore('user', () => {
}
}
/** 订阅 position_update 推送,返回取消订阅函数 */
function onPositionUpdate(cb: (data: PositionData & Record<string, unknown>) => void): () => void {
positionUpdateCallbacks.push(cb)
return () => {
const i = positionUpdateCallbacks.indexOf(cb)
if (i >= 0) positionUpdateCallbacks.splice(i, 1)
}
}
function setUser(loginData: { token?: string; user?: UserInfo }) {
const t = loginData.token ?? ''
const raw = loginData.user ?? null
@ -151,36 +165,38 @@ export const useUserStore = defineStore('user', () => {
}
}
/** 请求用户信息(需已登录),更新 store 中的 user */
/** 请求用户信息(需已登录),更新 store 中的 user 与 balance */
async function fetchUserInfo() {
const headers = getAuthHeaders()
if (!headers) return
try {
const res = await getUserInfo(headers)
const data = res.data as Record<string, unknown> | undefined
// 接口返回 data.userInfo 或 data.user取实际用户对象若仍含 userInfo 则再取一层
let u = (data?.userInfo ?? data?.user ?? data) as Record<string, unknown>
if (u?.userInfo && (u.ID == null && u.id == null)) {
u = u.userInfo as Record<string, unknown>
}
if ((res.code === 0 || res.code === 200) && u) {
const rawId = u.ID ?? u.id
const numId =
typeof rawId === 'number'
? rawId
: rawId != null
? parseInt(String(rawId), 10)
: undefined
user.value = {
...u,
userName: (u.userName ?? u.username) as string | undefined,
nickName: (u.nickName ?? u.nickname) as string | undefined,
headerImg: (u.headerImg ?? u.avatar ?? u.avatarUrl) as string | undefined,
id: (rawId ?? numId) as number | string | undefined,
ID: Number.isFinite(numId) ? numId : undefined,
} as UserInfo
if (token.value && user.value) saveToStorage(token.value, user.value)
if (res.code !== 0 && res.code !== 200) return
// 更新余额data.balance.available
const bal = data?.balance as { available?: string } | undefined
if (bal?.available != null) {
balance.value = formatUsdcBalance(String(bal.available))
}
// 更新用户信息data.userInfo
const u = (data?.userInfo ?? data?.user ?? data) as Record<string, unknown> | undefined
if (!u) return
const rawId = u.ID ?? u.id
const numId =
typeof rawId === 'number'
? rawId
: rawId != null
? parseInt(String(rawId), 10)
: undefined
user.value = {
...u,
userName: (u.userName ?? u.username) as string | undefined,
nickName: (u.nickName ?? u.nickname) as string | undefined,
headerImg: (u.headerImg ?? u.avatar ?? u.avatarUrl) as string | undefined,
id: (rawId ?? numId) as number | string | undefined,
ID: Number.isFinite(numId) ? numId : undefined,
} as UserInfo
if (token.value && user.value) saveToStorage(token.value, user.value)
} catch (e) {
console.error('[fetchUserInfo] 请求失败:', e)
}
@ -199,5 +215,6 @@ export const useUserStore = defineStore('user', () => {
fetchUserInfo,
connectUserSocket,
disconnectUserSocket,
onPositionUpdate,
}
})

View File

@ -58,6 +58,35 @@
</div>
</v-card>
<!-- 规则说明描述 + 裁决来源 -->
<v-card
v-if="eventDetail?.description || eventDetail?.resolutionSource"
class="rules-card"
elevation="0"
rounded="lg"
>
<div class="rules-pane">
<div v-if="eventDetail?.description" class="rules-section">
<h3 class="rules-title">{{ t('activity.rulesDescription') }}</h3>
<div class="rules-text">{{ eventDetail.description }}</div>
</div>
<div v-if="eventDetail?.resolutionSource" class="rules-section">
<h3 class="rules-title">{{ t('activity.rulesSource') }}</h3>
<a
v-if="isResolutionSourceUrl"
:href="eventDetail.resolutionSource"
target="_blank"
rel="noopener noreferrer"
class="rules-link"
>
{{ eventDetail.resolutionSource }}
<v-icon size="14">mdi-open-in-new</v-icon>
</a>
<div v-else class="rules-text">{{ eventDetail.resolutionSource }}</div>
</div>
</div>
</v-card>
<!-- 市场列表 -->
<v-card class="markets-list-card" elevation="0" rounded="lg">
<div class="markets-list">
@ -197,11 +226,13 @@ import { USE_MOCK_EVENT } from '../config/mock'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '../stores/user'
import { useToastStore } from '../stores/toast'
import { useLocaleStore } from '../stores/locale'
const route = useRoute()
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const localeStore = useLocaleStore()
const { mobile } = useDisplay()
const isMobile = computed(() => mobile.value)
@ -287,6 +318,11 @@ const resolutionDate = computed(() => {
return s ? s.replace(/,?\s*\d{4}$/, '').trim() || '' : ''
})
const isResolutionSourceUrl = computed(() => {
const src = eventDetail.value?.resolutionSource
return typeof src === 'string' && (src.startsWith('http://') || src.startsWith('https://'))
})
const timeRanges = [
{ label: '1H', value: '1H' },
{ label: '6H', value: '6H' },
@ -700,6 +736,14 @@ watch(
() => route.params.id,
() => loadEventDetail(),
)
//
watch(
() => localeStore.currentLocale,
() => {
loadEventDetail()
},
)
</script>
<style scoped>
@ -899,7 +943,58 @@ watch(
color: #dc2626;
}
.rules-card {
margin-top: 16px;
border: 1px solid #e7e7e7;
border-radius: 12px;
overflow: hidden;
box-shadow: none;
}
.rules-card .rules-pane {
padding: 16px 20px;
}
.rules-card .rules-section {
margin-bottom: 16px;
}
.rules-card .rules-section:last-child {
margin-bottom: 0;
}
.rules-card .rules-title {
font-size: 13px;
font-weight: 600;
color: #6b7280;
margin: 0 0 8px 0;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.rules-card .rules-text {
font-size: 14px;
color: #374151;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.rules-card .rules-link {
font-size: 14px;
color: #2563eb;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.rules-card .rules-link:hover {
text-decoration: underline;
}
.markets-list-card {
margin-top: 16px;
border: 1px solid #e7e7e7;
border-radius: 12px;
overflow: hidden;

View File

@ -165,6 +165,8 @@
:is-new="card.isNew"
:market-id="card.marketId"
:clob-token-ids="card.clobTokenIds"
:yes-price="card.yesPrice"
:no-price="card.noPrice"
@open-trade="onCardOpenTrade"
/>
<div v-if="eventList.length === 0 && !loadingMore" class="home-list-empty">
@ -302,7 +304,7 @@
<script setup lang="ts">
defineOptions({ name: 'Home' })
import { ref, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, computed } from 'vue'
import { ref, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, computed, watch } from 'vue'
import { useDisplay } from 'vuetify'
import MarketCard from '../components/MarketCard.vue'
import TradeComponent from '../components/TradeComponent.vue'
@ -326,6 +328,7 @@ import { USE_MOCK_CATEGORY } from '../config/mock'
import { useI18n } from 'vue-i18n'
import { useSearchHistory } from '../composables/useSearchHistory'
import { useToastStore } from '../stores/toast'
import { useLocaleStore } from '../stores/locale'
const { mobile } = useDisplay()
const { t } = useI18n()
@ -413,16 +416,28 @@ function findNodeById(nodes: CategoryTreeNode[], id: string): CategoryTreeNode |
return undefined
}
/** 当前选中分类的 tag 筛选(取最后选中的层级,用于 API tagId/tagSlug */
const activeTagFilter = computed(() => {
const ids = layerActiveValues.value
if (ids.length === 0) return { tagId: undefined as number | undefined, tagSlug: undefined as string | undefined }
const lastId = ids[ids.length - 1]
const root = filterVisible(categoryTree.value)
const node = lastId ? findNodeById(root, lastId) : undefined
if (!node) return { tagId: undefined, tagSlug: undefined }
const tagId = /^\d+$/.test(node.id) ? parseInt(node.id, 10) : undefined
return { tagId, tagSlug: node.slug || undefined }
/** 当前选中分类的 tagIds收集所有选中层级节点的 tagId 数组(含父级),用于事件筛选 */
const activeTagIds = computed(() => {
const activeIds = layerActiveValues.value
const tagIdSet = new Set<number>()
// tagIds
let currentNodes = filterVisible(categoryTree.value)
for (let i = 0; i < activeIds.length; i++) {
const selectedId = activeIds[i]
if (!selectedId) continue
const node = currentNodes.find((n) => n.id === selectedId)
if (node?.tagIds && node.tagIds.length > 0) {
// tagIds
node.tagIds.forEach((id) => tagIdSet.add(id))
}
//
currentNodes = filterVisible(node?.children)
}
return Array.from(tagIdSet)
})
/** 当前展示的层级数据:[[layer0], [layer1]?, [layer2]?] */
@ -514,12 +529,23 @@ const tradeDialogMarket = ref<{
clobTokenIds?: string[]
yesLabel?: string
noLabel?: string
yesPrice?: number
noPrice?: number
} | null>(null)
const scrollRef = ref<HTMLElement | null>(null)
function onCardOpenTrade(
side: 'yes' | 'no',
market?: { id: string; title: string; marketId?: string; yesLabel?: string; noLabel?: string },
market?: {
id: string
title: string
marketId?: string
yesLabel?: string
noLabel?: string
yesPrice?: number
noPrice?: number
clobTokenIds?: string[]
},
) {
tradeDialogSide.value = side
tradeDialogMarket.value = market ?? null
@ -527,6 +553,18 @@ function onCardOpenTrade(
}
const toastStore = useToastStore()
const localeStore = useLocaleStore()
//
watch(
() => localeStore.currentLocale,
() => {
clearEventListCache()
eventPage.value = 1
loadEvents(1, false, activeSearchKeyword.value)
},
)
function onOrderSuccess() {
tradeDialogOpen.value = false
toastStore.show(t('toast.orderSuccess'))
@ -537,9 +575,14 @@ const homeTradeMarketPayload = computed(() => {
const m = tradeDialogMarket.value
if (!m) return undefined
const marketId = m.marketId ?? m.id
const chance = 50
const yesPrice = Math.min(1, Math.max(0, chance / 100))
const noPrice = 1 - yesPrice
const yesPrice =
m.yesPrice != null && Number.isFinite(m.yesPrice)
? Math.min(1, Math.max(0, m.yesPrice))
: 0.5
const noPrice =
m.noPrice != null && Number.isFinite(m.noPrice)
? Math.min(1, Math.max(0, m.noPrice))
: 1 - yesPrice
const outcomes =
m.yesLabel != null || m.noLabel != null
? [m.yesLabel ?? 'Yes', m.noLabel ?? 'No']
@ -578,14 +621,13 @@ const activeSearchKeyword = ref('')
/** 请求事件列表并追加或覆盖到 eventList公开接口无需鉴权成功后会更新内存缓存 */
async function loadEvents(page: number, append: boolean, keyword?: string) {
const kw = keyword !== undefined ? keyword : activeSearchKeyword.value
const { tagId, tagSlug } = activeTagFilter.value
const tagIds = activeTagIds.value
try {
const res = await getPmEventPublic({
page,
pageSize: PAGE_SIZE,
keyword: kw || undefined,
tagId,
tagSlug,
tagIds: tagIds.length > 0 ? tagIds : undefined,
})
if (res.code !== 0 && res.code !== 200) {
throw new Error(res.msg || '请求失败')
@ -638,7 +680,7 @@ function checkScrollLoad() {
}
onMounted(() => {
/** 分类树就绪后加载列表(确保 activeTagFilter 已计算,与下拉刷新参数一致) */
/** 分类树就绪后加载列表(确保 activeTagIds 已计算,与下拉刷新参数一致) */
function loadEventListAfterCategoryReady() {
const cached = getEventListCache()
if (cached && cached.list.length > 0) {

View File

@ -44,6 +44,78 @@
</div>
</v-card>
<!-- 持仓 / 限价订单簿上方 -->
<v-card class="positions-orders-card" elevation="0" rounded="lg">
<v-tabs v-model="positionsOrdersTab" class="positions-orders-tabs" density="comfortable">
<v-tab value="positions">{{ t('activity.myPositions') }}</v-tab>
<v-tab value="orders">{{ t('activity.openOrders') }}</v-tab>
</v-tabs>
<v-window v-model="positionsOrdersTab" class="positions-orders-window">
<v-window-item value="positions" class="detail-pane">
<div v-if="positionLoading" class="placeholder-pane">{{ t('common.loading') }}</div>
<div v-else-if="marketPositionsFiltered.length === 0" class="placeholder-pane">
{{ t('activity.noPositionsInMarket') }}
</div>
<div v-else class="positions-list">
<div
v-for="pos in marketPositionsFiltered"
:key="pos.id"
class="position-row-item"
>
<div class="position-row-main">
<span :class="['position-outcome-pill', pos.outcomePillClass]">{{ pos.outcomeTag }}</span>
<span class="position-shares">{{ pos.shares }}</span>
<span class="position-value">{{ pos.value }}</span>
<v-btn
variant="outlined"
size="small"
color="primary"
class="position-sell-btn"
@click="openSellFromPosition(pos)"
>
{{ t('trade.sell') }}
</v-btn>
</div>
<div class="position-row-meta">{{ pos.bet }} {{ pos.toWin }}</div>
</div>
</div>
</v-window-item>
<v-window-item value="orders" class="detail-pane">
<div v-if="openOrderLoading" class="placeholder-pane">{{ t('common.loading') }}</div>
<div v-else-if="marketOpenOrders.length === 0" class="placeholder-pane">
{{ t('activity.noOpenOrdersInMarket') }}
</div>
<div v-else class="orders-list">
<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}` }}
</span>
<span class="order-price">{{ ord.price }}</span>
<span class="order-filled">{{ ord.filled }}</span>
<span class="order-total">{{ ord.total }}</span>
</div>
<div class="order-row-actions">
<v-btn
variant="text"
size="small"
color="error"
:disabled="cancelOrderLoading"
@click="cancelMarketOrder(ord)"
>
{{ t('activity.cancelOrder') }}
</v-btn>
</div>
</div>
</div>
</v-window-item>
</v-window>
</v-card>
<!-- Order Book Section -->
<v-card class="order-book-card" elevation="0" rounded="lg">
<OrderBook
@ -65,13 +137,37 @@
<!-- Comments / Top Holders / Activity与左侧图表订单簿同宽 -->
<v-card class="activity-card" elevation="0" rounded="lg">
<v-tabs v-model="detailTab" class="detail-tabs" density="comfortable">
<v-tab value="comments">{{ t('activity.comments') }}</v-tab>
<v-tab value="rules">{{ t('activity.rules') }}</v-tab>
<v-tab value="holders">{{ t('activity.topHolders') }}</v-tab>
<v-tab value="activity">{{ t('activity.activity') }}</v-tab>
</v-tabs>
<v-window v-model="detailTab" class="detail-window">
<v-window-item value="comments" class="detail-pane">
<div class="placeholder-pane">{{ t('activity.noCommentsYet') }}</div>
<v-window-item value="rules" class="detail-pane">
<div class="rules-pane">
<div v-if="!eventDetail?.description && !eventDetail?.resolutionSource" class="placeholder-pane">
{{ t('activity.rulesEmpty') }}
</div>
<template v-else>
<div v-if="eventDetail?.description" class="rules-section">
<h3 class="rules-title">{{ t('activity.rulesDescription') }}</h3>
<div class="rules-text">{{ eventDetail.description }}</div>
</div>
<div v-if="eventDetail?.resolutionSource" class="rules-section">
<h3 class="rules-title">{{ t('activity.rulesSource') }}</h3>
<a
v-if="isResolutionSourceUrl"
:href="eventDetail.resolutionSource"
target="_blank"
rel="noopener noreferrer"
class="rules-link"
>
{{ eventDetail.resolutionSource }}
<v-icon size="14">mdi-open-in-new</v-icon>
</a>
<div v-else class="rules-text">{{ eventDetail.resolutionSource }}</div>
</div>
</template>
</div>
</v-window-item>
<v-window-item value="holders" class="detail-pane">
<div class="placeholder-pane">{{ t('activity.topHoldersPlaceholder') }}</div>
@ -129,7 +225,14 @@
<!-- 右侧交易组件固定宽度传入当前市场以便 Split 调用拆单接口移动端隐藏改用底部栏+弹窗 -->
<v-col v-if="!isMobile" cols="12" class="trade-col">
<div class="trade-sidebar">
<TradeComponent ref="tradeComponentRef" :market="tradeMarketPayload" :initial-option="tradeInitialOption" />
<TradeComponent
ref="tradeComponentRef"
:market="tradeMarketPayload"
:initial-option="tradeInitialOption"
:positions="tradePositionsForComponent"
@merge-success="onMergeSuccess"
@split-success="onSplitSuccess"
/>
</div>
</v-col>
@ -186,11 +289,34 @@
ref="mobileTradeComponentRef"
:market="tradeMarketPayload"
:initial-option="tradeInitialOptionFromBar"
:initial-tab="tradeInitialTabFromBar"
:positions="tradePositionsForComponent"
embedded-in-sheet
@order-success="onOrderSuccess"
@merge-success="onMergeSuccess"
@split-success="onSplitSuccess"
/>
</v-bottom-sheet>
</template>
<!-- 从持仓点击 Sell 弹出的交易组件桌面/移动端通用 -->
<v-dialog
v-model="sellDialogOpen"
max-width="420"
content-class="trade-detail-sell-dialog"
transition="dialog-transition"
>
<TradeComponent
v-if="sellDialogOpen"
:market="tradeMarketPayload"
:initial-option="sellInitialOption"
:initial-tab="'sell'"
:positions="tradePositionsForComponent"
@order-success="onSellOrderSuccess"
@merge-success="onMergeSuccess"
@split-success="onSplitSuccess"
/>
</v-dialog>
</v-row>
</v-container>
</template>
@ -203,7 +329,7 @@ import { useDisplay } from 'vuetify'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import OrderBook from '../components/OrderBook.vue'
import TradeComponent from '../components/TradeComponent.vue'
import TradeComponent, { type TradePositionItem } from '../components/TradeComponent.vue'
import {
findPmEvent,
getMarketId,
@ -214,6 +340,16 @@ import {
import { getClobWsUrl } from '../api/request'
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 {
getOrderList,
mapOrderToOpenOrderItem,
OrderStatus,
type OpenOrderDisplayItem,
} from '../api/order'
import { cancelOrder as apiCancelOrder } from '../api/order'
const { t } = useI18n()
import {
@ -251,6 +387,8 @@ export type ChartIncrement = { point: ChartPoint }
const route = useRoute()
const userStore = useUserStore()
const { formatAuthError } = useAuthError()
const localeStore = useLocaleStore()
const { mobile } = useDisplay()
const isMobile = computed(() => mobile.value)
@ -322,7 +460,7 @@ async function loadEventDetail() {
eventDetail.value = null
}
} catch (e) {
detailError.value = e instanceof Error ? e.message : t('error.loadFailed')
detailError.value = formatAuthError(e, t('error.loadFailed'))
eventDetail.value = null
} finally {
detailLoading.value = false
@ -347,6 +485,11 @@ const resolutionDate = computed(() => {
return s ? s.replace(/,?\s*\d{4}$/, '').trim() || 'Mar 31' : 'Mar 31'
})
const isResolutionSourceUrl = computed(() => {
const src = eventDetail.value?.resolutionSource
return typeof src === 'string' && (src.startsWith('http://') || src.startsWith('https://'))
})
/** 当前市场(用于交易组件与 Split 拆单query.marketId 匹配或取第一个 */
const currentMarket = computed(() => {
const list = eventDetail.value?.markets ?? []
@ -379,6 +522,19 @@ const orderBookAsksYes = computed(() => orderBookByToken.value[0]?.asks ?? [])
const orderBookBidsYes = computed(() => orderBookByToken.value[0]?.bids ?? [])
const orderBookAsksNo = computed(() => orderBookByToken.value[1]?.asks ?? [])
const orderBookBidsNo = computed(() => orderBookByToken.value[1]?.bids ?? [])
/** 订单簿 Yes 卖单最低价(分),无数据时为 0 */
const orderBookLowestAskYesCents = computed(() => {
const asks = orderBookAsksYes.value
if (!asks.length) return 0
return Math.min(...asks.map((a) => a.price))
})
/** 订单簿 No 卖单最低价(分),无数据时为 0 */
const orderBookLowestAskNoCents = computed(() => {
const asks = orderBookAsksNo.value
if (!asks.length) return 0
return Math.min(...asks.map((a) => a.price))
})
const clobLastPriceYes = computed(() => clobLastPriceByToken.value[0])
const clobLastPriceNo = computed(() => clobLastPriceByToken.value[1])
const clobSpreadYes = computed(() => clobSpreadByToken.value[0])
@ -519,14 +675,12 @@ function disconnectClob() {
clobLoading.value = false
}
/** 传给 TradeComponent 的 market供 Split 调用 /PmMarket/split接口未返回时用 query 兜底 */
/** 传给 TradeComponent 的 market供 Split 调用 /PmMarket/splityesPrice/noPrice 取订单簿卖单最低价,无数据时为 0 */
const tradeMarketPayload = computed(() => {
const m = currentMarket.value
const yesPrice = orderBookLowestAskYesCents.value / 100
const noPrice = orderBookLowestAskNoCents.value / 100
if (m) {
const yesRaw = m.outcomePrices?.[0]
const noRaw = m.outcomePrices?.[1]
const yesPrice = yesRaw != null && Number.isFinite(Number(yesRaw)) ? Number(yesRaw) : 0.5
const noPrice = noRaw != null && Number.isFinite(Number(noRaw)) ? Number(noRaw) : 0.5
return {
marketId: getMarketId(m),
yesPrice,
@ -538,9 +692,6 @@ const tradeMarketPayload = computed(() => {
}
const qId = route.query.marketId
if (qId != null && String(qId).trim() !== '') {
const chance = route.query.chance != null ? Number(route.query.chance) : NaN
const yesPrice = Number.isFinite(chance) ? Math.min(1, Math.max(0, chance / 100)) : 0.5
const noPrice = Number.isFinite(chance) ? 1 - yesPrice : 0.5
return {
marketId: String(qId).trim(),
yesPrice,
@ -559,8 +710,14 @@ const tradeInitialOption = computed(() => {
/** 移动端底部栏点击 Yes/No 时传给弹窗内 TradeComponent 的初始选项 */
const tradeInitialOptionFromBar = ref<'yes' | 'no' | undefined>(undefined)
/** 移动端弹窗初始 Tab从持仓 Sell 打开时为 'sell',从底部栏 Yes/No 打开时为 undefined默认 Buy */
const tradeInitialTabFromBar = ref<'buy' | 'sell' | undefined>(undefined)
/** 移动端交易弹窗开关 */
const tradeSheetOpen = ref(false)
/** 从持仓 Sell 打开的弹窗 */
const sellDialogOpen = ref(false)
/** 从持仓 Sell 时预选的 Yes/No */
const sellInitialOption = ref<'yes' | 'no'>('yes')
/** 移动端三点菜单开关 */
const mobileMenuOpen = ref(false)
/** 桌面端 TradeComponent 引用Merge/Split */
@ -579,6 +736,7 @@ const noLabel = computed(() => currentMarket.value?.outcomes?.[1] ?? 'No')
function openSheetWithOption(side: 'yes' | 'no') {
tradeInitialOptionFromBar.value = side
tradeInitialTabFromBar.value = undefined
tradeSheetOpen.value = true
}
@ -602,11 +760,179 @@ const toastStore = useToastStore()
function onOrderSuccess() {
tradeSheetOpen.value = false
toastStore.show(t('toast.orderSuccess'))
loadMarketPositions()
loadMarketOpenOrders()
}
/** 合并成功后刷新持仓(根据 tokenId 更新本地持仓数据) */
function onMergeSuccess() {
toastStore.show(t('toast.mergeSuccess'))
loadMarketPositions()
}
/** 拆分成功后刷新持仓 */
function onSplitSuccess() {
loadMarketPositions()
}
/** 从持仓项点击 Sell弹出交易组件并切到 Sell、对应 Yes/No。移动端直接开底部弹窗桌面端开 Dialog */
function openSellFromPosition(pos: PositionDisplayItem) {
const option = pos.outcomeWord === 'No' ? 'no' : 'yes'
if (isMobile.value) {
tradeInitialOptionFromBar.value = option
tradeInitialTabFromBar.value = 'sell'
tradeSheetOpen.value = true
} else {
sellInitialOption.value = option
sellDialogOpen.value = true
}
}
function onSellOrderSuccess() {
sellDialogOpen.value = false
onOrderSuccess()
}
// marketID
const currentMarketId = computed(() => getMarketId(currentMarket.value))
//
const marketPositions = ref<PositionDisplayItem[]>([])
const positionLoading = ref(false)
/** 过滤掉份额为 0 的持仓项 */
const marketPositionsFiltered = computed(() =>
marketPositions.value.filter((p) => {
const n = parseFloat(p.shares?.replace(/[^0-9.]/g, '') ?? '')
return Number.isFinite(n) && n > 0
}),
)
/** 转为 TradeComponent 所需的 TradePositionItem[],保证 outcomeWord 为 'Yes' | 'No'(仅含份额>0 */
const tradePositionsForComponent = computed<TradePositionItem[]>(() =>
marketPositionsFiltered.value.map((p) => ({
id: p.id,
outcomeWord: (p.outcomeWord === 'No' ? 'No' : 'Yes') as 'Yes' | 'No',
shares: p.shares,
sharesNum: parseFloat(p.shares?.replace(/[^0-9.]/g, '')) || undefined,
}))
)
async function loadMarketPositions() {
const marketID = currentMarketId.value
if (!marketID) {
marketPositions.value = []
return
}
const headers = userStore.getAuthHeaders()
if (!headers) {
marketPositions.value = []
return
}
const uid = userStore.user?.id ?? userStore.user?.ID
const userID = uid != null ? Number(uid) : undefined
if (!userID || !Number.isFinite(userID)) {
marketPositions.value = []
return
}
positionLoading.value = true
try {
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 {
marketPositions.value = []
}
} catch {
marketPositions.value = []
} finally {
positionLoading.value = false
}
}
//
const marketOpenOrders = ref<OpenOrderDisplayItem[]>([])
const openOrderLoading = ref(false)
async function loadMarketOpenOrders() {
const marketID = currentMarketId.value
if (!marketID) {
marketOpenOrders.value = []
return
}
const headers = userStore.getAuthHeaders()
if (!headers) {
marketOpenOrders.value = []
return
}
const uid = userStore.user?.id ?? userStore.user?.ID
const userID = uid != null ? Number(uid) : undefined
if (!userID || !Number.isFinite(userID)) {
marketOpenOrders.value = []
return
}
openOrderLoading.value = true
try {
const res = await getOrderList(
{ page: 1, pageSize: 50, marketID, userID, status: OrderStatus.Live },
{ headers },
)
if (res.code === 0 || res.code === 200) {
const list = res.data?.list ?? []
const liveOnly = list.filter((o) => (o.status ?? 1) === OrderStatus.Live)
marketOpenOrders.value = liveOnly.map(mapOrderToOpenOrderItem)
} else {
marketOpenOrders.value = []
}
} catch {
marketOpenOrders.value = []
} finally {
openOrderLoading.value = false
}
}
const cancelOrderLoading = ref(false)
async function cancelMarketOrder(ord: OpenOrderDisplayItem) {
const orderID = ord.orderID ?? 0
const tokenID = ord.tokenID ?? ''
const uid = userStore.user?.id ?? userStore.user?.ID
const userID = uid != null ? Number(uid) : 0
if (!Number.isFinite(userID) || userID <= 0 || !tokenID) return
const headers = userStore.getAuthHeaders()
if (!headers) return
cancelOrderLoading.value = true
try {
const res = await apiCancelOrder({ orderID, tokenID, userID }, { headers })
if (res.code === 0 || res.code === 200) {
marketOpenOrders.value = marketOpenOrders.value.filter((o) => o.id !== ord.id)
userStore.fetchUsdcBalance()
}
} finally {
cancelOrderLoading.value = false
}
}
// / tab簿
const positionsOrdersTab = ref<'positions' | 'orders'>('positions')
// / tab
watch(
[() => positionsOrdersTab.value, currentMarketId],
([tab]) => {
if (tab === 'positions') loadMarketPositions()
else if (tab === 'orders') loadMarketOpenOrders()
},
{ immediate: true },
)
// Comments / Top Holders / Activity
const detailTab = ref('activity')
const detailTab = ref('rules')
const activityMinAmount = ref<string>('0')
const minAmountOptions = computed(() => [
{ title: t('activity.any'), value: '0' },
{ title: '$1', value: '1' },
@ -996,6 +1322,23 @@ watch(
{ immediate: false },
)
//
watch(
() => localeStore.currentLocale,
() => {
loadEventDetail()
loadMarketPositions()
},
)
// position_update
const unsubscribePositionUpdate = userStore.onPositionUpdate((data) => {
const marketID = data.marketID ?? (data as Record<string, unknown>).market_id
if (marketID && String(marketID) === String(currentMarketId.value)) {
loadMarketPositions()
}
})
onMounted(() => {
loadEventDetail()
initChart()
@ -1004,6 +1347,7 @@ onMounted(() => {
})
onUnmounted(() => {
unsubscribePositionUpdate()
stopDynamicUpdate()
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
@ -1270,6 +1614,30 @@ onUnmounted(() => {
}
/* Order Book Card Styles扁平化 */
.positions-orders-card {
margin-top: 16px;
border: 1px solid #e5e7eb;
overflow: hidden;
box-shadow: none;
}
.positions-orders-tabs {
border-bottom: 1px solid #e5e7eb;
}
.positions-orders-tabs :deep(.v-tab) {
text-transform: none;
font-size: 14px;
}
.positions-orders-window {
overflow: visible;
}
.positions-orders-card .detail-pane {
padding: 16px;
}
.order-book-card {
margin-top: 16px;
padding: 0;
@ -1426,6 +1794,124 @@ onUnmounted(() => {
padding: 24px 0;
}
.rules-pane {
padding: 4px 0;
}
.rules-section {
margin-bottom: 16px;
}
.rules-section:last-child {
margin-bottom: 0;
}
.rules-title {
font-size: 13px;
font-weight: 600;
color: #6b7280;
margin: 0 0 8px 0;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.rules-text {
font-size: 14px;
color: #374151;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.rules-link {
font-size: 14px;
color: #2563eb;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.rules-link:hover {
text-decoration: underline;
}
/* 持仓 / 限价订单列表 */
.positions-list,
.orders-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.position-row-item,
.order-row-item {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.position-row-item:last-child,
.order-row-item:last-child {
border-bottom: none;
}
.position-row-main,
.order-row-main {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.position-sell-btn {
margin-left: auto;
}
.position-outcome-pill,
.order-side-pill {
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
}
.position-outcome-pill.pill-yes,
.order-side-pill.side-yes {
background: #dcfce7;
color: #166534;
}
.position-outcome-pill.pill-down,
.order-side-pill.side-no {
background: #fee2e2;
color: #991b1b;
}
.position-shares,
.position-value,
.order-price,
.order-filled,
.order-total {
font-size: 14px;
color: #374151;
}
.position-row-meta {
font-size: 13px;
color: #6b7280;
margin-top: 4px;
}
.order-row-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.order-row-actions {
flex-shrink: 0;
}
.activity-toolbar {
display: flex;
align-items: center;

View File

@ -35,6 +35,15 @@
>
{{ t('wallet.withdraw') }}
</v-btn>
<!-- <v-btn
variant="outlined"
color="grey"
class="action-btn"
prepend-icon="mdi-shield-check-outline"
@click="onAuthorizeClick"
>
{{ t('wallet.authorize') }}
</v-btn> -->
</div>
</v-card>
</v-col>
@ -145,7 +154,10 @@
<template v-if="activeTab === 'positions'">
<!-- 移动端可折叠列表 -->
<div v-if="mobile" class="positions-mobile-list">
<template v-if="filteredPositions.length === 0">
<template v-if="positionLoading">
<div class="empty-cell">{{ t('common.loading') }}</div>
</template>
<template v-else-if="filteredPositions.length === 0">
<div class="empty-cell">{{ t('wallet.noPositionsFound') }}</div>
</template>
<div
@ -247,7 +259,10 @@
</tr>
</thead>
<tbody>
<tr v-if="filteredPositions.length === 0">
<tr v-if="positionLoading">
<td colspan="6" class="empty-cell">{{ t('common.loading') }}</td>
</tr>
<tr v-else-if="filteredPositions.length === 0">
<td colspan="6" class="empty-cell">{{ t('wallet.noPositionsFound') }}</td>
</tr>
<tr v-for="pos in paginatedPositions" :key="pos.id" class="position-row">
@ -316,7 +331,10 @@
<template v-else-if="activeTab === 'orders'">
<!-- 移动端挂单卡片列表 -->
<div v-if="mobile" class="orders-mobile-list">
<template v-if="filteredOpenOrders.length === 0">
<template v-if="openOrderLoading">
<div class="empty-cell">{{ t('common.loading') }}</div>
</template>
<template v-else-if="filteredOpenOrders.length === 0">
<div class="empty-cell">{{ t('wallet.noOpenOrdersFound') }}</div>
</template>
<div v-for="ord in paginatedOpenOrders" :key="ord.id" class="order-mobile-card">
@ -365,7 +383,10 @@
</tr>
</thead>
<tbody>
<tr v-if="filteredOpenOrders.length === 0">
<tr v-if="openOrderLoading">
<td colspan="8" class="empty-cell">{{ t('common.loading') }}</td>
</tr>
<tr v-else-if="filteredOpenOrders.length === 0">
<td colspan="8" class="empty-cell">{{ t('wallet.noOpenOrdersFound') }}</td>
</tr>
<tr v-for="ord in paginatedOpenOrders" :key="ord.id">
@ -396,7 +417,10 @@
<template v-else-if="activeTab === 'history'">
<!-- 移动端历史卡片列表 -->
<div v-if="mobile" class="history-mobile-list">
<template v-if="filteredHistory.length === 0">
<template v-if="historyLoading">
<div class="empty-cell">{{ t('common.loading') }}</div>
</template>
<template v-else-if="filteredHistory.length === 0">
<div class="empty-cell">{{ t('wallet.noHistoryFound') }}</div>
</template>
<div
@ -462,7 +486,10 @@
</tr>
</thead>
<tbody>
<tr v-if="filteredHistory.length === 0">
<tr v-if="historyLoading">
<td colspan="3" class="empty-cell">{{ t('common.loading') }}</td>
</tr>
<tr v-else-if="filteredHistory.length === 0">
<td colspan="3" class="empty-cell">{{ t('wallet.noHistoryFound') }}</td>
</tr>
<tr v-for="h in paginatedHistory" :key="h.id">
@ -510,6 +537,33 @@
@success="onWithdrawSuccess"
/>
<!-- 授权弹窗 -->
<v-dialog
v-model="authorizeDialogOpen"
max-width="420"
persistent
transition="dialog-transition"
>
<v-card rounded="lg">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-shield-check-outline</v-icon>
{{ t('wallet.authorize') }}
</v-card-title>
<v-card-text>
{{ t('wallet.authorizeDesc') }}
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="authorizeDialogOpen = false">
{{ t('deposit.close') }}
</v-btn>
<v-btn color="primary" variant="flat" @click="submitAuthorize">
{{ t('wallet.authorize') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Sell position dialog -->
<v-dialog
v-model="sellDialogOpen"
@ -572,7 +626,11 @@ import type { ECharts } from 'echarts'
import DepositDialog from '../components/DepositDialog.vue'
import WithdrawDialog from '../components/WithdrawDialog.vue'
import { useUserStore } from '../stores/user'
import { pmCancelOrder } from '../api/market'
import { useLocaleStore } from '../stores/locale'
import { useAuthError } from '../composables/useAuthError'
import { cancelOrder as apiCancelOrder } from '../api/order'
import { getOrderList, mapOrderToHistoryItem, mapOrderToOpenOrderItem, OrderStatus } from '../api/order'
import { getPositionList, mapPositionToDisplayItem } from '../api/position'
import {
MOCK_TOKEN_ID,
MOCK_WALLET_POSITIONS,
@ -580,9 +638,12 @@ import {
MOCK_WALLET_HISTORY,
} from '../api/mockData'
import { USE_MOCK_WALLET } from '../config/mock'
import { CrossChainUSDTAuth } from '../../sdk/approve'
const { mobile } = useDisplay()
const userStore = useUserStore()
const { formatAuthError } = useAuthError()
const localeStore = useLocaleStore()
const portfolioBalance = computed(() => userStore.balance)
const profitLoss = ref('0.00')
const plRange = ref('ALL')
@ -596,6 +657,7 @@ const activeTab = ref<'positions' | 'orders' | 'history'>('positions')
const search = ref('')
const depositDialogOpen = ref(false)
const withdrawDialogOpen = ref(false)
const authorizeDialogOpen = ref(false)
const sellDialogOpen = ref(false)
const sellPositionItem = ref<Position | null>(null)
/** 移动端展开的持仓 idnull 表示全部折叠 */
@ -679,20 +741,164 @@ interface HistoryItem {
const positions = ref<Position[]>(
USE_MOCK_WALLET ? [...MOCK_WALLET_POSITIONS] : [],
)
/** 持仓列表API 数据,非 mock 时使用) */
const positionList = ref<Position[]>([])
const positionTotal = ref(0)
const positionLoading = ref(false)
async function loadPositionList() {
if (USE_MOCK_WALLET) return
const headers = userStore.getAuthHeaders()
if (!headers) {
positionList.value = []
positionTotal.value = 0
return
}
const uid = userStore.user?.id ?? userStore.user?.ID
const userID = uid != null ? Number(uid) : undefined
if (!userID || !Number.isFinite(userID)) {
positionList.value = []
positionTotal.value = 0
return
}
positionLoading.value = true
try {
const res = await getPositionList(
{ page: page.value, pageSize: itemsPerPage.value, userID },
{ headers },
)
if (res.code === 0 || res.code === 200) {
const list = res.data?.list ?? []
positionList.value = list.map(mapPositionToDisplayItem)
positionTotal.value = res.data?.total ?? 0
} else {
positionList.value = []
positionTotal.value = 0
}
} catch {
positionList.value = []
positionTotal.value = 0
} finally {
positionLoading.value = false
}
}
const openOrders = ref<OpenOrder[]>(
USE_MOCK_WALLET ? [...MOCK_WALLET_ORDERS] : [],
)
/** 未成交订单API 数据,非 mock 时使用) */
const openOrderList = ref<OpenOrder[]>([])
const openOrderTotal = ref(0)
const openOrderLoading = ref(false)
async function loadOpenOrders() {
if (USE_MOCK_WALLET) return
const headers = userStore.getAuthHeaders()
if (!headers) {
openOrderList.value = []
openOrderTotal.value = 0
return
}
const uid = userStore.user?.id ?? userStore.user?.ID
const userID = uid != null ? Number(uid) : undefined
if (!userID || !Number.isFinite(userID)) {
openOrderList.value = []
openOrderTotal.value = 0
return
}
openOrderLoading.value = true
try {
const res = await getOrderList(
{
page: page.value,
pageSize: itemsPerPage.value,
userID,
status: OrderStatus.Live,
},
{ headers },
)
if (res.code === 0 || res.code === 200) {
const list = res.data?.list ?? []
const openOnly = list.filter((o) => (o.status ?? 1) === OrderStatus.Live)
openOrderList.value = openOnly.map(mapOrderToOpenOrderItem)
openOrderTotal.value = openOnly.length
} else {
openOrderList.value = []
openOrderTotal.value = 0
}
} catch {
openOrderList.value = []
openOrderTotal.value = 0
} finally {
openOrderLoading.value = false
}
}
const history = ref<HistoryItem[]>(
USE_MOCK_WALLET ? [...MOCK_WALLET_HISTORY] : [],
)
/** 订单历史API 数据,非 mock 时使用) */
const historyList = ref<HistoryItem[]>([])
const historyTotal = ref(0)
const historyLoading = ref(false)
async function loadHistoryOrders() {
if (USE_MOCK_WALLET) return
const headers = userStore.getAuthHeaders()
if (!headers) {
historyList.value = []
historyTotal.value = 0
return
}
const uid = userStore.user?.id ?? userStore.user?.ID
const userID = uid != null ? Number(uid) : undefined
if (!userID || !Number.isFinite(userID)) {
historyList.value = []
historyTotal.value = 0
return
}
historyLoading.value = true
try {
const res = await getOrderList(
{
page: page.value,
pageSize: itemsPerPage.value,
userID,
},
{ headers },
)
if (res.code === 0 || res.code === 200) {
const list = res.data?.list ?? []
historyList.value = list.map(mapOrderToHistoryItem)
historyTotal.value = res.data?.total ?? 0
} else {
historyList.value = []
historyTotal.value = 0
}
} catch {
historyList.value = []
historyTotal.value = 0
} finally {
historyLoading.value = false
}
}
function matchSearch(text: string): boolean {
const q = search.value.trim().toLowerCase()
return !q || text.toLowerCase().includes(q)
}
const filteredPositions = computed(() => positions.value.filter((p) => matchSearch(p.market)))
const filteredOpenOrders = computed(() => openOrders.value.filter((o) => matchSearch(o.market)))
const filteredHistory = computed(() => history.value.filter((h) => matchSearch(h.market)))
const filteredPositions = computed(() => {
const list = USE_MOCK_WALLET ? positions.value : positionList.value
return list.filter((p) => matchSearch(p.market))
})
const filteredOpenOrders = computed(() => {
const list = USE_MOCK_WALLET ? openOrders.value : openOrderList.value
return list.filter((o) => matchSearch(o.market))
})
const filteredHistory = computed(() => {
const list = USE_MOCK_WALLET ? history.value : historyList.value
return list.filter((h) => matchSearch(h.market))
})
const page = ref(1)
const itemsPerPage = ref(10)
@ -702,24 +908,38 @@ function paginate<T>(list: T[]) {
const start = (page.value - 1) * itemsPerPage.value
return list.slice(start, start + itemsPerPage.value)
}
const paginatedPositions = computed(() => paginate(filteredPositions.value))
const paginatedOpenOrders = computed(() => paginate(filteredOpenOrders.value))
const paginatedHistory = computed(() => paginate(filteredHistory.value))
const paginatedPositions = computed(() => {
if (USE_MOCK_WALLET) return paginate(filteredPositions.value)
return filteredPositions.value
})
const paginatedOpenOrders = computed(() => {
if (USE_MOCK_WALLET) return paginate(filteredOpenOrders.value)
return filteredOpenOrders.value
})
const paginatedHistory = computed(() => {
if (USE_MOCK_WALLET) return paginate(filteredHistory.value)
return filteredHistory.value
})
const totalPagesPositions = computed(() =>
Math.max(1, Math.ceil(filteredPositions.value.length / itemsPerPage.value)),
)
const totalPagesOrders = computed(() =>
Math.max(1, Math.ceil(filteredOpenOrders.value.length / itemsPerPage.value)),
)
const totalPagesHistory = computed(() =>
Math.max(1, Math.ceil(filteredHistory.value.length / itemsPerPage.value)),
)
const totalPagesPositions = computed(() => {
const total = USE_MOCK_WALLET ? filteredPositions.value.length : positionTotal.value
return Math.max(1, Math.ceil(total / itemsPerPage.value))
})
const totalPagesOrders = computed(() => {
const total = USE_MOCK_WALLET ? filteredOpenOrders.value.length : openOrderTotal.value
return Math.max(1, Math.ceil(total / itemsPerPage.value))
})
const totalPagesHistory = computed(() => {
const total = USE_MOCK_WALLET ? filteredHistory.value.length : historyTotal.value
return Math.max(1, Math.ceil(total / itemsPerPage.value))
})
const currentListTotal = computed(() => {
if (activeTab.value === 'positions') return filteredPositions.value.length
if (activeTab.value === 'orders') return filteredOpenOrders.value.length
return filteredHistory.value.length
if (activeTab.value === 'positions')
return USE_MOCK_WALLET ? filteredPositions.value.length : positionTotal.value
if (activeTab.value === 'orders')
return USE_MOCK_WALLET ? filteredOpenOrders.value.length : openOrderTotal.value
return USE_MOCK_WALLET ? filteredHistory.value.length : historyTotal.value
})
const currentTotalPages = computed(() => {
if (activeTab.value === 'positions') return totalPagesPositions.value
@ -733,8 +953,16 @@ const currentPageEnd = computed(() =>
Math.min(page.value * itemsPerPage.value, currentListTotal.value),
)
watch(activeTab, () => {
watch(activeTab, (tab) => {
page.value = 1
if (tab === 'positions' && !USE_MOCK_WALLET) loadPositionList()
if (tab === 'orders' && !USE_MOCK_WALLET) loadOpenOrders()
if (tab === 'history' && !USE_MOCK_WALLET) loadHistoryOrders()
})
watch([page, itemsPerPage], () => {
if (activeTab.value === 'positions' && !USE_MOCK_WALLET) loadPositionList()
if (activeTab.value === 'orders' && !USE_MOCK_WALLET) loadOpenOrders()
if (activeTab.value === 'history' && !USE_MOCK_WALLET) loadHistoryOrders()
})
watch([currentListTotal, itemsPerPage], () => {
const maxPage = currentTotalPages.value
@ -754,29 +982,34 @@ async function cancelOrder(ord: OpenOrder) {
const uid = userStore.user?.id ?? userStore.user?.ID
const userID = uid != null ? Number(uid) : 0
if (!Number.isFinite(userID) || userID <= 0) {
cancelOrderError.value = '请先登录'
cancelOrderError.value = t('error.pleaseLogin')
showCancelError.value = true
return
}
const headers = userStore.getAuthHeaders()
if (!headers) {
cancelOrderError.value = '请先登录'
cancelOrderError.value = t('error.pleaseLogin')
showCancelError.value = true
return
}
cancelOrderLoading.value = true
cancelOrderError.value = ''
try {
const res = await pmCancelOrder({ orderID, tokenID, userID }, { headers })
const res = await apiCancelOrder({ orderID, tokenID, userID }, { headers })
if (res.code === 0 || res.code === 200) {
openOrders.value = openOrders.value.filter((o) => o.id !== ord.id)
if (USE_MOCK_WALLET) {
openOrders.value = openOrders.value.filter((o) => o.id !== ord.id)
} else {
openOrderList.value = openOrderList.value.filter((o) => o.id !== ord.id)
openOrderTotal.value = openOrderList.value.length
}
userStore.fetchUsdcBalance()
} else {
cancelOrderError.value = res.msg || '取消失败'
showCancelError.value = true
}
} catch (e) {
cancelOrderError.value = e instanceof Error ? e.message : 'Request failed'
cancelOrderError.value = formatAuthError(e, t('error.requestFailed'))
showCancelError.value = true
} finally {
cancelOrderLoading.value = false
@ -784,7 +1017,12 @@ async function cancelOrder(ord: OpenOrder) {
}
function cancelAllOrders() {
openOrders.value = []
if (USE_MOCK_WALLET) {
openOrders.value = []
} else {
openOrderList.value = []
openOrderTotal.value = 0
}
}
const sellReceiveAmount = computed(() => {
@ -950,7 +1188,17 @@ const handleResize = () => plChartInstance?.resize()
watch(plRange, () => updatePlChart())
//
watch(
() => localeStore.currentLocale,
() => {
loadPositionList()
loadOpenOrders()
},
)
onMounted(() => {
if (!USE_MOCK_WALLET && activeTab.value === 'positions') loadPositionList()
nextTick(() => {
initPlChart()
})
@ -966,6 +1214,16 @@ onUnmounted(() => {
function onWithdrawSuccess() {
withdrawDialogOpen.value = false
}
function onAuthorizeClick() {
authorizeDialogOpen.value = true
}
async function submitAuthorize() {
// TODO: USDC approve CLOB
// authorizeDialogOpen.value = false
await CrossChainUSDTAuth.authorizeUSDT('eth', '0x024b7270Ee9c0Fc0de2E00a979d146255E0e9C00', '100')
}
</script>
<style scoped>