feat: animate dashboard metrics

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-05 22:26:30 +08:00
co-authored by Codex
parent 298357f97c
commit 2143de2791
5 changed files with 126 additions and 12 deletions
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { TransitionPresets, usePreferredReducedMotion, useTransition } from '@vueuse/core'
import { computed } from 'vue'
type MetricValueFormatter = (value: number) => string
const props = withDefaults(
defineProps<{
value: string
rawValue?: number | null
formatter?: MetricValueFormatter
duration?: number
}>(),
{
rawValue: null,
duration: 520,
},
)
const preferredMotion = usePreferredReducedMotion()
const hasAnimatedValue = computed(
() =>
typeof props.rawValue === 'number' &&
Number.isFinite(props.rawValue) &&
Boolean(props.formatter),
)
const sourceValue = computed(() => (hasAnimatedValue.value ? (props.rawValue ?? 0) : 0))
const disabled = computed(() => preferredMotion.value === 'reduce' || !hasAnimatedValue.value)
const animatedValue = useTransition(sourceValue, {
duration: computed(() => props.duration),
easing: TransitionPresets.easeOutCubic,
disabled,
})
const displayValue = computed(() =>
hasAnimatedValue.value && props.formatter ? props.formatter(animatedValue.value) : props.value,
)
</script>
<template>
<span>{{ displayValue }}</span>
</template>
@@ -1,15 +1,44 @@
<script setup lang="ts">
import type { EChartsOption } from 'echarts'
import type { Component } from 'vue'
import { computed } from 'vue'
import BaseCard from '../../../components/base/BaseCard.vue'
import BaseChart from '../../../components/charts/BaseChart.vue'
import AnimatedMetricValue from './AnimatedMetricValue.vue'
type MetricTone = 'normal' | 'info' | 'success' | 'warning' | 'danger'
interface MetricDetail {
label: string
value: string
tone?: MetricTone
}
interface Metric {
title: string
value: string
valueRaw?: number | null
valueFormatter?: (value: number) => string
icon: Component
tone: MetricTone
sparkline?: {
values: number[]
tone: MetricTone
}
trend?: {
direction: 'up' | 'down' | 'flat'
tone: MetricTone
}
details: MetricDetail[]
}
const props = defineProps<{
metric: any
metric: Metric
}>()
const iconToneClasses: Record<string, string> = {
const iconToneClasses: Record<MetricTone, string> = {
normal: 'bg-(--cp-normal-bg) text-(--cp-normal)',
info: 'bg-(--cp-info-bg) text-(--cp-info)',
success: 'bg-(--cp-success-bg) text-(--cp-success)',
@@ -17,7 +46,7 @@ const iconToneClasses: Record<string, string> = {
danger: 'bg-(--cp-danger-bg) text-(--cp-danger)',
}
const detailToneClasses: Record<string, string> = {
const detailToneClasses: Record<MetricTone, string> = {
normal: 'text-(--cp-normal-text)',
info: 'text-(--cp-info-text)',
success: 'text-(--cp-success-text)',
@@ -25,7 +54,7 @@ const detailToneClasses: Record<string, string> = {
danger: 'text-(--cp-danger-text)',
}
const trendToneClasses: Record<string, string> = {
const trendToneClasses: Record<MetricTone, string> = {
normal: 'bg-(--cp-normal-text)',
info: 'bg-(--cp-info-text)',
success: 'bg-(--cp-success-text)',
@@ -33,7 +62,7 @@ const trendToneClasses: Record<string, string> = {
danger: 'bg-(--cp-danger-text)',
}
const sparklineColors: Record<string, string> = {
const sparklineColors: Record<MetricTone, string> = {
normal: '#94A3B8',
info: '#60A5FA',
success: '#5CCB8A',
@@ -96,7 +125,11 @@ const sparklineOption = computed<EChartsOption | null>(() => {
<strong
class="font-mono text-[28px] leading-[1.05] font-[780] tabular-nums text-(--cp-text-primary)"
>
{{ metric.value }}
<AnimatedMetricValue
:value="metric.value"
:raw-value="metric.valueRaw"
:formatter="metric.valueFormatter"
/>
</strong>
<i
v-if="metric.trend && metric.trend.direction !== 'flat'"
@@ -73,16 +73,16 @@ function cellClass(point: string) {
<template #body>
<div class="mt-4.25">
<div class="grid w-full grid-cols-[repeat(24,minmax(0,1fr))] items-end gap-[3px]">
<div class="grid w-full grid-cols-24 items-end gap-0.75">
<div
v-for="(group, groupIndex) in pointGroups"
:key="groupIndex"
class="grid grid-cols-4 items-end gap-[2px]"
class="grid grid-cols-4 items-end gap-0.5"
>
<i
v-for="(point, pointIndex) in group"
:key="groupIndex * 4 + pointIndex"
class="h-3.5 min-w-[2px] rounded-[2px]"
class="h-3.5 min-w-0.5 rounded-xs"
:class="cellClass(point)"
/>
</div>
@@ -42,14 +42,14 @@ const filteredRows = computed(() => {
variant="dashboard"
title="使用记录"
description="最近 10 条网关请求"
class="h-[468px] w-full"
class="h-117 w-full"
>
<template #actions>
<BaseSegmented v-model="activeFilter" :options="filters" class="w-38" />
</template>
<template #body>
<div class="mt-4.25 flex h-[364px] w-full overflow-hidden">
<div class="mt-4.25 flex h-91 w-full overflow-hidden">
<BaseTable
class="min-w-0 flex-1"
:columns="dashboardUsageRecordColumns"
@@ -202,6 +202,8 @@ export function useDashboard(): any {
{
title: '账号',
value: accounts.total,
valueRaw: accounts.totalValue,
valueFormatter: formatDashboardCompactNumber,
icon: Users,
tone: 'normal',
details: [
@@ -211,7 +213,7 @@ export function useDashboard(): any {
tone: accounts.enabledValue > 0 ? 'success' : 'normal',
},
{
label: '异常',
label: '不可用',
value: accounts.abnormal,
tone: accounts.abnormalValue > 0 ? 'danger' : 'normal',
},
@@ -220,6 +222,8 @@ export function useDashboard(): any {
{
title: '今日请求',
value: traffic.todayRequests,
valueRaw: traffic.todayRequestsValue,
valueFormatter: formatDashboardCompactNumber,
icon: Activity,
tone: 'info',
sparkline: sparkline(
@@ -239,6 +243,8 @@ export function useDashboard(): any {
{
title: '今日 Token',
value: tokens.todayTokens,
valueRaw: tokens.todayTokensValue,
valueFormatter: formatDashboardCompactNumber,
icon: FileText,
tone: 'success',
sparkline: sparkline(
@@ -258,6 +264,8 @@ export function useDashboard(): any {
{
title: '今日缓存命中',
value: cache.todayHitRate,
valueRaw: cache.todayHitRateValue,
valueFormatter: formatDashboardRate,
icon: Timer,
tone: cache.todayHitRateValue && cache.todayHitRateValue > 0 ? 'warning' : 'normal',
sparkline: sparkline(
@@ -285,6 +293,37 @@ export function useDashboard(): any {
return values.some((value) => value > 0) ? { values, tone } : undefined
}
function formatDashboardCompactNumber(value: number) {
const normalized = Math.max(0, Math.round(value))
if (normalized < 1_000) {
return new Intl.NumberFormat('zh-CN').format(normalized)
}
for (const [unit, threshold] of [
['P', 1_000_000_000_000_000],
['T', 1_000_000_000_000],
['B', 1_000_000_000],
['M', 1_000_000],
['K', 1_000],
] as const) {
if (normalized >= threshold) {
return `${formatDashboardCompactScaled(normalized / threshold)}${unit}`
}
}
return new Intl.NumberFormat('zh-CN').format(normalized)
}
function formatDashboardCompactScaled(value: number) {
const rounded = value >= 10 ? value.toFixed(1) : value.toFixed(2)
return rounded.replace(/\.?0+$/, '')
}
function formatDashboardRate(value: number) {
return Number.isFinite(value) ? `${(value * 100).toFixed(1)}%` : '—'
}
function applyTrend(trend: any) {
trendPoints.value = trend.points
trendSummary.value = (trend.summary as any[]).map((item) => {