[阶段9] 界面打磨:hero 圆环、sparkline、骨架屏、动效
参考了 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>
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
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 are deliberately below 3:1. */
|
||||
light-surface status steps sit below 3:1 by design. */
|
||||
const STATUS_ICON: Record<Status, string> = {
|
||||
good: '●',
|
||||
warning: '▲',
|
||||
@@ -22,21 +24,36 @@ interface StatTileProps {
|
||||
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,
|
||||
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
|
||||
? '—'
|
||||
: typeof value === 'number'
|
||||
? value.toLocaleString(undefined, { maximumFractionDigits: 1 })
|
||||
: numeric != null
|
||||
? (animated ?? numeric).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: decimals,
|
||||
})
|
||||
: value;
|
||||
|
||||
return (
|
||||
<div className="tile">
|
||||
<div className="tile-label">{label}</div>
|
||||
<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>}
|
||||
|
||||
Reference in New Issue
Block a user