Compare commits

...

2 Commits

Author SHA1 Message Date
ivan
d571d1b9b0 新增:提现功能,提现记录 2026-03-03 18:05:24 +08:00
ivan
d377dd3523 新增:持仓数据接口对接 2026-03-03 14:22:49 +08:00
16 changed files with 1230 additions and 45 deletions

81
docs/api/pmset.md Normal file
View File

@ -0,0 +1,81 @@
# pmset.ts
**路径**`src/api/pmset.ts`
## 功能用途
Polymarket 结算/提现相关 API`withdrawByWallet` 用于客户端钱包提现申请需钱包验签SIWE
## 核心能力
- `withdrawByWallet`POST /pmset/withdrawByWallet提交提现申请需钱包 personal_sign 验签
- `usdcToAmount`:将 USDC 显示金额转为 API 所需整数6 位小数)
- `getSettlementRequestsList`:分页获取提现/结算请求列表(管理端),支持 status 筛选
- `getSettlementRequestsListClient`:客户端分页获取提现记录列表,支持 status 筛选pending/success/rejected/failed
## POST /pmset/withdrawByWallet
### 请求体request.PmSettlementWithdrawByWallet
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| amount | number | 是 | 提现金额6 位小数1000000 = 1 USDC |
| chain | string | 是 | 链标识,如 polygon、ethereum、arbitrum、optimism |
| message | string | 是 | SIWE 签名消息 |
| nonce | string | 是 | 随机 nonce |
| signature | string | 是 | 钱包签名 |
| tokenAddress | string | 是 | 出金地址(用户接收资金的地址) |
| tokenSymbol | string | 是 | 代币符号,默认 USDC |
| walletAddress | string | 是 | 钱包地址,取用户信息的 externalWalletAddress |
### 响应
`{ code, data, msg }``data``{ idempotencyKey?, requestNo? }`
### 验签流程(与 Login.vue 一致)
1. 使用 `eth_requestAccounts` 获取钱包地址
2. 使用 `eth_chainId` 获取链 ID
3. 构建 SiweMessagescheme、domain、address、statement、uri、version、chainId、nonce
4. `personal_sign` 签名消息
5. POST 请求体包含 message、nonce、signature、walletAddress、amount、chain
## 使用方式
```typescript
import { withdrawByWallet, usdcToAmount } from '@/api/pmset'
const amount = usdcToAmount(1.5) // 1500000
const res = await withdrawByWallet(
{ amount, chain: 'polygon', message, nonce, signature, walletAddress },
{ headers: authHeaders },
)
```
## GET /pmset/getPmSettlementRequestsListClient
客户端分页获取提现记录列表,需鉴权。钱包页面提现记录使用此接口。
### 响应 data 结构
`{ list, total, page, pageSize }`list 项含ID、CreatedAt、UpdatedAt、chain、amount、fee、status、reason、requestNo、tokenAddress。
## GET /pmset/getPmSettlementRequestsList
分页获取提现/结算请求列表(管理端),需鉴权。
### 请求参数
| 参数 | 类型 | 说明 |
|------|------|------|
| page | number | 页码 |
| pageSize | number | 每页数量 |
| status | string | 状态筛选pending、success、rejected、failed |
### 响应
`data``PageResult<SettlementRequestItem>`list 项含 amount、requestNo、chain、status、createdAt、reason、payoutError 等。
## 扩展方式
- 新增其他 pmset 相关接口时,在 `src/api/pmset.ts` 中追加

View File

@ -4,12 +4,62 @@
## 功能用途 ## 功能用途
持仓相关 API分页获取持仓列表以及将 `ClobPositionItem` 映射为钱包展示项。`PageResult` 来自 `@/api/types`,使用 `buildQuery` 构建请求参数。 持仓相关 API分页获取持仓列表以及将 `ClobPositionItem` 映射为钱包展示项。`PageResult` 来自 `@/api/types`,使用 `buildQuery` 构建请求参数。接口定义以 Swagger doc.json 为准。
## 核心能力 ## 核心能力
- `getPositionList`:分页获取持仓列表(需鉴权) - `getPositionList`:分页获取持仓列表(需鉴权 x-token、x-user-id返回项含 `needClaim``market`(内嵌市场 question、outcomes、outcomePrices
- `mapPositionToDisplayItem`:将接口项转为展示结构(含 locked、availableSharesNum、outcome 等);`outcome` 保留 API 原始值(如 "Up"/"Down"、"Yes"/"No"),供 TradeDetail 与市场 outcomes 匹配 - `mapPositionToDisplayItem`:将接口项转为展示结构;`market` 优先用 `market.question`,否则用 marketID`avgNow``market.outcomePrices` 时展示「AVG → NOW」格式`iconChar`/`iconClass`/`imageUrl` 用于展示图标market.image 优先)
## GET /clob/position/getPositionList
### 请求参数Query
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | number | 否 | 页码 |
| pageSize | number | 否 | 每页数量 |
| startCreatedAt | string | 否 | 开始时间 |
| endCreatedAt | string | 否 | 结束时间 |
| marketID | string | 否 | 市场ID |
| tokenID | string | 否 | Token ID |
| userID | number | 否 | 用户ID |
### 响应
`{ code, data, msg }``data``PageResult<ClobPositionItem>`
### ClobPositionItem实际返回结构
| 字段 | 类型 | 说明 |
|------|------|------|
| ID | number | 主键 |
| userID | number | 用户ID |
| marketID | number \| string | 市场ID |
| tokenId | string | Token ID |
| size | string | 份额6 位小数) |
| available | string | 可用份额 |
| lock | string | 锁单数量 |
| cost | string | 成本 |
| outcome | string | 方向 |
| version | number | 版本号 |
| needClaim | boolean | 是否待领取结算 |
| market | ClobPositionMarket | 内嵌市场详情question、outcomes、outcomePrices 等) |
| createdAt | string | 创建时间 |
| updatedAt | string | 更新时间 |
### ClobPositionMarketmarket 字段)
| 字段 | 类型 | 说明 |
|------|------|------|
| ID | number | 市场ID |
| question | string | 市场问题 |
| slug | string | 市场 slug |
| image | string | 市场图片 URL |
| icon | string | 市场图标 URL |
| outcomes | string[] | 选项(如 ["Up", "Down"] |
| outcomePrices | string[] \| number[] | 各选项当前价格 |
| clobTokenIds | string[] | CLOB Token ID 列表 |
## 使用方式 ## 使用方式

View File

@ -4,7 +4,7 @@
## 功能用途 ## 功能用途
提现弹窗支持输入金额、选择网络、选择提现目标Connected wallet / Custom address)。 提现弹窗支持输入金额、选择网络、选择提现目标Connected wallet)。仅支持已连接钱包提现,需验签;`walletAddress` 取用户信息的 `externalWalletAddress``tokenAddress` 为出金地址(用户接收资金的地址)。
## Props ## Props

View File

@ -11,7 +11,8 @@
- Portfolio 卡片余额、Deposit/Withdraw 按钮 - Portfolio 卡片余额、Deposit/Withdraw 按钮
- Profit/Loss 卡片时间范围切换、ECharts 图表 - Profit/Loss 卡片时间范围切换、ECharts 图表
- TabPositions、Open orders、History - TabPositions、Open orders、History、Withdrawals提现记录
- Withdrawals分页列表状态筛选全部/审核中/提现成功/审核不通过/提现失败),对接 GET /pmset/getPmSettlementRequestsListClient
- DepositDialog、WithdrawDialog 组件 - DepositDialog、WithdrawDialog 组件
- **401 权限错误**:取消订单等接口失败时,通过 `useAuthError().formatAuthError` 统一提示「请先登录」或「权限不足」 - **401 权限错误**:取消订单等接口失败时,通过 `useAuthError().formatAuthError` 统一提示「请先登录」或「权限不足」

102
src/api/chart.ts Normal file
View File

@ -0,0 +1,102 @@
/**
* / API
* TradeDetail.vue 线
*/
import type { ApiResponse } from './types'
/** 图表使用的基础数据格式:[时间戳(ms), 概率值(0-100)][] */
export type ChartDataPoint = [number, number]
/**
* Time Range
* - 1H / 6H / 1D / 1W / 1M
* - ALL
*/
export type ChartTimeRange = '1H' | '6H' | '1D' | '1W' | '1M' | 'ALL'
/** 请求参数:按市场 + 时间范围拉取历史 */
export interface ChartHistoryParams {
/** 市场 ID或 tokenId / conditionId以实际后端为准 */
marketID: string
/** 时间范围 */
range: ChartTimeRange
}
/**
* normalizer ChartDataPoint
* timestamp + value 0100
*/
export interface ChartHistoryItem {
/** 时间Unix 毫秒时间戳 */
timestamp: number
/** 概率/价格0100 表示百分比,或 01 表示小数normalizer 会乘 100 */
value: number
}
/** 可选:接口用秒级时间戳 + 小数概率 */
export interface ChartHistoryItemAlt {
/** 时间Unix 秒级时间戳 */
t?: number
/** 概率01 小数 */
probability?: number
}
/**
*
* data ChartHistoryItem[] ChartHistoryItemAlt[] ChartDataPoint[]
*/
export interface ChartHistoryResponse {
code?: number
data?: ChartHistoryItem[] | ChartHistoryItemAlt[] | ChartDataPoint[]
msg?: string
}
/**
* data ECharts 使 [timestamp, value][]
* - { timestamp, value }value 0100 01
* - { t, probability }t 1000probability 01 100
* - [number, number][]
*/
export function normalizeChartData(
raw: ChartHistoryResponse['data']
): ChartDataPoint[] {
if (!raw || !Array.isArray(raw)) return []
const out: ChartDataPoint[] = []
for (const item of raw) {
if (Array.isArray(item) && item.length >= 2 && typeof item[0] === 'number' && typeof item[1] === 'number') {
out.push([item[0], item[1]])
continue
}
const obj = item as Record<string, unknown>
if (typeof obj.timestamp === 'number' && typeof obj.value === 'number') {
const v = (obj.value as number) <= 1 ? (obj.value as number) * 100 : (obj.value as number)
out.push([obj.timestamp as number, v])
continue
}
if (typeof obj.t === 'number' && typeof obj.probability === 'number') {
const tMs = (obj.t as number) < 1e12 ? (obj.t as number) * 1000 : (obj.t as number)
out.push([tMs, (obj.probability as number) * 100])
}
}
out.sort((a, b) => a[0] - b[0])
return out
}
/**
*
* GET /api/market/{marketID}/chart?range=1D
* POST /api/chart/history body: { marketID, range }
*/
export async function fetchChartHistory(
_params: ChartHistoryParams,
_config?: { headers?: Record<string, string> }
): Promise<ApiResponse<ChartHistoryItem[]>> {
// TODO: 替换为真实 request.get/post 调用
// const res = await get<ChartHistoryResponse>(`/market/${params.marketID}/chart`, { params: { range: params.range } })
// return { ...res, data: normalizeChartData(res.data) }
return { code: 0, data: [], msg: '' }
}

View File

@ -70,6 +70,10 @@ export interface MockPosition {
valueChangeLoss?: boolean valueChangeLoss?: boolean
sellOutcome?: string sellOutcome?: string
outcomeWord?: string outcomeWord?: string
/** 用于领取结算mock 时可选,便于展示未结算行) */
marketID?: string
tokenID?: string
needClaim?: boolean
} }
export interface MockOpenOrder { export interface MockOpenOrder {
@ -123,6 +127,9 @@ export const MOCK_WALLET_POSITIONS: MockPosition[] = [
valueChangeLoss: true, valueChangeLoss: true,
sellOutcome: 'Down', sellOutcome: 'Down',
outcomeWord: 'Down', outcomeWord: 'Down',
marketID: 'mock-market-1',
tokenID: MOCK_TOKEN_ID,
needClaim: true,
}, },
{ {
id: 'p2', id: 'p2',

173
src/api/pmset.ts Normal file
View File

@ -0,0 +1,173 @@
import { buildQuery, get, post } from './request'
import type { ApiResponse } from './types'
import type { PageResult } from './types'
export type { PageResult }
/** USDC 6 位小数 */
const USDC_SCALE = 1_000_000
/**
* request.PmSettlementWithdrawByWallet
*/
export interface WithdrawByWalletRequest {
/** 提现金额6 位小数(如 1000000 = 1 USDC */
amount: number
/** 链标识,如 polygon、ethereum、arbitrum、optimism */
chain: string
/** SIWE 签名消息 */
message: string
/** 随机 nonce */
nonce: string
/** 钱包签名 */
signature: string
/** 出金地址(用户接收资金的地址) */
tokenAddress: string
/** 代币符号,默认 USDC */
tokenSymbol: string
/** 钱包地址 */
walletAddress: string
}
/** 各链 USDC 合约地址 */
export const USDC_ADDRESS_BY_CHAIN: Record<string, string> = {
ethereum: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
polygon: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174',
arbitrum: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',
optimism: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85',
}
/**
* datapolymarket.PmSettlementWithdrawResponse
*/
export interface WithdrawByWalletResponseData {
idempotencyKey?: string
requestNo?: string
}
export interface WithdrawByWalletResponse extends ApiResponse<WithdrawByWalletResponseData> {
code: number
data?: WithdrawByWalletResponseData
msg: string
}
/**
* POST /pmset/withdrawByWallet
* SIWE
* x-tokenx-user-id
*/
export async function withdrawByWallet(
data: WithdrawByWalletRequest,
config?: { headers?: Record<string, string> },
): Promise<WithdrawByWalletResponse> {
return post<WithdrawByWalletResponse>('/pmset/withdrawByWallet', data, config)
}
/** 将 USDC 显示金额转为 API 所需整数6 位小数) */
export function usdcToAmount(displayAmount: number): number {
return Math.round(displayAmount * USDC_SCALE)
}
/** 提现状态:审核中、提现成功、审核不通过、提现失败 */
export const WITHDRAW_STATUS = {
PENDING: 'pending',
SUCCESS: 'success',
REJECTED: 'rejected',
FAILED: 'failed',
} as const
/** 提现记录项polymarket.PmSettlementRequests */
export interface SettlementRequestItem {
ID?: number
amount?: number
auditTime?: string
auditUserId?: number
chain?: string
createdAt?: string
fee?: string
idempotencyKey?: string
netAmount?: string
payoutError?: string
payoutTime?: string
payoutTxHash?: string
reason?: string
requestNo?: string
status?: string
tokenSymbol?: string
token_address?: string
updatedAt?: string
userId?: number
walletAddress?: string
[key: string]: unknown
}
export interface SettlementListResponse {
code: number
data?: PageResult<SettlementRequestItem>
msg: string
}
export interface GetSettlementListParams {
page?: number
pageSize?: number
status?: string
keyword?: string
requestNo?: string
tokenSymbol?: string
}
/**
* GET /pmset/getPmSettlementRequestsList
* /
*/
export async function getSettlementRequestsList(
params: GetSettlementListParams = {},
config?: { headers?: Record<string, string> },
): Promise<SettlementListResponse> {
const { page = 1, pageSize = 10, status, keyword, requestNo, tokenSymbol } = params
const query = buildQuery({ page, pageSize, status, keyword, requestNo, tokenSymbol })
return get<SettlementListResponse>('/pmset/getPmSettlementRequestsList', query, config)
}
/** 客户端提现记录项getPmSettlementRequestsListClient 返回) */
export interface SettlementRequestClientItem {
ID?: number
CreatedAt?: string
UpdatedAt?: string
chain?: string
amount?: number
fee?: string
status?: string
reason?: string
requestNo?: string
tokenAddress?: string | null
/** 兼容旧接口字段 */
createdAt?: string
walletAddress?: string
[key: string]: unknown
}
export interface SettlementListClientResponse {
code: number
data?: PageResult<SettlementRequestClientItem>
msg: string
}
/**
* GET /pmset/getPmSettlementRequestsListClient
*
*/
export async function getSettlementRequestsListClient(
params: GetSettlementListParams = {},
config?: { headers?: Record<string, string> },
): Promise<SettlementListClientResponse> {
const { page = 1, pageSize = 10, status, keyword, requestNo, tokenSymbol } = params
const query = buildQuery({ page, pageSize, status, keyword, requestNo, tokenSymbol })
return get<SettlementListClientResponse>('/pmset/getPmSettlementRequestsListClient', query, config)
}
/** 将 amount6 位小数)转为显示金额 */
export function amountToUsdcDisplay(raw: number | undefined): string {
if (raw == null || !Number.isFinite(raw)) return '0.00'
return (raw / USDC_SCALE).toFixed(2)
}

View File

@ -1,16 +1,34 @@
import { buildQuery, get } from './request' import { buildQuery, get, post } from './request'
import type { ApiResponse } from './types'
import type { PageResult } from './types' import type { PageResult } from './types'
export type { PageResult } export type { PageResult }
/** /**
* /clob/position/getPositionList * getPositionList market
* sizeavailablecost 6 1000000 */
export interface ClobPositionMarket {
ID?: number
question?: string
slug?: string
image?: string
icon?: string
outcomes?: string[]
outcomePrices?: string[] | number[]
clobTokenIds?: string[]
[key: string]: unknown
}
/**
* getPositionList
* sizeavailablecostlock 6 1000000
* marketID number stringmarket
*/ */
export interface ClobPositionItem { export interface ClobPositionItem {
ID?: number ID?: number
userID?: number userID?: number
marketID?: string /** 市场 ID可能为 number 或 string */
marketID?: number | string
tokenId?: string tokenId?: string
/** 份额字符串6 位小数 */ /** 份额字符串6 位小数 */
size?: string size?: string
@ -20,9 +38,16 @@ export interface ClobPositionItem {
lock?: string lock?: string
/** 成本字符串6 位小数 */ /** 成本字符串6 位小数 */
cost?: string cost?: string
/** 方向Yes | No */ /** 方向Yes | No 或 Up | Down 等 */
outcome?: string outcome?: string
version?: number version?: number
/** 是否待领取结算 */
needClaim?: boolean
/** 内嵌市场详情问题、outcomes、outcomePrices 等) */
market?: ClobPositionMarket
/** 创建时间 */
createdAt?: string
updatedAt?: string
CreatedAt?: string CreatedAt?: string
UpdatedAt?: string UpdatedAt?: string
[key: string]: unknown [key: string]: unknown
@ -79,11 +104,37 @@ export async function getPositionList(
return get<PositionListResponse>('/clob/position/getPositionList', query, config) return get<PositionListResponse>('/clob/position/getPositionList', query, config)
} }
/**
* doc.json definitions.request.PositionClaimRequest
* POST /clob/position/claimPosition
*/
export interface ClaimPositionRequest {
/** 市场 ID 列表,与 tokenID 一一对应 */
marketID: string[]
/** Token ID 列表(如 CLOB tokenId与 marketID 一一对应 */
tokenID: string[]
}
/**
*
* POST /clob/position/claimPosition
* Body: { marketID: string[], tokenID: string[] }
* x-tokenx-user-id
*/
export async function claimPosition(
data: ClaimPositionRequest,
config?: { headers?: Record<string, string> },
): Promise<ApiResponse> {
return post<ApiResponse>('/clob/position/claimPosition', data, config)
}
/** 钱包 Positions 展示项(与 Wallet.vue Position 一致) */ /** 钱包 Positions 展示项(与 Wallet.vue Position 一致) */
export interface PositionDisplayItem { export interface PositionDisplayItem {
id: string id: string
/** 市场标题(优先 market.question否则 marketID */
market: string market: string
shares: string shares: string
/** AVG • NOW 格式,如 "50¢ → 39¢";有 market.outcomePrices 时展示当前价 */
avgNow: string avgNow: string
bet: string bet: string
toWin: string toWin: string
@ -105,15 +156,43 @@ export interface PositionDisplayItem {
availableSharesNum?: number availableSharesNum?: number
/** 原始 outcome 字段API 返回,如 "Up"/"Down" 或 "Yes"/"No"),用于与市场 outcomes 匹配 */ /** 原始 outcome 字段API 返回,如 "Up"/"Down" 或 "Yes"/"No"),用于与市场 outcomes 匹配 */
outcome?: string outcome?: string
/** 市场图片 URLmarket.image有则优先展示 */
imageUrl?: string
/** 市场 ID用于 claimPosition统一为 string */
marketID?: string
/** Token ID用于 claimPositionAPI 返回 tokenId */
tokenID?: string
/** 是否待领取/未结算needClaim 为 true 时显示领取按钮) */
needClaim?: boolean
}
/**
* market.outcomes outcome outcomePrices
*/
function getOutcomePriceIndex(
outcomes: string[] | undefined,
outcome: string,
): number {
if (!outcomes?.length) return 0
const idx = outcomes.findIndex((o) => o === outcome)
return idx >= 0 ? idx : 0
}
/** 根据 outcome 返回图标字符 */
function getOutcomeIconChar(outcome: string): string {
const o = outcome?.trim() || ''
if (o === 'Up' || o === 'Yes' || o === 'Above') return '↑'
if (o === 'Down' || o === 'No' || o === 'Below') return '↓'
return o.charAt(0)?.toUpperCase() || '•'
} }
/** /**
* ClobPositionItem Position * ClobPositionItem Position
* sizeavailablecost 6 * sizeavailablecost 6 market question
*/ */
export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplayItem { export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplayItem {
const id = String(pos.ID ?? '') const id = String(pos.ID ?? '')
const market = pos.marketID ?? '' const market = pos.market?.question ?? String(pos.marketID ?? pos.market?.ID ?? '')
const sizeRaw = parsePosNum(pos.size ?? pos.available) const sizeRaw = parsePosNum(pos.size ?? pos.available)
const availableRaw = parsePosNum(pos.available) const availableRaw = parsePosNum(pos.available)
const costRaw = parsePosNum(pos.cost) const costRaw = parsePosNum(pos.cost)
@ -131,11 +210,32 @@ export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplay
const outcomeTag = `${outcome}` 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
// AVG成本/份额NOWmarket.outcomePrices 中对应 outcome 的当前价
let avgNow = '—'
if (size > 0 && costUsd > 0) {
const avgCents = Math.round((costUsd / size) * 100)
const avgStr = `${avgCents}¢`
const prices = pos.market?.outcomePrices
if (prices?.length) {
const idx = getOutcomePriceIndex(pos.market?.outcomes, outcome)
const nowVal = typeof prices[idx] === 'string' ? parseFloat(prices[idx] as string) : Number(prices[idx])
const nowCents = Number.isFinite(nowVal) ? Math.round(nowVal * 100) : null
avgNow = nowCents != null ? `${avgStr}${nowCents}¢` : avgStr
} else {
avgNow = avgStr
}
}
const marketID = String(pos.marketID ?? pos.market?.ID ?? '')
const tokenID = pos.tokenId ?? (pos as { tokenID?: string }).tokenID ?? ''
const imageUrl = (pos.market?.image ?? pos.market?.icon) as string | undefined
return { return {
id, id,
market, market,
shares, shares,
avgNow: '—', avgNow,
bet, bet,
toWin, toWin,
value, value,
@ -144,8 +244,14 @@ export function mapPositionToDisplayItem(pos: ClobPositionItem): PositionDisplay
outcomeTag, outcomeTag,
outcomePillClass: pillClass, outcomePillClass: pillClass,
outcome, outcome,
iconChar: getOutcomeIconChar(outcome),
iconClass: pillClass,
imageUrl: imageUrl || undefined,
locked, locked,
lockedSharesNum, lockedSharesNum,
availableSharesNum: availableNum >= 0 ? availableNum : undefined, availableSharesNum: availableNum >= 0 ? availableNum : undefined,
marketID: marketID || undefined,
tokenID: tokenID || undefined,
needClaim: pos.needClaim,
} }
} }

View File

@ -82,7 +82,11 @@
/> />
</div> </div>
<div v-if="destinationType === 'address'" class="hint-msg">
{{ t('withdraw.customAddressNotSupported') || 'Custom address is not supported for this withdrawal.' }}
</div>
<div v-if="amountError" class="error-msg">{{ amountError }}</div> <div v-if="amountError" class="error-msg">{{ amountError }}</div>
<div v-if="errorMessage" class="error-msg">{{ errorMessage }}</div>
<v-btn <v-btn
class="withdraw-btn" class="withdraw-btn"
@ -105,8 +109,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { BrowserProvider } from 'ethers'
import { SiweMessage } from 'siwe'
import { useUserStore } from '@/stores/user'
import { withdrawByWallet, usdcToAmount } from '@/api/pmset'
const { t } = useI18n() const { t } = useI18n()
const userStore = useUserStore()
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
modelValue: boolean modelValue: boolean
@ -117,12 +126,13 @@ const props = withDefaults(
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; success: [] }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean]; success: [] }>()
const amount = ref('') const amount = ref('')
const selectedNetwork = ref('ethereum') const selectedNetwork = ref('polygon')
const destinationType = ref<'wallet' | 'address'>('wallet') const destinationType = ref<'wallet' | 'address'>('wallet')
const customAddress = ref('') const customAddress = ref('')
const connectedAddress = ref('') const connectedAddress = ref('')
const connecting = ref(false) const connecting = ref(false)
const submitting = ref(false) const submitting = ref(false)
const errorMessage = ref('')
const networks = [ const networks = [
{ id: 'ethereum', label: 'Ethereum' }, { id: 'ethereum', label: 'Ethereum' },
@ -141,9 +151,10 @@ const amountError = computed(() => {
return '' return ''
}) })
/** 仅支持已连接钱包提现(需验签);自定义地址暂不支持 */
const hasValidDestination = computed(() => { const hasValidDestination = computed(() => {
if (destinationType.value === 'wallet') return !!connectedAddress.value if (destinationType.value === 'wallet') return !!connectedAddress.value
return /^0x[a-fA-F0-9]{40}$/.test(customAddress.value.trim()) return false
}) })
const canSubmit = computed( const canSubmit = computed(
@ -151,7 +162,8 @@ const canSubmit = computed(
amountNum.value > 0 && amountNum.value > 0 &&
amountNum.value <= balanceNum.value && amountNum.value <= balanceNum.value &&
hasValidDestination.value && hasValidDestination.value &&
!amountError.value, !amountError.value &&
!errorMessage.value,
) )
function shortAddress(addr: string) { function shortAddress(addr: string) {
@ -175,27 +187,99 @@ function allowDecimal(e: KeyboardEvent) {
async function connectWallet() { async function connectWallet() {
if (!window.ethereum) { if (!window.ethereum) {
alert(t('deposit.installMetaMask')) errorMessage.value = t('deposit.installMetaMask')
return return
} }
connecting.value = true connecting.value = true
errorMessage.value = ''
try { try {
const accounts = (await window.ethereum.request({ method: 'eth_requestAccounts' })) as string[] const accounts = (await window.ethereum.request({ method: 'eth_requestAccounts' })) as string[]
connectedAddress.value = accounts[0] || '' connectedAddress.value = accounts[0] || ''
if (!connectedAddress.value) {
errorMessage.value = t('withdraw.connectFailed') || 'Failed to connect wallet'
}
} catch (e) { } catch (e) {
console.error(e) console.error(e)
errorMessage.value = (e as Error)?.message || 'Failed to connect wallet'
} finally { } finally {
connecting.value = false connecting.value = false
} }
} }
/** 钱包验签(与 Login.vue 逻辑一致) */
async function signWithWallet(walletAddress: string): Promise<{ message: string; nonce: string; signature: string }> {
if (!window.ethereum) throw new Error(t('deposit.installMetaMask'))
const chainIdRaw = await window.ethereum.request({ method: 'eth_chainId' })
const chainId = typeof chainIdRaw === 'string' ? parseInt(chainIdRaw, 16) : Number(chainIdRaw)
const scheme = window.location.protocol.slice(0, -1)
const domain = window.location.host
const origin = window.location.origin
const nonce = new Date().getTime().toString()
const statement = `Withdraw ${amountNum.value} USDC to ${networks.find((n) => n.id === selectedNetwork.value)?.label ?? selectedNetwork.value}`
const provider = new BrowserProvider(window.ethereum)
const signer = await provider.getSigner()
const siwe = new SiweMessage({
scheme,
domain,
address: signer.address,
statement,
uri: origin,
version: '1',
chainId,
nonce,
})
const message = siwe.prepareMessage()
const message1 = message.replace(/^https?:\/\//, '')
const signature = (await window.ethereum.request({
method: 'personal_sign',
params: [message1, walletAddress],
})) as string
return { message: message1, nonce, signature }
}
async function submitWithdraw() { async function submitWithdraw() {
if (!canSubmit.value) return if (!canSubmit.value) return
const addr = connectedAddress.value
if (!addr || !/^0x[0-9a-fA-F]{40}$/.test(addr)) {
errorMessage.value = t('withdraw.connectWalletFirst') || 'Please connect wallet first'
return
}
const walletAddr = userStore.user?.externalWalletAddress as string | undefined
if (!walletAddr || !/^0x[0-9a-fA-F]{40}$/.test(walletAddr)) {
errorMessage.value = t('withdraw.externalWalletRequired') || 'External wallet address is required'
return
}
submitting.value = true submitting.value = true
errorMessage.value = ''
try { try {
await new Promise((r) => setTimeout(r, 800)) const { message, nonce, signature } = await signWithWallet(addr)
emit('success') const headers = userStore.getAuthHeaders()
close() const chain = selectedNetwork.value
const res = await withdrawByWallet(
{
amount: usdcToAmount(amountNum.value),
chain,
message,
nonce,
signature,
tokenAddress: addr,
tokenSymbol: 'USDC',
walletAddress: walletAddr,
},
headers ? { headers } : undefined,
)
if (res.code === 0 || res.code === 200) {
emit('success')
close()
} else {
errorMessage.value = res.msg || (t('error.requestFailed') ?? 'Request failed')
}
} catch (e) {
console.error('[submitWithdraw]', e)
errorMessage.value = (e as Error)?.message || (t('error.requestFailed') ?? 'Request failed')
} finally { } finally {
submitting.value = false submitting.value = false
} }
@ -209,6 +293,7 @@ watch(
destinationType.value = 'wallet' destinationType.value = 'wallet'
customAddress.value = '' customAddress.value = ''
connectedAddress.value = '' connectedAddress.value = ''
errorMessage.value = ''
} }
}, },
) )
@ -292,6 +377,12 @@ watch(
margin-top: 8px; margin-top: 8px;
} }
.hint-msg {
font-size: 0.8rem;
color: #6b7280;
margin-bottom: 12px;
}
.error-msg { .error-msg {
font-size: 0.8rem; font-size: 0.8rem;
color: #dc2626; color: #dc2626;

View File

@ -17,7 +17,8 @@
"toast": { "toast": {
"orderSuccess": "Order placed successfully", "orderSuccess": "Order placed successfully",
"splitSuccess": "Split successful", "splitSuccess": "Split successful",
"mergeSuccess": "Merge successful" "mergeSuccess": "Merge successful",
"claimSuccess": "Claim successful"
}, },
"trade": { "trade": {
"buy": "Buy", "buy": "Buy",
@ -163,7 +164,22 @@
"expiration": "EXPIRATION", "expiration": "EXPIRATION",
"activity": "ACTIVITY", "activity": "ACTIVITY",
"view": "View", "view": "View",
"expirationLabel": "Expiration:" "expirationLabel": "Expiration:",
"youWon": "You won ${amount}",
"claim": "Claim",
"withdrawals": "Withdrawals",
"withdrawStatusAll": "All",
"withdrawStatusPending": "Under review",
"withdrawStatusSuccess": "Withdrawn",
"withdrawStatusRejected": "Rejected",
"withdrawStatusFailed": "Failed",
"withdrawAmount": "Amount",
"withdrawAddress": "Address",
"withdrawRequestNo": "Request No.",
"withdrawChain": "Chain",
"withdrawTime": "Time",
"withdrawStatus": "Status",
"noWithdrawalsFound": "No withdrawals found"
}, },
"deposit": { "deposit": {
"title": "Deposit", "title": "Deposit",
@ -201,7 +217,11 @@
"addressPlaceholder": "0x...", "addressPlaceholder": "0x...",
"amountMustBePositive": "Amount must be greater than 0", "amountMustBePositive": "Amount must be greater than 0",
"insufficientBalance": "Insufficient balance", "insufficientBalance": "Insufficient balance",
"close": "Close" "close": "Close",
"connectFailed": "Failed to connect wallet",
"connectWalletFirst": "Please connect wallet first",
"externalWalletRequired": "External wallet address is required in user info",
"customAddressNotSupported": "Custom address is not supported for this withdrawal."
}, },
"locale": { "locale": {
"zh": "简体中文", "zh": "简体中文",

View File

@ -17,7 +17,8 @@
"toast": { "toast": {
"orderSuccess": "注文が完了しました", "orderSuccess": "注文が完了しました",
"splitSuccess": "スプリット成功", "splitSuccess": "スプリット成功",
"mergeSuccess": "マージ成功" "mergeSuccess": "マージ成功",
"claimSuccess": "受け取り完了"
}, },
"trade": { "trade": {
"buy": "買う", "buy": "買う",
@ -163,7 +164,22 @@
"expiration": "有効期限", "expiration": "有効期限",
"activity": "アクティビティ", "activity": "アクティビティ",
"view": "表示", "view": "表示",
"expirationLabel": "有効期限:" "expirationLabel": "有効期限:",
"youWon": "獲得 $${amount}",
"claim": "受け取る",
"withdrawals": "出金履歴",
"withdrawStatusAll": "すべて",
"withdrawStatusPending": "審査中",
"withdrawStatusSuccess": "出金成功",
"withdrawStatusRejected": "審査不通過",
"withdrawStatusFailed": "出金失敗",
"withdrawAmount": "金額",
"withdrawAddress": "出金アドレス",
"withdrawRequestNo": "申請番号",
"withdrawChain": "チェーン",
"withdrawTime": "時間",
"withdrawStatus": "状態",
"noWithdrawalsFound": "出金履歴がありません"
}, },
"deposit": { "deposit": {
"title": "入金", "title": "入金",
@ -201,7 +217,11 @@
"addressPlaceholder": "0x...", "addressPlaceholder": "0x...",
"amountMustBePositive": "金額は 0 より大きい必要があります", "amountMustBePositive": "金額は 0 より大きい必要があります",
"insufficientBalance": "残高不足", "insufficientBalance": "残高不足",
"close": "閉じる" "close": "閉じる",
"connectFailed": "ウォレットの接続に失敗しました",
"connectWalletFirst": "まずウォレットを接続してください",
"externalWalletRequired": "ユーザー情報に外部ウォレットアドレスが必要です",
"customAddressNotSupported": "この出金ではカスタムアドレスはサポートされていません。"
}, },
"locale": { "locale": {
"zh": "简体中文", "zh": "简体中文",

View File

@ -17,7 +17,8 @@
"toast": { "toast": {
"orderSuccess": "주문이 완료되었습니다", "orderSuccess": "주문이 완료되었습니다",
"splitSuccess": "분할 완료", "splitSuccess": "분할 완료",
"mergeSuccess": "병합 완료" "mergeSuccess": "병합 완료",
"claimSuccess": "수령 완료"
}, },
"trade": { "trade": {
"buy": "매수", "buy": "매수",
@ -163,7 +164,22 @@
"expiration": "만료", "expiration": "만료",
"activity": "활동", "activity": "활동",
"view": "보기", "view": "보기",
"expirationLabel": "만료:" "expirationLabel": "만료:",
"youWon": "당첨 $${amount}",
"claim": "수령",
"withdrawals": "출금 내역",
"withdrawStatusAll": "전체",
"withdrawStatusPending": "검토 중",
"withdrawStatusSuccess": "출금 완료",
"withdrawStatusRejected": "승인 거부",
"withdrawStatusFailed": "출금 실패",
"withdrawAmount": "금액",
"withdrawAddress": "출금 주소",
"withdrawRequestNo": "신청 번호",
"withdrawChain": "체인",
"withdrawTime": "시간",
"withdrawStatus": "상태",
"noWithdrawalsFound": "출금 내역이 없습니다"
}, },
"deposit": { "deposit": {
"title": "입금", "title": "입금",
@ -201,7 +217,11 @@
"addressPlaceholder": "0x...", "addressPlaceholder": "0x...",
"amountMustBePositive": "금액은 0보다 커야 합니다", "amountMustBePositive": "금액은 0보다 커야 합니다",
"insufficientBalance": "잔액 부족", "insufficientBalance": "잔액 부족",
"close": "닫기" "close": "닫기",
"connectFailed": "지갑 연결에 실패했습니다",
"connectWalletFirst": "먼저 지갑을 연결해 주세요",
"externalWalletRequired": "사용자 정보에 외부 지갑 주소가 필요합니다",
"customAddressNotSupported": "이 출금에서는 사용자 지정 주소가 지원되지 않습니다."
}, },
"locale": { "locale": {
"zh": "简体中文", "zh": "简体中文",

View File

@ -17,7 +17,8 @@
"toast": { "toast": {
"orderSuccess": "下单成功", "orderSuccess": "下单成功",
"splitSuccess": "拆分成功", "splitSuccess": "拆分成功",
"mergeSuccess": "合并成功" "mergeSuccess": "合并成功",
"claimSuccess": "领取成功"
}, },
"trade": { "trade": {
"buy": "买入", "buy": "买入",
@ -163,7 +164,22 @@
"expiration": "到期", "expiration": "到期",
"activity": "活动", "activity": "活动",
"view": "查看", "view": "查看",
"expirationLabel": "到期" "expirationLabel": "到期",
"youWon": "您赢得 ${amount}",
"claim": "领取",
"withdrawals": "提现记录",
"withdrawStatusAll": "全部",
"withdrawStatusPending": "审核中",
"withdrawStatusSuccess": "提现成功",
"withdrawStatusRejected": "审核不通过",
"withdrawStatusFailed": "提现失败",
"withdrawAmount": "金额",
"withdrawRequestNo": "申请单号",
"withdrawChain": "链",
"withdrawTime": "时间",
"withdrawStatus": "状态",
"noWithdrawalsFound": "暂无提现记录",
"withdrawAddress": "提现地址"
}, },
"deposit": { "deposit": {
"title": "入金", "title": "入金",
@ -201,7 +217,23 @@
"addressPlaceholder": "0x...", "addressPlaceholder": "0x...",
"amountMustBePositive": "金额必须大于 0", "amountMustBePositive": "金额必须大于 0",
"insufficientBalance": "余额不足", "insufficientBalance": "余额不足",
"close": "关闭" "close": "关闭",
"withdrawals": "提现记录",
"withdrawStatusAll": "全部",
"withdrawStatusPending": "审核中",
"withdrawStatusSuccess": "提现成功",
"withdrawStatusRejected": "审核不通过",
"withdrawStatusFailed": "提现失败",
"withdrawAmount": "金额",
"withdrawRequestNo": "申请单号",
"withdrawChain": "链",
"withdrawTime": "时间",
"withdrawStatus": "状态",
"noWithdrawalsFound": "暂无提现记录",
"connectFailed": "连接钱包失败",
"connectWalletFirst": "请先连接钱包",
"externalWalletRequired": "用户信息中缺少外部钱包地址",
"customAddressNotSupported": "暂不支持自定义地址提现。"
}, },
"locale": { "locale": {
"zh": "简体中文", "zh": "简体中文",

View File

@ -17,7 +17,8 @@
"toast": { "toast": {
"orderSuccess": "下單成功", "orderSuccess": "下單成功",
"splitSuccess": "拆分成功", "splitSuccess": "拆分成功",
"mergeSuccess": "合併成功" "mergeSuccess": "合併成功",
"claimSuccess": "領取成功"
}, },
"trade": { "trade": {
"buy": "買入", "buy": "買入",
@ -163,7 +164,22 @@
"expiration": "到期", "expiration": "到期",
"activity": "活動", "activity": "活動",
"view": "查看", "view": "查看",
"expirationLabel": "到期" "expirationLabel": "到期",
"youWon": "您贏得 ${amount}",
"claim": "領取",
"withdrawals": "提現記錄",
"withdrawStatusAll": "全部",
"withdrawStatusPending": "審核中",
"withdrawStatusSuccess": "提現成功",
"withdrawStatusRejected": "審核不通過",
"withdrawStatusFailed": "提現失敗",
"withdrawAmount": "金額",
"withdrawAddress": "提現地址",
"withdrawRequestNo": "申請單號",
"withdrawChain": "鏈",
"withdrawTime": "時間",
"withdrawStatus": "狀態",
"noWithdrawalsFound": "暫無提現記錄"
}, },
"deposit": { "deposit": {
"title": "入金", "title": "入金",
@ -201,7 +217,11 @@
"addressPlaceholder": "0x...", "addressPlaceholder": "0x...",
"amountMustBePositive": "金額必須大於 0", "amountMustBePositive": "金額必須大於 0",
"insufficientBalance": "餘額不足", "insufficientBalance": "餘額不足",
"close": "關閉" "close": "關閉",
"connectFailed": "連接錢包失敗",
"connectWalletFirst": "請先連接錢包",
"externalWalletRequired": "用戶資訊中缺少外部錢包地址",
"customAddressNotSupported": "暫不支援自訂地址提現。"
}, },
"locale": { "locale": {
"zh": "繁體中文", "zh": "繁體中文",

View File

@ -62,6 +62,13 @@
:key="pos.id" :key="pos.id"
class="position-row-item" class="position-row-item"
> >
<div class="position-row-header">
<div class="position-row-icon" :class="pos.iconClass">
<img v-if="pos.imageUrl" :src="pos.imageUrl" alt="" class="position-row-icon-img" />
<span v-else class="position-row-icon-char">{{ pos.iconChar || '•' }}</span>
</div>
<span class="position-row-title">{{ pos.market }}</span>
</div>
<div class="position-row-main"> <div class="position-row-main">
<span :class="['position-outcome-pill', pos.outcomePillClass]">{{ pos.outcomeTag }}</span> <span :class="['position-outcome-pill', pos.outcomePillClass]">{{ pos.outcomeTag }}</span>
<span v-if="pos.locked" class="position-lock-badge"> <span v-if="pos.locked" class="position-lock-badge">
@ -355,6 +362,12 @@ import {
type OpenOrderDisplayItem, type OpenOrderDisplayItem,
} from '../api/order' } from '../api/order'
import { cancelOrder as apiCancelOrder } from '../api/order' import { cancelOrder as apiCancelOrder } from '../api/order'
import {
normalizeChartData,
fetchChartHistory,
type ChartDataPoint,
type ChartTimeRange,
} from '../api/chart'
const { t } = useI18n() const { t } = useI18n()
import { import {
@ -1162,8 +1175,8 @@ const timeRanges = [
{ label: 'ALL', value: 'ALL' }, { label: 'ALL', value: 'ALL' },
] ]
// [timestamp, value][] // [(ms), (0-100)][] src/api/chart.tsChartHistoryParams / ChartHistoryItem / normalizeChartData
function generateData(range: string): [number, number][] { function generateData(range: string): ChartDataPoint[] {
const now = Date.now() const now = Date.now()
const data: [number, number][] = [] const data: [number, number][] = []
let stepMs: number let stepMs: number
@ -1338,6 +1351,15 @@ function initChart() {
chartInstance.setOption(buildOption(data.value, w)) chartInstance.setOption(buildOption(data.value, w))
} }
/** 从接口拉取图表数据(接入时在 updateChartData 中调用并赋给 data.value */
async function loadChartFromApi(marketId: string): Promise<ChartDataPoint[]> {
const res = await fetchChartHistory(
{ marketID: marketId, range: selectedTimeRange.value as ChartTimeRange }
)
return normalizeChartData(res.data ?? [])
}
// 使 updateChartData await loadChartFromApi(marketId) setOption generateData
function updateChartData() { function updateChartData() {
data.value = generateData(selectedTimeRange.value) data.value = generateData(selectedTimeRange.value)
const w = chartContainerRef.value?.clientWidth const w = chartContainerRef.value?.clientWidth
@ -1967,6 +1989,54 @@ onUnmounted(() => {
border-bottom: none; border-bottom: none;
} }
.position-row-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.position-row-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-weight: 700;
font-size: 16px;
color: #fff;
}
.position-row-icon.pill-yes {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
}
.position-row-icon.pill-down {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
}
.position-row-icon-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.position-row-icon-char {
line-height: 1;
}
.position-row-title {
font-size: 14px;
font-weight: 500;
color: #111827;
line-height: 1.3;
flex: 1;
min-width: 0;
}
.position-row-main, .position-row-main,
.order-row-main { .order-row-main {
display: flex; display: flex;

View File

@ -78,12 +78,37 @@
</v-col> </v-col>
</v-row> </v-row>
<!-- 未结算汇总单条展示多条用 +N结算按钮不显示图标 -->
<v-row v-if="unsettledCount > 0" class="wallet-settlement-row">
<v-col cols="12">
<v-card class="settlement-card" elevation="0" rounded="lg">
<div class="settlement-inner">
<div class="settlement-label">
{{ t('wallet.youWon', { amount: unsettledTotalText }) }}
<span v-if="unsettledCount > 1" class="settlement-plus-n">+{{ unsettledCount - 1 }}</span>
</div>
<v-btn
color="primary"
variant="flat"
class="settlement-claim-btn"
:loading="claimLoading"
:disabled="claimLoading"
@click="onClaimSettlement"
>
{{ t('wallet.claim') }}
</v-btn>
</div>
</v-card>
</v-col>
</v-row>
<!-- 下方Positions / Open orders / History --> <!-- 下方Positions / Open orders / History -->
<div class="wallet-section"> <div class="wallet-section">
<v-tabs v-model="activeTab" class="wallet-tabs" density="comfortable"> <v-tabs v-model="activeTab" class="wallet-tabs" density="comfortable">
<v-tab value="positions">{{ t('wallet.positions') }}</v-tab> <v-tab value="positions">{{ t('wallet.positions') }}</v-tab>
<v-tab value="orders">{{ t('wallet.openOrders') }}</v-tab> <v-tab value="orders">{{ t('wallet.openOrders') }}</v-tab>
<v-tab value="history">{{ t('wallet.history') }}</v-tab> <v-tab value="history">{{ t('wallet.history') }}</v-tab>
<v-tab value="withdrawals">{{ t('wallet.withdrawals') }}</v-tab>
</v-tabs> </v-tabs>
<div class="toolbar"> <div class="toolbar">
<v-text-field <v-text-field
@ -129,6 +154,19 @@
{{ t('wallet.currentValue') }} {{ t('wallet.currentValue') }}
</v-btn> </v-btn>
</template> </template>
<template v-else-if="activeTab === 'withdrawals'">
<v-btn
v-for="opt in withdrawStatusOptions"
:key="opt.value"
:variant="withdrawStatusFilter === opt.value ? 'flat' : 'outlined'"
:color="withdrawStatusFilter === opt.value ? 'primary' : undefined"
size="small"
class="filter-btn"
@click="withdrawStatusFilter = opt.value"
>
{{ opt.label }}
</v-btn>
</template>
<template v-else-if="activeTab === 'orders'"> <template v-else-if="activeTab === 'orders'">
<v-btn variant="outlined" size="small" class="filter-btn"> <v-btn variant="outlined" size="small" class="filter-btn">
<v-icon size="18">mdi-filter</v-icon> <v-icon size="18">mdi-filter</v-icon>
@ -169,7 +207,9 @@
> >
<div class="position-mobile-row"> <div class="position-mobile-row">
<div class="position-icon" :class="pos.iconClass"> <div class="position-icon" :class="pos.iconClass">
<span class="position-icon-char">{{ pos.iconChar }}</span> <img v-if="pos.imageUrl" :src="pos.imageUrl" alt="" class="position-icon-img" />
<v-icon v-else-if="pos.icon" size="20" class="position-icon-svg">{{ pos.icon }}</v-icon>
<span v-else class="position-icon-char">{{ pos.iconChar || '•' }}</span>
</div> </div>
<div class="position-mobile-main"> <div class="position-mobile-main">
<div class="position-mobile-title">{{ pos.market }}</div> <div class="position-mobile-title">{{ pos.market }}</div>
@ -269,10 +309,9 @@
<td class="cell-market"> <td class="cell-market">
<div class="position-market-cell"> <div class="position-market-cell">
<div class="position-icon" :class="pos.iconClass"> <div class="position-icon" :class="pos.iconClass">
<v-icon v-if="pos.icon" size="20" class="position-icon-svg">{{ <img v-if="pos.imageUrl" :src="pos.imageUrl" alt="" class="position-icon-img" />
pos.icon <v-icon v-else-if="pos.icon" size="20" class="position-icon-svg">{{ pos.icon }}</v-icon>
}}</v-icon> <span v-else class="position-icon-char">{{ pos.iconChar || '•' }}</span>
<span v-else class="position-icon-char">{{ pos.iconChar }}</span>
</div> </div>
<div class="position-market-info"> <div class="position-market-info">
<span class="position-market-title">{{ pos.market }}</span> <span class="position-market-title">{{ pos.market }}</span>
@ -502,6 +541,71 @@
</tbody> </tbody>
</v-table> </v-table>
</template> </template>
<!-- 提现记录分页列表 -->
<template v-else-if="activeTab === 'withdrawals'">
<div v-if="mobile" class="withdrawals-mobile-list">
<template v-if="withdrawalsLoading">
<div class="empty-cell">{{ t('common.loading') }}</div>
</template>
<template v-else-if="withdrawalsList.length === 0">
<div class="empty-cell">{{ t('wallet.noWithdrawalsFound') }}</div>
</template>
<div
v-for="w in withdrawalsList"
:key="String(w.ID ?? w.requestNo ?? '')"
class="withdrawal-mobile-card"
>
<div class="withdrawal-mobile-row">
<div class="withdrawal-mobile-main">
<div class="withdrawal-mobile-amount">${{ formatWithdrawAmount(w.amount) }}</div>
<div class="withdrawal-mobile-meta">
{{ w.chain || '—' }} · {{ formatWithdrawTime(w.CreatedAt ?? w.createdAt) }}
</div>
<div class="withdrawal-mobile-address">{{ shortAddress(w.tokenAddress ?? w.walletAddress) }}</div>
</div>
<span :class="['withdrawal-status-pill', getWithdrawStatusClass(w.status)]">
{{ getWithdrawStatusLabel(w.status) }}
</span>
</div>
<div v-if="w.reason" class="withdrawal-mobile-reason">
{{ w.reason }}
</div>
</div>
</div>
<v-table v-else class="wallet-table">
<thead>
<tr>
<th class="text-left">{{ t('wallet.withdrawAmount') }}</th>
<th class="text-left">{{ t('wallet.withdrawStatus') }}</th>
<th class="text-left">{{ t('wallet.withdrawAddress') }}</th>
<th class="text-left">{{ t('wallet.withdrawChain') }}</th>
<th class="text-left">{{ t('wallet.withdrawTime') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="withdrawalsLoading">
<td colspan="5" class="empty-cell">{{ t('common.loading') }}</td>
</tr>
<tr v-else-if="withdrawalsList.length === 0">
<td colspan="5" class="empty-cell">{{ t('wallet.noWithdrawalsFound') }}</td>
</tr>
<tr v-for="w in withdrawalsList" :key="String(w.ID ?? w.requestNo ?? '')">
<td>${{ formatWithdrawAmount(w.amount) }}</td>
<td>
<span :class="['withdrawal-status-pill', getWithdrawStatusClass(w.status)]">
{{ getWithdrawStatusLabel(w.status) }}
</span>
<div v-if="w.reason" class="withdrawal-reason">
{{ w.reason }}
</div>
</td>
<td class="cell-address">{{ shortAddress(w.tokenAddress ?? w.walletAddress) }}</td>
<td>{{ w.chain || '—' }}</td>
<td>{{ formatWithdrawTime(w.CreatedAt ?? w.createdAt) }}</td>
</tr>
</tbody>
</v-table>
</template>
<!-- 分页 --> <!-- 分页 -->
<div v-if="currentListTotal > 0" class="pagination-bar"> <div v-if="currentListTotal > 0" class="pagination-bar">
<span class="pagination-info"> <span class="pagination-info">
@ -587,7 +691,9 @@
<v-card-text class="sell-dialog-body"> <v-card-text class="sell-dialog-body">
<div class="sell-dialog-icon-wrap"> <div class="sell-dialog-icon-wrap">
<div class="position-icon" :class="sellPositionItem.iconClass"> <div class="position-icon" :class="sellPositionItem.iconClass">
<span class="position-icon-char">{{ sellPositionItem.iconChar }}</span> <img v-if="sellPositionItem.imageUrl" :src="sellPositionItem.imageUrl" alt="" class="position-icon-img" />
<v-icon v-else-if="sellPositionItem.icon" size="20" class="position-icon-svg">{{ sellPositionItem.icon }}</v-icon>
<span v-else class="position-icon-char">{{ sellPositionItem.iconChar || '•' }}</span>
</div> </div>
</div> </div>
<h3 class="sell-dialog-title">Sell {{ sellPositionItem.sellOutcome || 'Position' }}</h3> <h3 class="sell-dialog-title">Sell {{ sellPositionItem.sellOutcome || 'Position' }}</h3>
@ -630,7 +736,13 @@ import { useLocaleStore } from '../stores/locale'
import { useAuthError } from '../composables/useAuthError' import { useAuthError } from '../composables/useAuthError'
import { cancelOrder as apiCancelOrder } from '../api/order' import { cancelOrder as apiCancelOrder } from '../api/order'
import { getOrderList, mapOrderToHistoryItem, mapOrderToOpenOrderItem, OrderStatus } from '../api/order' import { getOrderList, mapOrderToHistoryItem, mapOrderToOpenOrderItem, OrderStatus } from '../api/order'
import { getPositionList, mapPositionToDisplayItem } from '../api/position' import { getPositionList, mapPositionToDisplayItem, claimPosition } from '../api/position'
import {
getSettlementRequestsListClient,
amountToUsdcDisplay,
WITHDRAW_STATUS,
type SettlementRequestClientItem,
} from '../api/pmset'
import { import {
MOCK_TOKEN_ID, MOCK_TOKEN_ID,
MOCK_WALLET_POSITIONS, MOCK_WALLET_POSITIONS,
@ -639,6 +751,7 @@ import {
} from '../api/mockData' } from '../api/mockData'
import { USE_MOCK_WALLET } from '../config/mock' import { USE_MOCK_WALLET } from '../config/mock'
import { CrossChainUSDTAuth } from '../../sdk/approve' import { CrossChainUSDTAuth } from '../../sdk/approve'
import { useToastStore } from '../stores/toast'
const { mobile } = useDisplay() const { mobile } = useDisplay()
const userStore = useUserStore() const userStore = useUserStore()
@ -653,8 +766,69 @@ const plTimeRanges = computed(() => [
{ label: t('wallet.pl1M'), value: '1M' }, { label: t('wallet.pl1M'), value: '1M' },
{ label: t('wallet.plAll'), value: 'ALL' }, { label: t('wallet.plAll'), value: 'ALL' },
]) ])
const activeTab = ref<'positions' | 'orders' | 'history'>('positions') const activeTab = ref<'positions' | 'orders' | 'history' | 'withdrawals'>('positions')
const search = ref('') const search = ref('')
const withdrawStatusFilter = ref<string>('')
const withdrawStatusOptions = computed(() => [
{ label: t('wallet.withdrawStatusAll'), value: '' },
{ label: t('wallet.withdrawStatusPending'), value: WITHDRAW_STATUS.PENDING },
{ label: t('wallet.withdrawStatusSuccess'), value: WITHDRAW_STATUS.SUCCESS },
{ label: t('wallet.withdrawStatusRejected'), value: WITHDRAW_STATUS.REJECTED },
{ label: t('wallet.withdrawStatusFailed'), value: WITHDRAW_STATUS.FAILED },
])
/** 当前展示的持仓列表mock 或 API */
const currentPositionList = computed(() =>
USE_MOCK_WALLET ? positions.value : positionList.value,
)
/** 未结算项:从持仓列表中筛出可领取的(有 marketID+tokenID若后端有 needClaim 则仅 needClaim 为 true */
const unsettledItems = computed(() => {
const list = currentPositionList.value
return list
.filter(
(p) =>
p.marketID &&
p.tokenID &&
(p.needClaim === undefined || p.needClaim === true),
)
.map((p) => {
const amount = parseFloat(String(p.value).replace(/[^0-9.-]/g, '')) || 0
return { marketID: p.marketID!, tokenID: p.tokenID!, amount }
})
})
const unsettledCount = computed(() => unsettledItems.value.length)
const unsettledTotalText = computed(() => {
const sum = unsettledItems.value.reduce((a, b) => a + b.amount, 0)
return sum.toFixed(2)
})
const claimLoading = ref(false)
const toastStore = useToastStore()
async function onClaimSettlement() {
const items = unsettledItems.value
if (items.length === 0) return
const headers = userStore.getAuthHeaders()
if (!headers) {
toastStore.show(t('trade.pleaseLogin'), 'error')
return
}
claimLoading.value = true
try {
const res = await claimPosition(
{ marketID: items.map((i) => i.marketID), tokenID: items.map((i) => i.tokenID) },
{ headers },
)
if (res.code === 0 || res.code === 200) {
toastStore.show(t('toast.claimSuccess'))
userStore.fetchUsdcBalance()
if (activeTab.value === 'positions') loadPositionList()
} else {
toastStore.show(res.msg || t('error.requestFailed'), 'error')
}
} catch (e) {
toastStore.show(formatAuthError(e, t('error.requestFailed')), 'error')
} finally {
claimLoading.value = false
}
}
const depositDialogOpen = ref(false) const depositDialogOpen = ref(false)
const withdrawDialogOpen = ref(false) const withdrawDialogOpen = ref(false)
const authorizeDialogOpen = ref(false) const authorizeDialogOpen = ref(false)
@ -679,6 +853,7 @@ interface Position {
icon?: string icon?: string
iconChar?: string iconChar?: string
iconClass?: string iconClass?: string
imageUrl?: string
outcomeTag?: string outcomeTag?: string
outcomePillClass?: string outcomePillClass?: string
shares: string shares: string
@ -693,6 +868,12 @@ interface Position {
sellOutcome?: string sellOutcome?: string
/** 移动端副标题 "on Up/Down to win" 中的词 */ /** 移动端副标题 "on Up/Down to win" 中的词 */
outcomeWord?: string outcomeWord?: string
/** 市场 ID从持仓列表来用于领取结算 */
marketID?: string
/** Token ID从持仓列表来用于领取结算 */
tokenID?: string
/** 是否待领取/未结算(后端可选,无则按有 marketID+tokenID 视为可领取) */
needClaim?: boolean
} }
/** 从 avgNow "72¢ → 0.5¢" 解析出 [avg, now] */ /** 从 avgNow "72¢ → 0.5¢" 解析出 [avg, now] */
@ -743,6 +924,11 @@ interface HistoryItem {
const positions = ref<Position[]>( const positions = ref<Position[]>(
USE_MOCK_WALLET ? [...MOCK_WALLET_POSITIONS] : [], USE_MOCK_WALLET ? [...MOCK_WALLET_POSITIONS] : [],
) )
/** 提现记录列表 */
const withdrawalsList = ref<SettlementRequestClientItem[]>([])
const withdrawalsTotal = ref(0)
const withdrawalsLoading = ref(false)
/** 持仓列表API 数据,非 mock 时使用) */ /** 持仓列表API 数据,非 mock 时使用) */
const positionList = ref<Position[]>([]) const positionList = ref<Position[]>([])
const positionTotal = ref(0) const positionTotal = ref(0)
@ -885,6 +1071,75 @@ async function loadHistoryOrders() {
} }
} }
async function loadWithdrawals() {
const headers = userStore.getAuthHeaders()
if (!headers) {
withdrawalsList.value = []
withdrawalsTotal.value = 0
return
}
withdrawalsLoading.value = true
try {
const res = await getSettlementRequestsListClient(
{
page: page.value,
pageSize: itemsPerPage.value,
status: withdrawStatusFilter.value || undefined,
},
{ headers },
)
if (res.code === 0 || res.code === 200) {
withdrawalsList.value = res.data?.list ?? []
withdrawalsTotal.value = res.data?.total ?? 0
} else {
withdrawalsList.value = []
withdrawalsTotal.value = 0
}
} catch {
withdrawalsList.value = []
withdrawalsTotal.value = 0
} finally {
withdrawalsLoading.value = false
}
}
function formatWithdrawAmount(amount: number | undefined): string {
return amountToUsdcDisplay(amount)
}
function shortAddress(addr: string | undefined): string {
if (!addr) return '—'
return addr.length > 12 ? `${addr.slice(0, 6)}...${addr.slice(-4)}` : addr
}
function formatWithdrawTime(iso: string | undefined): string {
if (!iso) return '—'
try {
const d = new Date(iso)
return d.toLocaleString()
} catch {
return iso
}
}
function getWithdrawStatusLabel(status: string | undefined): string {
const s = (status ?? '').toLowerCase()
if (s === WITHDRAW_STATUS.PENDING || s === '0' || s === 'pending') return t('wallet.withdrawStatusPending')
if (s === WITHDRAW_STATUS.SUCCESS || s === '1' || s === 'success') return t('wallet.withdrawStatusSuccess')
if (s === WITHDRAW_STATUS.REJECTED || s === '2' || s === 'rejected') return t('wallet.withdrawStatusRejected')
if (s === WITHDRAW_STATUS.FAILED || s === '3' || s === 'failed') return t('wallet.withdrawStatusFailed')
return status ?? '—'
}
function getWithdrawStatusClass(status: string | undefined): string {
const s = (status ?? '').toLowerCase()
if (s === WITHDRAW_STATUS.PENDING || s === '0' || s === 'pending') return 'status-pending'
if (s === WITHDRAW_STATUS.SUCCESS || s === '1' || s === 'success') return 'status-success'
if (s === WITHDRAW_STATUS.REJECTED || s === '2' || s === 'rejected') return 'status-rejected'
if (s === WITHDRAW_STATUS.FAILED || s === '3' || s === 'failed') return 'status-failed'
return ''
}
function matchSearch(text: string): boolean { function matchSearch(text: string): boolean {
const q = search.value.trim().toLowerCase() const q = search.value.trim().toLowerCase()
return !q || text.toLowerCase().includes(q) return !q || text.toLowerCase().includes(q)
@ -935,17 +1190,22 @@ const totalPagesHistory = computed(() => {
const total = USE_MOCK_WALLET ? filteredHistory.value.length : historyTotal.value const total = USE_MOCK_WALLET ? filteredHistory.value.length : historyTotal.value
return Math.max(1, Math.ceil(total / itemsPerPage.value)) return Math.max(1, Math.ceil(total / itemsPerPage.value))
}) })
const totalPagesWithdrawals = computed(() =>
Math.max(1, Math.ceil(withdrawalsTotal.value / itemsPerPage.value)),
)
const currentListTotal = computed(() => { const currentListTotal = computed(() => {
if (activeTab.value === 'positions') if (activeTab.value === 'positions')
return USE_MOCK_WALLET ? filteredPositions.value.length : positionTotal.value return USE_MOCK_WALLET ? filteredPositions.value.length : positionTotal.value
if (activeTab.value === 'orders') if (activeTab.value === 'orders')
return USE_MOCK_WALLET ? filteredOpenOrders.value.length : openOrderTotal.value return USE_MOCK_WALLET ? filteredOpenOrders.value.length : openOrderTotal.value
if (activeTab.value === 'withdrawals') return withdrawalsTotal.value
return USE_MOCK_WALLET ? filteredHistory.value.length : historyTotal.value return USE_MOCK_WALLET ? filteredHistory.value.length : historyTotal.value
}) })
const currentTotalPages = computed(() => { const currentTotalPages = computed(() => {
if (activeTab.value === 'positions') return totalPagesPositions.value if (activeTab.value === 'positions') return totalPagesPositions.value
if (activeTab.value === 'orders') return totalPagesOrders.value if (activeTab.value === 'orders') return totalPagesOrders.value
if (activeTab.value === 'withdrawals') return totalPagesWithdrawals.value
return totalPagesHistory.value return totalPagesHistory.value
}) })
const currentPageStart = computed(() => const currentPageStart = computed(() =>
@ -960,11 +1220,17 @@ watch(activeTab, (tab) => {
if (tab === 'positions' && !USE_MOCK_WALLET) loadPositionList() if (tab === 'positions' && !USE_MOCK_WALLET) loadPositionList()
if (tab === 'orders' && !USE_MOCK_WALLET) loadOpenOrders() if (tab === 'orders' && !USE_MOCK_WALLET) loadOpenOrders()
if (tab === 'history' && !USE_MOCK_WALLET) loadHistoryOrders() if (tab === 'history' && !USE_MOCK_WALLET) loadHistoryOrders()
if (tab === 'withdrawals') loadWithdrawals()
}) })
watch([page, itemsPerPage], () => { watch([page, itemsPerPage], () => {
if (activeTab.value === 'positions' && !USE_MOCK_WALLET) loadPositionList() if (activeTab.value === 'positions' && !USE_MOCK_WALLET) loadPositionList()
if (activeTab.value === 'orders' && !USE_MOCK_WALLET) loadOpenOrders() if (activeTab.value === 'orders' && !USE_MOCK_WALLET) loadOpenOrders()
if (activeTab.value === 'history' && !USE_MOCK_WALLET) loadHistoryOrders() if (activeTab.value === 'history' && !USE_MOCK_WALLET) loadHistoryOrders()
if (activeTab.value === 'withdrawals') loadWithdrawals()
})
watch(withdrawStatusFilter, () => {
page.value = 1
if (activeTab.value === 'withdrawals') loadWithdrawals()
}) })
watch([currentListTotal, itemsPerPage], () => { watch([currentListTotal, itemsPerPage], () => {
const maxPage = currentTotalPages.value const maxPage = currentTotalPages.value
@ -1216,6 +1482,8 @@ onUnmounted(() => {
function onWithdrawSuccess() { function onWithdrawSuccess() {
withdrawDialogOpen.value = false withdrawDialogOpen.value = false
userStore.fetchUsdcBalance()
if (activeTab.value === 'withdrawals') loadWithdrawals()
} }
function onAuthorizeClick() { function onAuthorizeClick() {
@ -1332,6 +1600,47 @@ async function submitAuthorize() {
margin-top: 4px; margin-top: 4px;
} }
/* 未结算汇总:单条 item多条 +N领取按钮无图标 */
.wallet-settlement-row {
margin-top: 16px;
}
.settlement-card {
border: 1px solid #e5e7eb;
padding: 12px 16px;
}
.settlement-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.settlement-label {
font-size: 15px;
font-weight: 500;
color: #111827;
}
.settlement-plus-n {
display: inline-block;
margin-left: 6px;
padding: 2px 8px;
font-size: 13px;
font-weight: 500;
color: #6b7280;
background: #f3f4f6;
border-radius: 999px;
}
.settlement-claim-btn {
text-transform: none;
font-weight: 500;
min-width: 88px;
}
.wallet-section { .wallet-section {
margin-top: 8px; margin-top: 8px;
} }
@ -1430,6 +1739,13 @@ async function submitAuthorize() {
color: inherit; color: inherit;
} }
.position-icon-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.position-market-info { .position-market-info {
min-width: 0; min-width: 0;
} }
@ -1881,6 +2197,82 @@ async function submitAuthorize() {
font-size: 14px; font-size: 14px;
} }
/* 提现记录 */
.withdrawals-mobile-list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.withdrawal-mobile-card {
padding: 12px 16px;
border-radius: 8px;
background: #f9fafb;
border: 1px solid #e5e7eb;
}
.withdrawal-mobile-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.withdrawal-mobile-main {
min-width: 0;
}
.withdrawal-mobile-amount {
font-weight: 600;
font-size: 16px;
color: #111827;
}
.withdrawal-mobile-meta {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
}
.withdrawal-mobile-reason {
font-size: 12px;
color: #dc2626;
margin-top: 8px;
}
.withdrawal-status-pill {
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
border-radius: 999px;
flex-shrink: 0;
}
.withdrawal-status-pill.status-pending {
background: #fef3c7;
color: #b45309;
}
.withdrawal-status-pill.status-success {
background: #dcfce7;
color: #166534;
}
.withdrawal-status-pill.status-rejected {
background: #fee2e2;
color: #991b1b;
}
.withdrawal-status-pill.status-failed {
background: #fee2e2;
color: #991b1b;
}
.withdrawal-reason {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
}
.withdrawal-mobile-address {
font-size: 12px;
color: #6b7280;
font-family: monospace;
margin-top: 4px;
}
.cell-address {
font-family: monospace;
font-size: 13px;
}
.pagination-bar { .pagination-bar {
display: flex; display: flex;
align-items: center; align-items: center;