txw/txw-mhzc-web/src/pages/index/components/StatsSummary.vue
liulujian 847f3a52ea fix: StatsSummary 增量更新时只动画变化的列,不再全部重播
首次加载仍全部 count-up。后续 items 变化时对比新旧值,
只对值发生变化的列触发动画,其他列保持静止。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-05 00:01:28 +08:00

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>