新增:资产曲线

This commit is contained in:
马丁 2026-07-09 02:42:38 +08:00
parent 1d99f2871a
commit 844b755f20
8 changed files with 380 additions and 5 deletions

43
src/api/assetCurve.ts Normal file
View File

@ -0,0 +1,43 @@
import { get } from './request'
import type { ApiResponse } from './types'
export type AssetCurveRange = '1D' | '1W' | '1M' | '1Y' | 'YTD' | 'ALL'
export interface AssetCurvePoint {
ts: number
totalAsset: number
isRealtime?: boolean
}
export interface AssetCurveData {
range: AssetCurveRange
startTs: number
endTs: number
currentAsset: number
changeAmount: number
changePercent: number
noHistory: boolean
points: AssetCurvePoint[]
}
export interface AssetCurveResponse extends ApiResponse {
data: AssetCurveData
}
/**
* GET /uas/getAssetCurveClient
* 线
*/
export async function getAssetCurve(
range: AssetCurveRange,
config?: { headers?: Record<string, string> },
): Promise<AssetCurveResponse> {
return get<AssetCurveResponse>('/uas/getAssetCurveClient', { range }, config)
}
export const ASSET_CURVE_RANGES: AssetCurveRange[] = ['1D', '1W', '1M', '1Y', 'YTD', 'ALL']
export function formatAssetChange(amount: number, percent: number): string {
const sign = amount >= 0 ? '+' : ''
return `${sign}$${Math.abs(amount).toFixed(2)} (${sign}${Math.abs(percent).toFixed(2)}%)`
}

View File

@ -0,0 +1,269 @@
<template>
<section class="card asset-curve-card">
<div class="curve-head">
<div class="curve-head-left">
<span class="curve-title">{{ t('profile.assetCurve.title') }}</span>
<span
class="curve-change"
:class="{ up: changeAmount >= 0, down: changeAmount < 0 }"
>
{{ changeText }}
</span>
</div>
<div class="curve-amount">${{ displayAmount }}</div>
</div>
<div class="curve-range-row">
<button
v-for="r in ranges"
:key="r"
type="button"
class="range-btn"
:class="{ active: activeRange === r }"
@click="setRange(r)"
>
{{ t(`profile.assetCurve.range.${r}`) }}
</button>
</div>
<div ref="chartRef" class="curve-chart" :aria-label="t('profile.assetCurve.title')">
<v-progress-circular
v-if="loading"
indeterminate
size="28"
width="2"
class="curve-loading"
/>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, 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 {
ASSET_CURVE_RANGES,
formatAssetChange,
getAssetCurve,
type AssetCurveRange,
} from '@/api/assetCurve'
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 = ASSET_CURVE_RANGES
const activeRange = ref<AssetCurveRange>('1W')
const loading = ref(false)
const changeAmount = ref(0)
const changePercent = ref(0)
const displayAmount = ref('0.00')
const chartRef = ref<HTMLElement | null>(null)
let chartInstance: IChartApi | null = null
let chartSeries: ISeriesApi<'Area'> | null = null
const changeText = computed(() => formatAssetChange(changeAmount.value, changePercent.value))
function syncDisplayAmountFromStore() {
displayAmount.value = userStore.totalAssetValue || '0.00'
}
async function loadCurve() {
if (!userStore.isLoggedIn) return
const headers = userStore.getAuthHeaders()
if (!headers) return
loading.value = true
try {
const res = await getAssetCurve(activeRange.value, { headers })
if (res.code !== 0 && res.code !== 200) {
toastStore.show(res.msg || t('profile.assetCurve.loadFailed'), 'error')
return
}
const data = res.data
changeAmount.value = data.changeAmount ?? 0
changePercent.value = data.changePercent ?? 0
displayAmount.value = Number(data.currentAsset ?? 0).toFixed(2)
const points: [number, number][] = (data.points ?? []).map((p) => [p.ts, p.totalAsset])
if (chartSeries) {
chartSeries.setData(toLwcData(points))
}
} catch (e) {
const msg = e instanceof Error ? e.message : t('profile.assetCurve.loadFailed')
toastStore.show(msg, 'error')
} finally {
loading.value = false
}
}
function setRange(r: AssetCurveRange) {
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: 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 = '#5b5bd6'
chartSeries = chartInstance.addSeries(AreaSeries, {
topColor: color + '40',
bottomColor: color + '08',
lineColor: color,
lineWidth: 2,
lineType: LineType.Curved,
lastPriceAnimation: LastPriceAnimationMode.OnDataUpdate,
priceFormat: { type: 'price', precision: 2 },
})
}
function handleResize() {
if (chartInstance && chartRef.value) {
chartInstance.resize(chartRef.value.clientWidth, 160)
}
}
watch(activeRange, () => {
loadCurve()
})
watch(
() => userStore.totalAssetValue,
() => {
syncDisplayAmountFromStore()
},
)
onMounted(async () => {
syncDisplayAmountFromStore()
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">
.asset-curve-card {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.curve-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.curve-head-left {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.curve-title {
color: #111827;
font-size: 15px;
font-weight: 600;
line-height: 1.2;
}
.curve-change {
font-size: 12px;
font-weight: 600;
line-height: 1.2;
}
.curve-change.up {
color: #16a34a;
}
.curve-change.down {
color: #dc2626;
}
.curve-amount {
color: #111827;
font-size: 24px;
font-weight: 700;
line-height: 1.1;
flex-shrink: 0;
}
.curve-range-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.range-btn {
height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid #e5e7eb;
background: #fcfcfc;
color: #6b7280;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.range-btn.active {
border-color: #5b5bd6;
background: #eef2ff;
color: #5b5bd6;
}
.curve-chart {
position: relative;
width: 100%;
height: 160px;
}
.curve-loading {
position: absolute;
inset: 0;
margin: auto;
color: #5b5bd6 !important;
}
</style>

View File

@ -362,7 +362,19 @@
"avatarUploadFailed": "Failed to upload avatar",
"memberCenter": "Member Center",
"depositCoin": "Deposit",
"withdrawCoin": "Withdraw"
"withdrawCoin": "Withdraw",
"assetCurve": {
"title": "Asset Trend",
"loadFailed": "Failed to load asset curve",
"range": {
"1D": "1D",
"1W": "1W",
"1M": "1M",
"1Y": "1Y",
"YTD": "YTD",
"ALL": "All"
}
}
},
"memberCenter": {
"title": "Member Center",

View File

@ -362,7 +362,19 @@
"avatarUploadFailed": "アバターのアップロードに失敗しました",
"memberCenter": "メンバー",
"depositCoin": "入金",
"withdrawCoin": "出金"
"withdrawCoin": "出金",
"assetCurve": {
"title": "資産推移",
"loadFailed": "資産曲線の読み込みに失敗しました",
"range": {
"1D": "1日",
"1W": "1週",
"1M": "1月",
"1Y": "1年",
"YTD": "年初来",
"ALL": "すべて"
}
}
},
"memberCenter": {
"title": "メンバーセンター",

View File

@ -362,7 +362,19 @@
"avatarUploadFailed": "아바타 업로드에 실패했습니다",
"memberCenter": "멤버십",
"depositCoin": "입금",
"withdrawCoin": "출금"
"withdrawCoin": "출금",
"assetCurve": {
"title": "자산 추이",
"loadFailed": "자산 곡선을 불러오지 못했습니다",
"range": {
"1D": "1일",
"1W": "1주",
"1M": "1개월",
"1Y": "1년",
"YTD": "연초 이후",
"ALL": "전체"
}
}
},
"memberCenter": {
"title": "멤버 센터",

View File

@ -362,7 +362,19 @@
"avatarUploadFailed": "头像上传失败",
"memberCenter": "会员中心",
"depositCoin": "充币",
"withdrawCoin": "提币"
"withdrawCoin": "提币",
"assetCurve": {
"title": "资产走势",
"loadFailed": "资产曲线加载失败",
"range": {
"1D": "1天",
"1W": "1周",
"1M": "1月",
"1Y": "1年",
"YTD": "年初至今",
"ALL": "全部"
}
}
},
"memberCenter": {
"title": "会员中心",

View File

@ -362,7 +362,19 @@
"avatarUploadFailed": "頭像上傳失敗",
"memberCenter": "會員中心",
"depositCoin": "充幣",
"withdrawCoin": "提幣"
"withdrawCoin": "提幣",
"assetCurve": {
"title": "資產走勢",
"loadFailed": "資產曲線載入失敗",
"range": {
"1D": "1天",
"1W": "1週",
"1M": "1月",
"1Y": "1年",
"YTD": "年初至今",
"ALL": "全部"
}
}
},
"memberCenter": {
"title": "會員中心",

View File

@ -33,6 +33,8 @@
</button> -->
</section>
<AssetCurveCard />
<section class="card wallet-card">
<div class="wallet-head">
<span class="wallet-title">{{ t('profile.walletOverview') }}</span>
@ -134,6 +136,7 @@ import { useUserStore, type UserInfo } from '../stores/user'
import { setSelfUsername, setSelfHeaderImg } from '@/api/user'
import { getLockMoney } from '@/api/earnActivity'
import { upload } from '@/api/fileUpload'
import AssetCurveCard from '@/components/AssetCurveCard.vue'
import type { LocaleCode } from '@/plugins/i18n'
interface SettingItem {