From d571d1b9b09c872d67fc76e8677c953fe195d40c Mon Sep 17 00:00:00 2001 From: ivan Date: Tue, 3 Mar 2026 18:05:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E6=8F=90=E7=8E=B0?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=8F=90=E7=8E=B0=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api/pmset.md | 81 ++++++++++ docs/components/WithdrawDialog.md | 2 +- docs/views/Wallet.md | 3 +- src/api/pmset.ts | 173 ++++++++++++++++++++ src/components/WithdrawDialog.vue | 105 +++++++++++- src/locales/en.json | 21 ++- src/locales/ja.json | 21 ++- src/locales/ko.json | 21 ++- src/locales/zh-CN.json | 33 +++- src/locales/zh-TW.json | 21 ++- src/views/Wallet.vue | 258 +++++++++++++++++++++++++++++- 11 files changed, 719 insertions(+), 20 deletions(-) create mode 100644 docs/api/pmset.md create mode 100644 src/api/pmset.ts diff --git a/docs/api/pmset.md b/docs/api/pmset.md new file mode 100644 index 0000000..c2a4f82 --- /dev/null +++ b/docs/api/pmset.md @@ -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. 构建 SiweMessage(scheme、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`,list 项含 amount、requestNo、chain、status、createdAt、reason、payoutError 等。 + +## 扩展方式 + +- 新增其他 pmset 相关接口时,在 `src/api/pmset.ts` 中追加 diff --git a/docs/components/WithdrawDialog.md b/docs/components/WithdrawDialog.md index 75e8943..53dd243 100644 --- a/docs/components/WithdrawDialog.md +++ b/docs/components/WithdrawDialog.md @@ -4,7 +4,7 @@ ## 功能用途 -提现弹窗,支持输入金额、选择网络、选择提现目标(Connected wallet / Custom address)。 +提现弹窗,支持输入金额、选择网络、选择提现目标(Connected wallet)。仅支持已连接钱包提现,需验签;`walletAddress` 取用户信息的 `externalWalletAddress`,`tokenAddress` 为出金地址(用户接收资金的地址)。 ## Props diff --git a/docs/views/Wallet.md b/docs/views/Wallet.md index 0bd1755..0739618 100644 --- a/docs/views/Wallet.md +++ b/docs/views/Wallet.md @@ -11,7 +11,8 @@ - Portfolio 卡片:余额、Deposit/Withdraw 按钮 - Profit/Loss 卡片:时间范围切换、ECharts 图表 -- Tab:Positions、Open orders、History +- Tab:Positions、Open orders、History、Withdrawals(提现记录) +- Withdrawals:分页列表,状态筛选(全部/审核中/提现成功/审核不通过/提现失败),对接 GET /pmset/getPmSettlementRequestsListClient - DepositDialog、WithdrawDialog 组件 - **401 权限错误**:取消订单等接口失败时,通过 `useAuthError().formatAuthError` 统一提示「请先登录」或「权限不足」 diff --git a/src/api/pmset.ts b/src/api/pmset.ts new file mode 100644 index 0000000..99299f1 --- /dev/null +++ b/src/api/pmset.ts @@ -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 = { + ethereum: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + polygon: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + arbitrum: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + optimism: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', +} + +/** + * 提现响应 data(polymarket.PmSettlementWithdrawResponse) + */ +export interface WithdrawByWalletResponseData { + idempotencyKey?: string + requestNo?: string +} + +export interface WithdrawByWalletResponse extends ApiResponse { + code: number + data?: WithdrawByWalletResponseData + msg: string +} + +/** + * POST /pmset/withdrawByWallet + * 客户端钱包提现申请,需钱包验签(SIWE) + * 可选传 x-token、x-user-id 用于用户上下文 + */ +export async function withdrawByWallet( + data: WithdrawByWalletRequest, + config?: { headers?: Record }, +): Promise { + return post('/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 + 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 }, +): Promise { + const { page = 1, pageSize = 10, status, keyword, requestNo, tokenSymbol } = params + const query = buildQuery({ page, pageSize, status, keyword, requestNo, tokenSymbol }) + return get('/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 + msg: string +} + +/** + * GET /pmset/getPmSettlementRequestsListClient + * 客户端分页获取提现记录列表,需鉴权 + */ +export async function getSettlementRequestsListClient( + params: GetSettlementListParams = {}, + config?: { headers?: Record }, +): Promise { + const { page = 1, pageSize = 10, status, keyword, requestNo, tokenSymbol } = params + const query = buildQuery({ page, pageSize, status, keyword, requestNo, tokenSymbol }) + return get('/pmset/getPmSettlementRequestsListClient', query, config) +} + +/** 将 amount(6 位小数)转为显示金额 */ +export function amountToUsdcDisplay(raw: number | undefined): string { + if (raw == null || !Number.isFinite(raw)) return '0.00' + return (raw / USDC_SCALE).toFixed(2) +} diff --git a/src/components/WithdrawDialog.vue b/src/components/WithdrawDialog.vue index fcc32a6..6927dfb 100644 --- a/src/components/WithdrawDialog.vue +++ b/src/components/WithdrawDialog.vue @@ -82,7 +82,11 @@ /> +
+ {{ t('withdraw.customAddressNotSupported') || 'Custom address is not supported for this withdrawal.' }} +
{{ amountError }}
+
{{ errorMessage }}
import { ref, computed, watch } from 'vue' 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 userStore = useUserStore() const props = withDefaults( defineProps<{ modelValue: boolean @@ -117,12 +126,13 @@ const props = withDefaults( const emit = defineEmits<{ 'update:modelValue': [value: boolean]; success: [] }>() const amount = ref('') -const selectedNetwork = ref('ethereum') +const selectedNetwork = ref('polygon') const destinationType = ref<'wallet' | 'address'>('wallet') const customAddress = ref('') const connectedAddress = ref('') const connecting = ref(false) const submitting = ref(false) +const errorMessage = ref('') const networks = [ { id: 'ethereum', label: 'Ethereum' }, @@ -141,9 +151,10 @@ const amountError = computed(() => { return '' }) +/** 仅支持已连接钱包提现(需验签);自定义地址暂不支持 */ const hasValidDestination = computed(() => { if (destinationType.value === 'wallet') return !!connectedAddress.value - return /^0x[a-fA-F0-9]{40}$/.test(customAddress.value.trim()) + return false }) const canSubmit = computed( @@ -151,7 +162,8 @@ const canSubmit = computed( amountNum.value > 0 && amountNum.value <= balanceNum.value && hasValidDestination.value && - !amountError.value, + !amountError.value && + !errorMessage.value, ) function shortAddress(addr: string) { @@ -175,27 +187,99 @@ function allowDecimal(e: KeyboardEvent) { async function connectWallet() { if (!window.ethereum) { - alert(t('deposit.installMetaMask')) + errorMessage.value = t('deposit.installMetaMask') return } connecting.value = true + errorMessage.value = '' try { const accounts = (await window.ethereum.request({ method: 'eth_requestAccounts' })) as string[] connectedAddress.value = accounts[0] || '' + if (!connectedAddress.value) { + errorMessage.value = t('withdraw.connectFailed') || 'Failed to connect wallet' + } } catch (e) { console.error(e) + errorMessage.value = (e as Error)?.message || 'Failed to connect wallet' } finally { 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() { 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 + errorMessage.value = '' try { - await new Promise((r) => setTimeout(r, 800)) - emit('success') - close() + const { message, nonce, signature } = await signWithWallet(addr) + const headers = userStore.getAuthHeaders() + 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 { submitting.value = false } @@ -209,6 +293,7 @@ watch( destinationType.value = 'wallet' customAddress.value = '' connectedAddress.value = '' + errorMessage.value = '' } }, ) @@ -292,6 +377,12 @@ watch( margin-top: 8px; } +.hint-msg { + font-size: 0.8rem; + color: #6b7280; + margin-bottom: 12px; +} + .error-msg { font-size: 0.8rem; color: #dc2626; diff --git a/src/locales/en.json b/src/locales/en.json index 7a98b3c..11ec5d2 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -166,7 +166,20 @@ "view": "View", "expirationLabel": "Expiration:", "youWon": "You won ${amount}", - "claim": "Claim" + "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": { "title": "Deposit", @@ -204,7 +217,11 @@ "addressPlaceholder": "0x...", "amountMustBePositive": "Amount must be greater than 0", "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": { "zh": "简体中文", diff --git a/src/locales/ja.json b/src/locales/ja.json index 8f359d7..f3f84f0 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -166,7 +166,20 @@ "view": "表示", "expirationLabel": "有効期限:", "youWon": "獲得 $${amount}", - "claim": "受け取る" + "claim": "受け取る", + "withdrawals": "出金履歴", + "withdrawStatusAll": "すべて", + "withdrawStatusPending": "審査中", + "withdrawStatusSuccess": "出金成功", + "withdrawStatusRejected": "審査不通過", + "withdrawStatusFailed": "出金失敗", + "withdrawAmount": "金額", + "withdrawAddress": "出金アドレス", + "withdrawRequestNo": "申請番号", + "withdrawChain": "チェーン", + "withdrawTime": "時間", + "withdrawStatus": "状態", + "noWithdrawalsFound": "出金履歴がありません" }, "deposit": { "title": "入金", @@ -204,7 +217,11 @@ "addressPlaceholder": "0x...", "amountMustBePositive": "金額は 0 より大きい必要があります", "insufficientBalance": "残高不足", - "close": "閉じる" + "close": "閉じる", + "connectFailed": "ウォレットの接続に失敗しました", + "connectWalletFirst": "まずウォレットを接続してください", + "externalWalletRequired": "ユーザー情報に外部ウォレットアドレスが必要です", + "customAddressNotSupported": "この出金ではカスタムアドレスはサポートされていません。" }, "locale": { "zh": "简体中文", diff --git a/src/locales/ko.json b/src/locales/ko.json index fd85aa5..12cc8cd 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -166,7 +166,20 @@ "view": "보기", "expirationLabel": "만료:", "youWon": "당첨 $${amount}", - "claim": "수령" + "claim": "수령", + "withdrawals": "출금 내역", + "withdrawStatusAll": "전체", + "withdrawStatusPending": "검토 중", + "withdrawStatusSuccess": "출금 완료", + "withdrawStatusRejected": "승인 거부", + "withdrawStatusFailed": "출금 실패", + "withdrawAmount": "금액", + "withdrawAddress": "출금 주소", + "withdrawRequestNo": "신청 번호", + "withdrawChain": "체인", + "withdrawTime": "시간", + "withdrawStatus": "상태", + "noWithdrawalsFound": "출금 내역이 없습니다" }, "deposit": { "title": "입금", @@ -204,7 +217,11 @@ "addressPlaceholder": "0x...", "amountMustBePositive": "금액은 0보다 커야 합니다", "insufficientBalance": "잔액 부족", - "close": "닫기" + "close": "닫기", + "connectFailed": "지갑 연결에 실패했습니다", + "connectWalletFirst": "먼저 지갑을 연결해 주세요", + "externalWalletRequired": "사용자 정보에 외부 지갑 주소가 필요합니다", + "customAddressNotSupported": "이 출금에서는 사용자 지정 주소가 지원되지 않습니다." }, "locale": { "zh": "简体中文", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 7ea6934..8ee2f56 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -166,7 +166,20 @@ "view": "查看", "expirationLabel": "到期", "youWon": "您赢得 ${amount}", - "claim": "领取" + "claim": "领取", + "withdrawals": "提现记录", + "withdrawStatusAll": "全部", + "withdrawStatusPending": "审核中", + "withdrawStatusSuccess": "提现成功", + "withdrawStatusRejected": "审核不通过", + "withdrawStatusFailed": "提现失败", + "withdrawAmount": "金额", + "withdrawRequestNo": "申请单号", + "withdrawChain": "链", + "withdrawTime": "时间", + "withdrawStatus": "状态", + "noWithdrawalsFound": "暂无提现记录", + "withdrawAddress": "提现地址" }, "deposit": { "title": "入金", @@ -204,7 +217,23 @@ "addressPlaceholder": "0x...", "amountMustBePositive": "金额必须大于 0", "insufficientBalance": "余额不足", - "close": "关闭" + "close": "关闭", + "withdrawals": "提现记录", + "withdrawStatusAll": "全部", + "withdrawStatusPending": "审核中", + "withdrawStatusSuccess": "提现成功", + "withdrawStatusRejected": "审核不通过", + "withdrawStatusFailed": "提现失败", + "withdrawAmount": "金额", + "withdrawRequestNo": "申请单号", + "withdrawChain": "链", + "withdrawTime": "时间", + "withdrawStatus": "状态", + "noWithdrawalsFound": "暂无提现记录", + "connectFailed": "连接钱包失败", + "connectWalletFirst": "请先连接钱包", + "externalWalletRequired": "用户信息中缺少外部钱包地址", + "customAddressNotSupported": "暂不支持自定义地址提现。" }, "locale": { "zh": "简体中文", diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index 28b27d2..9ee5bbd 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -166,7 +166,20 @@ "view": "查看", "expirationLabel": "到期", "youWon": "您贏得 ${amount}", - "claim": "領取" + "claim": "領取", + "withdrawals": "提現記錄", + "withdrawStatusAll": "全部", + "withdrawStatusPending": "審核中", + "withdrawStatusSuccess": "提現成功", + "withdrawStatusRejected": "審核不通過", + "withdrawStatusFailed": "提現失敗", + "withdrawAmount": "金額", + "withdrawAddress": "提現地址", + "withdrawRequestNo": "申請單號", + "withdrawChain": "鏈", + "withdrawTime": "時間", + "withdrawStatus": "狀態", + "noWithdrawalsFound": "暫無提現記錄" }, "deposit": { "title": "入金", @@ -204,7 +217,11 @@ "addressPlaceholder": "0x...", "amountMustBePositive": "金額必須大於 0", "insufficientBalance": "餘額不足", - "close": "關閉" + "close": "關閉", + "connectFailed": "連接錢包失敗", + "connectWalletFirst": "請先連接錢包", + "externalWalletRequired": "用戶資訊中缺少外部錢包地址", + "customAddressNotSupported": "暫不支援自訂地址提現。" }, "locale": { "zh": "繁體中文", diff --git a/src/views/Wallet.vue b/src/views/Wallet.vue index 6c6216d..a366048 100644 --- a/src/views/Wallet.vue +++ b/src/views/Wallet.vue @@ -108,6 +108,7 @@ {{ t('wallet.positions') }} {{ t('wallet.openOrders') }} {{ t('wallet.history') }} + {{ t('wallet.withdrawals') }}
+ + +
@@ -658,6 +737,12 @@ import { useAuthError } from '../composables/useAuthError' import { cancelOrder as apiCancelOrder } from '../api/order' import { getOrderList, mapOrderToHistoryItem, mapOrderToOpenOrderItem, OrderStatus } from '../api/order' import { getPositionList, mapPositionToDisplayItem, claimPosition } from '../api/position' +import { + getSettlementRequestsListClient, + amountToUsdcDisplay, + WITHDRAW_STATUS, + type SettlementRequestClientItem, +} from '../api/pmset' import { MOCK_TOKEN_ID, MOCK_WALLET_POSITIONS, @@ -681,8 +766,16 @@ const plTimeRanges = computed(() => [ { label: t('wallet.pl1M'), value: '1M' }, { 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 withdrawStatusFilter = ref('') +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, @@ -831,6 +924,11 @@ interface HistoryItem { const positions = ref( USE_MOCK_WALLET ? [...MOCK_WALLET_POSITIONS] : [], ) +/** 提现记录列表 */ +const withdrawalsList = ref([]) +const withdrawalsTotal = ref(0) +const withdrawalsLoading = ref(false) + /** 持仓列表(API 数据,非 mock 时使用) */ const positionList = ref([]) const positionTotal = ref(0) @@ -973,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 { const q = search.value.trim().toLowerCase() return !q || text.toLowerCase().includes(q) @@ -1023,17 +1190,22 @@ const totalPagesHistory = computed(() => { const total = USE_MOCK_WALLET ? filteredHistory.value.length : historyTotal.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(() => { if (activeTab.value === 'positions') return USE_MOCK_WALLET ? filteredPositions.value.length : positionTotal.value if (activeTab.value === 'orders') return USE_MOCK_WALLET ? filteredOpenOrders.value.length : openOrderTotal.value + if (activeTab.value === 'withdrawals') return withdrawalsTotal.value return USE_MOCK_WALLET ? filteredHistory.value.length : historyTotal.value }) const currentTotalPages = computed(() => { if (activeTab.value === 'positions') return totalPagesPositions.value if (activeTab.value === 'orders') return totalPagesOrders.value + if (activeTab.value === 'withdrawals') return totalPagesWithdrawals.value return totalPagesHistory.value }) const currentPageStart = computed(() => @@ -1048,11 +1220,17 @@ watch(activeTab, (tab) => { if (tab === 'positions' && !USE_MOCK_WALLET) loadPositionList() if (tab === 'orders' && !USE_MOCK_WALLET) loadOpenOrders() if (tab === 'history' && !USE_MOCK_WALLET) loadHistoryOrders() + if (tab === 'withdrawals') loadWithdrawals() }) watch([page, itemsPerPage], () => { if (activeTab.value === 'positions' && !USE_MOCK_WALLET) loadPositionList() if (activeTab.value === 'orders' && !USE_MOCK_WALLET) loadOpenOrders() if (activeTab.value === 'history' && !USE_MOCK_WALLET) loadHistoryOrders() + if (activeTab.value === 'withdrawals') loadWithdrawals() +}) +watch(withdrawStatusFilter, () => { + page.value = 1 + if (activeTab.value === 'withdrawals') loadWithdrawals() }) watch([currentListTotal, itemsPerPage], () => { const maxPage = currentTotalPages.value @@ -1304,6 +1482,8 @@ onUnmounted(() => { function onWithdrawSuccess() { withdrawDialogOpen.value = false + userStore.fetchUsdcBalance() + if (activeTab.value === 'withdrawals') loadWithdrawals() } function onAuthorizeClick() { @@ -2017,6 +2197,82 @@ async function submitAuthorize() { 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 { display: flex; align-items: center;