新增:资产曲线
This commit is contained in:
parent
1d99f2871a
commit
844b755f20
43
src/api/assetCurve.ts
Normal file
43
src/api/assetCurve.ts
Normal 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)}%)`
|
||||
}
|
||||
269
src/components/AssetCurveCard.vue
Normal file
269
src/components/AssetCurveCard.vue
Normal 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>
|
||||
@ -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",
|
||||
|
||||
@ -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": "メンバーセンター",
|
||||
|
||||
@ -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": "멤버 센터",
|
||||
|
||||
@ -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": "会员中心",
|
||||
|
||||
@ -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": "會員中心",
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user