优化:修复持仓显示bug

This commit is contained in:
ivan 2026-03-02 11:47:34 +08:00
parent ff7bc3b685
commit 45a4b2c00f
20 changed files with 586 additions and 468 deletions

View File

@ -21,7 +21,7 @@ Event预测市场事件相关接口与类型定义对接 XTrader API
| `PmEventMarketItem` | 市场项,含 outcomes、outcomePrices、clobTokenIds | | `PmEventMarketItem` | 市场项,含 outcomes、outcomePrices、clobTokenIds |
| `EventCardItem` | 首页卡片所需结构,含 displayTypesingle/multi | | `EventCardItem` | 首页卡片所需结构,含 displayTypesingle/multi |
| `EventCardOutcome` | 多选项卡片中单个选项 | | `EventCardOutcome` | 多选项卡片中单个选项 |
| `PageResult<T>` | 分页结果 | | `PageResult<T>` | 分页结果(来自 `@/api/types` |
## 使用方式 ## 使用方式
@ -48,7 +48,7 @@ clearEventListCache()
1. **新增筛选参数**:在 `GetPmEventListParams` 中增加字段,并在 `getPmEventPublic` 的 query 中传入 1. **新增筛选参数**:在 `GetPmEventListParams` 中增加字段,并在 `getPmEventPublic` 的 query 中传入
2. **缓存策略**:可改为 sessionStorage 或带 TTL 的缓存 2. **缓存策略**:可改为 sessionStorage 或带 TTL 的缓存
3. **多选项展示**`mapEventItemToCard` 已支持 multi 类型,可扩展 `EventCardOutcome` 字段 3. **多选项展示**`mapEventItemToCard` 已支持 multi 类型,内部拆分为 `formatVolume``formatExpiresAt``parseOutcomePrices``mapMarketToOutcome` 等小函数,可扩展 `EventCardOutcome` 字段
## 参数传递方式 ## 参数传递方式

View File

@ -4,7 +4,7 @@
## 功能用途 ## 功能用途
交易与市场相关接口下单、取消订单、SplitUSDC 换 Yes+No、MergeYes+No 换 USDC。对接 CLOB Gateway 与 PmMarket 接口。 交易与市场相关接口下单、取消订单、SplitUSDC 换 Yes+No、MergeYes+No 换 USDC。对接 CLOB Gateway 与 PmMarket 接口。`ApiResponse` 来自 `@/api/types`
## 核心能力 ## 核心能力

View File

@ -4,7 +4,7 @@
## 功能用途 ## 功能用途
订单相关 API获取订单列表、取消订单以及将 `ClobOrderItem` 映射为展示项History、Open Orders 订单相关 API获取订单列表、取消订单以及将 `ClobOrderItem` 映射为展示项History、Open Orders`PageResult``ApiResponse` 来自 `@/api/types`,通过 `buildQuery` 构建请求参数。
## 使用方式 ## 使用方式

18
docs/api/position.md Normal file
View File

@ -0,0 +1,18 @@
# position.ts
**路径**`src/api/position.ts`
## 功能用途
持仓相关 API分页获取持仓列表以及将 `ClobPositionItem` 映射为钱包展示项。`PageResult` 来自 `@/api/types`,使用 `buildQuery` 构建请求参数。
## 核心能力
- `getPositionList`:分页获取持仓列表(需鉴权)
- `mapPositionToDisplayItem`:将接口项转为展示结构(含 locked、availableSharesNum、outcome 等);`outcome` 保留 API 原始值(如 "Up"/"Down"、"Yes"/"No"),供 TradeDetail 与市场 outcomes 匹配
## 使用方式
```typescript
import { getPositionList, mapPositionToDisplayItem } from '@/api/position'
```

View File

@ -4,31 +4,32 @@
## 功能用途 ## 功能用途
HTTP 请求基础封装,提供 `get``post` 方法,支持自定义请求头(如鉴权 `x-token``x-user-id`。所有 API 模块均通过此文件发起请求。 HTTP 请求基础封装,提供 `get``post``buildQuery` 方法,以及 WebSocket URL 生成。所有 API 模块均通过此文件发起请求。
## 核心能力 ## 核心能力
- 统一 BASE_URL默认 `https://api.xtrader.vip`,可通过环境变量 `VITE_API_BASE_URL` 覆盖 - 统一 BASE_URL默认 `https://api.xtrader.vip`,可通过环境变量 `VITE_API_BASE_URL` 覆盖
- CLOB WebSocket URL`getClobWsUrl()` 返回与 REST API 同源的 `ws(s)://host/clob/ws` - **buildQuery**过滤空值undefined、''、[]、NaN后构建 query 对象,供 GET 请求使用
- CLOB WebSocket URL`getClobWsUrl()` 返回 `ws(s)://host/clob/ws`
- User WebSocket URL`getUserWsUrl()` 返回 `ws(s)://host/clob/ws/user`(订单/持仓/余额推送) - User WebSocket URL`getUserWsUrl()` 返回 `ws(s)://host/clob/ws/user`(订单/持仓/余额推送)
- GET 请求:支持 query 参数,自动序列化 - GET 请求:支持 query 参数,自动序列化
- POST 请求:支持 JSON body - POST 请求:支持 JSON body
- 自定义 headers通过 `RequestConfig.headers` 传入 - 自定义 headers通过 `RequestConfig.headers` 传入
- **Accept-Language**:所有 GET/POST 请求自动附带标准 `Accept-Language` 头,值为当前 vue-i18n 的 `locale`(如 `zh-CN``en`),可在 `config.headers` 中覆盖 - **Accept-Language**:所有 GET/POST 请求自动附带当前 vue-i18n 的 `locale`
## 使用方式 ## 使用方式
```typescript ```typescript
import { get, post, getClobWsUrl, getUserWsUrl } from '@/api/request' import { get, post, buildQuery, getClobWsUrl, getUserWsUrl } from '@/api/request'
// CLOB WebSocket URL与 REST 同源) // 构建 query自动过滤空值
const query = buildQuery({ page: 1, pageSize: 10, keyword, tagIds })
const data = await get<MyResponse>('/path', query)
// CLOB / User WebSocket URL
const clobWsUrl = getClobWsUrl() const clobWsUrl = getClobWsUrl()
// User WebSocket URL订单/持仓/余额推送,需带 token 参数)
const userWsUrl = getUserWsUrl() const userWsUrl = getUserWsUrl()
// GET 请求
const data = await get<MyResponse>('/path', { page: 1, keyword: 'x' })
// 带鉴权 // 带鉴权
const data = await get<MyResponse>('/path', undefined, { const data = await get<MyResponse>('/path', undefined, {
headers: { 'x-token': token, 'x-user-id': userId }, headers: { 'x-token': token, 'x-user-id': userId },

53
docs/api/types.md Normal file
View File

@ -0,0 +1,53 @@
# types.ts
**路径**`src/api/types.ts`
## 功能用途
公共 API 类型定义,供 event、order、position、market 等模块复用,避免重复声明。
## 核心类型
### PageResult\<T\>
分页结果通用结构:
```typescript
interface PageResult<T> {
list: T[]
page: number
pageSize: number
total: number
}
```
### ApiResponse\<T\>
通用 API 响应:
```typescript
interface ApiResponse<T = unknown> {
code: number
data?: T
msg: string
}
```
## 使用方式
```typescript
import type { PageResult, ApiResponse } from '@/api/types'
// 在接口模块中导出供外部使用
export type { PageResult }
export interface MyListResponse {
code: number
data: PageResult<MyItem>
msg: string
}
```
## 扩展方式
- 新增通用类型时在此文件添加
- 各 API 模块通过 `export type { Xxx }` 重新导出,保持向后兼容

38
docs/components/Footer.md Normal file
View File

@ -0,0 +1,38 @@
# Footer.vue
**路径**`src/components/Footer.vue`
## 功能用途
首页底部 Footer 组件,展示 Polymarket 品牌、支持/社交链接、Polymarket 链接、法律声明、语言选择与社交图标。
## 核心能力
- 品牌 Logo 与 Slogan
- 两列链接Support & Social、Polymarket
- 底部法律链接、语言选择v-select、社交图标
- 免责声明文案
- 响应式布局(移动端垂直排列)
## 使用方式
```vue
<template>
<div class="home-page">
<!-- 主内容 -->
<Footer />
</div>
</template>
<script setup lang="ts">
import Footer from '@/components/Footer.vue'
</script>
```
父容器需设置 `display: flex; flex-direction: column; min-height: 100vh`Footer 通过 `margin-top: auto` 贴底。
## 扩展方式
1. **多语言**:将 `footerLang``useLocaleStore` 联动
2. **链接配置**:抽成 props 或常量数组,便于维护
3. **社交图标**:可改为 props 传入,支持外部配置

View File

@ -14,7 +14,8 @@
- `setUser`:设置登录数据并持久化,登录成功后自动连接 UserSocket - `setUser`:设置登录数据并持久化,登录成功后自动连接 UserSocket
- `logout`:清空并断开 UserSocket - `logout`:清空并断开 UserSocket
- `getAuthHeaders`:返回 `{ 'x-token', 'x-user-id' }`,未登录时返回 `undefined` - `getAuthHeaders`:返回 `{ 'x-token', 'x-user-id' }`,未登录时返回 `undefined`
- `fetchUserInfo``fetchUsdcBalance`:拉取并更新用户信息与余额;`fetchUserInfo` 兼容多种 API 字段名 - `fetchUserInfo``fetchUsdcBalance`:拉取并更新用户信息与余额;`fetchUserInfo` 兼容多种 API 字段名id/ID、userName/username 等)
- 内部 `parseUserId`:从 API 返回的 user 对象解析 id/ID兼容 number 与 string`setUser``fetchUserInfo` 复用
- `connectUserSocket``disconnectUserSocket`:连接/断开 `sdk/userSocket` 的 UserSdk用于订单/持仓/余额实时推送 - `connectUserSocket``disconnectUserSocket`:连接/断开 `sdk/userSocket` 的 UserSdk用于订单/持仓/余额实时推送
## 使用方式 ## 使用方式

View File

@ -4,7 +4,7 @@
## 功能用途 ## 功能用途
首页,展示分类导航栏(三层级)、事件卡片列表。支持分类筛选、搜索、下拉刷新、触底加载更多。 首页,展示分类导航栏(三层级)、事件卡片列表。支持分类筛选、搜索、下拉刷新、触底加载更多。底部 Footer 已抽成独立组件 `Footer.vue`
## 核心能力 ## 核心能力
@ -12,6 +12,7 @@
- **事件列表**:卡片式展示,支持下拉刷新、触底加载 - **事件列表**:卡片式展示,支持下拉刷新、触底加载
- **搜索**:可按关键词搜索事件 - **搜索**:可按关键词搜索事件
- **分类筛选**:选中分类后,自动提取所有层级节点的 `tagIds` 进行事件筛选 - **分类筛选**:选中分类后,自动提取所有层级节点的 `tagIds` 进行事件筛选
- **Footer**:使用 `<Footer />` 组件,包含品牌、链接、语言选择、免责声明
## 数据流 ## 数据流

View File

@ -31,7 +31,7 @@
- **合并成功**`TradeComponent` 触发 `mergeSuccess``onMergeSuccess()` 刷新持仓,显示 toast 提示 - **合并成功**`TradeComponent` 触发 `mergeSuccess``onMergeSuccess()` 刷新持仓,显示 toast 提示
- **拆分成功**`TradeComponent` 触发 `splitSuccess``onSplitSuccess()` 刷新持仓 - **拆分成功**`TradeComponent` 触发 `splitSuccess``onSplitSuccess()` 刷新持仓
持仓刷新调用 `loadMarketPositions()`,通过 `/clob/position/getPositionList` 接口获取最新持仓数据,并根据 `tokenId` 匹配 Yes/No 方向 持仓刷新调用 `loadMarketPositions()`,通过 `/clob/position/getPositionList` 接口获取最新持仓数据。**持仓类型用 API 返回的 `outcome` 字段匹配**(如 "Up"/"Down"、"Yes"/"No"),与当前市场的 `outcomes[0]``outcomes[1]` 对应,用于 Sell 选项与 TradeComponent 的 positions 映射
## 扩展方式 ## 扩展方式

View File

@ -1,12 +1,7 @@
import { get } from './request' import { buildQuery, get } from './request'
import type { PageResult } from './types'
/** 分页结果 */ export type { PageResult }
export interface PageResult<T> {
list: T[]
page: number
pageSize: number
total: number
}
/** /**
* Event doc.json definitions["polymarket.PmEvent"] * Event doc.json definitions["polymarket.PmEvent"]
@ -137,15 +132,7 @@ export async function getPmEventPublic(
params: GetPmEventListParams = {}, params: GetPmEventListParams = {},
): Promise<PmEventListResponse> { ): Promise<PmEventListResponse> {
const { page = 1, pageSize = 10, keyword, createdAtRange, tagIds } = params const { page = 1, pageSize = 10, keyword, createdAtRange, tagIds } = params
const query: Record<string, string | number | number[] | string[] | undefined> = { const query = buildQuery({ page, pageSize, keyword, createdAtRange, tagIds })
page,
pageSize,
}
if (keyword != null && keyword !== '') query.keyword = keyword
if (createdAtRange != null && createdAtRange.length) query.createdAtRange = createdAtRange
if (tagIds != null && tagIds.length > 0) {
query.tagIds = tagIds
}
return get<PmEventListResponse>('/PmEvent/getPmEventPublic', query) return get<PmEventListResponse>('/PmEvent/getPmEventPublic', query)
} }
@ -186,9 +173,7 @@ export async function findPmEvent(
params: FindPmEventParams, params: FindPmEventParams,
config?: { headers?: Record<string, string> }, config?: { headers?: Record<string, string> },
): Promise<PmEventDetailResponse> { ): Promise<PmEventDetailResponse> {
const query: Record<string, string | number> = {} const query = buildQuery({ ID: params.id, slug: params.slug })
if (params.id != null) query.ID = params.id
if (params.slug != null && params.slug !== '') query.slug = params.slug
if (Object.keys(query).length === 0) { if (Object.keys(query).length === 0) {
throw new Error('findPmEvent: 至少需要传 id 或 slug') throw new Error('findPmEvent: 至少需要传 id 或 slug')
} }
@ -276,53 +261,23 @@ function marketChance(market: PmEventMarketItem): number {
return Math.min(100, Math.max(0, Math.round(yesPrice * 100))) return Math.min(100, Math.max(0, Math.round(yesPrice * 100)))
} }
/** function formatVolume(volume: number | undefined): string {
* list MarketCard if (volume == null || !Number.isFinite(volume)) return '$0 Vol.'
* - market marketdisplayType single return volume >= 1000 ? `$${(volume / 1000).toFixed(1)}k Vol.` : `$${Math.round(volume)} Vol.`
* - marketsdisplayType multioutcomes +
*/
export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
const id = String(item.ID ?? '')
const marketTitle = item.title ?? ''
const imageUrl = item.image ?? item.icon ?? ''
const markets = item.markets ?? []
const multi = markets.length > 1
let chanceValue = 17
const firstMarket = markets[0]
if (firstMarket?.outcomePrices?.[0] != null) {
chanceValue = marketChance(firstMarket)
} }
let marketInfo = '$0 Vol.' function formatExpiresAt(endDate: string | undefined): string {
if (item.volume != null && Number.isFinite(item.volume)) { if (!endDate) return ''
const v = item.volume
if (v >= 1000) {
marketInfo = `$${(v / 1000).toFixed(1)}k Vol.`
} else {
marketInfo = `$${Math.round(v)} Vol.`
}
}
let expiresAt = ''
if (item.endDate) {
try { try {
const d = new Date(item.endDate) const d = new Date(endDate)
if (!Number.isNaN(d.getTime())) { return !Number.isNaN(d.getTime())
expiresAt = d.toLocaleDateString('en-US', { ? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
month: 'short', : endDate
day: 'numeric',
year: 'numeric',
})
}
} catch { } catch {
expiresAt = item.endDate return endDate
} }
} }
const category = item.series?.[0]?.title ?? item.tags?.[0]?.label ?? ''
function parseOutcomePrices(m: PmEventMarketItem): { yesPrice: number; noPrice: number } { function parseOutcomePrices(m: PmEventMarketItem): { yesPrice: number; noPrice: number } {
const y = m?.outcomePrices?.[0] const y = m?.outcomePrices?.[0]
const n = m?.outcomePrices?.[1] const n = m?.outcomePrices?.[1]
@ -337,8 +292,7 @@ export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
return { yesPrice, noPrice } return { yesPrice, noPrice }
} }
const outcomes: EventCardOutcome[] | undefined = multi function mapMarketToOutcome(m: PmEventMarketItem): EventCardOutcome {
? markets.map((m) => {
const { yesPrice, noPrice } = parseOutcomePrices(m) const { yesPrice, noPrice } = parseOutcomePrices(m)
return { return {
title: m.question ?? '', title: m.question ?? '',
@ -350,22 +304,30 @@ export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
marketId: getMarketId(m), marketId: getMarketId(m),
clobTokenIds: m.clobTokenIds, clobTokenIds: m.clobTokenIds,
} }
}) }
: undefined
/**
* list MarketCard
* - market marketdisplayType single
* - marketsdisplayType multioutcomes +
*/
export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
const markets = item.markets ?? []
const multi = markets.length > 1
const firstMarket = markets[0]
const firstPrices = firstMarket ? parseOutcomePrices(firstMarket) : { yesPrice: 0.5, noPrice: 0.5 } const firstPrices = firstMarket ? parseOutcomePrices(firstMarket) : { yesPrice: 0.5, noPrice: 0.5 }
return { return {
id, id: String(item.ID ?? ''),
slug: item.slug ?? undefined, slug: item.slug ?? undefined,
marketTitle, marketTitle: item.title ?? '',
chanceValue, chanceValue: firstMarket ? marketChance(firstMarket) : 17,
marketInfo, marketInfo: formatVolume(item.volume),
imageUrl, imageUrl: item.image ?? item.icon ?? '',
category, category: item.series?.[0]?.title ?? item.tags?.[0]?.label ?? '',
expiresAt, expiresAt: formatExpiresAt(item.endDate),
displayType: multi ? 'multi' : 'single', displayType: multi ? 'multi' : 'single',
outcomes, outcomes: multi ? markets.map(mapMarketToOutcome) : undefined,
yesLabel: firstMarket?.outcomes?.[0] ?? 'Yes', yesLabel: firstMarket?.outcomes?.[0] ?? 'Yes',
noLabel: firstMarket?.outcomes?.[1] ?? 'No', noLabel: firstMarket?.outcomes?.[1] ?? 'No',
isNew: item.new === true, isNew: item.new === true,

View File

@ -1,11 +1,7 @@
import { post } from './request' import { post } from './request'
import type { ApiResponse } from './types'
/** 通用响应:与 doc.json response.Response 一致 */ export type { ApiResponse }
export interface ApiResponse<T = unknown> {
code: number
data?: T
msg: string
}
/** /**
* /clob/gateway/submitOrder * /clob/gateway/submitOrder

View File

@ -1,12 +1,7 @@
import { get, post } from './request' import { buildQuery, get, post } from './request'
import type { ApiResponse, PageResult } from './types'
/** 分页结果 */ export type { PageResult }
export interface PageResult<T> {
list: T[]
page: number
pageSize: number
total: number
}
/** /**
* doc.json definitions["model.ClobOrder"] * doc.json definitions["model.ClobOrder"]
@ -71,13 +66,16 @@ export async function getOrderList(
): Promise<OrderListResponse> { ): Promise<OrderListResponse> {
const { page = 1, pageSize = 10, status, startCreatedAt, endCreatedAt, marketID, tokenID, userID } = const { page = 1, pageSize = 10, status, startCreatedAt, endCreatedAt, marketID, tokenID, userID } =
params params
const query: Record<string, string | number | undefined> = { page, pageSize } const query = buildQuery({
if (status != null && Number.isFinite(status)) query.status = status page,
if (startCreatedAt != null && startCreatedAt !== '') query.startCreatedAt = startCreatedAt pageSize,
if (endCreatedAt != null && endCreatedAt !== '') query.endCreatedAt = endCreatedAt status,
if (marketID != null && marketID !== '') query.marketID = marketID startCreatedAt,
if (tokenID != null && tokenID !== '') query.tokenID = tokenID endCreatedAt,
if (userID != null && Number.isFinite(userID)) query.userID = userID marketID,
tokenID,
userID,
})
return get<OrderListResponse>('/clob/order/getOrderList', query, config) return get<OrderListResponse>('/clob/order/getOrderList', query, config)
} }
@ -88,12 +86,7 @@ export interface CancelOrderReq {
userID: number userID: number
} }
/** 通用 API 响应 */ export type { ApiResponse }
export interface ApiResponse {
code: number
data?: unknown
msg: string
}
/** /**
* POST /clob/order/cancelOrder * POST /clob/order/cancelOrder

View File

@ -1,12 +1,7 @@
import { get } from './request' import { buildQuery, get } from './request'
import type { PageResult } from './types'
/** 分页结果 */ export type { PageResult }
export interface PageResult<T> {
list: T[]
page: number
pageSize: number
total: number
}
/** /**
* /clob/position/getPositionList * /clob/position/getPositionList
@ -72,12 +67,15 @@ export async function getPositionList(
config?: { headers?: Record<string, string> }, config?: { headers?: Record<string, string> },
): Promise<PositionListResponse> { ): Promise<PositionListResponse> {
const { page = 1, pageSize = 10, startCreatedAt, endCreatedAt, marketID, tokenID, userID } = params const { page = 1, pageSize = 10, startCreatedAt, endCreatedAt, marketID, tokenID, userID } = params
const query: Record<string, string | number | undefined> = { page, pageSize } const query = buildQuery({
if (startCreatedAt != null && startCreatedAt !== '') query.startCreatedAt = startCreatedAt page,
if (endCreatedAt != null && endCreatedAt !== '') query.endCreatedAt = endCreatedAt pageSize,
if (marketID != null && marketID !== '') query.marketID = marketID startCreatedAt,
if (tokenID != null && tokenID !== '') query.tokenID = tokenID endCreatedAt,
if (userID != null && Number.isFinite(userID)) query.userID = userID marketID,
tokenID,
userID,
})
return get<PositionListResponse>('/clob/position/getPositionList', query, config) return get<PositionListResponse>('/clob/position/getPositionList', query, config)
} }
@ -105,6 +103,8 @@ export interface PositionDisplayItem {
lockedSharesNum?: number lockedSharesNum?: number
/** 可卖份额数值(来自 availableavailable > 0 表示有订单可卖,此为最大可卖份额 */ /** 可卖份额数值(来自 availableavailable > 0 表示有订单可卖,此为最大可卖份额 */
availableSharesNum?: number availableSharesNum?: number
/** 原始 outcome 字段API 返回,如 "Up"/"Down" 或 "Yes"/"No"),用于与市场 outcomes 匹配 */
outcome?: string
} }
/** /**
@ -122,12 +122,13 @@ export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplay
const availableNum = availableRaw / SCALE const availableNum = availableRaw / SCALE
const costUsd = costRaw / SCALE const costUsd = costRaw / SCALE
const shares = `${size} shares` const shares = `${size} shares`
const outcomeWord = pos.outcome === 'No' ? 'No' : 'Yes' const outcome = pos.outcome ?? 'Yes'
const pillClass = outcomeWord === 'No' ? 'pill-down' : 'pill-yes' const pillClass =
outcome === 'No' || outcome === 'Down' || outcome === 'Below' ? 'pill-down' : 'pill-yes'
const value = `$${costUsd.toFixed(2)}` const value = `$${costUsd.toFixed(2)}`
const bet = value const bet = value
const toWin = `$${size.toFixed(2)}` const toWin = `$${size.toFixed(2)}`
const outcomeTag = `${outcomeWord}` const outcomeTag = `${outcome}`
const locked = lockRaw > 0 const locked = lockRaw > 0
const lockedSharesNum = lockRaw > 0 ? lockRaw / SCALE : undefined const lockedSharesNum = lockRaw > 0 ? lockRaw / SCALE : undefined
return { return {
@ -138,10 +139,11 @@ export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplay
bet, bet,
toWin, toWin,
value, value,
sellOutcome: outcomeWord, sellOutcome: outcome,
outcomeWord, outcomeWord: outcome,
outcomeTag, outcomeTag,
outcomePillClass: pillClass, outcomePillClass: pillClass,
outcome,
locked, locked,
lockedSharesNum, lockedSharesNum,
availableSharesNum: availableNum >= 0 ? availableNum : undefined, availableSharesNum: availableNum >= 0 ? availableNum : undefined,

View File

@ -1,28 +1,50 @@
import { i18n } from '@/plugins/i18n' import { i18n } from '@/plugins/i18n'
/** /** 请求基础 URL默认 https://api.xtrader.vip可通过环境变量 VITE_API_BASE_URL 覆盖 */
* URL https://api.xtrader.vip可通过环境变量 VITE_API_BASE_URL 覆盖
*/
const BASE_URL = const BASE_URL =
typeof import.meta !== 'undefined' && (import.meta as { env?: { VITE_API_BASE_URL?: string } }).env?.VITE_API_BASE_URL ??
(import.meta as unknown as { env?: Record<string, string> }).env?.VITE_API_BASE_URL 'https://api.xtrader.vip'
? (import.meta as unknown as { env: Record<string, string> }).env.VITE_API_BASE_URL
: 'https://api.xtrader.vip'
/** CLOB WebSocket URL与 REST API 同源 */ const FALLBACK_BASE =
export function getClobWsUrl(): string { typeof window !== 'undefined' ? window.location.origin : 'https://api.xtrader.vip'
const base = BASE_URL || (typeof window !== 'undefined' ? window.location.origin : 'https://api.xtrader.vip')
/** 生成 WebSocket URL与 REST API 同源 */
function getWsUrl(path: string): string {
const base = BASE_URL || FALLBACK_BASE
const url = new URL(base) const url = new URL(base)
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${url.host}/clob/ws` return `${protocol}//${url.host}${path.startsWith('/') ? path : `/${path}`}`
} }
/** User WebSocket URL订单/持仓/余额推送),与 REST API 同源 */ /** CLOB WebSocket URL */
export function getClobWsUrl(): string {
return getWsUrl('/clob/ws')
}
/** User WebSocket URL订单/持仓/余额推送) */
export function getUserWsUrl(): string { export function getUserWsUrl(): string {
const base = BASE_URL || (typeof window !== 'undefined' ? window.location.origin : 'https://api.xtrader.vip') return getWsUrl('/clob/ws/user')
const url = new URL(base) }
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${url.host}/clob/ws/user` /**
* query GET 使
* - undefined/null
* - ''
* - []
* - NaN
*/
export function buildQuery(
params: Record<string, string | number | string[] | number[] | undefined | null>,
): Record<string, string | number | string[] | number[]> {
const result: Record<string, string | number | string[] | number[]> = {}
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null) continue
if (typeof v === 'string' && v === '') continue
if (Array.isArray(v) && v.length === 0) continue
if (typeof v === 'number' && !Number.isFinite(v)) continue
result[k] = v
}
return result
} }
export interface RequestConfig { export interface RequestConfig {

19
src/api/types.ts Normal file
View File

@ -0,0 +1,19 @@
/**
* API
* eventorderposition
*/
/** 分页结果 */
export interface PageResult<T> {
list: T[]
page: number
pageSize: number
total: number
}
/** 通用 API 响应 */
export interface ApiResponse<T = unknown> {
code: number
data?: T
msg: string
}

274
src/components/Footer.vue Normal file
View File

@ -0,0 +1,274 @@
<template>
<footer class="home-footer">
<div class="footer-inner">
<div class="footer-top">
<div class="footer-brand">
<div class="footer-logo">
<span class="logo-mark">M</span>
<span class="logo-text">Polymarket</span>
</div>
<p class="footer-slogan">The World's Largest Prediction Market</p>
</div>
<div class="footer-links-row">
<div class="footer-col">
<h4 class="footer-col-title">Support & Social</h4>
<ul class="footer-link-list">
<li><a href="#">Contact us</a></li>
<li><a href="#">Learn</a></li>
<li><a href="#">X (Twitter)</a></li>
<li><a href="#">Instagram</a></li>
<li><a href="#">Discord</a></li>
<li><a href="#">TikTok</a></li>
<li><a href="#">News</a></li>
</ul>
</div>
<div class="footer-col">
<h4 class="footer-col-title">Polymarket</h4>
<ul class="footer-link-list">
<li><a href="#">Accuracy</a></li>
<li><a href="#">Activity</a></li>
<li><a href="#">Leaderboard</a></li>
<li><a href="#">Rewards</a></li>
<li><a href="#">Press</a></li>
<li><a href="#">Careers</a></li>
<li><a href="#">APIs</a></li>
</ul>
</div>
</div>
</div>
<div class="footer-bottom">
<div class="footer-legal-links">
<a href="#">Adventure One QSS Inc. © 2026</a>
<span class="sep">/</span>
<a href="#">Privacy</a>
<span class="sep">/</span>
<a href="#">Terms of Use</a>
<span class="sep">/</span>
<a href="#">Help Center</a>
<span class="sep">/</span>
<a href="#">Docs</a>
</div>
<div class="footer-lang-social">
<v-select
v-model="footerLang"
:items="['English']"
density="compact"
hide-details
variant="outlined"
class="footer-lang-select"
/>
<div class="footer-social-icons">
<v-icon size="20">mdi-email-outline</v-icon>
<v-icon size="20">mdi-twitter</v-icon>
<v-icon size="20">mdi-instagram</v-icon>
<v-icon size="20">mdi-discord</v-icon>
<v-icon size="20">mdi-music</v-icon>
</div>
</div>
</div>
<p class="footer-disclaimer">
Polymarket operates globally through separate legal entities. Polymarket US is operated by
QCX LLC d/b/a Polymarket US, a CFTC-regulated Designated Contract Market. This
international platform is not regulated by the CFTC and operates independently. Trading
involves substantial risk of loss. See our
<a href="#">Terms of Service &amp; Privacy Policy</a>.
</p>
</div>
</footer>
</template>
<script setup lang="ts">
defineOptions({ name: 'Footer' })
import { ref } from 'vue'
const footerLang = ref('English')
</script>
<style scoped>
.home-footer {
width: 100%;
background-color: #374151;
color: rgba(255, 255, 255, 0.85);
margin-top: auto;
padding: 48px 24px 32px;
}
.footer-inner {
max-width: 1200px;
margin: 0 auto;
}
.footer-top {
display: flex;
flex-wrap: wrap;
gap: 48px;
padding-bottom: 32px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.footer-brand {
flex-shrink: 0;
}
.footer-logo {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.logo-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.15);
color: #fff;
font-weight: 700;
font-size: 18px;
border-radius: 6px;
}
.logo-text {
font-size: 1.25rem;
font-weight: 600;
color: #fff;
}
.footer-slogan {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.7);
margin: 0;
}
.footer-links-row {
display: flex;
gap: 64px;
flex-wrap: wrap;
}
.footer-col-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.9);
margin: 0 0 12px 0;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.footer-link-list {
list-style: none;
margin: 0;
padding: 0;
}
.footer-link-list li {
margin-bottom: 6px;
}
.footer-link-list a {
color: rgba(255, 255, 255, 0.75);
text-decoration: none;
font-size: 0.875rem;
}
.footer-link-list a:hover {
color: #fff;
}
.footer-bottom {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 24px;
padding-bottom: 24px;
}
.footer-legal-links {
font-size: 0.8125rem;
}
.footer-legal-links a {
color: rgba(255, 255, 255, 0.75);
text-decoration: none;
}
.footer-legal-links a:hover {
color: #fff;
}
.footer-legal-links .sep {
margin: 0 8px;
color: rgba(255, 255, 255, 0.4);
}
.footer-lang-social {
display: flex;
align-items: center;
gap: 16px;
}
.footer-lang-select {
max-width: 120px;
}
.footer-lang-select :deep(.v-field) {
background: rgba(255, 255, 255, 0.08);
color: #fff;
font-size: 0.875rem;
}
.footer-social-icons {
display: flex;
gap: 12px;
color: rgba(255, 255, 255, 0.8);
}
.footer-social-icons .v-icon {
cursor: pointer;
}
.footer-social-icons .v-icon:hover {
color: #fff;
}
.footer-disclaimer {
font-size: 0.75rem;
line-height: 1.5;
color: rgba(255, 255, 255, 0.6);
margin: 0;
max-width: 720px;
}
.footer-disclaimer a {
color: rgba(255, 255, 255, 0.85);
text-decoration: underline;
}
.footer-disclaimer a:hover {
color: #fff;
}
@media (max-width: 600px) {
.home-footer {
padding: 32px 16px 24px;
}
.footer-top {
flex-direction: column;
gap: 32px;
padding-bottom: 24px;
}
.footer-links-row {
gap: 32px;
}
.footer-bottom {
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@ -42,6 +42,21 @@ function clearStorage() {
} }
} }
/** 从 API 返回的 user 对象解析 id/ID兼容 number 与 string */
function parseUserId(raw: { id?: number | string; ID?: number } | null | undefined): {
id: number | string | undefined
numId: number | undefined
} {
const rawId = raw?.ID ?? raw?.id
const numId =
typeof rawId === 'number'
? rawId
: rawId != null
? parseInt(String(rawId), 10)
: undefined
return { id: rawId ?? numId, numId: Number.isFinite(numId) ? numId : undefined }
}
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
const stored = loadStored() const stored = loadStored()
const token = ref<string>(stored?.token ?? '') const token = ref<string>(stored?.token ?? '')
@ -110,18 +125,8 @@ export const useUserStore = defineStore('user', () => {
const raw = loginData.user ?? null const raw = loginData.user ?? null
token.value = t token.value = t
if (raw) { if (raw) {
const rawId = raw.ID ?? raw.id const { id, numId } = parseUserId(raw)
const numId = user.value = { ...raw, id, ID: numId ?? raw.ID }
typeof rawId === 'number'
? rawId
: rawId != null
? parseInt(String(rawId), 10)
: undefined
user.value = {
...raw,
id: rawId ?? numId,
ID: Number.isFinite(numId) ? numId : raw.ID,
}
} else { } else {
user.value = null user.value = null
} }
@ -181,20 +186,14 @@ export const useUserStore = defineStore('user', () => {
// 更新用户信息data.userInfo // 更新用户信息data.userInfo
const u = (data?.userInfo ?? data?.user ?? data) as Record<string, unknown> | undefined const u = (data?.userInfo ?? data?.user ?? data) as Record<string, unknown> | undefined
if (!u) return if (!u) return
const rawId = u.ID ?? u.id const { id, numId } = parseUserId(u as { id?: number | string; ID?: number })
const numId =
typeof rawId === 'number'
? rawId
: rawId != null
? parseInt(String(rawId), 10)
: undefined
user.value = { user.value = {
...u, ...u,
userName: (u.userName ?? u.username) as string | undefined, userName: (u.userName ?? u.username) as string | undefined,
nickName: (u.nickName ?? u.nickname) as string | undefined, nickName: (u.nickName ?? u.nickname) as string | undefined,
headerImg: (u.headerImg ?? u.avatar ?? u.avatarUrl) as string | undefined, headerImg: (u.headerImg ?? u.avatar ?? u.avatarUrl) as string | undefined,
id: (rawId ?? numId) as number | string | undefined, id,
ID: Number.isFinite(numId) ? numId : undefined, ID: numId,
} as UserInfo } as UserInfo
if (token.value && user.value) saveToStorage(token.value, user.value) if (token.value && user.value) saveToStorage(token.value, user.value)
} catch (e) { } catch (e) {

View File

@ -223,82 +223,7 @@
</v-bottom-sheet> </v-bottom-sheet>
</v-container> </v-container>
<footer class="home-footer"> <Footer />
<div class="footer-inner">
<div class="footer-top">
<div class="footer-brand">
<div class="footer-logo">
<span class="logo-mark">M</span>
<span class="logo-text">Polymarket</span>
</div>
<p class="footer-slogan">The World's Largest Prediction Market</p>
</div>
<div class="footer-links-row">
<div class="footer-col">
<h4 class="footer-col-title">Support & Social</h4>
<ul class="footer-link-list">
<li><a href="#">Contact us</a></li>
<li><a href="#">Learn</a></li>
<li><a href="#">X (Twitter)</a></li>
<li><a href="#">Instagram</a></li>
<li><a href="#">Discord</a></li>
<li><a href="#">TikTok</a></li>
<li><a href="#">News</a></li>
</ul>
</div>
<div class="footer-col">
<h4 class="footer-col-title">Polymarket</h4>
<ul class="footer-link-list">
<li><a href="#">Accuracy</a></li>
<li><a href="#">Activity</a></li>
<li><a href="#">Leaderboard</a></li>
<li><a href="#">Rewards</a></li>
<li><a href="#">Press</a></li>
<li><a href="#">Careers</a></li>
<li><a href="#">APIs</a></li>
</ul>
</div>
</div>
</div>
<div class="footer-bottom">
<div class="footer-legal-links">
<a href="#">Adventure One QSS Inc. © 2026</a>
<span class="sep">/</span>
<a href="#">Privacy</a>
<span class="sep">/</span>
<a href="#">Terms of Use</a>
<span class="sep">/</span>
<a href="#">Help Center</a>
<span class="sep">/</span>
<a href="#">Docs</a>
</div>
<div class="footer-lang-social">
<v-select
v-model="footerLang"
:items="['English']"
density="compact"
hide-details
variant="outlined"
class="footer-lang-select"
/>
<div class="footer-social-icons">
<v-icon size="20">mdi-email-outline</v-icon>
<v-icon size="20">mdi-twitter</v-icon>
<v-icon size="20">mdi-instagram</v-icon>
<v-icon size="20">mdi-discord</v-icon>
<v-icon size="20">mdi-music</v-icon>
</div>
</div>
</div>
<p class="footer-disclaimer">
Polymarket operates globally through separate legal entities. Polymarket US is operated by
QCX LLC d/b/a Polymarket US, a CFTC-regulated Designated Contract Market. This
international platform is not regulated by the CFTC and operates independently. Trading
involves substantial risk of loss. See our
<a href="#">Terms of Service &amp; Privacy Policy</a>.
</p>
</div>
</footer>
</div> </div>
</template> </template>
@ -306,6 +231,7 @@
defineOptions({ name: 'Home' }) defineOptions({ name: 'Home' })
import { ref, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, computed, watch } from 'vue' import { ref, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, computed, watch } from 'vue'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import Footer from '../components/Footer.vue'
import MarketCard from '../components/MarketCard.vue' import MarketCard from '../components/MarketCard.vue'
import TradeComponent from '../components/TradeComponent.vue' import TradeComponent from '../components/TradeComponent.vue'
import { import {
@ -519,7 +445,6 @@ const noMoreEvents = computed(() => {
) )
}) })
const footerLang = ref('English')
const tradeDialogOpen = ref(false) const tradeDialogOpen = ref(false)
const tradeDialogSide = ref<'yes' | 'no'>('yes') const tradeDialogSide = ref<'yes' | 'no'>('yes')
const tradeDialogMarket = ref<{ const tradeDialogMarket = ref<{
@ -1186,197 +1111,10 @@ onActivated(() => {
} }
} }
/* Footer */ /* 页面布局flex 列,Footer 通过 margin-top: auto 贴底 */
.home-page { .home-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
} }
.home-footer {
width: 100%;
background-color: #374151;
color: rgba(255, 255, 255, 0.85);
margin-top: auto;
padding: 48px 24px 32px;
}
.footer-inner {
max-width: 1200px;
margin: 0 auto;
}
.footer-top {
display: flex;
flex-wrap: wrap;
gap: 48px;
padding-bottom: 32px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.footer-brand {
flex-shrink: 0;
}
.footer-logo {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.logo-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.15);
color: #fff;
font-weight: 700;
font-size: 18px;
border-radius: 6px;
}
.logo-text {
font-size: 1.25rem;
font-weight: 600;
color: #fff;
}
.footer-slogan {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.7);
margin: 0;
}
.footer-links-row {
display: flex;
gap: 64px;
flex-wrap: wrap;
}
.footer-col-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.9);
margin: 0 0 12px 0;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.footer-link-list {
list-style: none;
margin: 0;
padding: 0;
}
.footer-link-list li {
margin-bottom: 6px;
}
.footer-link-list a {
color: rgba(255, 255, 255, 0.75);
text-decoration: none;
font-size: 0.875rem;
}
.footer-link-list a:hover {
color: #fff;
}
.footer-bottom {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 24px;
padding-bottom: 24px;
}
.footer-legal-links {
font-size: 0.8125rem;
}
.footer-legal-links a {
color: rgba(255, 255, 255, 0.75);
text-decoration: none;
}
.footer-legal-links a:hover {
color: #fff;
}
.footer-legal-links .sep {
margin: 0 8px;
color: rgba(255, 255, 255, 0.4);
}
.footer-lang-social {
display: flex;
align-items: center;
gap: 16px;
}
.footer-lang-select {
max-width: 120px;
}
.footer-lang-select :deep(.v-field) {
background: rgba(255, 255, 255, 0.08);
color: #fff;
font-size: 0.875rem;
}
.footer-social-icons {
display: flex;
gap: 12px;
color: rgba(255, 255, 255, 0.8);
}
.footer-social-icons .v-icon {
cursor: pointer;
}
.footer-social-icons .v-icon:hover {
color: #fff;
}
.footer-disclaimer {
font-size: 0.75rem;
line-height: 1.5;
color: rgba(255, 255, 255, 0.6);
margin: 0;
max-width: 720px;
}
.footer-disclaimer a {
color: rgba(255, 255, 255, 0.85);
text-decoration: underline;
}
.footer-disclaimer a:hover {
color: #fff;
}
@media (max-width: 600px) {
.home-footer {
padding: 32px 16px 24px;
}
.footer-top {
flex-direction: column;
gap: 32px;
padding-bottom: 24px;
}
.footer-links-row {
gap: 32px;
}
.footer-bottom {
flex-direction: column;
align-items: flex-start;
}
}
</style> </style>

View File

@ -818,13 +818,13 @@ function onSplitSuccess() {
loadMarketPositions() loadMarketPositions()
} }
/** 从持仓项点击 Sell弹出交易组件并切到 Sell、对应 Yes/No。是否可卖由 available 判断available > 0 才可卖。 */ /** 从持仓项点击 Sell弹出交易组件并切到 Sell、对应 Yes/No。持仓类型用 outcome 字段与 noLabel 匹配。 */
function openSellFromPosition(pos: PositionDisplayItem) { function openSellFromPosition(pos: PositionDisplayItem) {
if (pos.availableSharesNum == null || pos.availableSharesNum <= 0) { if (pos.availableSharesNum == null || pos.availableSharesNum <= 0) {
toastStore.show(t('activity.noAvailableSharesToSell'), 'warning') toastStore.show(t('activity.noAvailableSharesToSell'), 'warning')
return return
} }
const option = pos.outcomeWord === 'No' ? 'no' : 'yes' const option = pos.outcome === noLabel.value ? 'no' : 'yes'
if (isMobile.value) { if (isMobile.value) {
tradeInitialOptionFromBar.value = option tradeInitialOptionFromBar.value = option
tradeInitialTabFromBar.value = 'sell' tradeInitialTabFromBar.value = 'sell'
@ -914,11 +914,12 @@ const marketPositionsFiltered = computed(() =>
}), }),
) )
/** 转为 TradeComponent 所需的 TradePositionItem[]sharesNum 用 available最大可卖份额available > 0 表示有订单可卖 */ /** 转为 TradeComponent 所需的 TradePositionItem[]sharesNum 用 available最大可卖份额available > 0 表示有订单可卖outcome 与 noLabel 匹配则为 No */
const tradePositionsForComponent = computed<TradePositionItem[]>(() => const tradePositionsForComponent = computed<TradePositionItem[]>(() => {
marketPositionsFiltered.value.map((p) => ({ const no = noLabel.value
return marketPositionsFiltered.value.map((p) => ({
id: p.id, id: p.id,
outcomeWord: (p.outcomeWord === 'No' ? 'No' : 'Yes') as 'Yes' | 'No', outcomeWord: (p.outcome === no ? 'No' : 'Yes') as 'Yes' | 'No',
shares: p.shares, shares: p.shares,
sharesNum: sharesNum:
p.availableSharesNum != null && Number.isFinite(p.availableSharesNum) p.availableSharesNum != null && Number.isFinite(p.availableSharesNum)
@ -926,7 +927,7 @@ const tradePositionsForComponent = computed<TradePositionItem[]>(() =>
: parseFloat(p.shares?.replace(/[^0-9.]/g, '')) || undefined, : parseFloat(p.shares?.replace(/[^0-9.]/g, '')) || undefined,
locked: p.locked, locked: p.locked,
})) }))
) })
async function loadMarketPositions() { async function loadMarketPositions() {
const marketID = currentMarketId.value const marketID = currentMarketId.value