参考了 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>
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { apiClient, Activity, errorMessage, HealthDay } from '../services/api';
|
||
import Skeleton from '../components/Skeleton';
|
||
import './Daily.css';
|
||
import './Pages.css';
|
||
|
||
/** Every stored metric, grouped the way the device groups them. */
|
||
interface Field {
|
||
key: keyof HealthDay | 'sleepDeep' | 'sleepLight' | 'sleepRem' | 'sleepAwake';
|
||
label: string;
|
||
unit?: string;
|
||
/** Convert the raw stored value for display. */
|
||
transform?: (v: number) => number;
|
||
decimals?: number;
|
||
hint?: string;
|
||
}
|
||
|
||
const SECONDS_TO_HOURS = (v: number) => v / 3600;
|
||
const SECONDS_TO_MINUTES = (v: number) => v / 60;
|
||
|
||
const GROUPS: Array<{ title: string; fields: Field[] }> = [
|
||
{
|
||
title: '活动',
|
||
fields: [
|
||
{ key: 'steps', label: '步数', unit: '步' },
|
||
{ key: 'stepGoal', label: '步数目标', unit: '步' },
|
||
{ key: 'distanceMeters', label: '距离', unit: 'km', transform: (v) => v / 1000, decimals: 2 },
|
||
{ key: 'floorsAscended', label: '爬楼上行', unit: '层' },
|
||
{ key: 'floorsDescended', label: '爬楼下行', unit: '层' },
|
||
{ key: 'intensityMinutes', label: '强度分钟', unit: '分钟', hint: '中等及以上强度' },
|
||
{ key: 'activeSeconds', label: '活动时长', unit: '小时', transform: SECONDS_TO_HOURS, decimals: 1 },
|
||
{ key: 'sedentarySeconds', label: '久坐时长', unit: '小时', transform: SECONDS_TO_HOURS, decimals: 1 },
|
||
],
|
||
},
|
||
{
|
||
title: '能量',
|
||
fields: [
|
||
{ key: 'caloriesBurned', label: '总消耗', unit: 'kcal' },
|
||
{ key: 'activeCalories', label: '活动消耗', unit: 'kcal' },
|
||
{ key: 'bmrCalories', label: '基础代谢', unit: 'kcal' },
|
||
],
|
||
},
|
||
{
|
||
title: '心率',
|
||
fields: [
|
||
{ key: 'heartRate', label: '静息心率', unit: 'bpm' },
|
||
{ key: 'heartRateMin', label: '最低心率', unit: 'bpm' },
|
||
{ key: 'heartRateMax', label: '最高心率', unit: 'bpm' },
|
||
{ key: 'heartRateVariability', label: '心率变异性', unit: 'ms', decimals: 1, hint: 'HRV,反映恢复情况' },
|
||
],
|
||
},
|
||
{
|
||
title: '压力与身体电量',
|
||
fields: [
|
||
{ key: 'stress', label: '平均压力' },
|
||
{ key: 'stressMax', label: '最高压力' },
|
||
{ key: 'bodyBatteryHigh', label: '身体电量最高' },
|
||
{ key: 'bodyBatteryLow', label: '身体电量最低' },
|
||
{ key: 'bodyBatteryCharged', label: '当日充能' },
|
||
{ key: 'bodyBatteryDrained', label: '当日消耗' },
|
||
],
|
||
},
|
||
{
|
||
title: '睡眠',
|
||
fields: [
|
||
{ key: 'sleepDuration', label: '总时长', unit: '小时', decimals: 1 },
|
||
{ key: 'sleepQuality', label: '睡眠评分', unit: '/100' },
|
||
{ key: 'sleepDeep', label: '深睡', unit: '分钟', transform: SECONDS_TO_MINUTES },
|
||
{ key: 'sleepLight', label: '浅睡', unit: '分钟', transform: SECONDS_TO_MINUTES },
|
||
{ key: 'sleepRem', label: 'REM', unit: '分钟', transform: SECONDS_TO_MINUTES },
|
||
{ key: 'sleepAwake', label: '夜间清醒', unit: '分钟', transform: SECONDS_TO_MINUTES },
|
||
{ key: 'sleepSpo2Avg', label: '睡眠血氧', unit: '%', decimals: 1 },
|
||
{ key: 'sleepRespirationAvg', label: '睡眠呼吸', unit: '次/分', decimals: 1 },
|
||
{ key: 'sleepStressAvg', label: '睡眠压力', decimals: 1 },
|
||
],
|
||
},
|
||
{
|
||
title: '血氧与呼吸',
|
||
fields: [
|
||
{ key: 'spo2Avg', label: '平均血氧', unit: '%', decimals: 1 },
|
||
{ key: 'spo2Min', label: '最低血氧', unit: '%' },
|
||
{ key: 'respirationAvg', label: '平均呼吸', unit: '次/分', decimals: 1 },
|
||
{ key: 'respirationMin', label: '最低呼吸', unit: '次/分', decimals: 1 },
|
||
{ key: 'respirationMax', label: '最高呼吸', unit: '次/分', decimals: 1 },
|
||
],
|
||
},
|
||
{
|
||
title: '训练',
|
||
fields: [
|
||
{ key: 'trainingReadiness', label: '训练准备度', unit: '/100' },
|
||
{ key: 'vo2max', label: 'VO2max', decimals: 1 },
|
||
{ key: 'enduranceScore', label: '耐力分' },
|
||
],
|
||
},
|
||
];
|
||
|
||
const ACTIVITY_LABEL: Record<string, string> = {
|
||
running: '跑步', cycling: '骑行', walking: '步行', hiking: '徒步',
|
||
swimming: '游泳', table_tennis: '乒乓球', strength_training: '力量训练',
|
||
indoor_cycling: '室内骑行', treadmill_running: '跑步机',
|
||
};
|
||
|
||
function valueOf(day: HealthDay, key: Field['key']): number | null {
|
||
if (key === 'sleepDeep') return day.sleep?.deepSeconds ?? null;
|
||
if (key === 'sleepLight') return day.sleep?.lightSeconds ?? null;
|
||
if (key === 'sleepRem') return day.sleep?.remSeconds ?? null;
|
||
if (key === 'sleepAwake') return day.sleep?.awakeSeconds ?? null;
|
||
const v = (day as any)[key];
|
||
return typeof v === 'number' ? v : null;
|
||
}
|
||
|
||
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||
|
||
function Daily() {
|
||
const [date, setDate] = useState(() => iso(new Date()));
|
||
const [day, setDay] = useState<HealthDay | null>(null);
|
||
const [activities, setActivities] = useState<Activity[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [onlyRecorded, setOnlyRecorded] = useState(true);
|
||
|
||
const load = useCallback(async (target: string) => {
|
||
setLoading(true);
|
||
setError('');
|
||
try {
|
||
const [summary, acts] = await Promise.all([
|
||
apiClient.getHealthSummary(target, target),
|
||
apiClient.getActivities(target, target),
|
||
]);
|
||
setDay(summary[0] ?? null);
|
||
setActivities(acts);
|
||
} catch (err: any) {
|
||
setError(errorMessage(err, '加载失败'));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { load(date); }, [date, load]);
|
||
|
||
const shift = (delta: number) => {
|
||
const d = new Date(date);
|
||
d.setDate(d.getDate() + delta);
|
||
if (d > new Date()) return;
|
||
setDate(iso(d));
|
||
};
|
||
|
||
const recorded = useMemo(() => {
|
||
if (!day) return 0;
|
||
return GROUPS.reduce(
|
||
(n, g) => n + g.fields.filter((f) => valueOf(day, f.key) != null).length, 0
|
||
);
|
||
}, [day]);
|
||
|
||
const totalFields = GROUPS.reduce((n, g) => n + g.fields.length, 0);
|
||
const isToday = date === iso(new Date());
|
||
|
||
const fmt = (f: Field, raw: number) => {
|
||
const v = f.transform ? f.transform(raw) : raw;
|
||
const decimals = f.decimals ?? 0;
|
||
return v.toLocaleString(undefined, {
|
||
minimumFractionDigits: 0,
|
||
maximumFractionDigits: decimals,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="page">
|
||
<header className="page-head">
|
||
<div>
|
||
<h2>每日数据</h2>
|
||
<p className="subtitle">
|
||
{day
|
||
? `已记录 ${recorded} / ${totalFields} 项指标`
|
||
: '该日无数据'}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="day-nav">
|
||
<button className="btn btn-plain" onClick={() => shift(-1)} aria-label="前一天">
|
||
‹ 前一天
|
||
</button>
|
||
<input
|
||
type="date"
|
||
className="day-input"
|
||
value={date}
|
||
max={iso(new Date())}
|
||
onChange={(e) => e.target.value && setDate(e.target.value)}
|
||
/>
|
||
<button
|
||
className="btn btn-plain"
|
||
onClick={() => shift(1)}
|
||
disabled={isToday}
|
||
aria-label="后一天"
|
||
>
|
||
后一天 ›
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{error && <div className="error-message">{error}</div>}
|
||
{loading && <Skeleton count={8} variant="row" />}
|
||
|
||
{!loading && !error && !day && (
|
||
<div className="empty-state">
|
||
<p>{date} 没有数据。可能当天未佩戴设备,或尚未同步到这一天。</p>
|
||
</div>
|
||
)}
|
||
|
||
{!loading && !error && day && (
|
||
<>
|
||
<label className="toggle-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={onlyRecorded}
|
||
onChange={(e) => setOnlyRecorded(e.target.checked)}
|
||
/>
|
||
只显示有数据的指标
|
||
</label>
|
||
|
||
{GROUPS.map((g) => {
|
||
const fields = onlyRecorded
|
||
? g.fields.filter((f) => valueOf(day, f.key) != null)
|
||
: g.fields;
|
||
if (fields.length === 0) return null;
|
||
return (
|
||
<section className="section" key={g.title}>
|
||
<h3 className="section-title">
|
||
{g.title}
|
||
<span className="section-count">{fields.length} 项</span>
|
||
</h3>
|
||
<dl className="metric-list">
|
||
{fields.map((f) => {
|
||
const raw = valueOf(day, f.key);
|
||
return (
|
||
<div className="metric-row" key={String(f.key)}>
|
||
<dt>
|
||
{f.label}
|
||
{f.hint && <span className="metric-hint">{f.hint}</span>}
|
||
</dt>
|
||
<dd>
|
||
{raw == null ? (
|
||
<span className="metric-empty">未记录</span>
|
||
) : (
|
||
<>
|
||
<span className="metric-value">{fmt(f, raw)}</span>
|
||
{f.unit && <span className="metric-unit">{f.unit}</span>}
|
||
</>
|
||
)}
|
||
</dd>
|
||
</div>
|
||
);
|
||
})}
|
||
</dl>
|
||
</section>
|
||
);
|
||
})}
|
||
|
||
<section className="section">
|
||
<h3 className="section-title">
|
||
运动记录
|
||
<span className="section-count">{activities.length} 条</span>
|
||
</h3>
|
||
{activities.length === 0 ? (
|
||
<p className="placeholder">当天没有运动记录。</p>
|
||
) : (
|
||
<div className="table-wrap">
|
||
<table className="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th scope="col">开始</th>
|
||
<th scope="col">类型</th>
|
||
<th scope="col">时长</th>
|
||
<th scope="col">距离</th>
|
||
<th scope="col">消耗</th>
|
||
<th scope="col">平均心率</th>
|
||
<th scope="col">最高心率</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{activities.map((a) => (
|
||
<tr key={a.id}>
|
||
<th scope="row">{a.start_time?.slice(11, 16)}</th>
|
||
<td>
|
||
{ACTIVITY_LABEL[a.activity_type] ??
|
||
a.activity_type?.replace(/_/g, ' ')}
|
||
</td>
|
||
<td className="num">
|
||
{a.duration != null ? `${Math.round(a.duration / 60)} 分` : '—'}
|
||
</td>
|
||
<td className="num">
|
||
{a.distance ? `${(a.distance / 1000).toFixed(2)} km` : '—'}
|
||
</td>
|
||
<td className="num">
|
||
{a.calories != null ? Math.round(a.calories) : '—'}
|
||
</td>
|
||
<td className="num">{a.heart_rate_average ?? '—'}</td>
|
||
<td className="num">{a.heart_rate_max ?? '—'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default Daily;
|