新增:资产曲线
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",
|
"avatarUploadFailed": "Failed to upload avatar",
|
||||||
"memberCenter": "Member Center",
|
"memberCenter": "Member Center",
|
||||||
"depositCoin": "Deposit",
|
"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": {
|
"memberCenter": {
|
||||||
"title": "Member Center",
|
"title": "Member Center",
|
||||||
|
|||||||
@ -362,7 +362,19 @@
|
|||||||
"avatarUploadFailed": "アバターのアップロードに失敗しました",
|
"avatarUploadFailed": "アバターのアップロードに失敗しました",
|
||||||
"memberCenter": "メンバー",
|
"memberCenter": "メンバー",
|
||||||
"depositCoin": "入金",
|
"depositCoin": "入金",
|
||||||
"withdrawCoin": "出金"
|
"withdrawCoin": "出金",
|
||||||
|
"assetCurve": {
|
||||||
|
"title": "資産推移",
|
||||||
|
"loadFailed": "資産曲線の読み込みに失敗しました",
|
||||||
|
"range": {
|
||||||
|
"1D": "1日",
|
||||||
|
"1W": "1週",
|
||||||
|
"1M": "1月",
|
||||||
|
"1Y": "1年",
|
||||||
|
"YTD": "年初来",
|
||||||
|
"ALL": "すべて"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"memberCenter": {
|
"memberCenter": {
|
||||||
"title": "メンバーセンター",
|
"title": "メンバーセンター",
|
||||||
|
|||||||
@ -362,7 +362,19 @@
|
|||||||
"avatarUploadFailed": "아바타 업로드에 실패했습니다",
|
"avatarUploadFailed": "아바타 업로드에 실패했습니다",
|
||||||
"memberCenter": "멤버십",
|
"memberCenter": "멤버십",
|
||||||
"depositCoin": "입금",
|
"depositCoin": "입금",
|
||||||
"withdrawCoin": "출금"
|
"withdrawCoin": "출금",
|
||||||
|
"assetCurve": {
|
||||||
|
"title": "자산 추이",
|
||||||
|
"loadFailed": "자산 곡선을 불러오지 못했습니다",
|
||||||
|
"range": {
|
||||||
|
"1D": "1일",
|
||||||
|
"1W": "1주",
|
||||||
|
"1M": "1개월",
|
||||||
|
"1Y": "1년",
|
||||||
|
"YTD": "연초 이후",
|
||||||
|
"ALL": "전체"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"memberCenter": {
|
"memberCenter": {
|
||||||
"title": "멤버 센터",
|
"title": "멤버 센터",
|
||||||
|
|||||||
@ -362,7 +362,19 @@
|
|||||||
"avatarUploadFailed": "头像上传失败",
|
"avatarUploadFailed": "头像上传失败",
|
||||||
"memberCenter": "会员中心",
|
"memberCenter": "会员中心",
|
||||||
"depositCoin": "充币",
|
"depositCoin": "充币",
|
||||||
"withdrawCoin": "提币"
|
"withdrawCoin": "提币",
|
||||||
|
"assetCurve": {
|
||||||
|
"title": "资产走势",
|
||||||
|
"loadFailed": "资产曲线加载失败",
|
||||||
|
"range": {
|
||||||
|
"1D": "1天",
|
||||||
|
"1W": "1周",
|
||||||
|
"1M": "1月",
|
||||||
|
"1Y": "1年",
|
||||||
|
"YTD": "年初至今",
|
||||||
|
"ALL": "全部"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"memberCenter": {
|
"memberCenter": {
|
||||||
"title": "会员中心",
|
"title": "会员中心",
|
||||||
|
|||||||
@ -362,7 +362,19 @@
|
|||||||
"avatarUploadFailed": "頭像上傳失敗",
|
"avatarUploadFailed": "頭像上傳失敗",
|
||||||
"memberCenter": "會員中心",
|
"memberCenter": "會員中心",
|
||||||
"depositCoin": "充幣",
|
"depositCoin": "充幣",
|
||||||
"withdrawCoin": "提幣"
|
"withdrawCoin": "提幣",
|
||||||
|
"assetCurve": {
|
||||||
|
"title": "資產走勢",
|
||||||
|
"loadFailed": "資產曲線載入失敗",
|
||||||
|
"range": {
|
||||||
|
"1D": "1天",
|
||||||
|
"1W": "1週",
|
||||||
|
"1M": "1月",
|
||||||
|
"1Y": "1年",
|
||||||
|
"YTD": "年初至今",
|
||||||
|
"ALL": "全部"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"memberCenter": {
|
"memberCenter": {
|
||||||
"title": "會員中心",
|
"title": "會員中心",
|
||||||
|
|||||||
@ -33,6 +33,8 @@
|
|||||||
</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>
|
||||||
@ -134,6 +136,7 @@ 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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user