[阶段7] 前端重做:31 项指标全部露出,配色经色盲校验

原来仪表板只有 4 张卡片和 4 张图,31 项指标里绝大多数没有出口。

信息架构重组为 7 个页面(原 5 个):
- 今日   按活动 / 心率压力 / 睡眠呼吸三组展示 16 项指标
- 趋势   15 组指标可切换,5 档时间窗口(最长一年)
- 睡眠   新增。分期堆叠图 + 夜间血氧/呼吸/压力
- 成就   新增。奖励徽章按年份分组、个人纪录、运动记录
- 建议 / 同步 / 设置 保持

配色(依据 dataviz 规范,用校验器实测而非目测):
- 采用验证过的分类色板并按既定 slot 顺序取色 —— 顺序本身就是
  色盲安全机制,不是审美选择,因此只取不循环
- 浅色 worst adjacent CVD ΔE 9.1 / 常视觉 22.9
  深色 worst adjacent CVD ΔE 8.4 / 常视觉 19.8,两档全部通过
- 浅色表面下 aqua 2.74:1、yellow 2.11:1 低于 3:1,按规范提供
  "relief":每张图都带表格视图,且图例始终与文字标签同现
- 深色不是自动反色,是同色相针对深色表面重新取阶并单独校验
- 状态色(good/warning/serious/critical)保留专用,绝不当作
  第 N 个系列色;且始终图标 + 文字同现,不靠颜色单独表意

图表规范:
- 单一 y 轴,绝不双轴;量纲不同的指标拆成不同图
- 2px 线宽、4px 圆角柱端锚定基线、堆叠段间 2px 表面色间隙、
  悬停标记 2px 表面色描边
- 两系列以上必有图例,单系列不加(标题已经点明)
- 折线 connectNulls,设备漏记的日子不把线打断
- 数值文字一律用文字色令牌,不染系列色

其他:
- 主题切换(自动/浅色/深色),选择写入 localStorage 并标在 <html>
- 导航改为顶部横向,移动端可横滑
- 表格、徽章、空状态等组件统一到设计令牌

验证方式: DOM 审计确认 2px 线宽、圆角为 A 4,4 弧、堆叠段
2px 间隙、图例数量与系列数匹配、两档主题令牌各自解析到校验过的色值。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 21:17:08 +08:00
parent cbbff61082
commit ad88ec7e41
20 changed files with 2131 additions and 767 deletions

View File

@@ -1,52 +1,53 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { apiClient, errorMessage, HealthDay } from '../services/api';
import Chart from '../components/Chart';
import './Dashboard.css';
import Chart from '../components/charts/Chart';
import StatTile, { Status } from '../components/charts/StatTile';
import './Pages.css';
/** Flattened row so every chart can address its metric with a plain key. */
interface ChartRow {
date: string;
steps: number | null;
heartRate: number | null;
sleepHours: number | null;
calories: number | null;
const DAYS = 30;
/** MM-DD keeps the axis readable at 30 points. */
const short = (iso: string) => iso.slice(5);
function avg(values: Array<number | null | undefined>): number | null {
const present = values.filter((v): v is number => v != null);
if (!present.length) return null;
return present.reduce((a, b) => a + b, 0) / present.length;
}
function average(values: Array<number | null | undefined>): number {
const present = values.filter((v): v is number => v != null);
if (present.length === 0) return 0;
return present.reduce((a, b) => a + b, 0) / present.length;
/* Thresholds follow the same rules the recommendation engine uses, so the
dashboard and the advice never disagree about what counts as low. */
function sleepStatus(hours: number | null): [Status, string] | [] {
if (hours == null) return [];
if (hours < 6) return ['critical', '偏少'];
if (hours < 7) return ['warning', '略少'];
return ['good', '充足'];
}
function rhrStatus(bpm: number | null): [Status, string] | [] {
if (bpm == null) return [];
if (bpm > 70) return ['serious', '偏高'];
if (bpm > 65) return ['warning', '略高'];
return ['good', '正常'];
}
function Dashboard() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [rows, setRows] = useState<ChartRow[]>([]);
const [today, setToday] = useState<HealthDay | null>(null);
const [days, setDays] = useState<HealthDay[]>([]);
useEffect(() => {
const load = async () => {
try {
const end = new Date();
const start = new Date(end.getTime() - 29 * 24 * 60 * 60 * 1000);
const endStr = end.toISOString().slice(0, 10);
const summary = await apiClient.getHealthSummary(
start.toISOString().slice(0, 10),
endStr
const start = new Date(end.getTime() - (DAYS - 1) * 86400000);
setDays(
await apiClient.getHealthSummary(
start.toISOString().slice(0, 10),
end.toISOString().slice(0, 10)
)
);
setRows(
summary.map((d) => ({
date: d.date.slice(5), // MM-DD keeps the axis readable
steps: d.steps,
heartRate: d.heartRate,
sleepHours: d.sleep?.duration ?? null,
calories: d.caloriesBurned,
}))
);
setToday(summary.find((d) => d.date === endStr) ?? null);
} catch (err: any) {
setError(errorMessage(err, '加载数据失败'));
} finally {
@@ -58,83 +59,218 @@ function Dashboard() {
if (loading) return <div className="page-loading"></div>;
const hasData = rows.length > 0;
const stats = {
steps: Math.round(average(rows.map((r) => r.steps))),
heartRate: Math.round(average(rows.map((r) => r.heartRate))),
sleep: Math.round(average(rows.map((r) => r.sleepHours)) * 10) / 10,
calories: Math.round(
rows.reduce((sum, r) => sum + (r.calories ?? 0), 0)
),
};
if (error) {
return (
<div className="page">
<h2></h2>
<div className="error-message">{error}</div>
</div>
);
}
const show = (v: number | null | undefined, suffix = '') =>
v == null ? '—' : `${v.toLocaleString()}${suffix}`;
if (days.length === 0) {
return (
<div className="page">
<h2></h2>
<div className="empty-state">
<p></p>
<Link to="/sync" className="btn btn-primary"> Garmin </Link>
</div>
</div>
);
}
const today = days[days.length - 1];
const rows = days.map((d) => ({ ...d, date: short(d.date) }));
const avgSteps = avg(days.map((d) => d.steps));
const avgSleep = avg(days.map((d) => d.sleepDuration));
const avgRhr = avg(days.map((d) => d.heartRate));
const avgHrv = avg(days.map((d) => d.heartRateVariability));
const [sleepTone, sleepWord] = sleepStatus(today.sleepDuration);
const [rhrTone, rhrWord] = rhrStatus(today.heartRate);
const round = (v: number | null, d = 0) =>
v == null ? null : Math.round(v * 10 ** d) / 10 ** d;
return (
<div className="page">
<h2></h2>
{error && <div className="error-message">{error}</div>}
{!hasData && !error && (
<div className="empty-state">
<p></p>
<Link to="/sync" className="btn btn-primary">
Garmin
</Link>
<header className="page-head">
<div>
<h2></h2>
<p className="subtitle">{today.date} · {days.length} </p>
</div>
)}
<Link to="/trends" className="btn btn-plain"> </Link>
</header>
{hasData && (
<>
<section className="today-summary">
<h3></h3>
{today ? (
<div className="stats-grid">
<StatCard icon="🚶" label="步数" value={show(today.steps)} />
<StatCard icon="❤️" label="静息心率" value={show(today.heartRate, ' bpm')} />
<StatCard icon="😴" label="睡眠" value={show(today.sleep?.duration, ' 小时')} />
<StatCard icon="🔥" label="消耗" value={show(today.caloriesBurned, ' kcal')} />
</div>
) : (
<p className="placeholder"></p>
{/* Activity ---------------------------------------------------------- */}
<section className="section">
<h3 className="section-title"></h3>
<div className="tile-grid">
<StatTile
label="步数"
value={today.steps}
detail={today.stepGoal ? `目标 ${today.stepGoal.toLocaleString()}` : undefined}
progress={
today.steps != null && today.stepGoal ? today.steps / today.stepGoal : null
}
/>
<StatTile
label="距离"
value={round(today.distanceMeters != null ? today.distanceMeters / 1000 : null, 2)}
unit="km"
/>
<StatTile label="爬楼" value={round(today.floorsAscended)} unit="层" />
<StatTile
label="强度分钟"
value={today.intensityMinutes}
unit="分钟"
detail="中等以上强度"
/>
<StatTile
label="总消耗"
value={round(today.caloriesBurned)}
unit="kcal"
detail={
today.activeCalories != null
? `其中活动 ${Math.round(today.activeCalories)}`
: undefined
}
/>
<StatTile
label="久坐"
value={round(
today.sedentarySeconds != null ? today.sedentarySeconds / 3600 : null, 1
)}
</section>
unit="小时"
/>
</div>
</section>
<section className="statistics">
<h3> 30 </h3>
<div className="stats-grid">
<StatCard icon="📈" label="日均步数" value={show(stats.steps)} />
<StatCard icon="❤️" label="平均静息心率" value={show(stats.heartRate, ' bpm')} />
<StatCard icon="😴" label="日均睡眠" value={show(stats.sleep, ' 小时')} />
<StatCard icon="🔥" label="累计消耗" value={show(stats.calories, ' kcal')} />
</div>
</section>
{/* Heart & stress ---------------------------------------------------- */}
<section className="section">
<h3 className="section-title"></h3>
<div className="tile-grid">
<StatTile
label="静息心率"
value={today.heartRate}
unit="bpm"
status={rhrTone}
statusLabel={rhrWord}
detail={avgRhr != null ? `30 日均 ${Math.round(avgRhr)}` : undefined}
/>
<StatTile
label="心率区间"
value={
today.heartRateMin != null && today.heartRateMax != null
? `${today.heartRateMin}${today.heartRateMax}`
: null
}
unit="bpm"
/>
<StatTile
label="心率变异性"
value={round(today.heartRateVariability)}
unit="ms"
detail={avgHrv != null ? `30 日均 ${Math.round(avgHrv)}` : undefined}
/>
<StatTile
label="平均压力"
value={today.stress}
detail={today.stressMax != null ? `峰值 ${today.stressMax}` : undefined}
/>
<StatTile
label="身体电量"
value={
today.bodyBatteryLow != null && today.bodyBatteryHigh != null
? `${today.bodyBatteryLow}${today.bodyBatteryHigh}`
: null
}
detail={
today.bodyBatteryCharged != null
? `${today.bodyBatteryCharged} / 耗 ${today.bodyBatteryDrained}`
: undefined
}
/>
<StatTile label="训练准备度" value={today.trainingReadiness} unit="/100" />
</div>
</section>
<section className="charts-section">
<h3></h3>
<div className="charts-grid">
<Chart title="步数" data={rows} type="bar" dataKey="steps" fill="#667eea" />
<Chart title="静息心率 (bpm)" data={rows} type="line" dataKey="heartRate" stroke="#ff6b9d" />
<Chart title="睡眠时长 (小时)" data={rows} type="line" dataKey="sleepHours" stroke="#f0a500" />
<Chart title="卡路里消耗" data={rows} type="bar" dataKey="calories" fill="#4caf50" />
</div>
</section>
</>
)}
</div>
);
}
{/* Sleep & breathing -------------------------------------------------- */}
<section className="section">
<h3 className="section-title"></h3>
<div className="tile-grid">
<StatTile
label="睡眠时长"
value={today.sleepDuration}
unit="小时"
status={sleepTone}
statusLabel={sleepWord}
detail={avgSleep != null ? `30 日均 ${avgSleep.toFixed(1)}` : undefined}
/>
<StatTile label="睡眠评分" value={round(today.sleepQuality)} unit="/100" />
<StatTile
label="血氧"
value={round(today.spo2Avg)}
unit="%"
detail={today.spo2Min != null ? `最低 ${today.spo2Min}%` : undefined}
/>
<StatTile
label="呼吸频率"
value={round(today.respirationAvg)}
unit="次/分"
detail={
today.respirationMin != null && today.respirationMax != null
? `${today.respirationMin}${today.respirationMax}`
: undefined
}
/>
</div>
<p className="section-link">
<Link to="/sleep"> </Link>
</p>
</section>
function StatCard({ icon, label, value }: { icon: string; label: string; value: string }) {
return (
<div className="stat-card">
<div className="stat-icon">{icon}</div>
<div className="stat-content">
<div className="stat-label">{label}</div>
<div className="stat-value">{value}</div>
</div>
{/* Trends ------------------------------------------------------------- */}
<section className="section">
<h3 className="section-title"> {days.length} </h3>
<div className="chart-grid">
<Chart
title="步数"
subtitle={avgSteps != null ? `日均 ${Math.round(avgSteps).toLocaleString()}` : undefined}
data={rows}
type="bar"
series={[{ key: 'steps', label: '步数', slot: 1, unit: '步' }]}
/>
<Chart
title="心率"
unit="bpm"
data={rows}
type="line"
series={[
{ key: 'heartRate', label: '静息', slot: 1, unit: 'bpm' },
{ key: 'heartRateMax', label: '最高', slot: 2, unit: 'bpm' },
]}
/>
<Chart
title="睡眠时长"
unit="小时"
data={rows}
type="area"
series={[{ key: 'sleepDuration', label: '睡眠', slot: 1, unit: '小时', decimals: 1 }]}
/>
<Chart
title="身体电量"
data={rows}
type="line"
series={[
{ key: 'bodyBatteryHigh', label: '最高', slot: 1 },
{ key: 'bodyBatteryLow', label: '最低', slot: 2 },
]}
/>
</div>
</section>
</div>
);
}