首次加载仍全部 count-up。后续 items 变化时对比新旧值, 只对值发生变化的列触发动画,其他列保持静止。 Co-Authored-By: Claude <noreply@anthropic.com>
172 lines
4.5 KiB
Vue
172 lines
4.5 KiB
Vue
<template>
|
|
<div ref="root" class="stats-summary">
|
|
<div
|
|
v-for="(item, idx) in items"
|
|
:key="idx"
|
|
class="stats-item"
|
|
>
|
|
<div class="stats-item__icon" v-if="item.icon" aria-hidden="true">
|
|
<component :is="item.icon" />
|
|
</div>
|
|
<div class="stats-item__value">{{ displayValues[idx] }}</div>
|
|
<div class="stats-item__label">{{ item.label }}</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
export default {
|
|
name: 'StatsSummary',
|
|
props: {
|
|
/** [{ label, value, icon? }] */
|
|
items: { type: Array, default: () => [] },
|
|
},
|
|
data() {
|
|
return { displayValues: [], _animated: false, _observer: null, _prevItems: null };
|
|
},
|
|
watch: {
|
|
items: {
|
|
immediate: true,
|
|
handler(cur, old) {
|
|
if (!old || !this._prevItems) {
|
|
// 首次加载:全部初始化
|
|
this.displayValues = cur.map((v) => v.value);
|
|
this._animated = false;
|
|
this._prevItems = cur.map((v) => v.value);
|
|
this.$nextTick(() => this._observe());
|
|
return;
|
|
}
|
|
// 增量更新:只动画变化的值
|
|
const prev = this._prevItems;
|
|
cur.forEach((item, idx) => {
|
|
if (String(item.value) !== String(prev[idx])) {
|
|
// 值变了,触发该位置的动画
|
|
this._animateOne(idx, Number(item.value));
|
|
}
|
|
});
|
|
this._prevItems = cur.map((v) => v.value);
|
|
},
|
|
},
|
|
},
|
|
mounted() {
|
|
this._observe();
|
|
},
|
|
beforeDestroy() {
|
|
if (this._observer) this._observer.disconnect();
|
|
},
|
|
methods: {
|
|
_observe() {
|
|
if (this._animated) return;
|
|
const root = this.$refs.root;
|
|
if (!root || typeof IntersectionObserver === 'undefined') {
|
|
this._animateAll();
|
|
return;
|
|
}
|
|
if (this._observer) this._observer.disconnect();
|
|
this._observer = new IntersectionObserver((entries) => {
|
|
if (entries[0] && entries[0].isIntersecting) {
|
|
this._animateAll();
|
|
this._observer.disconnect();
|
|
}
|
|
}, { threshold: 0.2 });
|
|
this._observer.observe(root);
|
|
},
|
|
_animateOne(idx, target) {
|
|
if (Number.isNaN(target)) {
|
|
this.$set(this.displayValues, idx, target);
|
|
return;
|
|
}
|
|
const startVal = Number(this.displayValues[idx]) || 0;
|
|
const diff = target - startVal;
|
|
if (diff === 0) return;
|
|
const start = performance.now();
|
|
const duration = Math.min(600 + Math.abs(diff) * 3, 1200);
|
|
const tick = (now) => {
|
|
const p = Math.min((now - start) / duration, 1);
|
|
const eased = 1 - Math.pow(1 - p, 3);
|
|
const current = Math.round(startVal + eased * diff);
|
|
this.$set(this.displayValues, idx, current.toLocaleString());
|
|
if (p < 1) requestAnimationFrame(tick);
|
|
};
|
|
requestAnimationFrame(tick);
|
|
},
|
|
_animateAll() {
|
|
if (this._animated) return;
|
|
this._animated = true;
|
|
this.items.forEach((item, idx) => {
|
|
const target = Number(item.value);
|
|
if (Number.isNaN(target)) return;
|
|
const start = performance.now();
|
|
const duration = 800 + idx * 120;
|
|
const tick = (now) => {
|
|
const p = Math.min((now - start) / duration, 1);
|
|
const eased = 1 - Math.pow(1 - p, 3); // ease-out
|
|
const current = Math.round(eased * target);
|
|
this.$set(this.displayValues, idx, current.toLocaleString());
|
|
if (p < 1) requestAnimationFrame(tick);
|
|
};
|
|
requestAnimationFrame(tick);
|
|
});
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<style lang="less" scoped>
|
|
.stats-summary {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.stats-item {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
padding: 20px 24px;
|
|
background: #fff;
|
|
border-radius: 10px;
|
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
|
overflow: hidden;
|
|
|
|
&__icon {
|
|
position: absolute;
|
|
top: 12px;
|
|
right: 14px;
|
|
width: 20px;
|
|
height: 20px;
|
|
color: rgba(0, 185, 107, 0.25);
|
|
pointer-events: none;
|
|
}
|
|
|
|
&__value {
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
line-height: 1.2;
|
|
color: #009a29;
|
|
letter-spacing: -0.5px;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
&__label {
|
|
margin-top: 6px;
|
|
font-size: 13px;
|
|
font-weight: 400;
|
|
color: #8c9ba5;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.stats-summary {
|
|
gap: 10px;
|
|
}
|
|
.stats-item {
|
|
padding: 16px 18px;
|
|
&__value { font-size: 22px; }
|
|
&__label { font-size: 12px; }
|
|
}
|
|
}
|
|
</style>
|