新增:钱包页交易盈亏曲线,并对齐 Polymarket 双卡布局。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d6f1cd956e
commit
d3f237168c
42
src/api/pnlCurve.ts
Normal file
42
src/api/pnlCurve.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { get } from './request'
|
||||||
|
import type { ApiResponse } from './types'
|
||||||
|
|
||||||
|
export type PnlCurveRange = '1D' | '1W' | '1M' | '1Y' | 'YTD' | 'ALL'
|
||||||
|
|
||||||
|
export interface PnlCurvePoint {
|
||||||
|
ts: number
|
||||||
|
pnl: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PnlCurveData {
|
||||||
|
range: PnlCurveRange
|
||||||
|
startTs: number
|
||||||
|
endTs: number
|
||||||
|
currentPnl: number
|
||||||
|
changeAmount: number
|
||||||
|
changePercent: number
|
||||||
|
noHistory: boolean
|
||||||
|
points: PnlCurvePoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PnlCurveResponse extends ApiResponse {
|
||||||
|
data: PnlCurveData
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /uas/getPnlCurveClient
|
||||||
|
* 获取用户交易盈亏曲线(卖出 − 买入 USDC)
|
||||||
|
*/
|
||||||
|
export async function getPnlCurve(
|
||||||
|
range: PnlCurveRange,
|
||||||
|
config?: { headers?: Record<string, string> },
|
||||||
|
): Promise<PnlCurveResponse> {
|
||||||
|
return get<PnlCurveResponse>('/uas/getPnlCurveClient', { range }, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PNL_CURVE_RANGES: PnlCurveRange[] = ['1D', '1W', '1M', '1Y', 'YTD', 'ALL']
|
||||||
|
|
||||||
|
export function formatPnlAmount(amount: number): string {
|
||||||
|
const sign = amount > 0 ? '+' : amount < 0 ? '-' : ''
|
||||||
|
return `${sign}$${Math.abs(amount).toFixed(2)}`
|
||||||
|
}
|
||||||
296
src/components/PnlCurveCard.vue
Normal file
296
src/components/PnlCurveCard.vue
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
<template>
|
||||||
|
<section class="card pnl-curve-card">
|
||||||
|
<div class="pnl-head">
|
||||||
|
<div class="pnl-head-left">
|
||||||
|
<span class="pnl-dot" aria-hidden="true" />
|
||||||
|
<span class="pnl-title">{{ t('wallet.pnlCurve.title') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pnl-range-row">
|
||||||
|
<button
|
||||||
|
v-for="r in ranges"
|
||||||
|
:key="r"
|
||||||
|
type="button"
|
||||||
|
class="range-btn"
|
||||||
|
:class="{ active: activeRange === r }"
|
||||||
|
@click="setRange(r)"
|
||||||
|
>
|
||||||
|
{{ t(`wallet.pnlCurve.range.${r}`) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pnl-value-row">
|
||||||
|
<div class="pnl-amount-wrap">
|
||||||
|
<div class="pnl-amount" :class="{ up: currentPnl > 0, down: currentPnl < 0 }">
|
||||||
|
{{ formatPnlAmount(currentPnl) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pnl-period">{{ t(`wallet.pnlCurve.period.${activeRange}`) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="chartRef" class="pnl-chart" :aria-label="t('wallet.pnlCurve.title')">
|
||||||
|
<v-progress-circular
|
||||||
|
v-if="loading"
|
||||||
|
indeterminate
|
||||||
|
size="28"
|
||||||
|
width="2"
|
||||||
|
class="pnl-loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import {
|
||||||
|
AreaSeries,
|
||||||
|
createChart,
|
||||||
|
LastPriceAnimationMode,
|
||||||
|
LineType,
|
||||||
|
type IChartApi,
|
||||||
|
type ISeriesApi,
|
||||||
|
} from 'lightweight-charts'
|
||||||
|
import {
|
||||||
|
PNL_CURVE_RANGES,
|
||||||
|
formatPnlAmount,
|
||||||
|
getPnlCurve,
|
||||||
|
type PnlCurveRange,
|
||||||
|
} from '@/api/pnlCurve'
|
||||||
|
import { toLwcData } from '@/composables/useLightweightChart'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const toastStore = useToastStore()
|
||||||
|
|
||||||
|
const ranges = PNL_CURVE_RANGES
|
||||||
|
const activeRange = ref<PnlCurveRange>('1D')
|
||||||
|
const loading = ref(false)
|
||||||
|
const currentPnl = ref(0)
|
||||||
|
|
||||||
|
const chartRef = ref<HTMLElement | null>(null)
|
||||||
|
let chartInstance: IChartApi | null = null
|
||||||
|
let chartSeries: ISeriesApi<'Area'> | null = null
|
||||||
|
|
||||||
|
const LINE_UP = '#2E5CFF'
|
||||||
|
const LINE_DOWN = '#dc2626'
|
||||||
|
|
||||||
|
function seriesColors(pnl: number) {
|
||||||
|
const color = pnl >= 0 ? LINE_UP : LINE_DOWN
|
||||||
|
return {
|
||||||
|
topColor: color + '33',
|
||||||
|
bottomColor: color + '00',
|
||||||
|
lineColor: color,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCurve() {
|
||||||
|
if (!userStore.isLoggedIn) return
|
||||||
|
const headers = userStore.getAuthHeaders()
|
||||||
|
if (!headers) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getPnlCurve(activeRange.value, { headers })
|
||||||
|
if (res.code !== 0 && res.code !== 200) {
|
||||||
|
toastStore.show(res.msg || t('wallet.pnlCurve.loadFailed'), 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = res.data
|
||||||
|
currentPnl.value = data.currentPnl ?? 0
|
||||||
|
|
||||||
|
const points: [number, number][] = (data.points ?? []).map((p) => [p.ts, p.pnl])
|
||||||
|
if (chartSeries) {
|
||||||
|
chartSeries.applyOptions(seriesColors(currentPnl.value))
|
||||||
|
chartSeries.setData(toLwcData(points))
|
||||||
|
chartInstance?.timeScale().fitContent()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : t('wallet.pnlCurve.loadFailed')
|
||||||
|
toastStore.show(msg, 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRange(r: PnlCurveRange) {
|
||||||
|
if (activeRange.value === r) return
|
||||||
|
activeRange.value = r
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChart() {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
const el = chartRef.value
|
||||||
|
chartInstance = createChart(el, {
|
||||||
|
width: el.clientWidth,
|
||||||
|
height: 140,
|
||||||
|
layout: {
|
||||||
|
background: { color: '#ffffff' },
|
||||||
|
textColor: '#9ca3af',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 9,
|
||||||
|
attributionLogo: false,
|
||||||
|
},
|
||||||
|
grid: { vertLines: { visible: false }, horzLines: { visible: false } },
|
||||||
|
leftPriceScale: { visible: false },
|
||||||
|
rightPriceScale: { visible: false, borderVisible: false },
|
||||||
|
timeScale: { visible: false, borderVisible: false },
|
||||||
|
handleScroll: false,
|
||||||
|
handleScale: false,
|
||||||
|
crosshair: {
|
||||||
|
vertLine: { visible: true, labelVisible: false },
|
||||||
|
horzLine: { visible: false, labelVisible: false },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const colors = seriesColors(0)
|
||||||
|
chartSeries = chartInstance.addSeries(AreaSeries, {
|
||||||
|
...colors,
|
||||||
|
lineWidth: 2,
|
||||||
|
lineType: LineType.Curved,
|
||||||
|
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
|
||||||
|
priceFormat: { type: 'price', precision: 2 },
|
||||||
|
crosshairMarkerVisible: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
if (chartInstance && chartRef.value) {
|
||||||
|
chartInstance.resize(chartRef.value.clientWidth, 140)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(activeRange, () => {
|
||||||
|
loadCurve()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
initChart()
|
||||||
|
await loadCurve()
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
chartInstance?.remove()
|
||||||
|
chartInstance = null
|
||||||
|
chartSeries = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.pnl-curve-card {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-head-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #9ca3af;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-title {
|
||||||
|
color: #111827;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-range-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-btn {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-btn.active {
|
||||||
|
background: #e8efff;
|
||||||
|
color: #2e5cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-value-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-amount-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-amount {
|
||||||
|
color: #111827;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-amount.up {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-amount.down {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-period {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-chart {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 140px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pnl-loading {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
margin: auto;
|
||||||
|
color: #2e5cff !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,10 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-btn
|
<v-btn
|
||||||
color="default"
|
color="default"
|
||||||
variant="outlined"
|
variant="flat"
|
||||||
class="action-btn design-tu-action"
|
class="action-btn"
|
||||||
@click="dialogOpen = true"
|
@click="dialogOpen = true"
|
||||||
>
|
>
|
||||||
|
<v-icon size="18" start>mdi-arrow-down</v-icon>
|
||||||
{{ t('wallet.deposit') }}
|
{{ t('wallet.deposit') }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
@ -28,22 +29,8 @@ defineExpose({ open })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.design-tu-action {
|
|
||||||
flex: 1;
|
|
||||||
height: 72px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
background: #fff;
|
|
||||||
color: #111827;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.design-tu-action :deep(.v-btn__content) {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -234,6 +234,7 @@
|
|||||||
},
|
},
|
||||||
"wallet": {
|
"wallet": {
|
||||||
"availableAssets": "Available Assets",
|
"availableAssets": "Available Assets",
|
||||||
|
"availableForTrading": "Available to trade",
|
||||||
"portfolio": "Portfolio",
|
"portfolio": "Portfolio",
|
||||||
"cash": "Cash",
|
"cash": "Cash",
|
||||||
"today": "Today",
|
"today": "Today",
|
||||||
@ -247,6 +248,26 @@
|
|||||||
"pl1W": "1W",
|
"pl1W": "1W",
|
||||||
"pl1M": "1M",
|
"pl1M": "1M",
|
||||||
"plAll": "ALL",
|
"plAll": "ALL",
|
||||||
|
"pnlCurve": {
|
||||||
|
"title": "Profit/Loss",
|
||||||
|
"loadFailed": "Failed to load P&L curve",
|
||||||
|
"range": {
|
||||||
|
"1D": "1D",
|
||||||
|
"1W": "1W",
|
||||||
|
"1M": "1M",
|
||||||
|
"1Y": "1Y",
|
||||||
|
"YTD": "YTD",
|
||||||
|
"ALL": "ALL"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"1D": "Past 24 hours",
|
||||||
|
"1W": "Past week",
|
||||||
|
"1M": "Past month",
|
||||||
|
"1Y": "Past year",
|
||||||
|
"YTD": "Year to date",
|
||||||
|
"ALL": "All time"
|
||||||
|
}
|
||||||
|
},
|
||||||
"positions": "Positions",
|
"positions": "Positions",
|
||||||
"openOrders": "Open orders",
|
"openOrders": "Open orders",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
|
|||||||
@ -234,6 +234,7 @@
|
|||||||
},
|
},
|
||||||
"wallet": {
|
"wallet": {
|
||||||
"availableAssets": "利用可能資産",
|
"availableAssets": "利用可能資産",
|
||||||
|
"availableForTrading": "取引可能",
|
||||||
"portfolio": "ポートフォリオ",
|
"portfolio": "ポートフォリオ",
|
||||||
"cash": "現金",
|
"cash": "現金",
|
||||||
"today": "今日",
|
"today": "今日",
|
||||||
@ -247,6 +248,26 @@
|
|||||||
"pl1W": "1週",
|
"pl1W": "1週",
|
||||||
"pl1M": "1月",
|
"pl1M": "1月",
|
||||||
"plAll": "全て",
|
"plAll": "全て",
|
||||||
|
"pnlCurve": {
|
||||||
|
"title": "損益",
|
||||||
|
"loadFailed": "損益曲線の読み込みに失敗しました",
|
||||||
|
"range": {
|
||||||
|
"1D": "1日",
|
||||||
|
"1W": "1週",
|
||||||
|
"1M": "1ヶ月",
|
||||||
|
"1Y": "1年",
|
||||||
|
"YTD": "年初来",
|
||||||
|
"ALL": "全て"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"1D": "過去24時間",
|
||||||
|
"1W": "過去1週間",
|
||||||
|
"1M": "過去1ヶ月",
|
||||||
|
"1Y": "過去1年",
|
||||||
|
"YTD": "年初来",
|
||||||
|
"ALL": "全期間"
|
||||||
|
}
|
||||||
|
},
|
||||||
"positions": "ポジション",
|
"positions": "ポジション",
|
||||||
"openOrders": "未約定",
|
"openOrders": "未約定",
|
||||||
"history": "履歴",
|
"history": "履歴",
|
||||||
|
|||||||
@ -234,6 +234,7 @@
|
|||||||
},
|
},
|
||||||
"wallet": {
|
"wallet": {
|
||||||
"availableAssets": "사용 가능 자산",
|
"availableAssets": "사용 가능 자산",
|
||||||
|
"availableForTrading": "거래 가능",
|
||||||
"portfolio": "포트폴리오",
|
"portfolio": "포트폴리오",
|
||||||
"cash": "현금",
|
"cash": "현금",
|
||||||
"today": "오늘",
|
"today": "오늘",
|
||||||
@ -247,6 +248,26 @@
|
|||||||
"pl1W": "1주",
|
"pl1W": "1주",
|
||||||
"pl1M": "1월",
|
"pl1M": "1월",
|
||||||
"plAll": "전체",
|
"plAll": "전체",
|
||||||
|
"pnlCurve": {
|
||||||
|
"title": "손익",
|
||||||
|
"loadFailed": "손익 곡선 로드 실패",
|
||||||
|
"range": {
|
||||||
|
"1D": "1일",
|
||||||
|
"1W": "1주",
|
||||||
|
"1M": "1개월",
|
||||||
|
"1Y": "1년",
|
||||||
|
"YTD": "연초 이후",
|
||||||
|
"ALL": "전체"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"1D": "지난 24시간",
|
||||||
|
"1W": "지난 1주",
|
||||||
|
"1M": "지난 1개월",
|
||||||
|
"1Y": "지난 1년",
|
||||||
|
"YTD": "연초 이후",
|
||||||
|
"ALL": "전체 기간"
|
||||||
|
}
|
||||||
|
},
|
||||||
"positions": "포지션",
|
"positions": "포지션",
|
||||||
"openOrders": "미체결",
|
"openOrders": "미체결",
|
||||||
"history": "내역",
|
"history": "내역",
|
||||||
|
|||||||
@ -234,6 +234,7 @@
|
|||||||
},
|
},
|
||||||
"wallet": {
|
"wallet": {
|
||||||
"availableAssets": "可用资产",
|
"availableAssets": "可用资产",
|
||||||
|
"availableForTrading": "可用于交易",
|
||||||
"portfolio": "资产组合",
|
"portfolio": "资产组合",
|
||||||
"cash": "现金",
|
"cash": "现金",
|
||||||
"today": "今日",
|
"today": "今日",
|
||||||
@ -247,6 +248,26 @@
|
|||||||
"pl1W": "1周",
|
"pl1W": "1周",
|
||||||
"pl1M": "1月",
|
"pl1M": "1月",
|
||||||
"plAll": "全部",
|
"plAll": "全部",
|
||||||
|
"pnlCurve": {
|
||||||
|
"title": "盈亏",
|
||||||
|
"loadFailed": "盈亏曲线加载失败",
|
||||||
|
"range": {
|
||||||
|
"1D": "1天",
|
||||||
|
"1W": "1周",
|
||||||
|
"1M": "1个月",
|
||||||
|
"1Y": "1年",
|
||||||
|
"YTD": "年初至今",
|
||||||
|
"ALL": "全部"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"1D": "过去 24 小时",
|
||||||
|
"1W": "过去 1 周",
|
||||||
|
"1M": "过去 1 个月",
|
||||||
|
"1Y": "过去 1 年",
|
||||||
|
"YTD": "年初至今",
|
||||||
|
"ALL": "全部时间"
|
||||||
|
}
|
||||||
|
},
|
||||||
"positions": "持仓",
|
"positions": "持仓",
|
||||||
"openOrders": "未成交",
|
"openOrders": "未成交",
|
||||||
"history": "历史",
|
"history": "历史",
|
||||||
|
|||||||
@ -234,6 +234,7 @@
|
|||||||
},
|
},
|
||||||
"wallet": {
|
"wallet": {
|
||||||
"availableAssets": "可用資產",
|
"availableAssets": "可用資產",
|
||||||
|
"availableForTrading": "可用於交易",
|
||||||
"portfolio": "資產組合",
|
"portfolio": "資產組合",
|
||||||
"cash": "現金",
|
"cash": "現金",
|
||||||
"today": "今日",
|
"today": "今日",
|
||||||
@ -247,6 +248,26 @@
|
|||||||
"pl1W": "1週",
|
"pl1W": "1週",
|
||||||
"pl1M": "1月",
|
"pl1M": "1月",
|
||||||
"plAll": "全部",
|
"plAll": "全部",
|
||||||
|
"pnlCurve": {
|
||||||
|
"title": "盈虧",
|
||||||
|
"loadFailed": "盈虧曲線載入失敗",
|
||||||
|
"range": {
|
||||||
|
"1D": "1天",
|
||||||
|
"1W": "1週",
|
||||||
|
"1M": "1個月",
|
||||||
|
"1Y": "1年",
|
||||||
|
"YTD": "年初至今",
|
||||||
|
"ALL": "全部"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"1D": "過去 24 小時",
|
||||||
|
"1W": "過去 1 週",
|
||||||
|
"1M": "過去 1 個月",
|
||||||
|
"1Y": "過去 1 年",
|
||||||
|
"YTD": "年初至今",
|
||||||
|
"ALL": "全部時間"
|
||||||
|
}
|
||||||
|
},
|
||||||
"positions": "持倉",
|
"positions": "持倉",
|
||||||
"openOrders": "未成交",
|
"openOrders": "未成交",
|
||||||
"history": "歷史",
|
"history": "歷史",
|
||||||
|
|||||||
@ -32,9 +32,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</button> -->
|
</button> -->
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<AssetCurveCard />
|
|
||||||
|
|
||||||
<section class="card wallet-card">
|
<section class="card wallet-card">
|
||||||
<div class="wallet-head">
|
<div class="wallet-head">
|
||||||
<span class="wallet-title">{{ t('profile.walletOverview') }}</span>
|
<span class="wallet-title">{{ t('profile.walletOverview') }}</span>
|
||||||
@ -136,7 +133,6 @@ import { useUserStore, type UserInfo } from '../stores/user'
|
|||||||
import { setSelfUsername, setSelfHeaderImg } from '@/api/user'
|
import { setSelfUsername, setSelfHeaderImg } from '@/api/user'
|
||||||
import { getLockMoney } from '@/api/earnActivity'
|
import { getLockMoney } from '@/api/earnActivity'
|
||||||
import { upload } from '@/api/fileUpload'
|
import { upload } from '@/api/fileUpload'
|
||||||
import AssetCurveCard from '@/components/AssetCurveCard.vue'
|
|
||||||
import type { LocaleCode } from '@/plugins/i18n'
|
import type { LocaleCode } from '@/plugins/i18n'
|
||||||
|
|
||||||
interface SettingItem {
|
interface SettingItem {
|
||||||
|
|||||||
@ -2,23 +2,36 @@
|
|||||||
<v-container class="wallet-container">
|
<v-container class="wallet-container">
|
||||||
<div class="wallet-mobile-frame">
|
<div class="wallet-mobile-frame">
|
||||||
<div class="wallet-mobile-header">{{ t('wallet.portfolio') }}</div>
|
<div class="wallet-mobile-header">{{ t('wallet.portfolio') }}</div>
|
||||||
<v-card class="wallet-card portfolio-card design-tu-asset" elevation="0" rounded="lg">
|
<div class="wallet-top-row">
|
||||||
<div class="card-header">
|
<v-card class="wallet-card portfolio-card" elevation="0" rounded="lg">
|
||||||
<span class="card-title">{{ t('wallet.portfolio') }}</span>
|
<div class="portfolio-head">
|
||||||
</div>
|
<div class="portfolio-title-wrap">
|
||||||
<div class="card-value">
|
<v-icon size="18" class="portfolio-title-icon">mdi-wallet-outline</v-icon>
|
||||||
<div class="main-balance">${{ portfolioBalance }}</div>
|
<span class="card-title">{{ t('wallet.portfolio') }}</span>
|
||||||
<div class="available-balance">
|
</div>
|
||||||
({{ t('wallet.availableAssets') }}: ${{ userStore.balance }})
|
<div class="portfolio-available">
|
||||||
|
<span class="portfolio-available-label">{{ t('wallet.availableForTrading') }}</span>
|
||||||
|
<span class="portfolio-available-value">${{ userStore.balance }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="card-value">
|
||||||
</v-card>
|
<div class="main-balance">${{ portfolioBalance }}</div>
|
||||||
<div class="card-actions design-tu-actions">
|
</div>
|
||||||
<WalletDepositModule />
|
<div class="card-actions portfolio-actions">
|
||||||
<v-btn color="default" variant="outlined" class="action-btn design-tu-action"
|
<div class="portfolio-deposit">
|
||||||
@click="withdrawDialogOpen = true">
|
<WalletDepositModule />
|
||||||
{{ t('wallet.withdraw') }}
|
</div>
|
||||||
</v-btn>
|
<v-btn
|
||||||
|
variant="outlined"
|
||||||
|
class="action-btn portfolio-withdraw-btn"
|
||||||
|
@click="withdrawDialogOpen = true"
|
||||||
|
>
|
||||||
|
<v-icon size="18" start>mdi-arrow-up</v-icon>
|
||||||
|
{{ t('wallet.withdraw') }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
<PnlCurveCard class="wallet-pnl-card" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-card v-if="unsettledCount > 0" class="settlement-card design-tu-settlement" elevation="0" rounded="lg">
|
<v-card v-if="unsettledCount > 0" class="settlement-card design-tu-settlement" elevation="0" rounded="lg">
|
||||||
@ -401,18 +414,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
import { createChart, AreaSeries, LineType, LastPriceAnimationMode } from 'lightweight-charts'
|
|
||||||
import { toLwcData } from '../composables/useLightweightChart'
|
|
||||||
import WalletDepositModule from '../components/WalletDepositModule.vue'
|
import WalletDepositModule from '../components/WalletDepositModule.vue'
|
||||||
import WalletDepositHistoryPanel from '../components/WalletDepositHistoryPanel.vue'
|
import WalletDepositHistoryPanel from '../components/WalletDepositHistoryPanel.vue'
|
||||||
import WithdrawDialog from '../components/WithdrawDialog.vue'
|
import WithdrawDialog from '../components/WithdrawDialog.vue'
|
||||||
import MarqueeTitle from '../components/MarqueeTitle.vue'
|
import MarqueeTitle from '../components/MarqueeTitle.vue'
|
||||||
|
import PnlCurveCard from '../components/PnlCurveCard.vue'
|
||||||
import { useUserStore } from '../stores/user'
|
import { useUserStore } from '../stores/user'
|
||||||
import { useLocaleStore } from '../stores/locale'
|
import { useLocaleStore } from '../stores/locale'
|
||||||
import { useAuthError } from '../composables/useAuthError'
|
import { useAuthError } from '../composables/useAuthError'
|
||||||
@ -467,14 +479,6 @@ const { formatAuthError } = useAuthError()
|
|||||||
const localeStore = useLocaleStore()
|
const localeStore = useLocaleStore()
|
||||||
const portfolioBalance = computed(() => userStore.totalAssetValue)
|
const portfolioBalance = computed(() => userStore.totalAssetValue)
|
||||||
const positionsValueText = computed(() => userStore.positionsValue.toFixed(2))
|
const positionsValueText = computed(() => userStore.positionsValue.toFixed(2))
|
||||||
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' | 'depositHistory' | 'withdrawals'>(
|
const activeTab = ref<'positions' | 'orders' | 'history' | 'depositHistory' | 'withdrawals'>(
|
||||||
'positions',
|
'positions',
|
||||||
)
|
)
|
||||||
@ -1257,96 +1261,6 @@ function shareHistory(id: string) {
|
|||||||
// TODO: 分享或复制链接
|
// TODO: 分享或复制链接
|
||||||
}
|
}
|
||||||
|
|
||||||
const plChartRef = ref<HTMLElement | null>(null)
|
|
||||||
let plChartInstance: ReturnType<typeof createChart> | null = null
|
|
||||||
let plChartSeries: ReturnType<ReturnType<typeof createChart>['addSeries']> | null = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 资产变化折线图数据 [timestamp_ms, pnl]
|
|
||||||
* 暂无接口时返回真实格式的时间序列,数值均为 0;有接口后改为从 API 拉取并在此处做分时过滤
|
|
||||||
*/
|
|
||||||
function getPlChartData(range: string): [number, number][] {
|
|
||||||
const now = Date.now()
|
|
||||||
let stepMs: number
|
|
||||||
let count: number
|
|
||||||
switch (range) {
|
|
||||||
case '1D':
|
|
||||||
stepMs = 60 * 60 * 1000
|
|
||||||
count = 24
|
|
||||||
break
|
|
||||||
case '1W':
|
|
||||||
stepMs = 24 * 60 * 60 * 1000
|
|
||||||
count = 7
|
|
||||||
break
|
|
||||||
case '1M':
|
|
||||||
stepMs = 24 * 60 * 60 * 1000
|
|
||||||
count = 30
|
|
||||||
break
|
|
||||||
case 'ALL':
|
|
||||||
default:
|
|
||||||
stepMs = 24 * 60 * 60 * 1000
|
|
||||||
count = 30
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const data: [number, number][] = []
|
|
||||||
for (let i = count; i >= 0; i--) {
|
|
||||||
const t = now - i * stepMs
|
|
||||||
data.push([t, 0])
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
const plChartData = ref<[number, number][]>([])
|
|
||||||
|
|
||||||
function updatePlChart() {
|
|
||||||
plChartData.value = getPlChartData(plRange.value)
|
|
||||||
const last = plChartData.value[plChartData.value.length - 1]
|
|
||||||
if (last != null) profitLoss.value = last[1].toFixed(2)
|
|
||||||
if (plChartSeries) plChartSeries.setData(toLwcData(plChartData.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
function initPlChart() {
|
|
||||||
if (!plChartRef.value) return
|
|
||||||
plChartData.value = getPlChartData(plRange.value)
|
|
||||||
const last = plChartData.value[plChartData.value.length - 1]
|
|
||||||
if (last != null) profitLoss.value = last[1].toFixed(2)
|
|
||||||
const el = plChartRef.value
|
|
||||||
plChartInstance = createChart(el, {
|
|
||||||
width: el.clientWidth,
|
|
||||||
height: 160,
|
|
||||||
layout: {
|
|
||||||
background: { color: '#ffffff' },
|
|
||||||
textColor: '#9ca3af',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
fontSize: 9,
|
|
||||||
attributionLogo: false,
|
|
||||||
},
|
|
||||||
grid: { vertLines: { visible: false }, horzLines: { color: '#f3f4f6' } },
|
|
||||||
rightPriceScale: { borderVisible: false },
|
|
||||||
timeScale: { borderVisible: false },
|
|
||||||
handleScroll: false,
|
|
||||||
handleScale: false,
|
|
||||||
crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true } },
|
|
||||||
})
|
|
||||||
const color = '#4b5563'
|
|
||||||
plChartSeries = plChartInstance.addSeries(AreaSeries, {
|
|
||||||
topColor: color + '40',
|
|
||||||
bottomColor: color + '08',
|
|
||||||
lineColor: color,
|
|
||||||
lineWidth: 2,
|
|
||||||
lineType: LineType.Curved,
|
|
||||||
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
|
|
||||||
priceFormat: { type: 'price', precision: 2 },
|
|
||||||
})
|
|
||||||
plChartSeries.setData(toLwcData(plChartData.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResize = () => {
|
|
||||||
if (plChartInstance && plChartRef.value) plChartInstance.resize(plChartRef.value.clientWidth, 160)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(plRange, () => updatePlChart())
|
|
||||||
|
|
||||||
// 监听语言切换,语言变化时重新加载数据
|
// 监听语言切换,语言变化时重新加载数据
|
||||||
watch(
|
watch(
|
||||||
() => localeStore.currentLocale,
|
() => localeStore.currentLocale,
|
||||||
@ -1358,17 +1272,6 @@ watch(
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!USE_MOCK_WALLET && activeTab.value === 'positions') loadPositionList()
|
if (!USE_MOCK_WALLET && activeTab.value === 'positions') loadPositionList()
|
||||||
nextTick(() => {
|
|
||||||
initPlChart()
|
|
||||||
})
|
|
||||||
window.addEventListener('resize', handleResize)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('resize', handleResize)
|
|
||||||
plChartInstance?.remove()
|
|
||||||
plChartInstance = null
|
|
||||||
plChartSeries = null
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function onWithdrawSuccess() {
|
function onWithdrawSuccess() {
|
||||||
@ -1391,13 +1294,11 @@ async function submitAuthorize() {
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.wallet-container {
|
.wallet-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 1080px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: #fcfcfc;
|
background: #fcfcfc;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0 auto;
|
|
||||||
max-width: 1440px;
|
|
||||||
margin-top: $header-height;
|
margin-top: $header-height;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1414,46 +1315,122 @@ async function submitAuthorize() {
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wallet-top-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-pnl-card {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.wallet-top-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-card,
|
||||||
|
.wallet-pnl-card {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.wallet-card {
|
.wallet-card {
|
||||||
padding: 16px;
|
padding: 20px;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 16px;
|
border-radius: 12px;
|
||||||
height: auto;
|
height: auto;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.design-tu-asset {
|
.portfolio-card {
|
||||||
background: #5b5bd6;
|
display: flex;
|
||||||
border: 0;
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.design-tu-asset .card-title,
|
.portfolio-head {
|
||||||
.design-tu-asset .card-value {
|
display: flex;
|
||||||
color: #fff;
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.design-tu-asset .card-title {
|
.portfolio-title-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-title-icon {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-available {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-available-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: #9ca3af;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.design-tu-actions {
|
.portfolio-available-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-actions {
|
||||||
|
margin-top: auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.design-tu-action {
|
.portfolio-deposit {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 72px;
|
display: flex;
|
||||||
border-radius: 12px;
|
min-width: 0;
|
||||||
border: 1px solid #e5e7eb;
|
}
|
||||||
background: #fff;
|
|
||||||
color: #111827;
|
.portfolio-deposit :deep(.action-btn),
|
||||||
font-size: 20px;
|
.portfolio-withdraw-btn {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-transform: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-deposit :deep(.action-btn) {
|
||||||
|
border: none !important;
|
||||||
|
background: #2e5cff !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-deposit :deep(.v-btn__content) {
|
||||||
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.design-tu-action :deep(.v-btn__content) {
|
.portfolio-withdraw-btn {
|
||||||
font-size: 20px;
|
border: 1px solid #e5e7eb !important;
|
||||||
|
background: #fff !important;
|
||||||
|
color: #111827 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.design-tu-settlement {
|
.design-tu-settlement {
|
||||||
@ -1471,43 +1448,30 @@ async function submitAuthorize() {
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: #111827;
|
color: #111827;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-value {
|
.card-value {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-balance {
|
.main-balance {
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: inherit;
|
color: #111827;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.available-balance {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
opacity: 0.8;
|
|
||||||
color: inherit;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-actions {
|
.card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user