xtraderClient/src/views/Wallet.vue
2026-03-20 17:04:05 +08:00

2804 lines
70 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<v-container class="wallet-container">
<div class="wallet-mobile-frame">
<div class="wallet-mobile-header">{{ t('wallet.walletTitle') }}</div>
<v-card class="wallet-card portfolio-card design-tu-asset" elevation="0" rounded="lg">
<div class="card-header">
<span class="card-title">{{ t('wallet.portfolio') }}</span>
</div>
<div class="card-value">${{ portfolioBalance }}</div>
</v-card>
<div class="card-actions design-tu-actions">
<v-btn
color="default"
variant="outlined"
class="action-btn design-tu-action"
@click="depositDialogOpen = true"
>
{{ t('wallet.deposit') }}
</v-btn>
<v-btn
color="default"
variant="outlined"
class="action-btn design-tu-action"
@click="withdrawDialogOpen = true"
>
{{ t('wallet.withdraw') }}
</v-btn>
</div>
<v-card
v-if="unsettledCount > 0"
class="settlement-card design-tu-settlement"
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 design-tu-claim-btn"
:loading="claimLoading"
:disabled="claimLoading"
@click="onClaimSettlement"
>
{{ t('wallet.claim') }}
</v-btn>
</div>
</v-card>
<!-- 下方Positions / Open orders / History -->
<div class="wallet-section">
<v-tabs v-model="activeTab" class="wallet-tabs" density="comfortable">
<v-tab value="positions">{{ t('wallet.positions') }}</v-tab>
<v-tab value="orders">{{ t('wallet.openOrders') }}</v-tab>
<v-tab value="history">{{ t('wallet.history') }}</v-tab>
<v-tab value="withdrawals">{{ t('wallet.withdrawals') }}</v-tab>
</v-tabs>
<v-card class="table-card wallet-design-card" elevation="0" rounded="lg">
<template v-if="activeTab === 'positions'">
<div class="positions-mobile-list">
<div v-if="positionLoading" class="empty-cell">{{ t('common.loading') }}</div>
<div v-else-if="filteredPositions.length === 0" class="empty-cell">
{{ t('wallet.noPositionsFound') }}
</div>
<div
v-for="pos in paginatedPositions"
:key="pos.id"
class="position-mobile-card design-pos-card"
>
<div class="design-pos-top">
<div class="design-pos-left">
<div class="position-icon" :class="pos.iconClass">
<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 class="design-pos-title-col">
<div class="position-mobile-title marquee-container">
<div class="marquee-track">
<span>{{ pos.market }}</span>
<span aria-hidden="true">{{ pos.market }}</span>
</div>
</div>
<div class="design-pos-tags">
<span
v-if="pos.outcomeTag"
class="position-outcome-pill"
:class="getOutcomeClass(pos.outcomeTag, pos.outcomePillClass)"
>
{{ pos.outcomeTag }}
</span>
</div>
</div>
</div>
<div class="design-pos-right">
<div class="position-value">{{ pos.value }}</div>
<div
v-if="pos.valueChange != null"
:class="[
'position-value-change',
pos.valueChangeLoss ? 'value-loss' : 'value-gain',
]"
>
{{ pos.valueChange
}}{{ pos.valueChangePct != null ? ` (${pos.valueChangePct})` : '' }}
</div>
</div>
</div>
<div class="design-pos-stats">
<div class="design-pos-stat">
<div class="design-pos-stat-label">{{ t('wallet.sharesLabel') }}</div>
<div class="design-pos-stat-value">{{ pos.shares }}</div>
</div>
<div class="design-pos-stat">
<div class="design-pos-stat-label">{{ t('wallet.avgPriceLabel') }}</div>
<div class="design-pos-stat-value">{{ parseAvgNow(pos.avgNow)[0] }}</div>
</div>
<div class="design-pos-stat">
<div class="design-pos-stat-label">{{ t('wallet.currentPriceLabel') }}</div>
<div class="design-pos-stat-value">
{{ parseAvgNow(pos.avgNow)[1] || parseAvgNow(pos.avgNow)[0] }}
</div>
</div>
</div>
</div>
</div>
</template>
<template v-else-if="activeTab === 'orders'">
<div class="orders-mobile-list">
<div v-if="openOrderLoading" class="empty-cell">{{ t('common.loading') }}</div>
<div v-else-if="filteredOpenOrders.length === 0" class="empty-cell">
{{ t('wallet.noOpenOrdersFound') }}
</div>
<div v-for="ord in paginatedOpenOrders" :key="ord.id" class="order-mobile-card design-order-card">
<div class="design-order-top">
<div class="order-mobile-icon" :class="ord.iconClass">
<span class="position-icon-char">{{ ord.iconChar || '•' }}</span>
</div>
<div class="order-mobile-main design-order-title-col">
<div class="order-mobile-title marquee-container order-title-marquee">
<div class="marquee-track">
<span>{{ getOrderDisplayTitle(ord) }}</span>
<span aria-hidden="true">{{ getOrderDisplayTitle(ord) }}</span>
</div>
</div>
<div class="design-order-tags">
<span class="order-side-pill" :class="getOrderSideClass(ord)">{{
getOrderActionLabel(ord)
}}</span>
<span class="order-outcome-pill" :class="ord.side === 'Yes' ? 'outcome-yes' : 'outcome-no'">{{
ord.side
}}</span>
</div>
</div>
<v-btn
icon
variant="text"
size="small"
class="order-cancel-icon"
:disabled="cancelOrderLoading || ord.fullyFilled"
@click.stop="cancelOrder(ord)"
>
<v-icon size="20">mdi-close</v-icon>
</v-btn>
</div>
<div class="design-order-stats">
<div class="design-order-stat">
<div class="design-order-stat-label">{{ t('wallet.openOrderPriceLabel') }}</div>
<div class="design-order-stat-value">{{ ord.price }}</div>
</div>
<div class="design-order-stat">
<div class="design-order-stat-label">{{ t('wallet.filledTotalLabel') }}</div>
<div class="design-order-stat-value">
{{ ord.filledDisplay || `${ord.filled}/${ord.total}` }}
</div>
</div>
<div class="design-order-stat">
<div class="design-order-stat-label">{{ t('wallet.orderValueLabel') }}</div>
<div class="design-order-stat-value" :class="getOrderValueClass(ord)">
{{ getOrderValueDisplay(ord) }}
</div>
</div>
</div>
</div>
</div>
</template>
<template v-else-if="activeTab === 'history'">
<div class="history-mobile-list">
<div v-if="historyLoading" class="empty-cell">{{ t('common.loading') }}</div>
<div v-else-if="filteredHistory.length === 0" class="empty-cell">
{{ t('wallet.noHistoryFound') }}
</div>
<div
v-for="h in paginatedHistory"
:key="h.id"
class="history-mobile-card"
:class="isFundingHistory(h) ? 'design-funding-card' : 'design-trade-card'"
>
<template v-if="!isFundingHistory(h)">
<div class="design-history-top">
<div class="design-history-left">
<div class="history-mobile-icon" :class="h.iconClass">
<img v-if="h.imageUrl" :src="h.imageUrl" alt="" class="position-icon-img" />
<span v-else class="position-icon-char">{{ h.iconChar || '•' }}</span>
</div>
<div class="history-mobile-main">
<div class="history-mobile-title marquee-container history-title-marquee">
<div class="marquee-track">
<span>{{ h.market }}</span>
<span aria-hidden="true">{{ h.market }}</span>
</div>
</div>
<div class="history-mobile-activity">
{{ h.timeAgo || h.activityDetail || h.activity }}
</div>
</div>
</div>
<span
:class="['history-mobile-pl', h.profitLossNegative ? 'pl-loss' : 'pl-gain']"
>
{{ h.profitLoss ?? h.value }}
</span>
</div>
<div class="design-history-bottom">
<span class="order-side-pill" :class="getHistoryTagClass(h)">
{{ getHistoryTagLabel(h) }}
</span>
<span class="design-history-meta"
>{{ t('wallet.priceLabel') }}: {{ h.avgPrice || '—' }} · {{ t('wallet.sharesLabel') }}: {{
h.shares || '—'
}}</span
>
</div>
</template>
<template v-else>
<div class="design-funding-left">
<div class="history-mobile-icon" :class="getFundingIconClass(h)">
<img v-if="h.imageUrl" :src="h.imageUrl" alt="" class="position-icon-img" />
<span v-else class="position-icon-char">{{ getFundingIconText(h) }}</span>
</div>
<div class="history-mobile-main">
<div class="history-mobile-title marquee-container history-title-marquee">
<div class="marquee-track">
<span>{{ getFundingTitle(h) }}</span>
<span aria-hidden="true">{{ getFundingTitle(h) }}</span>
</div>
</div>
<div class="history-mobile-activity">{{ h.timeAgo || '—' }}</div>
</div>
</div>
<span
:class="[
'history-mobile-pl',
isWithdrawalHistory(h) || h.profitLossNegative ? 'pl-loss' : 'pl-gain',
]"
>
{{ h.profitLoss ?? h.value }}
</span>
</template>
</div>
</div>
</template>
<template v-else-if="activeTab === 'withdrawals'">
<div class="withdrawals-mobile-list">
<div v-if="withdrawalsLoading" class="empty-cell">{{ t('common.loading') }}</div>
<div v-else-if="displayedWithdrawals.length === 0" class="empty-cell">
{{ t('wallet.noWithdrawalsFound') }}
</div>
<div
v-for="w in displayedWithdrawals"
:key="String(w.ID ?? w.requestNo ?? '')"
class="withdrawal-mobile-card design-withdraw-card"
>
<div class="design-withdraw-top">
<div class="design-withdraw-head">
<div class="design-withdraw-date">
{{ formatWithdrawTime(w.CreatedAt ?? w.createdAt) }}
</div>
<div class="design-withdraw-chain">{{ w.chain || '—' }}</div>
</div>
<span :class="['withdrawal-status-pill', getWithdrawStatusClass(w.status)]">
{{ getWithdrawStatusLabel(w.status) }}
</span>
</div>
<div class="design-withdraw-mid">
<div class="design-withdraw-col">
<div class="design-withdraw-label">{{ t('wallet.withdrawAmountLabel') }}</div>
<div class="design-withdraw-value">{{ getWithdrawAmountText(w) }}</div>
</div>
<div class="design-withdraw-col right">
<div class="design-withdraw-label">{{ t('wallet.feeLabel') }}</div>
<div class="design-withdraw-value">{{ getWithdrawFeeText(w) }}</div>
</div>
</div>
<div class="design-withdraw-bottom">
<div class="design-withdraw-label">{{ t('wallet.withdrawAddressLabel') }}</div>
<div class="withdrawal-mobile-address">
{{ shortAddress(w.tokenAddress ?? w.walletAddress) }}
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div class="empty-cell">{{ t('common.noData') }}</div>
</template>
<div v-if="false && currentListTotal > 0" class="pagination-bar">
<span class="pagination-info">
{{ currentPageStart }}{{ currentPageEnd }} of {{ currentListTotal }}
</span>
<div class="pagination-controls">
<v-select
v-model="itemsPerPage"
:items="pageSizeOptions"
density="compact"
hide-details
variant="outlined"
class="page-size-select"
@update:model-value="page = 1"
/>
<v-pagination
v-model="page"
:length="currentTotalPages"
:total-visible="5"
density="comfortable"
class="pagination"
@update:model-value="onPageChange"
/>
</div>
</div>
</v-card>
</div>
</div>
<DepositDialog v-model="depositDialogOpen" :balance="portfolioBalance" />
<WithdrawDialog
v-model="withdrawDialogOpen"
:balance="portfolioBalance"
@success="onWithdrawSuccess"
/>
<!-- 授权弹窗 -->
<v-dialog
v-model="authorizeDialogOpen"
max-width="420"
persistent
transition="dialog-transition"
>
<v-card rounded="lg">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-shield-check-outline</v-icon>
{{ t('wallet.authorize') }}
</v-card-title>
<v-card-text>
{{ t('wallet.authorizeDesc') }}
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="authorizeDialogOpen = false">
{{ t('deposit.close') }}
</v-btn>
<v-btn color="primary" variant="flat" @click="submitAuthorize">
{{ t('wallet.authorize') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Sell position dialog -->
<v-dialog
v-model="sellDialogOpen"
max-width="440"
persistent
content-class="sell-dialog"
transition="dialog-transition"
>
<v-card v-if="sellPositionItem" class="sell-dialog-card" rounded="lg">
<div class="sell-dialog-header">
<v-btn
icon
variant="text"
size="small"
class="sell-dialog-close"
@click="closeSellDialog"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<v-card-text class="sell-dialog-body">
<div class="sell-dialog-icon-wrap">
<div class="position-icon" :class="sellPositionItem.iconClass">
<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>
<h3 class="sell-dialog-title">
{{ t('wallet.sellDialogTitle', { outcome: sellPositionItem.sellOutcome || t('wallet.position') }) }}
</h3>
<p class="sell-dialog-market">{{ sellPositionItem.market }}</p>
<div class="sell-receive-box">
<div class="sell-receive-label">
<v-icon size="20" color="success">mdi-sack</v-icon>
<span>{{ t('wallet.sellDialogReceive') }}</span>
</div>
<div class="sell-receive-value">{{ sellReceiveAmount }}</div>
</div>
</v-card-text>
<v-card-actions class="sell-dialog-actions">
<v-btn color="success" variant="flat" block class="sell-redeem-btn" @click="redeemSell">
{{ t('wallet.sellDialogRedeem') }}
</v-btn>
<a href="#" class="sell-edit-link" @click.prevent="editSellOrder">{{ t('wallet.sellDialogEditOrder') }}</a>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="showCancelError" color="error" :timeout="4000">
{{ cancelOrderError }}
</v-snackbar>
</v-container>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
const { t } = useI18n()
import { createChart, AreaSeries, LineType, LastPriceAnimationMode } from 'lightweight-charts'
import { toLwcData } from '../composables/useLightweightChart'
import DepositDialog from '../components/DepositDialog.vue'
import WithdrawDialog from '../components/WithdrawDialog.vue'
import { useUserStore } from '../stores/user'
import { useLocaleStore } from '../stores/locale'
import { useAuthError } from '../composables/useAuthError'
import { cancelOrder as apiCancelOrder } from '../api/order'
import { getOrderList, mapOrderToOpenOrderItem, OrderStatus } from '../api/order'
import { getHistoryRecordListClient, getHistoryRecordList } from '../api/historyRecord'
import { getPositionList, mapPositionToDisplayItem, claimPosition } from '../api/position'
import {
getSettlementRequestsListClient,
amountToUsdcDisplay,
WITHDRAW_STATUS,
type SettlementRequestClientItem,
} from '../api/pmset'
import {
MOCK_TOKEN_ID,
MOCK_WALLET_POSITIONS,
MOCK_WALLET_ORDERS,
MOCK_WALLET_HISTORY,
} from '../api/mockData'
import { USE_MOCK_WALLET } from '../config/mock'
import { CrossChainUSDTAuth } from '../../sdk/approve'
import { useToastStore } from '../stores/toast'
const { mobile } = useDisplay()
const userStore = useUserStore()
const { formatAuthError } = useAuthError()
const localeStore = useLocaleStore()
const portfolioBalance = computed(() => userStore.balance)
const profitLoss = ref('0.00')
const plRange = ref('ALL')
const plTimeRanges = computed(() => [
{ label: t('wallet.pl1D'), value: '1D' },
{ label: t('wallet.pl1W'), value: '1W' },
{ label: t('wallet.pl1M'), value: '1M' },
{ label: t('wallet.plAll'), value: 'ALL' },
])
const activeTab = ref<'positions' | 'orders' | 'history' | 'withdrawals'>('positions')
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且所属市场已关闭 market.closed=true */
const unsettledItems = computed(() => {
const list = currentPositionList.value
return list
.filter((p) => p.marketID && p.tokenID && p.marketClosed === 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 withdrawDialogOpen = ref(false)
const authorizeDialogOpen = ref(false)
const sellDialogOpen = ref(false)
const sellPositionItem = ref<Position | null>(null)
/** 移动端展开的持仓 idnull 表示全部折叠 */
const expandedPositionId = ref<string | null>(null)
/** 移动端展开的历史记录 id */
const expandedHistoryId = ref<string | null>(null)
function togglePositionExpanded(id: string) {
expandedPositionId.value = expandedPositionId.value === id ? null : id
}
function toggleHistoryExpanded(id: string) {
expandedHistoryId.value = expandedHistoryId.value === id ? null : id
}
interface Position {
id: string
market: string
icon?: string
iconChar?: string
iconClass?: string
imageUrl?: string
outcomeTag?: string
outcomePillClass?: string
shares: string
avgNow: string
bet: string
toWin: string
value: string
valueChange?: string
valueChangePct?: string
valueChangeLoss?: boolean
/** 用于 Sell 弹窗标题,如 "Down" | "Yes" | "No" */
sellOutcome?: string
/** 移动端副标题 "on Up/Down to win" 中的词 */
outcomeWord?: string
/** 市场 ID从持仓列表来用于领取结算 */
marketID?: string
/** Token ID从持仓列表来用于领取结算 */
tokenID?: string
/** 所属市场是否已关闭marketClosed=true 表示可结算/可领取 */
marketClosed?: boolean
}
/** 从 avgNow "72¢ → 0.5¢" 解析出 [avg, now] */
function parseAvgNow(avgNow: string): [string, string] {
const parts = avgNow.split(' → ')
return parts.length >= 2 ? [parts[0]!.trim(), parts[1]!.trim()] : [avgNow, '']
}
interface OpenOrder {
id: string
market: string
side: 'Yes' | 'No'
outcome: string
price: string
filled: string
total: string
expiration: string
/** 移动端展示:如 "Buy Up" */
actionLabel?: string
/** 移动端展示:如 "0/5" */
filledDisplay?: string
iconChar?: string
iconClass?: string
/** 取消订单 API 用 */
orderID?: number
tokenID?: string
/** 已成交数量达到原始总数量,不可撤单 */
fullyFilled?: boolean
}
interface HistoryItem {
id: string
market: string
side: 'Yes' | 'No'
activity: string
value: string
/** 移动端:如 "Sold 1 Down at 50¢" */
activityDetail?: string
/** 移动端:盈亏展示如 "+$0.50" */
profitLoss?: string
profitLossNegative?: boolean
/** 移动端:如 "3 minutes ago" */
timeAgo?: string
avgPrice?: string
shares?: string
iconChar?: string
iconClass?: string
/** 图标 URL来自 record.icon */
imageUrl?: string
}
function getOutcomeClass(outcomeTag?: string, fallback?: string): string {
if (fallback) return fallback
const normalized = (outcomeTag ?? '').toLowerCase()
return normalized.includes('yes') ? 'outcome-yes' : 'outcome-no'
}
function getOrderActionLabel(ord: OpenOrder): string {
const action = (ord.actionLabel ?? '').toLowerCase()
if (action.includes('sell')) return 'SELL'
return 'BUY'
}
function getOrderSideClass(ord: OpenOrder): string {
return getOrderActionLabel(ord) === 'SELL' ? 'side-sell' : 'side-buy'
}
function getOrderValueDisplay(ord: OpenOrder): string {
return `${ord.price} × ${ord.total}`
}
function getOrderValueClass(ord: OpenOrder): string {
return getOrderActionLabel(ord) === 'BUY' ? 'order-value-primary' : 'order-value-secondary'
}
function getOrderDisplayTitle(ord: OpenOrder): string {
const title = ord.market?.trim()
if (title) return title
return ord.outcome?.trim() ? `Market · ${ord.outcome}` : 'Untitled Market'
}
function isWithdrawalHistory(h: HistoryItem): boolean {
const text = `${h.activity} ${h.activityDetail ?? ''} ${h.market}`.toLowerCase()
return text.includes('withdraw') || text.includes('提现')
}
function isFundingHistory(h: HistoryItem): boolean {
const text = `${h.activity} ${h.activityDetail ?? ''} ${h.market}`.toLowerCase()
return (
text.includes('deposit') ||
text.includes('充值') ||
text.includes('withdraw') ||
text.includes('提现')
)
}
function getFundingTitle(h: HistoryItem): string {
if (h.market?.trim()) return h.market
return isWithdrawalHistory(h) ? t('wallet.btcWithdrawHistoryLabel') : t('wallet.usdtDepositHistoryLabel')
}
function getFundingIconText(h: HistoryItem): string {
return isWithdrawalHistory(h) ? t('wallet.withdrawIconText') : t('wallet.depositIconText')
}
function getFundingIconClass(h: HistoryItem): string {
return isWithdrawalHistory(h) ? 'funding-icon-withdraw' : 'funding-icon-deposit'
}
function getHistoryTagLabel(h: HistoryItem): 'BUY' | 'SELL' {
return h.profitLossNegative ? 'BUY' : 'SELL'
}
function getHistoryTagClass(h: HistoryItem): string {
return h.profitLossNegative ? 'history-tag-buy' : 'history-tag-sell'
}
const positions = ref<Position[]>(USE_MOCK_WALLET ? [...MOCK_WALLET_POSITIONS] : [])
/** 提现记录列表 */
const withdrawalsList = ref<SettlementRequestClientItem[]>([])
const withdrawalsTotal = ref(0)
const withdrawalsLoading = ref(false)
const previewWithdrawals = ref<SettlementRequestClientItem[]>([
{
ID: -1,
CreatedAt: '2024-01-15T14:30:25Z',
chain: 'Polygon',
amount: 2500000000,
fee: '0.00',
status: WITHDRAW_STATUS.PENDING,
tokenAddress: '0x27fa...79ab',
tokenSymbol: 'USDT',
},
{
ID: -2,
CreatedAt: '2024-01-14T09:16:53Z',
chain: 'Bitcoin',
amount: 850000000,
fee: '0.0065',
status: WITHDRAW_STATUS.SUCCESS,
tokenAddress: 'bc1q...49x7n',
tokenSymbol: 'BTC',
},
{
ID: -3,
CreatedAt: '2024-01-12T11:26:03Z',
chain: 'Ethereum',
amount: 5260000000,
fee: '0.05',
status: WITHDRAW_STATUS.REJECTED,
tokenAddress: '0x7f42...f9d9',
tokenSymbol: 'ETH',
},
])
/** 持仓列表API 数据,非 mock 时使用) */
const positionList = ref<Position[]>([])
const positionTotal = ref(0)
const positionLoading = ref(false)
async function loadPositionList() {
if (USE_MOCK_WALLET) return
const headers = userStore.getAuthHeaders()
if (!headers) {
positionList.value = []
positionTotal.value = 0
return
}
const uid = userStore.user?.id ?? userStore.user?.ID
const userID = uid != null ? Number(uid) : undefined
if (!userID || !Number.isFinite(userID)) {
positionList.value = []
positionTotal.value = 0
return
}
positionLoading.value = true
try {
const res = await getPositionList(
{ page: page.value, pageSize: itemsPerPage.value, userID },
{ headers },
)
if (res.code === 0 || res.code === 200) {
const list = res.data?.list ?? []
positionList.value = list.map(mapPositionToDisplayItem)
positionTotal.value = res.data?.total ?? 0
} else {
positionList.value = []
positionTotal.value = 0
}
} catch {
positionList.value = []
positionTotal.value = 0
} finally {
positionLoading.value = false
}
}
const openOrders = ref<OpenOrder[]>(USE_MOCK_WALLET ? [...MOCK_WALLET_ORDERS] : [])
/** 未成交订单API 数据,非 mock 时使用) */
const openOrderList = ref<OpenOrder[]>([])
const openOrderTotal = ref(0)
const openOrderLoading = ref(false)
async function loadOpenOrders() {
if (USE_MOCK_WALLET) return
const headers = userStore.getAuthHeaders()
if (!headers) {
openOrderList.value = []
openOrderTotal.value = 0
return
}
const uid = userStore.user?.id ?? userStore.user?.ID
const userID = uid != null ? Number(uid) : undefined
if (!userID || !Number.isFinite(userID)) {
openOrderList.value = []
openOrderTotal.value = 0
return
}
openOrderLoading.value = true
try {
const res = await getOrderList(
{
page: page.value,
pageSize: itemsPerPage.value,
userID,
status: OrderStatus.Live,
},
{ headers },
)
if (res.code === 0 || res.code === 200) {
const list = res.data?.list ?? []
const openOnly = list.filter((o) => (o.status ?? 1) === OrderStatus.Live)
openOrderList.value = openOnly.map(mapOrderToOpenOrderItem)
openOrderTotal.value = openOnly.length
} else {
openOrderList.value = []
openOrderTotal.value = 0
}
} catch {
openOrderList.value = []
openOrderTotal.value = 0
} finally {
openOrderLoading.value = false
}
}
const history = ref<HistoryItem[]>(USE_MOCK_WALLET ? [...MOCK_WALLET_HISTORY] : [])
/** 订单历史API 数据,非 mock 时使用) */
const historyList = ref<HistoryItem[]>([])
const historyTotal = ref(0)
const historyLoading = ref(false)
/** 历史记录来自 GET /hr/getHistoryRecordListClient需鉴权按当前用户分页 */
async function loadHistoryOrders() {
if (USE_MOCK_WALLET) return
const headers = userStore.getAuthHeaders()
if (!headers) {
historyList.value = []
historyTotal.value = 0
return
}
const uid = userStore.user?.id ?? userStore.user?.ID
const userID = uid != null ? Number(uid) : undefined
if (!userID || !Number.isFinite(userID)) {
historyList.value = []
historyTotal.value = 0
return
}
historyLoading.value = true
try {
const res = await getHistoryRecordListClient(
{
page: page.value,
pageSize: itemsPerPage.value,
userId: userID,
},
{ headers },
)
if (res.code === 0 || res.code === 200) {
const { list, total } = getHistoryRecordList(res.data)
historyList.value = list
historyTotal.value = total
} else {
historyList.value = []
historyTotal.value = 0
}
} catch {
historyList.value = []
historyTotal.value = 0
} finally {
historyLoading.value = false
}
}
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
}
}
const displayedWithdrawals = computed(() =>
withdrawalsList.value.length > 0 ? withdrawalsList.value : previewWithdrawals.value,
)
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 getWithdrawFeeDisplay(w: SettlementRequestClientItem): string {
const fee = (w as { fee?: number; gasFee?: number; serviceFee?: number }).fee
const gasFee = (w as { fee?: number; gasFee?: number; serviceFee?: number }).gasFee
const serviceFee = (w as { fee?: number; gasFee?: number; serviceFee?: number }).serviceFee
const value = fee ?? gasFee ?? serviceFee
return value == null ? '—' : `$${amountToUsdcDisplay(value)}`
}
function getWithdrawTokenSymbol(w: SettlementRequestClientItem): string {
const symbol = (w as { tokenSymbol?: string }).tokenSymbol
if (symbol && symbol.trim()) return symbol.toUpperCase()
const chain = (w.chain ?? '').toLowerCase()
if (chain.includes('btc') || chain.includes('bitcoin')) return 'BTC'
if (chain.includes('eth') || chain.includes('ethereum')) return 'ETH'
return 'USDT'
}
function getWithdrawAmountText(w: SettlementRequestClientItem): string {
const amount = Number(amountToUsdcDisplay(w.amount))
const amountText = Number.isFinite(amount)
? amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: '0.00'
return `${amountText} ${getWithdrawTokenSymbol(w)}`
}
function getWithdrawFeeText(w: SettlementRequestClientItem): string {
const feeRaw = Number(
(w as { fee?: string | number; gasFee?: string | number; serviceFee?: string | number }).fee ??
(w as { fee?: string | number; gasFee?: string | number; serviceFee?: string | number }).gasFee ??
(w as { fee?: string | number; gasFee?: string | number; serviceFee?: string | number }).serviceFee ??
0,
)
const feeText = Number.isFinite(feeRaw)
? feeRaw.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
: '0.00'
return `${feeText} ${getWithdrawTokenSymbol(w)}`
}
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)
}
const filteredPositions = computed(() => {
const list = USE_MOCK_WALLET ? positions.value : positionList.value
return list.filter((p) => matchSearch(p.market))
})
const filteredOpenOrders = computed(() => {
const list = USE_MOCK_WALLET ? openOrders.value : openOrderList.value
return list.filter((o) => matchSearch(o.market))
})
const filteredHistory = computed(() => {
const list = USE_MOCK_WALLET ? history.value : historyList.value
return list.filter((h) => matchSearch(h.market))
})
const page = ref(1)
const itemsPerPage = ref(10)
const pageSizeOptions = [5, 10, 25, 50]
function paginate<T>(list: T[]) {
const start = (page.value - 1) * itemsPerPage.value
return list.slice(start, start + itemsPerPage.value)
}
const paginatedPositions = computed(() => {
if (USE_MOCK_WALLET) return paginate(filteredPositions.value)
return filteredPositions.value
})
const paginatedOpenOrders = computed(() => {
if (USE_MOCK_WALLET) return paginate(filteredOpenOrders.value)
return filteredOpenOrders.value
})
const paginatedHistory = computed(() => {
if (USE_MOCK_WALLET) return paginate(filteredHistory.value)
return filteredHistory.value
})
const totalPagesPositions = computed(() => {
const total = USE_MOCK_WALLET ? filteredPositions.value.length : positionTotal.value
return Math.max(1, Math.ceil(total / itemsPerPage.value))
})
const totalPagesOrders = computed(() => {
const total = USE_MOCK_WALLET ? filteredOpenOrders.value.length : openOrderTotal.value
return Math.max(1, Math.ceil(total / itemsPerPage.value))
})
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(() =>
currentListTotal.value === 0 ? 0 : (page.value - 1) * itemsPerPage.value + 1,
)
const currentPageEnd = computed(() =>
Math.min(page.value * itemsPerPage.value, currentListTotal.value),
)
watch(activeTab, (tab) => {
page.value = 1
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
if (page.value > maxPage) page.value = Math.max(1, maxPage)
})
function onPageChange() {
// 可选:翻页后滚动到表格顶部
}
const cancelOrderLoading = ref(false)
const cancelOrderError = ref('')
const showCancelError = ref(false)
async function cancelOrder(ord: OpenOrder) {
if (ord.fullyFilled) return
const orderID = ord.orderID ?? 5
const tokenID = ord.tokenID ?? MOCK_TOKEN_ID
const uid = userStore.user?.id ?? userStore.user?.ID
const userID = uid != null ? Number(uid) : 0
if (!Number.isFinite(userID) || userID <= 0) {
cancelOrderError.value = t('error.pleaseLogin')
showCancelError.value = true
return
}
const headers = userStore.getAuthHeaders()
if (!headers) {
cancelOrderError.value = t('error.pleaseLogin')
showCancelError.value = true
return
}
cancelOrderLoading.value = true
cancelOrderError.value = ''
try {
const res = await apiCancelOrder({ orderID, tokenID, userID }, { headers })
if (res.code === 0 || res.code === 200) {
if (USE_MOCK_WALLET) {
openOrders.value = openOrders.value.filter((o) => o.id !== ord.id)
} else {
openOrderList.value = openOrderList.value.filter((o) => o.id !== ord.id)
openOrderTotal.value = openOrderList.value.length
}
userStore.fetchUsdcBalance()
} else {
cancelOrderError.value = res.msg || t('wallet.cancelFailed')
showCancelError.value = true
}
} catch (e) {
cancelOrderError.value = formatAuthError(e, t('error.requestFailed'))
showCancelError.value = true
} finally {
cancelOrderLoading.value = false
}
}
function cancelAllOrders() {
if (USE_MOCK_WALLET) {
openOrders.value = []
} else {
openOrderList.value = []
openOrderTotal.value = 0
}
}
const sellReceiveAmount = computed(() => {
const pos = sellPositionItem.value
return pos ? pos.value : '$0.00'
})
function openSellDialog(pos: Position) {
sellPositionItem.value = pos
sellDialogOpen.value = true
}
function closeSellDialog() {
sellDialogOpen.value = false
sellPositionItem.value = null
}
function sellPosition(id: string) {
const pos = positions.value.find((p) => p.id === id)
if (pos) openSellDialog(pos)
}
function redeemSell() {
if (sellPositionItem.value) {
// TODO: 调用赎回接口,成功后从持仓移除
positions.value = positions.value.filter((p) => p.id !== sellPositionItem.value!.id)
closeSellDialog()
}
}
function editSellOrder() {
// TODO: 跳转交易页或打开限价单
closeSellDialog()
}
function sharePosition(id: string) {
// TODO: 分享或复制链接
}
function closeLosses() {
// TODO: 关闭亏损仓位或筛选
}
function viewHistory(id: string) {
// TODO: 跳转交易详情或订单详情
}
function shareHistory(id: string) {
// TODO: 分享或复制链接
}
const plChartRef = ref<HTMLElement | null>(null)
let plChartInstance: ReturnType<typeof createChart> | null = null
let plChartSeries: ReturnType<ReturnType<typeof createChart>['addSeries']> | null = null
/**
* 资产变化折线图数据 [timestamp_ms, pnl]
* 暂无接口时返回真实格式的时间序列,数值均为 0有接口后改为从 API 拉取并在此处做分时过滤
*/
function getPlChartData(range: string): [number, number][] {
const now = Date.now()
let stepMs: number
let count: number
switch (range) {
case '1D':
stepMs = 60 * 60 * 1000
count = 24
break
case '1W':
stepMs = 24 * 60 * 60 * 1000
count = 7
break
case '1M':
stepMs = 24 * 60 * 60 * 1000
count = 30
break
case 'ALL':
default:
stepMs = 24 * 60 * 60 * 1000
count = 30
break
}
const data: [number, number][] = []
for (let i = count; i >= 0; i--) {
const t = now - i * stepMs
data.push([t, 0])
}
return data
}
const plChartData = ref<[number, number][]>([])
function updatePlChart() {
plChartData.value = getPlChartData(plRange.value)
const last = plChartData.value[plChartData.value.length - 1]
if (last != null) profitLoss.value = last[1].toFixed(2)
if (plChartSeries) plChartSeries.setData(toLwcData(plChartData.value))
}
function initPlChart() {
if (!plChartRef.value) return
plChartData.value = getPlChartData(plRange.value)
const last = plChartData.value[plChartData.value.length - 1]
if (last != null) profitLoss.value = last[1].toFixed(2)
const el = plChartRef.value
plChartInstance = createChart(el, {
width: el.clientWidth,
height: 160,
layout: {
background: { color: '#ffffff' },
textColor: '#9ca3af',
fontFamily: 'inherit',
fontSize: 9,
attributionLogo: false,
},
grid: { vertLines: { visible: false }, horzLines: { color: '#f3f4f6' } },
rightPriceScale: { borderVisible: false },
timeScale: { borderVisible: false },
crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } },
})
const lastVal = last != null ? last[1] : 0
const color = lastVal >= 0 ? '#059669' : '#dc2626'
plChartSeries = plChartInstance.addSeries(AreaSeries, {
topColor: color + '40',
bottomColor: color + '08',
lineColor: color,
lineWidth: 2,
lineType: LineType.Curved,
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
priceFormat: { type: 'price', precision: 2 },
})
plChartSeries.setData(toLwcData(plChartData.value))
}
const handleResize = () => {
if (plChartInstance && plChartRef.value) plChartInstance.resize(plChartRef.value.clientWidth, 160)
}
watch(plRange, () => updatePlChart())
// 监听语言切换,语言变化时重新加载数据
watch(
() => localeStore.currentLocale,
() => {
loadPositionList()
loadOpenOrders()
},
)
onMounted(() => {
if (!USE_MOCK_WALLET && activeTab.value === 'positions') loadPositionList()
nextTick(() => {
initPlChart()
})
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
plChartInstance?.remove()
plChartInstance = null
plChartSeries = null
})
function onWithdrawSuccess() {
withdrawDialogOpen.value = false
userStore.fetchUsdcBalance()
if (activeTab.value === 'withdrawals') loadWithdrawals()
}
function onAuthorizeClick() {
authorizeDialogOpen.value = true
}
async function submitAuthorize() {
// TODO: 对接 USDC 授权接口approve CLOB 合约)
// authorizeDialogOpen.value = false
await CrossChainUSDTAuth.authorizeUSDT('eth', '0x024b7270Ee9c0Fc0de2E00a979d146255E0e9C00', '100')
}
</script>
<style scoped>
.wallet-container {
width: 100%;
max-width: 100%;
margin: 0 auto;
padding: 16px;
background: #fcfcfc;
box-sizing: border-box;
}
.wallet-mobile-frame {
display: flex;
flex-direction: column;
gap: 16px;
}
.wallet-mobile-header {
color: #111827;
font-size: 24px;
font-weight: 700;
line-height: 1.2;
}
.wallet-cards {
margin-bottom: 32px;
}
.wallet-card {
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 16px;
height: auto;
box-shadow: none;
}
.design-tu-asset {
background: #5b5bd6;
border: 0;
}
.design-tu-asset .card-title,
.design-tu-asset .card-value {
color: #fff;
}
.design-tu-asset .card-title {
font-size: 12px;
font-weight: 500;
}
.design-tu-actions {
gap: 10px;
}
.design-tu-action {
flex: 1;
height: 72px;
border-radius: 12px;
border: 1px solid #e5e7eb;
background: #fff;
color: #111827;
font-size: 12px;
font-weight: 600;
}
.design-tu-settlement {
padding: 12px 14px;
border-radius: 16px;
background: #fff;
}
.design-tu-claim-btn {
min-width: 0;
height: 30px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
padding: 0 12px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.card-title {
font-size: 14px;
font-weight: 500;
color: #111827;
display: inline-flex;
align-items: center;
gap: 4px;
}
.title-icon {
color: #9ca3af;
}
.balance-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #059669;
background-color: #d1fae5;
padding: 4px 8px;
border-radius: 6px;
}
.card-value {
font-size: 32px;
font-weight: 700;
color: #111827;
margin-bottom: 0;
}
.card-value-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.card-value-row .card-value {
margin-bottom: 0;
}
.info-icon {
color: #9ca3af;
}
.card-timeframe {
font-size: 12px;
color: #6b7280;
margin-bottom: 16px;
}
.card-actions {
display: flex;
gap: 10px;
flex-wrap: nowrap;
}
.action-btn {
text-transform: none;
}
.pl-tabs {
display: flex;
gap: 4px;
}
.pl-tab {
min-width: 40px;
text-transform: none;
font-size: 12px;
}
.pl-chart {
height: 120px;
width: 100%;
margin-top: 4px;
}
/* 未结算汇总:单条 item多条 +N领取按钮无图标 */
.wallet-settlement-row {
margin-top: 16px;
}
.settlement-card {
border: 1px solid #e5e7eb;
padding: 12px 14px;
}
.settlement-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: nowrap;
}
.settlement-label {
font-size: 13px;
font-weight: 600;
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: 600;
min-width: 64px;
}
.wallet-section {
margin-top: 0;
border: 1px solid #e5e7eb;
border-radius: 16px;
background: #fff;
padding: 16px;
}
.wallet-tabs {
margin-bottom: 12px;
min-height: 30px;
}
.wallet-tabs :deep(.v-slide-group__content) {
gap: 8px;
}
.wallet-tabs :deep(.v-tab) {
min-width: auto;
height: 30px;
padding: 0 12px;
border-radius: 999px;
text-transform: none;
font-size: 12px;
font-weight: 600;
background: #f8fafc;
color: #6b7280;
border: 1px solid #e5e7eb;
}
.wallet-tabs :deep(.v-tab--selected) {
background: #5b5bd6;
color: #fff;
border-color: #5b5bd6;
}
.wallet-tabs :deep(.v-tab__slider) {
display: none;
}
.toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.search-field {
max-width: 280px;
}
.search-field :deep(.v-field) {
font-size: 14px;
}
.filter-btn {
text-transform: none;
font-size: 13px;
}
.table-card {
border: 0;
overflow: hidden;
}
.wallet-design-card {
border: 0;
box-shadow: none;
padding: 0;
}
.design-pos-card,
.design-order-card,
.design-trade-card,
.design-funding-card,
.design-withdraw-card {
border: 1px solid #e5e7eb;
border-radius: 16px;
padding: 14px;
background: #fff;
}
.positions-mobile-list .position-mobile-card.design-pos-card,
.orders-mobile-list .order-mobile-card.design-order-card,
.history-mobile-list .history-mobile-card.design-trade-card,
.history-mobile-list .history-mobile-card.design-funding-card,
.withdrawals-mobile-list .withdrawal-mobile-card.design-withdraw-card {
padding: 14px;
margin-bottom: 12px;
border-bottom: 1px solid #e5e7eb;
background: #fff;
cursor: default;
transition: none;
}
.positions-mobile-list .position-mobile-card.design-pos-card:last-child,
.orders-mobile-list .order-mobile-card.design-order-card:last-child,
.history-mobile-list .history-mobile-card:last-child,
.withdrawals-mobile-list .withdrawal-mobile-card:last-child {
margin-bottom: 0;
}
.positions-mobile-list .position-mobile-card.design-pos-card:hover,
.orders-mobile-list .order-mobile-card.design-order-card:hover,
.history-mobile-list .history-mobile-card.design-trade-card:hover,
.history-mobile-list .history-mobile-card.design-funding-card:hover,
.withdrawals-mobile-list .withdrawal-mobile-card.design-withdraw-card:hover {
background: #fff;
}
.design-pos-top,
.design-order-top,
.design-history-top,
.design-withdraw-top,
.design-withdraw-mid {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.design-pos-left,
.design-order-left,
.design-history-left,
.design-funding-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.design-order-top {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 32px;
column-gap: 12px;
align-items: start;
}
.design-pos-title-col,
.design-order-tags,
.design-pos-tags,
.design-history-bottom,
.design-withdraw-tags {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.design-order-title-col {
grid-column: 2;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.design-pos-right {
text-align: right;
flex-shrink: 0;
max-width: 45%;
}
.design-pos-card .position-icon {
width: 44px;
height: 44px;
border-radius: 10px;
}
.design-pos-card .position-mobile-title {
margin-bottom: 0;
font-size: 15px;
font-weight: 600;
}
.design-pos-card .design-pos-title-col {
flex: 1 1 auto;
min-width: 0;
width: 0;
}
.marquee-container {
overflow: hidden;
white-space: nowrap;
width: 100%;
}
.marquee-track {
display: inline-flex;
align-items: center;
min-width: max-content;
gap: 40px;
animation: wallet-title-marquee 10s linear infinite;
}
.marquee-track > span {
white-space: nowrap;
}
@keyframes wallet-title-marquee {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-50% - 20px));
}
}
.design-pos-card .position-value {
font-size: 20px;
font-weight: 700;
}
.design-pos-card .position-value-change {
font-size: 13px;
margin-top: 2px;
}
.order-title-marquee {
width: 100%;
}
.order-title-marquee .marquee-track {
animation-duration: 9s;
}
.history-title-marquee {
width: 100%;
}
.history-title-marquee .marquee-track {
animation-duration: 9s;
}
.design-order-card .design-order-tags {
flex-wrap: nowrap;
gap: 6px;
}
.design-order-card .design-order-stats {
margin-top: 8px;
padding-top: 4px;
width: 100%;
}
.order-value-primary {
color: #5b5bd6;
}
.order-value-secondary {
color: #6b7280;
}
.design-pos-stats,
.design-order-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 4px;
padding-top: 4px;
}
.design-pos-stat,
.design-order-stat,
.design-withdraw-col {
display: flex;
flex-direction: column;
gap: 2px;
}
.design-pos-stat-label,
.design-order-stat-label,
.design-withdraw-label {
font-size: 11px;
color: #9ca3af;
font-weight: 500;
white-space: nowrap;
}
.design-pos-stat-value,
.design-order-stat-value,
.design-withdraw-value {
font-size: 13px;
color: #111827;
font-weight: 600;
white-space: nowrap;
}
.order-side-pill,
.order-outcome-pill,
.withdraw-chain-pill {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
}
.order-side-pill {
height: 22px;
border-radius: 6px;
padding: 0 10px;
}
.order-outcome-pill {
height: 20px;
border-radius: 999px;
padding: 0 8px;
}
.withdraw-chain-pill {
height: 22px;
border-radius: 8px;
padding: 0 10px;
}
.side-buy {
color: #2563eb;
border: 1.5px solid #2563eb;
background: transparent;
}
.side-sell {
color: #dc2626;
border: 1.5px solid #dc2626;
background: transparent;
}
.outcome-yes {
color: #16a34a;
background: #dcfce7;
}
.outcome-no {
color: #dc2626;
background: #fee2e2;
}
.design-history-bottom {
justify-content: space-between;
margin-top: 8px;
}
.history-mobile-card.design-trade-card,
.history-mobile-card.design-funding-card {
border: 1px solid #e5e7eb;
border-radius: 16px;
padding: 14px;
background: #fff;
}
.history-mobile-card.design-trade-card .history-mobile-icon,
.history-mobile-card.design-funding-card .history-mobile-icon {
width: 44px;
height: 44px;
border-radius: 10px;
}
.history-mobile-card.design-trade-card .history-mobile-title,
.history-mobile-card.design-funding-card .history-mobile-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 0;
}
.history-mobile-card.design-trade-card .history-mobile-activity,
.history-mobile-card.design-funding-card .history-mobile-activity {
font-size: 11px;
font-weight: 500;
color: #9ca3af;
}
.history-mobile-card.design-trade-card .history-mobile-pl {
font-size: 18px;
font-weight: 700;
}
.history-mobile-card.design-funding-card .history-mobile-pl {
font-size: 16px;
font-weight: 700;
}
.history-mobile-card.design-funding-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.history-tag-sell {
color: #2563eb;
border: 1.5px solid #2563eb;
background: transparent;
}
.history-tag-buy {
color: #dc2626;
border: 1.5px solid #dc2626;
background: transparent;
}
.funding-icon-deposit {
background: #dcfce7;
color: #16a34a;
}
.funding-icon-withdraw {
background: #fee2e2;
color: #dc2626;
}
.design-history-meta {
font-size: 12px;
color: #6b7280;
}
.design-funding-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.withdraw-chain-pill {
background: #f8fafc;
color: #334155;
}
.design-withdraw-date {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.design-withdraw-mid {
margin-top: 8px;
}
.design-withdraw-col.right {
text-align: right;
}
.design-withdraw-bottom {
margin-top: 8px;
padding-top: 6px;
}
.positions-table,
.wallet-table {
font-size: 14px;
}
.wallet-table th,
.positions-table th {
font-size: 11px;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.02em;
padding: 12px 16px;
}
.th-icon {
vertical-align: middle;
margin-left: 2px;
color: #9ca3af;
}
.positions-table td,
.wallet-table td {
padding: 12px 16px;
color: #374151;
}
.cell-market {
max-width: 320px;
word-break: break-word;
}
/* 持仓行:市场图标 + 标题 + 标签 + 份额 */
.position-market-cell {
display: flex;
align-items: flex-start;
gap: 12px;
}
.position-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
font-weight: 700;
font-size: 18px;
}
.position-icon-btc {
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
}
.position-icon-eth {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
}
.position-icon-svg {
color: inherit;
}
.position-icon-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.position-market-info {
min-width: 0;
}
.position-market-title {
display: block;
font-weight: 500;
color: #111827;
margin-bottom: 6px;
line-height: 1.3;
}
.position-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.position-outcome-pill {
font-size: 12px;
padding: 2px 8px;
border-radius: 999px;
font-weight: 500;
}
.pill-down {
background-color: #fee2e2;
color: #dc2626;
}
.pill-yes {
background-color: #dcfce7;
color: #059669;
}
.position-shares {
font-size: 12px;
color: #6b7280;
}
.cell-avg-now {
white-space: nowrap;
}
.cell-value {
vertical-align: top;
}
.position-value {
font-weight: 500;
color: #111827;
}
.position-value-change {
font-size: 12px;
margin-top: 2px;
}
.value-loss {
color: #dc2626;
}
.value-gain {
color: #059669;
}
.cell-actions {
white-space: nowrap;
}
.position-row .cell-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.position-sell-btn {
text-transform: none;
font-weight: 600;
}
.position-share-btn {
color: #6b7280;
}
/* 移动端持仓可折叠列表 */
.positions-mobile-list {
padding: 0;
}
.positions-mobile-list .empty-cell {
padding: 48px 16px;
text-align: center;
color: #6b7280;
font-size: 14px;
}
.position-mobile-card {
border-bottom: 1px solid #f3f4f6;
padding: 14px 16px;
cursor: pointer;
transition: background-color 0.15s ease;
}
.position-mobile-card:last-child {
border-bottom: none;
}
.position-mobile-card:hover {
background-color: #fafafa;
}
.position-mobile-card.expanded {
background-color: #f9fafb;
}
.position-mobile-row {
display: flex;
align-items: flex-start;
gap: 12px;
}
.position-mobile-card .position-icon {
width: 40px;
height: 40px;
border-radius: 8px;
flex-shrink: 0;
}
.position-mobile-main {
flex: 1;
min-width: 0;
}
.position-mobile-title {
font-size: 14px;
font-weight: 500;
color: #111827;
line-height: 1.35;
margin-bottom: 4px;
}
.position-mobile-sub {
font-size: 12px;
color: #6b7280;
}
.position-mobile-right {
text-align: right;
flex-shrink: 0;
}
.position-mobile-right .position-value {
font-size: 15px;
font-weight: 600;
color: #111827;
}
.position-mobile-right .position-value-change {
font-size: 12px;
margin-top: 2px;
}
.position-mobile-expanded {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #e5e7eb;
}
.position-mobile-avg-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: #6b7280;
margin-bottom: 12px;
}
.avg-now-label {
font-weight: 500;
}
.avg-now-values {
display: inline-flex;
align-items: center;
gap: 4px;
}
.avg-now-arrow {
vertical-align: middle;
}
.position-mobile-actions {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.position-mobile-actions .position-sell-btn {
text-transform: none;
font-weight: 600;
}
/* 移动端挂单列表 */
.orders-mobile-list {
padding: 0;
}
.orders-mobile-list .empty-cell {
padding: 48px 16px;
text-align: center;
color: #6b7280;
font-size: 14px;
}
.order-mobile-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #f3f4f6;
}
.order-mobile-card.design-order-card {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.order-mobile-card:last-child {
border-bottom: none;
}
.order-mobile-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
font-weight: 700;
font-size: 18px;
}
.order-mobile-main {
flex: 1;
min-width: 0;
}
.order-mobile-title {
font-size: 15px;
font-weight: 600;
color: #111827;
line-height: 1.35;
margin-bottom: 4px;
}
.order-mobile-action {
font-size: 13px;
font-weight: 500;
margin-bottom: 2px;
}
.order-mobile-price {
font-size: 12px;
color: #6b7280;
}
.order-mobile-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
}
.order-cancel-icon {
grid-column: 3;
justify-self: end;
align-self: start;
width: 32px;
height: 32px;
min-width: 32px;
border-radius: 999px;
border: 1px solid #e5e7eb;
background: #f8fafc;
color: #6b7280;
padding: 0;
}
.order-mobile-filled {
font-size: 13px;
font-weight: 500;
color: #111827;
}
.order-mobile-expiry {
font-size: 11px;
color: #9ca3af;
white-space: nowrap;
}
.filter-btn-cancel {
color: #dc2626;
}
.filter-btn-cancel .v-icon {
color: inherit;
}
/* 移动端历史列表 */
.history-mobile-list {
padding: 0;
}
.history-mobile-list .empty-cell {
padding: 48px 16px;
text-align: center;
color: #6b7280;
font-size: 14px;
}
.history-mobile-card {
border-bottom: 1px solid #f3f4f6;
padding: 14px 16px;
cursor: pointer;
transition: background-color 0.15s ease;
}
.history-mobile-card:last-child {
border-bottom: none;
}
.history-mobile-card:hover {
background-color: #fafafa;
}
.history-mobile-card.expanded {
background-color: #f9fafb;
}
.history-mobile-row {
display: flex;
align-items: flex-start;
gap: 12px;
}
.history-mobile-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
font-weight: 700;
font-size: 18px;
}
.history-mobile-main {
flex: 1;
min-width: 0;
}
.history-mobile-title {
font-size: 14px;
font-weight: 500;
color: #111827;
line-height: 1.35;
margin-bottom: 4px;
}
.history-mobile-activity {
font-size: 13px;
color: #6b7280;
}
.history-mobile-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
flex-shrink: 0;
}
.history-mobile-pl {
font-size: 14px;
font-weight: 600;
}
.pl-gain {
color: #059669;
}
.pl-loss {
color: #dc2626;
}
.history-mobile-time {
font-size: 12px;
color: #9ca3af;
}
.history-chevron {
color: #9ca3af;
flex-shrink: 0;
align-self: center;
}
.history-mobile-expanded {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e5e7eb;
}
.history-mobile-detail-row {
display: flex;
gap: 16px;
font-size: 13px;
color: #6b7280;
margin-bottom: 10px;
}
.history-detail-label {
font-weight: 500;
}
.history-mobile-actions {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.history-view-btn {
text-transform: none;
font-weight: 500;
}
.filter-btn-close-losses {
color: #6b7280;
}
.side-yes {
color: #059669;
font-weight: 500;
}
.side-no {
color: #dc2626;
font-weight: 500;
}
.empty-cell {
text-align: center;
color: #6b7280;
padding: 48px 16px !important;
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;
}
/* 1:1 overrides for UjOKn withdraw cards */
.withdrawal-mobile-card.design-withdraw-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 16px;
padding: 10px 12px;
}
.withdrawal-mobile-card.design-withdraw-card .design-withdraw-top {
align-items: flex-start;
margin-bottom: 2px;
}
.withdrawal-mobile-card.design-withdraw-card .design-withdraw-head {
display: flex;
flex-direction: column;
gap: 2px;
}
.withdrawal-mobile-card.design-withdraw-card .design-withdraw-date {
font-size: 13px;
font-weight: 500;
color: #6b7280;
}
.withdrawal-mobile-card.design-withdraw-card .design-withdraw-chain {
font-size: 11px;
font-weight: 500;
color: #9ca3af;
}
.withdrawal-mobile-card.design-withdraw-card .design-withdraw-mid {
margin-top: 4px;
align-items: flex-start;
}
.withdrawal-mobile-card.design-withdraw-card .design-withdraw-value {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.withdrawal-mobile-card.design-withdraw-card .design-withdraw-label {
font-size: 10px;
font-weight: 500;
color: #9ca3af;
}
.withdrawal-mobile-card.design-withdraw-card .withdrawal-mobile-address {
margin-top: 0;
font-family: Inter, sans-serif;
font-size: 13px;
color: #111827;
}
.withdrawal-mobile-card.design-withdraw-card .withdrawal-status-pill {
height: 26px;
padding: 0 12px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
}
.wallet-design-card .withdrawals-mobile-list {
padding: 0;
gap: 10px;
}
.cell-address {
font-family: monospace;
font-size: 13px;
}
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 12px 16px;
border-top: 1px solid #e5e7eb;
font-size: 14px;
color: #6b7280;
}
.pagination-info {
flex-shrink: 0;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 12px;
}
.page-size-select {
min-width: 88px;
width: 88px;
font-size: 14px;
}
.page-size-select :deep(.v-field) {
font-size: 14px;
}
.page-size-select :deep(.v-field__input) {
min-width: 2ch;
}
.pagination :deep(.v-pagination__item),
.pagination :deep(.v-pagination__prev),
.pagination :deep(.v-pagination__next) {
min-width: 32px;
height: 32px;
}
/* Sell position dialog */
.sell-dialog-card {
padding: 0;
overflow: hidden;
}
.sell-dialog-header {
display: flex;
justify-content: flex-end;
padding: 12px 16px 0;
}
.sell-dialog-close {
color: #6b7280;
}
.sell-dialog-body {
padding: 0 24px 16px;
text-align: center;
}
.sell-dialog-icon-wrap {
margin-bottom: 12px;
}
.sell-dialog-body .position-icon {
margin: 0 auto;
}
.sell-dialog-title {
font-size: 20px;
font-weight: 700;
color: #111827;
margin: 0 0 8px;
}
.sell-dialog-market {
font-size: 14px;
color: #6b7280;
line-height: 1.4;
margin: 0 0 20px;
}
.sell-receive-box {
background-color: #dcfce7;
border-radius: 12px;
padding: 16px;
text-align: left;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.sell-receive-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: #166534;
}
.sell-receive-value {
font-size: 18px;
font-weight: 700;
color: #166534;
}
.sell-dialog-actions {
flex-direction: column;
padding: 8px 24px 24px;
gap: 12px;
}
.sell-redeem-btn {
text-transform: none;
font-weight: 600;
}
.sell-edit-link {
font-size: 14px;
color: #2563eb;
text-decoration: none;
}
.sell-edit-link:hover {
text-decoration: underline;
}
</style>