[阶段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:
175
client/src/pages/Sleep.tsx
Normal file
175
client/src/pages/Sleep.tsx
Normal 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="成人参考:深睡约占 13–23%,REM 约占 20–25%。"
|
||||
/>
|
||||
</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;
|
||||
Reference in New Issue
Block a user