参考了 itsdrchen/Garmin-AI-Coach 与主流健康 App(Apple 活动圆环、 Oura/Whoop 的评分环、Garmin Connect 的卡内趋势)的做法。 hero 区(每个视图只有一个 hero 数字): - 步数目标圆环,超额时转为 status-good 色并保持满环 + 端点标记, 而不是绕第二圈 —— 绕圈会让 101% 看起来比 100% 还少 - 圆环轨道用填充色同色系的浅阶而非中性灰,两者才读作同一个量器 sparkline(12 点,卡片内嵌): - 线用去强调色,只有最新一点用强调色,让它成为数字的背景而非对手 - null 处断开而不插值 —— 设备没记录的那天连一条平线过去等于编数据 骨架屏取代转圈: - 形状与将要出现的内容一致,数据到位时布局不跳 - 用扫光而非闪烁,读起来像进度而不是元素在求关注 动效: - 卡片/图表依次入场(45ms 递增),引导视线扫过而不是整屏同时砸下来 - 数字滚动到位、圆环扫过、sparkline 描线、悬停抬升、按下微缩 - 全部尊重 prefers-reduced-motion:直接跳过而非缩短时长 —— 引起不适的是位移本身,不是它持续多久 深色改为冷调(借鉴参考项目): - 表面从暖近黑 #1a1a19 改为冷近黑 #181b21,更像运动 App - 换表面意味着原有校验作废,已用校验器对新表面重跑:四个系列色 全部通过含 3:1 对比度;文字亦逐个复核(正文 14.56:1、 次要 7.98、弱化 4.38、按钮白字 6.63) fix(a11y): 大号独立数字不应使用 tabular-nums - 等宽数字让每个字形都占 0 的宽度,大字号下显得松散; 等宽只保留给需要纵向对齐的表格列与坐标轴刻度 fix: 数字滚动动画吞掉了小数位 - StatTile 内部按 decimals 格式化,而调用方已经先 round 过一次, 距离显示成 "9 km"、久坐成 "11 小时"。改为传原值 + decimals Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
93 lines
2.5 KiB
TypeScript
93 lines
2.5 KiB
TypeScript
import { ReactNode } from 'react';
|
||
import { useCountUp } from '../../lib/motion';
|
||
import Sparkline from './Sparkline';
|
||
import './StatTile.css';
|
||
|
||
export type Status = 'good' | 'warning' | 'serious' | 'critical';
|
||
|
||
/* Status is carried by an icon plus a label, never by colour alone — the
|
||
light-surface status steps sit below 3:1 by design. */
|
||
const STATUS_ICON: Record<Status, string> = {
|
||
good: '●',
|
||
warning: '▲',
|
||
serious: '▲',
|
||
critical: '■',
|
||
};
|
||
|
||
interface StatTileProps {
|
||
label: string;
|
||
value: number | string | null | undefined;
|
||
unit?: string;
|
||
/** Secondary line: a goal, a range, a comparison. */
|
||
detail?: ReactNode;
|
||
status?: Status;
|
||
statusLabel?: string;
|
||
/** 0–1; draws a goal meter under the value. */
|
||
progress?: number | null;
|
||
/** Recent history for an inline trend. */
|
||
trend?: Array<number | null | undefined>;
|
||
decimals?: number;
|
||
}
|
||
|
||
function StatTile({
|
||
label, value, unit, detail, status, statusLabel, progress, trend, decimals = 0,
|
||
}: StatTileProps) {
|
||
const numeric = typeof value === 'number' ? value : null;
|
||
const animated = useCountUp(numeric);
|
||
|
||
const display =
|
||
value == null
|
||
? '—'
|
||
: numeric != null
|
||
? (animated ?? numeric).toLocaleString(undefined, {
|
||
minimumFractionDigits: 0,
|
||
maximumFractionDigits: decimals,
|
||
})
|
||
: value;
|
||
|
||
return (
|
||
<div className="tile">
|
||
<div className="tile-head">
|
||
<span className="tile-label">{label}</span>
|
||
{trend && trend.some((v) => v != null) && (
|
||
<Sparkline values={trend} label={`${label}近期走势`} />
|
||
)}
|
||
</div>
|
||
|
||
<div className="tile-value">
|
||
{display}
|
||
{unit && value != null && <span className="tile-unit">{unit}</span>}
|
||
</div>
|
||
|
||
{progress != null && (
|
||
<div
|
||
className="tile-meter"
|
||
role="meter"
|
||
aria-valuenow={Math.round(progress * 100)}
|
||
aria-valuemin={0}
|
||
aria-valuemax={100}
|
||
aria-label={`${label}完成度`}
|
||
>
|
||
<div
|
||
className="tile-meter-fill"
|
||
style={{ width: `${Math.min(100, Math.max(0, progress * 100))}%` }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{(detail || status) && (
|
||
<div className="tile-detail">
|
||
{status && (
|
||
<span className={`tile-status status-${status}`}>
|
||
<span aria-hidden="true">{STATUS_ICON[status]}</span> {statusLabel}
|
||
</span>
|
||
)}
|
||
{detail}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default StatTile;
|