[阶段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

175
client/src/pages/Sleep.tsx Normal file
View File

@@ -0,0 +1,175 @@
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 from '../components/charts/StatTile';
import './Pages.css';
const RANGES = [7, 14, 30, 90];
const H = 3600;
function avg(values: Array<number | null | undefined>): number | null {
const present = values.filter((v): v is number => v != null);
return present.length ? present.reduce((a, b) => a + b, 0) / present.length : null;
}
function Sleep() {
const [days, setDays] = useState<HealthDay[]>([]);
const [range, setRange] = useState(30);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const end = new Date();
const start = new Date(end.getTime() - (range - 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();
}, [range]);
const nights = days.filter((d) => d.sleepDuration != null);
// Stage seconds are converted to hours here so the stacked bar and the
// duration chart share one y-scale — a chart never carries two scales.
const rows = nights.map((d) => ({
date: d.date.slice(5),
deep: d.sleep?.deepSeconds != null ? d.sleep.deepSeconds / H : null,
light: d.sleep?.lightSeconds != null ? d.sleep.lightSeconds / H : null,
rem: d.sleep?.remSeconds != null ? d.sleep.remSeconds / H : null,
awake: d.sleep?.awakeSeconds != null ? d.sleep.awakeSeconds / H : null,
quality: d.sleepQuality,
spo2: d.sleepSpo2Avg,
respiration: d.sleepRespirationAvg,
stress: d.sleepStressAvg,
}));
const avgDeep = avg(rows.map((r) => r.deep));
const avgRem = avg(rows.map((r) => r.rem));
const avgLight = avg(rows.map((r) => r.light));
const avgAwake = avg(rows.map((r) => r.awake));
const avgDuration = avg(nights.map((d) => d.sleepDuration));
const avgQuality = avg(nights.map((d) => d.sleepQuality));
const totalStages = [avgDeep, avgLight, avgRem].reduce<number>(
(sum, v) => sum + (v ?? 0), 0
);
const share = (v: number | null) =>
v == null || totalStages === 0 ? undefined : `${Math.round((v / totalStages) * 100)}%`;
const hrs = (v: number | null, d = 1) => (v == null ? null : Math.round(v * 10 ** d) / 10 ** d);
return (
<div className="page">
<header className="page-head">
<div>
<h2></h2>
<p className="subtitle"></p>
</div>
<div className="range-tabs">
{RANGES.map((r) => (
<button
key={r}
className={`range-tab ${r === range ? 'active' : ''}`}
onClick={() => setRange(r)}
>
{r}
</button>
))}
</div>
</header>
{error && <div className="error-message">{error}</div>}
{loading && <div className="page-loading"></div>}
{!loading && !error && nights.length === 0 && (
<div className="empty-state">
<p></p>
<Link to="/sync" className="btn btn-primary"></Link>
</div>
)}
{!loading && !error && nights.length > 0 && (
<>
<section className="section">
<h3 className="section-title">{nights.length} </h3>
<div className="tile-grid">
<StatTile label="总时长" value={hrs(avgDuration)} unit="小时" />
<StatTile label="睡眠评分" value={hrs(avgQuality, 0)} unit="/100" />
<StatTile label="深睡" value={hrs(avgDeep)} unit="小时" detail={share(avgDeep)} />
<StatTile label="浅睡" value={hrs(avgLight)} unit="小时" detail={share(avgLight)} />
<StatTile label="REM" value={hrs(avgRem)} unit="小时" detail={share(avgRem)} />
<StatTile label="夜间清醒" value={hrs(avgAwake)} unit="小时" />
</div>
</section>
<section className="section">
<div className="chart-grid one-col">
<Chart
title="睡眠分期"
unit="小时"
subtitle="每晚各阶段时长堆叠;总高度即当晚睡眠总时长"
data={rows}
type="stacked-bar"
height={280}
series={[
{ key: 'deep', label: '深睡', slot: 1, unit: '小时', decimals: 1 },
{ key: 'light', label: '浅睡', slot: 2, unit: '小时', decimals: 1 },
{ key: 'rem', label: 'REM', slot: 3, unit: '小时', decimals: 1 },
{ key: 'awake', label: '清醒', slot: 4, unit: '小时', decimals: 1 },
]}
footer="成人参考:深睡约占 1323%REM 约占 2025%。"
/>
</div>
<div className="chart-grid">
<Chart
title="睡眠评分"
unit="/100"
data={rows}
type="area"
series={[{ key: 'quality', label: '评分', slot: 1 }]}
/>
<Chart
title="夜间血氧"
unit="%"
data={rows}
type="line"
series={[{ key: 'spo2', label: '血氧', slot: 1, unit: '%', decimals: 1 }]}
/>
<Chart
title="夜间呼吸频率"
unit="次/分"
data={rows}
type="line"
series={[
{ key: 'respiration', label: '呼吸', slot: 1, unit: '次/分', decimals: 1 },
]}
/>
<Chart
title="睡眠压力"
data={rows}
type="line"
series={[{ key: 'stress', label: '压力', slot: 1, decimals: 1 }]}
/>
</div>
</section>
</>
)}
</div>
);
}
export default Sleep;