xtraderClient/src/views/Wallet.vue

2415 lines
67 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">
<!-- 顶部Portfolio + Profit/Loss 卡片 -->
<v-row class="wallet-cards">
<v-col cols="12" md="6">
<v-card class="wallet-card portfolio-card" elevation="0" rounded="lg">
<div class="card-header">
<span class="card-title">
{{ t('wallet.portfolio') }}
<v-icon size="16" class="title-icon">mdi-eye-off-outline</v-icon>
</span>
<div class="balance-badge">
<v-icon size="14">mdi-sack</v-icon>
<span>${{ portfolioBalance }}</span>
</div>
</div>
<div class="card-value">${{ portfolioBalance }}</div>
<div class="card-timeframe">{{ t('wallet.today') }}</div>
<div class="card-actions">
<v-btn
color="primary"
variant="flat"
class="action-btn"
prepend-icon="mdi-arrow-down"
@click="depositDialogOpen = true"
>
{{ t('wallet.deposit') }}
</v-btn>
<v-btn
variant="outlined"
color="grey"
class="action-btn"
prepend-icon="mdi-arrow-up"
@click="withdrawDialogOpen = true"
>
{{ t('wallet.withdraw') }}
</v-btn>
<!-- <v-btn
variant="outlined"
color="grey"
class="action-btn"
prepend-icon="mdi-shield-check-outline"
@click="onAuthorizeClick"
>
{{ t('wallet.authorize') }}
</v-btn> -->
</div>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card class="wallet-card pl-card" elevation="0" rounded="lg">
<div class="card-header">
<span class="card-title">
<v-icon size="16" color="success">mdi-triangle-small-up</v-icon>
{{ t('wallet.profitLoss') }}
</span>
<div class="pl-tabs">
<v-btn
v-for="tr in plTimeRanges"
:key="tr.value"
:variant="plRange === tr.value ? 'flat' : 'text'"
:color="plRange === tr.value ? 'primary' : undefined"
size="small"
class="pl-tab"
@click="plRange = tr.value"
>
{{ tr.label }}
</v-btn>
</div>
</div>
<div class="card-value-row">
<span class="card-value">${{ profitLoss }}</span>
<v-icon size="18" class="info-icon">mdi-information-outline</v-icon>
</div>
<div class="card-timeframe">{{ t('wallet.allTime') }}</div>
<div ref="plChartRef" class="pl-chart"></div>
</v-card>
</v-col>
</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 -->
<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>
<div class="toolbar">
<v-text-field
v-model="search"
:placeholder="t('wallet.searchPlaceholder')"
density="compact"
hide-details
variant="outlined"
rounded
class="search-field"
prepend-inner-icon="mdi-magnify"
/>
<template v-if="activeTab === 'history'">
<v-btn
v-if="mobile"
variant="outlined"
size="small"
class="filter-btn filter-btn-close-losses"
@click="closeLosses"
>
<v-icon size="18">mdi-delete-outline</v-icon>
{{ t('wallet.closeLosses') }}
</v-btn>
<v-btn variant="outlined" size="small" class="filter-btn">
<v-icon size="18">mdi-filter</v-icon>
{{ t('wallet.all') }}
</v-btn>
<v-btn variant="outlined" size="small" class="filter-btn">
<v-icon size="18">mdi-sort</v-icon>
{{ t('wallet.newest') }}
</v-btn>
<v-btn variant="outlined" size="small" class="filter-btn" icon>
<v-icon size="18">mdi-calendar</v-icon>
</v-btn>
<v-btn variant="outlined" size="small" class="filter-btn">
<v-icon size="18">mdi-download</v-icon>
{{ t('wallet.export') }}
</v-btn>
</template>
<template v-else-if="activeTab === 'positions'">
<v-btn variant="outlined" size="small" class="filter-btn">
<v-icon size="18">mdi-filter</v-icon>
{{ t('wallet.currentValue') }}
</v-btn>
</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'">
<v-btn variant="outlined" size="small" class="filter-btn">
<v-icon size="18">mdi-filter</v-icon>
{{ t('wallet.market') }}
</v-btn>
<v-btn
variant="outlined"
size="small"
class="filter-btn filter-btn-cancel"
@click="cancelAllOrders"
>
<v-icon size="18">mdi-close</v-icon>
{{ t('wallet.cancelAll') }}
</v-btn>
</template>
<v-btn v-else variant="outlined" size="small" class="filter-btn">
<v-icon size="18">mdi-filter</v-icon>
{{ t('wallet.market') }}
</v-btn>
</div>
<v-card class="table-card" elevation="0" rounded="lg">
<!-- 持仓:桌面端表格 / 移动端可折叠列表 -->
<template v-if="activeTab === 'positions'">
<!-- 移动端:可折叠列表 -->
<div v-if="mobile" class="positions-mobile-list">
<template v-if="positionLoading">
<div class="empty-cell">{{ t('common.loading') }}</div>
</template>
<template v-else-if="filteredPositions.length === 0">
<div class="empty-cell">{{ t('wallet.noPositionsFound') }}</div>
</template>
<div
v-for="pos in paginatedPositions"
:key="pos.id"
class="position-mobile-card"
:class="{ expanded: expandedPositionId === pos.id }"
@click="togglePositionExpanded(pos.id)"
>
<div class="position-mobile-row">
<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="position-mobile-main">
<div class="position-mobile-title">{{ pos.market }}</div>
<div class="position-mobile-sub">
{{ pos.bet }} on {{ pos.outcomeWord || pos.sellOutcome || 'Position' }} to win
{{ pos.toWin }}
</div>
</div>
<div class="position-mobile-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>
<!-- 展开内容AVG • NOW + 操作按钮 -->
<div
v-show="expandedPositionId === pos.id"
class="position-mobile-expanded"
@click.stop
>
<div class="position-mobile-avg-row">
<span class="avg-now-label">AVG • NOW</span>
<span class="avg-now-values">
<template v-if="parseAvgNow(pos.avgNow)[1]">
{{ parseAvgNow(pos.avgNow)[0] }}
<v-icon
v-if="pos.valueChangeLoss"
size="14"
color="error"
class="avg-now-arrow"
>mdi-chevron-down</v-icon
>
<v-icon v-else size="14" color="success" class="avg-now-arrow"
>mdi-chevron-up</v-icon
>
{{ parseAvgNow(pos.avgNow)[1] }}
</template>
<template v-else>{{ pos.avgNow }}</template>
</span>
</div>
<div class="position-mobile-actions">
<v-btn
color="primary"
variant="flat"
size="small"
class="position-sell-btn"
@click="sellPosition(pos.id)"
>{{ t('trade.sell') }}</v-btn
>
<v-btn
icon
variant="text"
size="small"
class="position-share-btn"
@click="sharePosition(pos.id)"
>
<v-icon size="18">mdi-share-variant</v-icon>
</v-btn>
</div>
</div>
</div>
</div>
<!-- 桌面端:表格 -->
<v-table v-else class="wallet-table positions-table-full">
<thead>
<tr>
<th class="text-left">{{ t('wallet.market') }}</th>
<th class="text-left">
{{ t('wallet.avgNow') }}
<v-icon size="14" class="th-icon">mdi-information-outline</v-icon>
</th>
<th class="text-left">{{ t('wallet.bet') }}</th>
<th class="text-left">{{ t('wallet.toWin') }}</th>
<th class="text-left">
{{ t('wallet.value') }}
<v-icon size="14" class="th-icon">mdi-chevron-down</v-icon>
</th>
<th class="text-right">{{ t('wallet.action') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="positionLoading">
<td colspan="6" class="empty-cell">{{ t('common.loading') }}</td>
</tr>
<tr v-else-if="filteredPositions.length === 0">
<td colspan="6" class="empty-cell">{{ t('wallet.noPositionsFound') }}</td>
</tr>
<tr v-for="pos in paginatedPositions" :key="pos.id" class="position-row">
<td class="cell-market">
<div class="position-market-cell">
<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="position-market-info">
<span class="position-market-title">{{ pos.market }}</span>
<div class="position-meta">
<span
v-if="pos.outcomeTag"
class="position-outcome-pill"
:class="pos.outcomePillClass"
>{{ pos.outcomeTag }}</span
>
<span class="position-shares">{{ pos.shares }}</span>
</div>
</div>
</div>
</td>
<td class="cell-avg-now">{{ pos.avgNow }}</td>
<td>{{ pos.bet }}</td>
<td>{{ pos.toWin }}</td>
<td class="cell-value">
<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>
</td>
<td class="text-right cell-actions">
<v-btn
color="primary"
variant="flat"
size="small"
class="position-sell-btn"
@click="sellPosition(pos.id)"
>{{ t('trade.sell') }}</v-btn
>
<v-btn
icon
variant="text"
size="small"
class="position-share-btn"
@click="sharePosition(pos.id)"
>
<v-icon size="18">mdi-share-variant</v-icon>
</v-btn>
</td>
</tr>
</tbody>
</v-table>
</template>
<!-- 未成交:移动端卡片列表 / 桌面端表格 -->
<template v-else-if="activeTab === 'orders'">
<!-- 移动端:挂单卡片列表 -->
<div v-if="mobile" class="orders-mobile-list">
<template v-if="openOrderLoading">
<div class="empty-cell">{{ t('common.loading') }}</div>
</template>
<template v-else-if="filteredOpenOrders.length === 0">
<div class="empty-cell">{{ t('wallet.noOpenOrdersFound') }}</div>
</template>
<div v-for="ord in paginatedOpenOrders" :key="ord.id" class="order-mobile-card">
<div class="order-mobile-icon" :class="ord.iconClass">
<span class="position-icon-char">{{ ord.iconChar }}</span>
</div>
<div class="order-mobile-main">
<div class="order-mobile-title">{{ ord.market }}</div>
<div
class="order-mobile-action"
:class="ord.side === 'Yes' ? 'side-yes' : 'side-no'"
>
{{ ord.actionLabel || `Buy ${ord.outcome}` }}
</div>
<div class="order-mobile-price">{{ ord.price }} • {{ ord.total }}</div>
</div>
<div class="order-mobile-right">
<v-btn
icon
variant="text"
size="small"
class="order-cancel-icon"
color="error"
:disabled="cancelOrderLoading || ord.fullyFilled"
@click.stop="cancelOrder(ord)"
>
<v-icon size="20">mdi-close</v-icon>
</v-btn>
<div class="order-mobile-filled">{{ ord.filledDisplay || ord.filled }}</div>
<div class="order-mobile-expiry">{{ t('wallet.expirationLabel') }} {{ ord.expiration }}</div>
</div>
</div>
</div>
<!-- 桌面端:表格 -->
<v-table v-else class="wallet-table">
<thead>
<tr>
<th class="text-left">{{ t('wallet.market') }}</th>
<th class="text-left">{{ t('wallet.side') }}</th>
<th class="text-left">{{ t('wallet.outcome') }}</th>
<th class="text-left">{{ t('wallet.price') }}</th>
<th class="text-left">{{ t('wallet.filled') }}</th>
<th class="text-left">{{ t('wallet.total') }}</th>
<th class="text-left">{{ t('wallet.expiration') }}</th>
<th class="text-right">{{ t('wallet.action') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="openOrderLoading">
<td colspan="8" class="empty-cell">{{ t('common.loading') }}</td>
</tr>
<tr v-else-if="filteredOpenOrders.length === 0">
<td colspan="8" class="empty-cell">{{ t('wallet.noOpenOrdersFound') }}</td>
</tr>
<tr v-for="ord in paginatedOpenOrders" :key="ord.id">
<td class="cell-market">{{ ord.market }}</td>
<td>
<span :class="ord.side === 'Yes' ? 'side-yes' : 'side-no'">{{ ord.side }}</span>
</td>
<td>{{ ord.outcome }}</td>
<td>{{ ord.price }}</td>
<td>{{ ord.filled }}</td>
<td>{{ ord.total }}</td>
<td>{{ ord.expiration }}</td>
<td class="text-right">
<v-btn
variant="text"
size="small"
color="error"
:disabled="cancelOrderLoading || ord.fullyFilled"
@click="cancelOrder(ord)"
>Cancel</v-btn
>
</td>
</tr>
</tbody>
</v-table>
</template>
<!-- 历史记录:移动端可展开列表 / 桌面端表格 -->
<template v-else-if="activeTab === 'history'">
<!-- 移动端:历史卡片列表 -->
<div v-if="mobile" class="history-mobile-list">
<template v-if="historyLoading">
<div class="empty-cell">{{ t('common.loading') }}</div>
</template>
<template v-else-if="filteredHistory.length === 0">
<div class="empty-cell">{{ t('wallet.noHistoryFound') }}</div>
</template>
<div
v-for="h in paginatedHistory"
:key="h.id"
class="history-mobile-card"
:class="{ expanded: expandedHistoryId === h.id }"
@click="toggleHistoryExpanded(h.id)"
>
<div class="history-mobile-row">
<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">{{ h.market }}</div>
<div class="history-mobile-activity">{{ h.activityDetail || h.activity }}</div>
</div>
<div class="history-mobile-right">
<span
:class="['history-mobile-pl', h.profitLossNegative ? 'pl-loss' : 'pl-gain']"
>
{{ h.profitLoss ?? h.value }}
</span>
<div class="history-mobile-time">{{ h.timeAgo || '' }}</div>
</div>
<v-icon size="20" class="history-chevron">
{{ expandedHistoryId === h.id ? 'mdi-chevron-up' : 'mdi-chevron-down' }}
</v-icon>
</div>
<div v-show="expandedHistoryId === h.id" class="history-mobile-expanded" @click.stop>
<div class="history-mobile-detail-row">
<span v-if="h.avgPrice" class="history-detail-label">AVG {{ h.avgPrice }}</span>
<span v-if="h.shares" class="history-detail-label">SHARES {{ h.shares }}</span>
</div>
<div class="history-mobile-actions">
<v-btn
variant="outlined"
size="small"
class="history-view-btn"
@click="viewHistory(h.id)"
>{{ t('wallet.view') }}</v-btn
>
<v-btn
icon
variant="text"
size="small"
class="position-share-btn"
@click="shareHistory(h.id)"
>
<v-icon size="18">mdi-open-in-new</v-icon>
</v-btn>
</div>
</div>
</div>
</div>
<!-- 桌面端:表格 -->
<v-table v-else class="wallet-table">
<thead>
<tr>
<th class="text-left">{{ t('wallet.activity') }}</th>
<th class="text-left">{{ t('wallet.market') }}</th>
<th class="text-left">{{ t('wallet.value') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="historyLoading">
<td colspan="3" class="empty-cell">{{ t('common.loading') }}</td>
</tr>
<tr v-else-if="filteredHistory.length === 0">
<td colspan="3" class="empty-cell">{{ t('wallet.noHistoryFound') }}</td>
</tr>
<tr v-for="h in paginatedHistory" :key="h.id">
<td>
<span :class="h.side === 'Yes' ? 'side-yes' : 'side-no'">{{ h.activity }}</span>
</td>
<td class="cell-market">{{ h.market }}</td>
<td>{{ h.value }}</td>
</tr>
</tbody>
</v-table>
</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">
<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>
<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">Sell {{ sellPositionItem.sellOutcome || '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>Receive</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">
Redeem
</v-btn>
<a href="#" class="sell-edit-link" @click.prevent="editSellOrder">Edit order</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 * as echarts from 'echarts'
import type { ECharts } from 'echarts'
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
}
const positions = ref<Position[]>(
USE_MOCK_WALLET ? [...MOCK_WALLET_POSITIONS] : [],
)
/** 提现记录列表 */
const withdrawalsList = ref<SettlementRequestClientItem[]>([])
const withdrawalsTotal = ref(0)
const withdrawalsLoading = ref(false)
/** 持仓列表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
}
}
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)
}
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 || '取消失败'
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: ECharts | 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
}
function buildPlChartOption(chartData: [number, number][]) {
const lastPoint = chartData[chartData.length - 1]
const lastVal = lastPoint != null ? lastPoint[1] : 0
const lineColor = lastVal >= 0 ? '#059669' : '#dc2626'
return {
animation: false,
tooltip: {
trigger: 'axis',
formatter: (params: unknown) => {
const p = (Array.isArray(params) ? params[0] : params) as {
name: string | number
value: unknown
}
const date = new Date(p.name as number)
const val = Array.isArray(p.value) ? (p.value as number[])[1] : p.value
const sign = Number(val) >= 0 ? '+' : ''
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}<br/>${sign}$${Number(val).toFixed(2)}`
},
axisPointer: { animation: false },
},
grid: { left: 8, right: 8, top: 8, bottom: 24, containLabel: false },
xAxis: {
type: 'time',
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#9ca3af', fontSize: 10 },
splitLine: { show: false },
},
yAxis: {
type: 'value',
position: 'right',
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#9ca3af', fontSize: 10, formatter: (v: number) => `$${v}` },
splitLine: { lineStyle: { type: 'dashed', color: '#e5e7eb' } },
},
series: [
{
type: 'line',
data: chartData,
smooth: true,
showSymbol: false,
lineStyle: { width: 2, color: lineColor },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: lineColor + '40' },
{ offset: 1, color: lineColor + '08' },
]),
},
},
],
}
}
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 (plChartInstance)
plChartInstance.setOption(buildPlChartOption(plChartData.value), { replaceMerge: ['series'] })
}
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)
plChartInstance = echarts.init(plChartRef.value)
plChartInstance.setOption(buildPlChartOption(plChartData.value))
}
const handleResize = () => plChartInstance?.resize()
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?.dispose()
plChartInstance = 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 {
max-width: 1200px;
margin: 0 auto;
padding: 24px 16px;
}
.wallet-cards {
margin-bottom: 32px;
}
.wallet-card {
padding: 20px;
border: 1px solid #e5e7eb;
height: 100%;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.card-title {
font-size: 14px;
font-weight: 500;
color: #374151;
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: 28px;
font-weight: 600;
color: #111827;
margin-bottom: 4px;
}
.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: 12px;
flex-wrap: wrap;
}
.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 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 {
margin-top: 8px;
}
.wallet-tabs {
margin-bottom: 16px;
}
.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: 1px solid #e5e7eb;
overflow: hidden;
}
.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:last-child {
border-bottom: none;
}
.order-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;
}
.order-mobile-main {
flex: 1;
min-width: 0;
}
.order-mobile-title {
font-size: 14px;
font-weight: 500;
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 {
min-width: 32px;
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;
}
.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>