import { effect, h, signal } from '@ui-arch/frame'; import type { StatisticProps } from './types.js'; /*** 剩余毫秒 → DD 天 HH:MM:SS(不足一天省略天数,保持等宽跳动不抖动) ***/ function fmtLeft(ms: number): string { const total = Math.floor(ms / 1000); const d = Math.floor(total / 86400); const hh = String(Math.floor((total % 86400) / 3600)).padStart(2, '0'); const mm = String(Math.floor((total % 3600) / 60)).padStart(2, '0'); const ss = String(total % 60).padStart(2, '0'); return d > 0 ? `${d} 天 ${hh}:${mm}:${ss}` : `${hh}:${mm}:${ss}`; } /*** 统计数值:标题 + 大号数字 + 前后缀 + 环比趋势;数值滚动过渡,传 countdown 则改为每秒倒计时 ***/ export function statistic(props: StatisticProps): HTMLElement { // —— 状态集中声明 —— const precision = props.precision ?? 0; // 小数位 const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; // 减弱动效偏好 const num = h('span', { class: 'ua-stat-num' }, '0'); // 数字节点:逐帧改文本,不重建 DOM let shown = 0; // 当前显示中的值(动画中间态) let raf = 0; // 进行中的动画帧句柄,重复调用时先取消 /*** 格式化:千分位 + 固定小数位 ***/ const fmt = (v: number): string => v.toLocaleString('zh-CN', { minimumFractionDigits: precision, maximumFractionDigits: precision }); /*** 从当前显示值滚到目标值;偏好减弱动效时直接落终值 ***/ const roll = (to: number): void => { cancelAnimationFrame(raf); // 新目标打断上一段动画,避免数值打架 if (reduce) { shown = to; num.textContent = fmt(to); return; } const from = shown; const start = performance.now(); const step = (t: number): void => { const k = Math.min((t - start) / 400, 1); // 归一化进度 0→1 shown = from + (to - from) * (1 - (1 - k) ** 3); // easeOutCubic 缓出 num.textContent = fmt(shown); if (k < 1) raf = requestAnimationFrame(step); else shown = to; // 收尾对齐目标值,避免浮点残差 }; raf = requestAnimationFrame(step); }; const row = h('div', { class: 'ua-stat-value' }, props.prefix ? h('span', { class: 'ua-stat-affix' }, props.prefix) : null, num, props.suffix ? h('span', { class: 'ua-stat-affix' }, props.suffix) : null, props.trend ? h('span', { class: `ua-stat-trend is-${props.trend}` }, props.trend === 'up' ? '▲' : '▼') : null); const view = h('div', { class: 'ua-stat' }, props.title ? h('div', { class: 'ua-stat-title' }, props.title) : null, row); const target = props.countdown; if (target) { const left = signal(Math.max(0, target - Date.now())); // 剩余毫秒 const timer = window.setInterval(() => { const ms = Math.max(0, target - Date.now()); left(ms); if (!ms || !view.isConnected) window.clearInterval(timer); // 到点或已卸载即停表,不留常驻定时器 }, 1000); effect(() => { num.textContent = fmtLeft(left()); }); // 倒计时模式不走滚动动画,每秒直落 } else if (props.value) { effect(() => roll(props.value!())); // 信号变化即滚动到新值(首跑做入场动画) } return view; }