原来仪表板只有 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>
279 lines
9.1 KiB
TypeScript
279 lines
9.1 KiB
TypeScript
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;
|