1742 lines
45 KiB
Vue
1742 lines
45 KiB
Vue
<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)
|
||
/** 移动端展开的持仓 id,null 表示全部折叠 */
|
||
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>
|