feat(analytics): 添加数字滚动动画并优化数字显示样式

- 新增 CountTo 组件实现平滑数字滚动效果,提升用户体验
- 在统计数字区域应用等宽字体和字体回退方案,防止滚动时宽度抖动
- 替换静态数字显示为动态滚动动画,增强数据展示的视觉吸引力
This commit is contained in:
anghunk
2026-02-05 16:27:52 +08:00
parent 2a8d73dff9
commit 683457cc0b
3 changed files with 78 additions and 5 deletions

View File

@@ -0,0 +1,68 @@
<template>
<span>{{ displayValue }}</span>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({
startVal: {
type: Number,
default: 0
},
endVal: {
type: Number,
required: true
},
duration: {
type: Number,
default: 1000
}
});
const displayValue = ref(props.startVal);
const localStartVal = ref(props.startVal);
const startTime = ref<number | null>(null);
const rAF = ref<number | null>(null);
const easeOutExpo = (t: number, b: number, c: number, d: number) => {
return t === d ? b + c : c * (-Math.pow(2, -10 * t / d) + 1) + b;
};
const count = (timestamp: number) => {
if (!startTime.value) startTime.value = timestamp;
const progress = timestamp - startTime.value;
if (props.duration > 0 && progress < props.duration) {
const val = easeOutExpo(progress, localStartVal.value, props.endVal - localStartVal.value, props.duration);
displayValue.value = Math.floor(val);
rAF.value = requestAnimationFrame(count);
} else {
displayValue.value = props.endVal;
rAF.value = null;
}
};
const startAnimation = () => {
startTime.value = null;
localStartVal.value = displayValue.value;
if (rAF.value) cancelAnimationFrame(rAF.value);
rAF.value = requestAnimationFrame(count);
};
watch(() => props.endVal, () => {
startAnimation();
});
onMounted(() => {
if (props.endVal !== props.startVal) {
startAnimation();
}
});
onBeforeUnmount(() => {
if (rAF.value) {
cancelAnimationFrame(rAF.value);
}
});
</script>

View File

@@ -46,6 +46,10 @@
color: var(--text-primary);
display: flex;
align-items: baseline;
/* 使用等宽数字,防止数字滚动时宽度抖动 */
font-variant-numeric: tabular-nums;
/* 确保数字字体回退到系统等宽字体,增强兼容性 */
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
.trend {

View File

@@ -21,12 +21,12 @@
<div class="stats-grid">
<div class="stats-item">
<div class="stats-label">全站总访问量</div>
<div class="stats-value">{{ overview.totalPv }}</div>
<div class="stats-value"><CountTo :end-val="overview.totalPv" /></div>
</div>
<div class="stats-item">
<div class="stats-label">今日访问量</div>
<div class="stats-value">
{{ overview.todayPv }}
<CountTo :end-val="overview.todayPv" />
<span
v-if="overview.yesterdayPv !== undefined"
class="trend"
@@ -41,7 +41,7 @@
<div class="stats-item">
<div class="stats-label">本周访问量</div>
<div class="stats-value">
{{ overview.weekPv }}
<CountTo :end-val="overview.weekPv" />
<span
v-if="overview.lastWeekPv !== undefined"
class="trend"
@@ -56,7 +56,7 @@
<div class="stats-item">
<div class="stats-label">本月访问量</div>
<div class="stats-value">
{{ overview.monthPv }}
<CountTo :end-val="overview.monthPv" />
<span
v-if="overview.lastMonthPv !== undefined"
class="trend"
@@ -70,7 +70,7 @@
</div>
<div class="stats-item">
<div class="stats-label">有访问记录的页面数</div>
<div class="stats-value">{{ overview.totalPages }}</div>
<div class="stats-value"><CountTo :end-val="overview.totalPages" /></div>
</div>
</div>
</div>
@@ -215,6 +215,7 @@
import { onMounted, onBeforeUnmount, ref, nextTick, watch, inject, computed } from "vue";
import type { Ref } from "vue";
import * as echarts from "echarts";
import CountTo from "../../components/CountTo.vue";
import {
fetchVisitOverview,
fetchVisitPages,