参考了 itsdrchen/Garmin-AI-Coach 与主流健康 App(Apple 活动圆环、 Oura/Whoop 的评分环、Garmin Connect 的卡内趋势)的做法。 hero 区(每个视图只有一个 hero 数字): - 步数目标圆环,超额时转为 status-good 色并保持满环 + 端点标记, 而不是绕第二圈 —— 绕圈会让 101% 看起来比 100% 还少 - 圆环轨道用填充色同色系的浅阶而非中性灰,两者才读作同一个量器 sparkline(12 点,卡片内嵌): - 线用去强调色,只有最新一点用强调色,让它成为数字的背景而非对手 - null 处断开而不插值 —— 设备没记录的那天连一条平线过去等于编数据 骨架屏取代转圈: - 形状与将要出现的内容一致,数据到位时布局不跳 - 用扫光而非闪烁,读起来像进度而不是元素在求关注 动效: - 卡片/图表依次入场(45ms 递增),引导视线扫过而不是整屏同时砸下来 - 数字滚动到位、圆环扫过、sparkline 描线、悬停抬升、按下微缩 - 全部尊重 prefers-reduced-motion:直接跳过而非缩短时长 —— 引起不适的是位移本身,不是它持续多久 深色改为冷调(借鉴参考项目): - 表面从暖近黑 #1a1a19 改为冷近黑 #181b21,更像运动 App - 换表面意味着原有校验作废,已用校验器对新表面重跑:四个系列色 全部通过含 3:1 对比度;文字亦逐个复核(正文 14.56:1、 次要 7.98、弱化 4.38、按钮白字 6.63) fix(a11y): 大号独立数字不应使用 tabular-nums - 等宽数字让每个字形都占 0 的宽度,大字号下显得松散; 等宽只保留给需要纵向对齐的表格列与坐标轴刻度 fix: 数字滚动动画吞掉了小数位 - StatTile 内部按 decimals 格式化,而调用方已经先 round 过一次, 距离显示成 "9 km"、久坐成 "11 小时"。改为传原值 + decimals Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
186 lines
6.6 KiB
TypeScript
186 lines
6.6 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 from '../components/charts/StatTile';
|
||
import Skeleton from '../components/Skeleton';
|
||
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[]>([]);
|
||
// 14 by default: the stacked chart needs bars wide enough to read the
|
||
// thinnest stage and to give hover a ~24px hit target. Longer windows stay
|
||
// available for the trend, where density matters less.
|
||
const [range, setRange] = useState(14);
|
||
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 && (
|
||
<>
|
||
<Skeleton count={6} />
|
||
<div style={{ height: '1rem' }} />
|
||
<Skeleton count={2} variant="chart" />
|
||
</>
|
||
)}
|
||
|
||
{!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;
|