新增:持仓数据对接

This commit is contained in:
ivan 2026-02-27 22:25:35 +08:00
parent 3e329c307e
commit ef4b0c8922
15 changed files with 905 additions and 41 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

@ -13,6 +13,20 @@
| 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。
## 核心能力
@ -22,6 +36,7 @@
- 余额不足时 Buy 显示 Deposit 按钮
- 25%/50%/Max 快捷份额
- 调用 market API 下单、Split、Merge
- **合并/拆分成功后触发事件**`mergeSuccess``splitSuccess`,父组件监听后可刷新持仓列表
## 使用方式
@ -29,10 +44,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

@ -11,15 +11,25 @@
- 分时图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` 事件刷新持仓
## 使用方式
- 从首页卡片点击进入,或直接访问 `/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

@ -1,4 +1,4 @@
import { get } from './request'
import { get, post } from './request'
/** 分页结果 */
export interface PageResult<T> {
@ -81,6 +81,31 @@ export async function getOrderList(
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

View File

@ -9,22 +9,37 @@ export interface PageResult<T> {
}
/**
* doc.json definitions["model.ClobPosition"]
* GET /clob/position/getPositionList
* /clob/position/getPositionList
* sizeavailablecost 6 1000000
*/
export interface ClobPositionItem {
ID?: number
available?: number
createdAt?: string
updatedAt?: string
marketID?: string
side?: number
size?: number
tokenId?: string
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
@ -84,37 +99,33 @@ export interface PositionDisplayItem {
outcomePillClass?: string
}
/** Side: Buy=1, Sell=2 */
const Side = { Buy: 1, Sell: 2 } as const
/**
* ClobPositionItem Position
* model.ClobPosition sizeavailablemarketIDside
* sizeavailablecost 6
*/
export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplayItem {
const id = String(pos.ID ?? '')
const market = pos.marketID ?? ''
const size = pos.size ?? pos.available ?? 0
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 sideNum = pos.side ?? Side.Buy
const outcomeWord = sideNum === Side.Sell ? 'No' : 'Yes'
const pillClass = sideNum === Side.Sell ? 'pill-down' : 'pill-yes'
const sellOutcome = outcomeWord
const valueUsd = size
const value = `$${valueUsd.toFixed(2)}`
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 = `$${valueUsd.toFixed(2)}`
const avgNow = '—'
const toWin = `$${size.toFixed(2)}`
const outcomeTag = `${outcomeWord}`
return {
id,
market,
shares,
avgNow,
avgNow: '—',
bet,
toWin,
value,
sellOutcome,
sellOutcome: outcomeWord,
outcomeWord,
outcomeTag,
outcomePillClass: pillClass,

View File

@ -1295,12 +1295,14 @@ import { ref, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useUserStore } from '../stores/user'
import { useToastStore } from '../stores/toast'
import { pmMarketMerge, pmMarketSplit, pmOrderPlace } from '../api/market'
import { OrderType, Side } from '../api/constants'
const { mobile } = useDisplay()
const { t } = useI18n()
const userStore = useUserStore()
const toastStore = useToastStore()
/** 限价单允许的 135 个价格档位01 区间规则19/1090/1009900/99109990/99919999 */
function buildAllowedLimitPrices(): number[] {
@ -1349,14 +1351,27 @@ export interface TradeMarketPayload {
outcomes?: string[]
}
/** 持仓展示项(由父组件传入,用于计算可合并份额) */
export interface TradePositionItem {
id: string
/** 方向Yes | No */
outcomeWord: 'Yes' | 'No'
/** 份额数(如 "5 shares" */
shares: string
/** 份数数值(纯数字) */
sharesNum?: number
}
const props = withDefaults(
defineProps<{
initialOption?: 'yes' | 'no'
embeddedInSheet?: boolean
/** 从外部传入的市场数据(如 EventMarkets 点击 Yes/No 传入yesPrice/noPrice 为 01 */
market?: TradeMarketPayload
/** 当前市场持仓列表,用于计算可合并份额 */
positions?: TradePositionItem[]
}>(),
{ initialOption: undefined, embeddedInSheet: false, market: undefined },
{ initialOption: undefined, embeddedInSheet: false, market: undefined, positions: () => [] },
)
//
@ -1365,9 +1380,23 @@ const sheetOpen = ref(false)
// Merge shares dialog /PmMarket/merge
const mergeDialogOpen = ref(false)
const mergeAmount = ref(0)
const availableMergeShares = ref(0)
const mergeLoading = ref(false)
const mergeError = ref('')
/** 计算可合并份额Yes 和 No 持仓的最小值(成对数量) */
const availableMergeShares = computed(() => {
const positions = props.positions ?? []
let yesShares = 0
let noShares = 0
for (const pos of positions) {
const num = pos.sharesNum ?? parseFloat(pos.shares?.replace(/[^0-9.]/g, ''))
if (!Number.isFinite(num) || num <= 0) continue
if (pos.outcomeWord === 'Yes') yesShares += num
else if (pos.outcomeWord === 'No') noShares += num
}
return Math.floor(Math.min(yesShares, noShares))
})
function openMergeDialog() {
mergeAmount.value = 0
mergeError.value = ''
@ -1390,6 +1419,7 @@ async function submitMerge() {
if (res.code === 0 || res.code === 200) {
mergeDialogOpen.value = false
userStore.fetchUsdcBalance()
emit('mergeSuccess')
} else {
mergeError.value = res.msg || 'Merge failed'
}
@ -1423,6 +1453,8 @@ async function submitSplit() {
)
if (res.code === 0 || res.code === 200) {
splitDialogOpen.value = false
toastStore.show(t('toast.splitSuccess'))
emit('splitSuccess')
} else {
splitError.value = res.msg || 'Split failed'
}
@ -1475,6 +1507,8 @@ const orderError = ref('')
const emit = defineEmits<{
optionChange: [option: 'yes' | 'no']
orderSuccess: []
mergeSuccess: []
splitSuccess: []
submit: [
payload: {
side: 'buy' | 'sell'

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",
@ -88,6 +90,16 @@
"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",

View File

@ -15,7 +15,9 @@
"chance": "確率"
},
"toast": {
"orderSuccess": "注文が完了しました"
"orderSuccess": "注文が完了しました",
"splitSuccess": "スプリット成功",
"mergeSuccess": "マージ成功"
},
"trade": {
"buy": "買う",
@ -88,6 +90,16 @@
"comments": "コメント",
"topHolders": "持倉トップ",
"activity": "アクティビティ",
"rules": "ルール",
"rulesDescription": "説明",
"rulesSource": "決裁ソース",
"rulesEmpty": "ルールはありません",
"mine": "マイ",
"myPositions": "ポジション",
"openOrders": "注文",
"noPositionsInMarket": "この市場にポジションはありません",
"noOpenOrdersInMarket": "この市場に未約定注文はありません",
"cancelOrder": "キャンセル",
"noCommentsYet": "コメントはまだありません",
"topHoldersPlaceholder": "持倉トップがここに表示されます",
"minAmount": "最小金額",

View File

@ -15,7 +15,9 @@
"chance": "확률"
},
"toast": {
"orderSuccess": "주문이 완료되었습니다"
"orderSuccess": "주문이 완료되었습니다",
"splitSuccess": "분할 완료",
"mergeSuccess": "병합 완료"
},
"trade": {
"buy": "매수",
@ -88,6 +90,16 @@
"comments": "댓글",
"topHolders": "보유자 순위",
"activity": "활동",
"rules": "규칙",
"rulesDescription": "설명",
"rulesSource": "결정 출처",
"rulesEmpty": "규칙이 없습니다",
"mine": "내 것",
"myPositions": "포지션",
"openOrders": "주문",
"noPositionsInMarket": "이 시장에 포지션이 없습니다",
"noOpenOrdersInMarket": "이 시장에 미체결 주문이 없습니다",
"cancelOrder": "취소",
"noCommentsYet": "아직 댓글이 없습니다",
"topHoldersPlaceholder": "보유자 순위가 여기에 표시됩니다",
"minAmount": "최소 금액",

View File

@ -15,7 +15,9 @@
"chance": "概率"
},
"toast": {
"orderSuccess": "下单成功"
"orderSuccess": "下单成功",
"splitSuccess": "拆分成功",
"mergeSuccess": "合并成功"
},
"trade": {
"buy": "买入",
@ -88,6 +90,16 @@
"comments": "评论",
"topHolders": "持仓大户",
"activity": "动态",
"rules": "规则",
"rulesDescription": "描述",
"rulesSource": "裁决来源",
"rulesEmpty": "暂无规则说明",
"mine": "我的",
"myPositions": "持仓",
"openOrders": "限价",
"noPositionsInMarket": "本市场暂无持仓",
"noOpenOrdersInMarket": "本市场暂无未成交订单",
"cancelOrder": "撤单",
"noCommentsYet": "暂无评论",
"topHoldersPlaceholder": "持仓大户将在此显示",
"minAmount": "最小金额",

View File

@ -15,7 +15,9 @@
"chance": "機率"
},
"toast": {
"orderSuccess": "下單成功"
"orderSuccess": "下單成功",
"splitSuccess": "拆分成功",
"mergeSuccess": "合併成功"
},
"trade": {
"buy": "買入",
@ -88,6 +90,16 @@
"comments": "評論",
"topHolders": "持倉大戶",
"activity": "動態",
"rules": "規則",
"rulesDescription": "描述",
"rulesSource": "裁決來源",
"rulesEmpty": "暫無規則說明",
"mine": "我的",
"myPositions": "持倉",
"openOrders": "限價",
"noPositionsInMarket": "本市場暫無持倉",
"noOpenOrdersInMarket": "本市場暫無未成交訂單",
"cancelOrder": "撤單",
"noCommentsYet": "暫無評論",
"topHoldersPlaceholder": "持倉大戶將在此顯示",
"minAmount": "最小金額",

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">
@ -287,6 +316,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' },
@ -899,7 +933,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

@ -44,6 +44,69 @@
</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="marketPositions.length === 0" class="placeholder-pane">
{{ t('activity.noPositionsInMarket') }}
</div>
<div v-else class="positions-list">
<div
v-for="pos in marketPositions"
: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>
</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 +128,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 +216,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="marketPositions"
@merge-success="onMergeSuccess"
@split-success="onSplitSuccess"
/>
</div>
</v-col>
@ -186,8 +280,11 @@
ref="mobileTradeComponentRef"
:market="tradeMarketPayload"
:initial-option="tradeInitialOptionFromBar"
:positions="marketPositions"
embedded-in-sheet
@order-success="onOrderSuccess"
@merge-success="onMergeSuccess"
@split-success="onSplitSuccess"
/>
</v-bottom-sheet>
</template>
@ -214,6 +311,14 @@ import {
import { getClobWsUrl } from '../api/request'
import { useUserStore } from '../stores/user'
import { useToastStore } from '../stores/toast'
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 {
@ -347,6 +452,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 ?? []
@ -602,11 +712,143 @@ 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()
}
// marketID
const currentMarketId = computed(() => getMarketId(currentMarket.value))
//
const marketPositions = ref<PositionDisplayItem[]>([])
const positionLoading = ref(false)
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' },
@ -1270,6 +1512,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 +1692,120 @@ 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-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

@ -626,7 +626,7 @@ 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 { cancelOrder as apiCancelOrder } from '../api/order'
import { getOrderList, mapOrderToHistoryItem, mapOrderToOpenOrderItem, OrderStatus } from '../api/order'
import { getPositionList, mapPositionToDisplayItem } from '../api/position'
import {
@ -991,7 +991,7 @@ async function cancelOrder(ord: OpenOrder) {
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) {
if (USE_MOCK_WALLET) {
openOrders.value = openOrders.value.filter((o) => o.id !== ord.id)