add AnimatedNumber component

This commit is contained in:
Timi
2025-12-03 11:50:08 +08:00
parent 0ac836eaa5
commit 00816f223a
3 changed files with 194 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
import view from "./index.vue";
import Toolkit from "~/utils/Toolkit";
export const AnimatedNumber = Toolkit.withInstall(view);
export default AnimatedNumber;

View File

@@ -0,0 +1,184 @@
<template>
<span class="tui-animated-number">{{ displayValue }}</span>
</template>
<script lang="ts" setup>
defineOptions({
name: "AnimatedNumber"
});
const props = withDefaults(defineProps<{
/** 目标数值 */
value: number;
/** 动画持续时间(毫秒),默认 1000ms */
duration?: number;
/** 小数位数undefined 表示自动 */
decimals?: number;
/** 缓动函数类型 */
easing?: "linear" | "easeOut" | "easeInOut";
/** 是否启用千分位分隔符 */
thousands?: boolean;
/** 千分位分隔符,默认逗号 */
separator?: string;
}>(), {
duration: 1000,
easing: "easeOut",
thousands: false,
separator: ","
});
const { value, duration, decimals, easing, thousands, separator } = toRefs(props);
// 当前显示的数值
const currentValue = ref(value.value);
// 格式化后的显示值
const displayValue = ref(formatNumber(value.value));
// 动画相关
let animationFrameId: number | null = null;
let startValue = value.value;
let startTime: number | null = null;
/**
* 缓动函数
*/
function ease(t: number, type: string): number {
switch (type) {
case "linear":
return t;
case "easeOut":
return 1 - Math.pow(1 - t, 3);
case "easeInOut":
return t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
default:
return t;
}
}
/**
* 添加千分位分隔符
*/
function addThousandsSeparator(numStr: string, sep: string): string {
const parts = numStr.split(".");
const integerPart = parts[0];
const decimalPart = parts[1] ? "." + parts[1] : "";
// 处理负号
const isNegative = integerPart.startsWith("-");
const absoluteInteger = isNegative ? integerPart.slice(1) : integerPart;
// 对整数部分添加千分位分隔符
const formatted = absoluteInteger.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
return (isNegative ? "-" : "") + formatted + decimalPart;
}
/**
* 格式化数字
*/
function formatNumber(num: number): string {
let result: string;
if (decimals.value !== undefined) {
result = num.toFixed(decimals.value);
} else {
// 自动检测小数位数
const str = num.toString();
if (str.includes(".")) {
// 保留原有的小数位数,但最多保留 6 位
const decimalPart = str.split(".")[1];
const decimalLength = Math.min(decimalPart.length, 6);
result = num.toFixed(decimalLength);
} else {
result = num.toString();
}
}
// 应用千分位分隔符
if (thousands.value) {
result = addThousandsSeparator(result, separator.value);
}
return result;
}
/**
* 动画更新函数
*/
function animate(timestamp: number) {
if (startTime === null) {
startTime = timestamp;
}
const elapsed = timestamp - startTime;
const progress = Math.min(elapsed / duration.value, 1);
const easedProgress = ease(progress, easing.value);
// 计算当前值
currentValue.value = startValue + (value.value - startValue) * easedProgress;
displayValue.value = formatNumber(currentValue.value);
if (progress < 1) {
animationFrameId = requestAnimationFrame(animate);
} else {
// 动画结束,确保显示精确的目标值
currentValue.value = value.value;
displayValue.value = formatNumber(value.value);
animationFrameId = null;
startTime = null;
}
}
/**
* 开始动画
*/
function startAnimation() {
// 取消之前的动画
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
startValue = currentValue.value;
startTime = null;
animationFrameId = requestAnimationFrame(animate);
}
// 监听 value 变化
watch(value, (newValue) => {
if (newValue !== currentValue.value) {
startAnimation();
}
});
// 监听 decimals 变化,立即更新显示格式
watch(decimals, () => {
displayValue.value = formatNumber(currentValue.value);
});
// 监听 thousands 和 separator 变化,立即更新显示格式
watch([thousands, separator], () => {
displayValue.value = formatNumber(currentValue.value);
});
// 组件卸载时清理动画
onUnmounted(() => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
});
// 初始化
onMounted(() => {
currentValue.value = value.value;
displayValue.value = formatNumber(value.value);
});
</script>
<style lang="less" scoped>
.tui-animated-number {
display: inline-block;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
</style>

View File

@@ -3,17 +3,20 @@ import Popup from "./popup";
import Captcha from "./captcha"; import Captcha from "./captcha";
import MarkdownView from "./markdown-view"; import MarkdownView from "./markdown-view";
import MarkdownEditor from "./markdown-editor"; import MarkdownEditor from "./markdown-editor";
import AnimatedNumber from "./animated-number";
export default [ export default [
Popup, Popup,
Captcha, Captcha,
MarkdownView, MarkdownView,
MarkdownEditor MarkdownEditor,
AnimatedNumber
]; ];
export { export {
Popup, Popup,
Captcha, Captcha,
MarkdownView, MarkdownView,
MarkdownEditor MarkdownEditor,
AnimatedNumber
}; };