mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
feat: animate dashboard metrics
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user