优化:修复持仓显示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 |
| `EventCardItem` | 首页卡片所需结构,含 displayTypesingle/multi |
| `EventCardOutcome` | 多选项卡片中单个选项 |
| `PageResult<T>` | 分页结果 |
| `PageResult<T>` | 分页结果(来自 `@/api/types` |
## 使用方式
@ -48,7 +48,7 @@ clearEventListCache()
1. **新增筛选参数**:在 `GetPmEventListParams` 中增加字段,并在 `getPmEventPublic` 的 query 中传入
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` 覆盖
- 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`(订单/持仓/余额推送)
- GET 请求:支持 query 参数,自动序列化
- POST 请求:支持 JSON body
- 自定义 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
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()
// User WebSocket URL订单/持仓/余额推送,需带 token 参数)
const userWsUrl = getUserWsUrl()
// GET 请求
const data = await get<MyResponse>('/path', { page: 1, keyword: 'x' })
// 带鉴权
const data = await get<MyResponse>('/path', undefined, {
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
- `logout`:清空并断开 UserSocket
- `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用于订单/持仓/余额实时推送
## 使用方式

View File

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

View File

@ -31,7 +31,7 @@
- **合并成功**`TradeComponent` 触发 `mergeSuccess``onMergeSuccess()` 刷新持仓,显示 toast 提示
- **拆分成功**`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 interface PageResult<T> {
list: T[]
page: number
pageSize: number
total: number
}
export type { PageResult }
/**
* Event doc.json definitions["polymarket.PmEvent"]
@ -137,15 +132,7 @@ export async function getPmEventPublic(
params: GetPmEventListParams = {},
): Promise<PmEventListResponse> {
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 (tagIds != null && tagIds.length > 0) {
query.tagIds = tagIds
}
const query = buildQuery({ page, pageSize, keyword, createdAtRange, tagIds })
return get<PmEventListResponse>('/PmEvent/getPmEventPublic', query)
}
@ -186,9 +173,7 @@ export async function findPmEvent(
params: FindPmEventParams,
config?: { headers?: Record<string, string> },
): Promise<PmEventDetailResponse> {
const query: Record<string, string | number> = {}
if (params.id != null) query.ID = params.id
if (params.slug != null && params.slug !== '') query.slug = params.slug
const query = buildQuery({ ID: params.id, slug: params.slug })
if (Object.keys(query).length === 0) {
throw new Error('findPmEvent: 至少需要传 id 或 slug')
}
@ -276,54 +261,24 @@ function marketChance(market: PmEventMarketItem): number {
return Math.min(100, Math.max(0, Math.round(yesPrice * 100)))
}
/**
* list MarketCard
* - market marketdisplayType single
* - marketsdisplayType multioutcomes +
*/
export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
const id = String(item.ID ?? '')
const marketTitle = item.title ?? ''
const imageUrl = item.image ?? item.icon ?? ''
function formatVolume(volume: number | undefined): string {
if (volume == null || !Number.isFinite(volume)) return '$0 Vol.'
return volume >= 1000 ? `$${(volume / 1000).toFixed(1)}k Vol.` : `$${Math.round(volume)} Vol.`
}
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.'
if (item.volume != null && Number.isFinite(item.volume)) {
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) {
function formatExpiresAt(endDate: string | undefined): string {
if (!endDate) return ''
try {
const d = new Date(item.endDate)
if (!Number.isNaN(d.getTime())) {
expiresAt = d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
const d = new Date(endDate)
return !Number.isNaN(d.getTime())
? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: endDate
} 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 n = m?.outcomePrices?.[1]
const yesPrice =
@ -335,10 +290,9 @@ export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
? Math.min(1, Math.max(0, parseFloat(String(n))))
: 1 - yesPrice
return { yesPrice, noPrice }
}
}
const outcomes: EventCardOutcome[] | undefined = multi
? markets.map((m) => {
function mapMarketToOutcome(m: PmEventMarketItem): EventCardOutcome {
const { yesPrice, noPrice } = parseOutcomePrices(m)
return {
title: m.question ?? '',
@ -350,22 +304,30 @@ export function mapEventItemToCard(item: PmEventListItem): EventCardItem {
marketId: getMarketId(m),
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 }
return {
id,
id: String(item.ID ?? ''),
slug: item.slug ?? undefined,
marketTitle,
chanceValue,
marketInfo,
imageUrl,
category,
expiresAt,
marketTitle: item.title ?? '',
chanceValue: firstMarket ? marketChance(firstMarket) : 17,
marketInfo: formatVolume(item.volume),
imageUrl: item.image ?? item.icon ?? '',
category: item.series?.[0]?.title ?? item.tags?.[0]?.label ?? '',
expiresAt: formatExpiresAt(item.endDate),
displayType: multi ? 'multi' : 'single',
outcomes,
outcomes: multi ? markets.map(mapMarketToOutcome) : undefined,
yesLabel: firstMarket?.outcomes?.[0] ?? 'Yes',
noLabel: firstMarket?.outcomes?.[1] ?? 'No',
isNew: item.new === true,

View File

@ -1,11 +1,7 @@
import { post } from './request'
import type { ApiResponse } from './types'
/** 通用响应:与 doc.json response.Response 一致 */
export interface ApiResponse<T = unknown> {
code: number
data?: T
msg: string
}
export type { ApiResponse }
/**
* /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 interface PageResult<T> {
list: T[]
page: number
pageSize: number
total: number
}
export type { PageResult }
/**
* doc.json definitions["model.ClobOrder"]
@ -71,13 +66,16 @@ export async function getOrderList(
): 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
const query = buildQuery({
page,
pageSize,
status,
startCreatedAt,
endCreatedAt,
marketID,
tokenID,
userID,
})
return get<OrderListResponse>('/clob/order/getOrderList', query, config)
}
@ -88,12 +86,7 @@ export interface CancelOrderReq {
userID: number
}
/** 通用 API 响应 */
export interface ApiResponse {
code: number
data?: unknown
msg: string
}
export type { ApiResponse }
/**
* 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 interface PageResult<T> {
list: T[]
page: number
pageSize: number
total: number
}
export type { PageResult }
/**
* /clob/position/getPositionList
@ -72,12 +67,15 @@ export async function getPositionList(
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
const query = buildQuery({
page,
pageSize,
startCreatedAt,
endCreatedAt,
marketID,
tokenID,
userID,
})
return get<PositionListResponse>('/clob/position/getPositionList', query, config)
}
@ -105,6 +103,8 @@ export interface PositionDisplayItem {
lockedSharesNum?: number
/** 可卖份额数值(来自 availableavailable > 0 表示有订单可卖,此为最大可卖份额 */
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 costUsd = costRaw / SCALE
const shares = `${size} shares`
const outcomeWord = pos.outcome === 'No' ? 'No' : 'Yes'
const pillClass = outcomeWord === 'No' ? 'pill-down' : 'pill-yes'
const outcome = pos.outcome ?? 'Yes'
const pillClass =
outcome === 'No' || outcome === 'Down' || outcome === 'Below' ? 'pill-down' : 'pill-yes'
const value = `$${costUsd.toFixed(2)}`
const bet = value
const toWin = `$${size.toFixed(2)}`
const outcomeTag = `${outcomeWord}`
const outcomeTag = `${outcome}`
const locked = lockRaw > 0
const lockedSharesNum = lockRaw > 0 ? lockRaw / SCALE : undefined
return {
@ -138,10 +139,11 @@ export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplay
bet,
toWin,
value,
sellOutcome: outcomeWord,
outcomeWord,
sellOutcome: outcome,
outcomeWord: outcome,
outcomeTag,
outcomePillClass: pillClass,
outcome,
locked,
lockedSharesNum,
availableSharesNum: availableNum >= 0 ? availableNum : undefined,

View File

@ -1,28 +1,50 @@
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 =
typeof import.meta !== 'undefined' &&
(import.meta as unknown as { env?: Record<string, 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 { env?: { VITE_API_BASE_URL?: string } }).env?.VITE_API_BASE_URL ??
'https://api.xtrader.vip'
/** CLOB WebSocket URL与 REST API 同源 */
export function getClobWsUrl(): string {
const base = BASE_URL || (typeof window !== 'undefined' ? window.location.origin : 'https://api.xtrader.vip')
const FALLBACK_BASE =
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 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 {
const base = BASE_URL || (typeof window !== 'undefined' ? window.location.origin : 'https://api.xtrader.vip')
const url = new URL(base)
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${url.host}/clob/ws/user`
return getWsUrl('/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 {

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', () => {
const stored = loadStored()
const token = ref<string>(stored?.token ?? '')
@ -110,18 +125,8 @@ export const useUserStore = defineStore('user', () => {
const raw = loginData.user ?? null
token.value = t
if (raw) {
const rawId = raw.ID ?? raw.id
const numId =
typeof rawId === 'number'
? rawId
: rawId != null
? parseInt(String(rawId), 10)
: undefined
user.value = {
...raw,
id: rawId ?? numId,
ID: Number.isFinite(numId) ? numId : raw.ID,
}
const { id, numId } = parseUserId(raw)
user.value = { ...raw, id, ID: numId ?? raw.ID }
} else {
user.value = null
}
@ -181,20 +186,14 @@ export const useUserStore = defineStore('user', () => {
// 更新用户信息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
const { id, numId } = parseUserId(u as { id?: number | string; ID?: number })
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,
id,
ID: numId,
} as UserInfo
if (token.value && user.value) saveToStorage(token.value, user.value)
} catch (e) {

View File

@ -223,82 +223,7 @@
</v-bottom-sheet>
</v-container>
<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>
<Footer />
</div>
</template>
@ -306,6 +231,7 @@
defineOptions({ name: 'Home' })
import { ref, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, computed, watch } from 'vue'
import { useDisplay } from 'vuetify'
import Footer from '../components/Footer.vue'
import MarketCard from '../components/MarketCard.vue'
import TradeComponent from '../components/TradeComponent.vue'
import {
@ -519,7 +445,6 @@ const noMoreEvents = computed(() => {
)
})
const footerLang = ref('English')
const tradeDialogOpen = ref(false)
const tradeDialogSide = ref<'yes' | 'no'>('yes')
const tradeDialogMarket = ref<{
@ -1186,197 +1111,10 @@ onActivated(() => {
}
}
/* Footer */
/* 页面布局flex 列,Footer 通过 margin-top: auto 贴底 */
.home-page {
display: flex;
flex-direction: column;
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>

View File

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