Files
GarminHealthLab/client/src/pages/Dashboard.tsx
ericwyuan ad88ec7e41 [阶段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>
2026-08-23 21:17:08 +08:00

279 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { apiClient, errorMessage, HealthDay } from '../services/api';
import Chart from '../components/charts/Chart';
import StatTile, { Status } from '../components/charts/StatTile';
import './Pages.css';
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;
}
/* 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 [days, setDays] = useState<HealthDay[]>([]);
useEffect(() => {
const load = async () => {
try {
const end = new Date();
const start = new Date(end.getTime() - (DAYS - 1) * 86400000);
setDays(
await apiClient.getHealthSummary(
start.toISOString().slice(0, 10),
end.toISOString().slice(0, 10)
)
);
} catch (err: any) {
setError(errorMessage(err, '加载数据失败'));
} finally {
setLoading(false);
}
};
load();
}, []);
if (loading) return <div className="page-loading"></div>;
if (error) {
return (
<div className="page">
<h2></h2>
<div className="error-message">{error}</div>
</div>
);
}
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">
<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>
{/* 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
)}
unit="小时"
/>
</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>
{/* 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>
{/* 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>
);
}
export default Dashboard;