xtraderClient/src/views/Wallet.vue
2026-02-11 21:51:12 +08:00

1742 lines
45 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">
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">Today</div>
<div class="card-actions">
<v-btn
color="primary"
variant="flat"
class="action-btn"
prepend-icon="mdi-arrow-down"
@click="depositDialogOpen = true"
>
Deposit
</v-btn>
<v-btn
variant="outlined"
color="grey"
class="action-btn"
prepend-icon="mdi-arrow-up"
@click="withdrawDialogOpen = true"
>
Withdraw
</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>
Profit/Loss
</span>
<div class="pl-tabs">
<v-btn
v-for="t in plTimeRanges"
:key="t"
:variant="plRange === t ? 'flat' : 'text'"
:color="plRange === t ? 'primary' : undefined"
size="small"
class="pl-tab"
@click="plRange = t"
>
{{ t }}
</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">All-Time</div>
<div ref="plChartRef" class="pl-chart"></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">Positions</v-tab>
<v-tab value="orders">Open orders</v-tab>
<v-tab value="history">History</v-tab>
</v-tabs>
<div class="toolbar">
<v-text-field
v-model="search"
placeholder="Search"
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>
Close Losses
</v-btn>
<v-btn variant="outlined" size="small" class="filter-btn">
<v-icon size="18">mdi-filter</v-icon>
All
</v-btn>
<v-btn variant="outlined" size="small" class="filter-btn">
<v-icon size="18">mdi-sort</v-icon>
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>
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>
Current value
</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>
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>
Cancel all
</v-btn>
</template>
<v-btn v-else variant="outlined" size="small" class="filter-btn">
<v-icon size="18">mdi-filter</v-icon>
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="filteredPositions.length === 0">
<div class="empty-cell">No positions found.</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">
<span 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)">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">MARKET</th>
<th class="text-left">
AVG → NOW
<v-icon size="14" class="th-icon">mdi-information-outline</v-icon>
</th>
<th class="text-left">BET</th>
<th class="text-left">TO WIN</th>
<th class="text-left">
VALUE
<v-icon size="14" class="th-icon">mdi-chevron-down</v-icon>
</th>
<th class="text-right">ACTION</th>
</tr>
</thead>
<tbody>
<tr v-if="filteredPositions.length === 0">
<td colspan="6" class="empty-cell">No positions found.</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">
<v-icon v-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)">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="filteredOpenOrders.length === 0">
<div class="empty-cell">No open orders found.</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" @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">Expiration: {{ ord.expiration }}</div>
</div>
</div>
</div>
<!-- 桌面端:表格 -->
<v-table v-else class="wallet-table">
<thead>
<tr>
<th class="text-left">MARKET</th>
<th class="text-left">SIDE</th>
<th class="text-left">OUTCOME</th>
<th class="text-left">PRICE</th>
<th class="text-left">FILLED</th>
<th class="text-left">TOTAL</th>
<th class="text-left">EXPIRATION</th>
<th class="text-right">ACTION</th>
</tr>
</thead>
<tbody>
<tr v-if="filteredOpenOrders.length === 0">
<td colspan="8" class="empty-cell">No open orders found.</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" @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="filteredHistory.length === 0">
<div class="empty-cell">You haven't traded any polymarkets yet</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">
<span 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)">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">ACTIVITY</th>
<th class="text-left">MARKET</th>
<th class="text-left">VALUE</th>
</tr>
</thead>
<tbody>
<tr v-if="filteredHistory.length === 0">
<td colspan="3" class="empty-cell">You haven't traded any polymarkets yet</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>
<!-- 分页 -->
<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"
/>
<!-- 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">
<span 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 { useDisplay } from 'vuetify'
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 { pmCancelOrder } from '../api/market'
const { mobile } = useDisplay()
const userStore = useUserStore()
const portfolioBalance = computed(() => userStore.balance)
const profitLoss = ref('0.00')
const plRange = ref('ALL')
const plTimeRanges = ['1D', '1W', '1M', 'ALL']
const activeTab = ref<'positions' | 'orders' | 'history'>('positions')
const search = ref('')
const depositDialogOpen = ref(false)
const withdrawDialogOpen = 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
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
}
/** 从 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
}
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
}
const positions = ref<Position[]>([
{
id: 'p1',
market: 'Bitcoin Up or Down - February 9, 2:00AM-2:15AM ET',
iconChar: '₿',
iconClass: 'position-icon-btc',
outcomeTag: 'Down 72¢',
outcomePillClass: 'pill-down',
shares: '6.9 shares',
avgNow: '72¢ → 0.5¢',
bet: '$4.95',
toWin: '$6.87',
value: '$0.03',
valueChange: '-$4.91',
valueChangePct: '99.31%',
valueChangeLoss: true,
sellOutcome: 'Down',
outcomeWord: 'Down',
},
{
id: 'p2',
market: 'Bitcoin Up or Down - February 9, 2:15AM-2:30AM ET',
iconChar: '₿',
iconClass: 'position-icon-btc',
outcomeTag: 'Up 26¢',
outcomePillClass: 'pill-yes',
shares: '3.8 shares',
avgNow: '28¢ → 26¢',
bet: '$0.99',
toWin: '$3.54',
value: '$0.90',
valueChange: '-$0.09',
valueChangePct: '8.9%',
valueChangeLoss: true,
sellOutcome: 'Up',
outcomeWord: 'Up',
},
{
id: 'p3',
market: 'Will ETH merge complete by Q3?',
iconChar: 'Ξ',
iconClass: 'position-icon-eth',
outcomeTag: 'Yes 48¢',
outcomePillClass: 'pill-yes',
shares: '30 shares',
avgNow: '45¢ → 48¢',
bet: '$30',
toWin: '$36',
value: '$32',
valueChange: '+$2',
valueChangePct: '6.67%',
valueChangeLoss: false,
sellOutcome: 'Yes',
outcomeWord: 'Yes',
},
])
const MOCK_TOKEN_ID = '59966088656508531737144108943848781534186324373509174641856486864137458635937'
const openOrders = ref<OpenOrder[]>([
{
id: 'o1',
market: 'Bitcoin Up or Down - February 9, 2:45AM-3:00AM ET',
side: 'Yes',
outcome: 'Up',
price: '1¢',
filled: '0',
total: '$0.05',
expiration: 'Until Cancelled',
actionLabel: 'Buy Up',
filledDisplay: '0/5',
iconChar: '₿',
iconClass: 'position-icon-btc',
orderID: 5,
tokenID: MOCK_TOKEN_ID,
},
{
id: 'o2',
market: 'Will Bitcoin hit $100k by end of 2025?',
side: 'Yes',
outcome: 'Yes',
price: '70¢',
filled: '0',
total: '$70',
expiration: 'Dec 31, 2025',
orderID: 5,
tokenID: MOCK_TOKEN_ID,
},
{
id: 'o3',
market: 'Will ETH merge complete by Q3?',
side: 'No',
outcome: 'No',
price: '52¢',
filled: '25',
total: '$26',
expiration: 'Sep 30, 2025',
orderID: 5,
tokenID: MOCK_TOKEN_ID,
},
])
const history = ref<HistoryItem[]>([
{
id: 'h1',
market: 'Bitcoin Up or Down - February 9, 3:00AM-3:15AM ET',
side: 'No',
activity: 'Sell No',
activityDetail: 'Sold 1 Down at 50¢',
value: '$0.50',
profitLoss: '+$0.50',
profitLossNegative: false,
timeAgo: '3 minutes ago',
avgPrice: '50¢',
shares: '1',
iconChar: '₿',
iconClass: 'position-icon-btc',
},
{
id: 'h2',
market: 'Bitcoin Up or Down - February 9, 3:00AM-3:15AM ET',
side: 'Yes',
activity: 'Sell Yes',
activityDetail: 'Sold 1 Up at 63¢',
value: '$0.63',
profitLoss: '+$0.63',
profitLossNegative: false,
timeAgo: '6 minutes ago',
iconChar: '₿',
iconClass: 'position-icon-btc',
},
{
id: 'h3',
market: 'Bitcoin Up or Down - February 9, 3:00AM-3:15AM ET',
side: 'Yes',
activity: 'Split',
activityDetail: 'Split',
value: '-$1.00',
profitLoss: '-$1.00',
profitLossNegative: true,
timeAgo: '7 minutes ago',
iconChar: '₿',
iconClass: 'position-icon-btc',
},
{ id: 'h4', market: 'Will ETH merge complete by Q3?', side: 'No', activity: 'Sell No', value: '$35.20' },
])
function matchSearch(text: string): boolean {
const q = search.value.trim().toLowerCase()
return !q || text.toLowerCase().includes(q)
}
const filteredPositions = computed(() => positions.value.filter((p) => matchSearch(p.market)))
const filteredOpenOrders = computed(() => openOrders.value.filter((o) => matchSearch(o.market)))
const filteredHistory = computed(() => history.value.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(() => paginate(filteredPositions.value))
const paginatedOpenOrders = computed(() => paginate(filteredOpenOrders.value))
const paginatedHistory = computed(() => paginate(filteredHistory.value))
const totalPagesPositions = computed(() => Math.max(1, Math.ceil(filteredPositions.value.length / itemsPerPage.value)))
const totalPagesOrders = computed(() => Math.max(1, Math.ceil(filteredOpenOrders.value.length / itemsPerPage.value)))
const totalPagesHistory = computed(() => Math.max(1, Math.ceil(filteredHistory.value.length / itemsPerPage.value)))
const currentListTotal = computed(() => {
if (activeTab.value === 'positions') return filteredPositions.value.length
if (activeTab.value === 'orders') return filteredOpenOrders.value.length
return filteredHistory.value.length
})
const currentTotalPages = computed(() => {
if (activeTab.value === 'positions') return totalPagesPositions.value
if (activeTab.value === 'orders') return totalPagesOrders.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, () => { page.value = 1 })
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) {
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 = '请先登录'
showCancelError.value = true
return
}
const headers = userStore.getAuthHeaders()
if (!headers) {
cancelOrderError.value = '请先登录'
showCancelError.value = true
return
}
cancelOrderLoading.value = true
cancelOrderError.value = ''
try {
const res = await pmCancelOrder({ orderID, tokenID, userID }, { headers })
if (res.code === 0 || res.code === 200) {
openOrders.value = openOrders.value.filter((o) => o.id !== ord.id)
userStore.fetchUsdcBalance()
} else {
cancelOrderError.value = res.msg || '取消失败'
showCancelError.value = true
}
} catch (e) {
cancelOrderError.value = e instanceof Error ? e.message : 'Request failed'
showCancelError.value = true
} finally {
cancelOrderLoading.value = false
}
}
function cancelAllOrders() {
openOrders.value = []
}
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, pnl] */
function generatePlData(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][] = []
let pnl = 0
for (let i = count; i >= 0; i--) {
const t = now - i * stepMs
pnl += (Math.random() - 0.48) * 20
data.push([t, Math.round(pnl * 100) / 100])
}
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 = generatePlData(plRange.value)
const last = plChartData.value[plChartData.value.length - 1]
if (last) profitLoss.value = last[1].toFixed(2)
if (plChartInstance) plChartInstance.setOption(buildPlChartOption(plChartData.value), { replaceMerge: ['series'] })
}
function initPlChart() {
if (!plChartRef.value) return
plChartData.value = generatePlData(plRange.value)
const last = plChartData.value[plChartData.value.length - 1]
if (last) profitLoss.value = last[1].toFixed(2)
plChartInstance = echarts.init(plChartRef.value)
plChartInstance.setOption(buildPlChartOption(plChartData.value))
}
const handleResize = () => plChartInstance?.resize()
watch(plRange, () => updatePlChart())
onMounted(() => {
nextTick(() => {
initPlChart()
})
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
plChartInstance?.dispose()
plChartInstance = null
})
function onWithdrawSuccess() {
withdrawDialogOpen.value = false
}
</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;
}
.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-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;
}
.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>