feat(ui): 卡片可点进详情 + 身体年龄 + 运动详情 + 指标选择弹窗
需求 3.5 / 3.6 / 4.4 / 4.5 / 4.8 / 4.9 - 删掉导航栏右上角那两个 Link,就是它们渲染成半透明椭圆的。 睡眠入口改为点健康页的睡眠卡片,每日入口移进趋势页内容里。 - 新增 lib/metrics.ts 作为唯一的指标注册表。label/unit/取值函数原本在 今日、健康、趋势各写一份,改一处要改三处,也就有三次写不一致的机会。 - /metric/:id/ 指标详情:大数值 + 参考区间 + 7/30/90/365 趋势图 + 平均最高最低达标天数 + 这个指标是什么 + 评分依据(取自后端,不在前端另写一份) - /activity/:id/ 运动详情:概览/数据/分段/图表,数据分组照搬手表的排法, 心率区间用单色顺序色阶(区间是有序刻度,不是分类,不能用分类色) - /body-age/ 身体年龄:逐步展示 VO₂max 基准与各项修正,以及每步的出处 - 趋势页 15 个 chip 占满一屏且像张表单,改成弹窗选择,页面上只留一行摘要 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,94 +1,72 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'framework7-react';
|
||||
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
||||
import { Link, f7 } from 'framework7-react';
|
||||
import { apiClient, errorMessage, FitnessAge, HealthDay } from '../services/api';
|
||||
import { METRICS, metricHref } from '../lib/metrics';
|
||||
import MetricCard from '../components/charts/MetricCard';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import Screen from '../components/Screen';
|
||||
import './Health.css';
|
||||
|
||||
const WINDOW_DAYS = 30;
|
||||
|
||||
interface Item {
|
||||
metric?: string;
|
||||
label: string;
|
||||
pick: (d: HealthDay) => number | null;
|
||||
unit?: string;
|
||||
decimals?: number;
|
||||
detail?: (d: HealthDay) => string | undefined;
|
||||
}
|
||||
|
||||
const SECTIONS: Array<{ title: string; items: Item[] }> = [
|
||||
{
|
||||
title: '身体指标',
|
||||
items: [
|
||||
{ metric: 'heartRate', label: '静息心率', pick: (d) => d.heartRate, unit: 'bpm' },
|
||||
{
|
||||
metric: 'heartRateVariability', label: '心率变异性',
|
||||
pick: (d) => d.heartRateVariability, unit: 'ms', decimals: 1,
|
||||
},
|
||||
{ metric: 'respirationAvg', label: '呼吸频率', pick: (d) => d.respirationAvg, unit: '次/分', decimals: 1 },
|
||||
{ metric: 'spo2Avg', label: '血氧', pick: (d) => d.spo2Avg, unit: '%' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '恢复',
|
||||
items: [
|
||||
{ metric: 'bodyBatteryHigh', label: '身体电量峰值', pick: (d) => d.bodyBatteryHigh },
|
||||
{ metric: 'stress', label: '平均压力', pick: (d) => d.stress },
|
||||
{ metric: 'trainingReadiness', label: '训练准备度', pick: (d) => d.trainingReadiness, unit: '/100' },
|
||||
{ label: '耐力分', pick: (d) => d.enduranceScore },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '睡眠',
|
||||
items: [
|
||||
{ metric: 'sleepDuration', label: '睡眠时长', pick: (d) => d.sleepDuration, unit: '小时', decimals: 1 },
|
||||
{ metric: 'sleepQuality', label: '睡眠评分', pick: (d) => d.sleepQuality, unit: '/100' },
|
||||
{
|
||||
label: '深睡占比', unit: '%',
|
||||
pick: (d) =>
|
||||
d.sleep?.deepSeconds != null && d.sleepDuration
|
||||
? (d.sleep.deepSeconds / 3600 / d.sleepDuration) * 100
|
||||
: null,
|
||||
decimals: 0,
|
||||
},
|
||||
{
|
||||
label: 'REM 占比', unit: '%',
|
||||
pick: (d) =>
|
||||
d.sleep?.remSeconds != null && d.sleepDuration
|
||||
? (d.sleep.remSeconds / 3600 / d.sleepDuration) * 100
|
||||
: null,
|
||||
decimals: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '活动',
|
||||
items: [
|
||||
{ metric: 'steps', label: '步数', pick: (d) => d.steps, unit: '步' },
|
||||
{ metric: 'intensityMinutes', label: '强度分钟', pick: (d) => d.intensityMinutes, unit: '分钟' },
|
||||
{ metric: 'floorsAscended', label: '爬楼', pick: (d) => d.floorsAscended, unit: '层' },
|
||||
{
|
||||
label: '距离', unit: 'km', decimals: 2,
|
||||
pick: (d) => (d.distanceMeters != null ? d.distanceMeters / 1000 : null),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '能量',
|
||||
items: [
|
||||
{ label: '总消耗', pick: (d) => d.caloriesBurned, unit: 'kcal' },
|
||||
{ label: '活动消耗', pick: (d) => d.activeCalories, unit: 'kcal' },
|
||||
{ label: '基础代谢', pick: (d) => d.bmrCalories, unit: 'kcal' },
|
||||
{
|
||||
label: '久坐', unit: '小时', decimals: 1,
|
||||
pick: (d) => (d.sedentarySeconds != null ? d.sedentarySeconds / 3600 : null),
|
||||
},
|
||||
],
|
||||
},
|
||||
/* Sections name metric ids; the labels, units and accessors come from the
|
||||
registry so 今日 / 健康 / 趋势 cannot disagree about what a metric is. */
|
||||
const SECTIONS: Array<{ title: string; items: string[] }> = [
|
||||
{ title: '身体指标', items: ['heartRate', 'heartRateVariability', 'respirationAvg', 'spo2Avg'] },
|
||||
{ title: '恢复', items: ['bodyBatteryHigh', 'stress', 'trainingReadiness', 'enduranceScore'] },
|
||||
{ title: '睡眠', items: ['sleepDuration', 'sleepQuality', 'deepShare', 'remShare'] },
|
||||
{ title: '活动', items: ['steps', 'intensityMinutes', 'floorsAscended', 'distance'] },
|
||||
{ title: '能量', items: ['caloriesBurned', 'activeCalories', 'bmrCalories', 'sedentary'] },
|
||||
];
|
||||
|
||||
function BodyAge({ data }: { data: FitnessAge | null }) {
|
||||
if (!data) return null;
|
||||
|
||||
const go = () => f7.views.current.router.navigate('/body-age/');
|
||||
|
||||
if (data.value == null) {
|
||||
return (
|
||||
<section className="sec">
|
||||
<h3 className="sec-title">身体年龄</h3>
|
||||
<button className="bodyage bodyage-empty" onClick={go} type="button">
|
||||
<span className="bodyage-missing">
|
||||
还差 {data.missing.join('、')} 才能推算
|
||||
</span>
|
||||
<span className="mcard-chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const delta = data.delta ?? 0;
|
||||
|
||||
return (
|
||||
<section className="sec">
|
||||
<h3 className="sec-title">身体年龄</h3>
|
||||
<button className="bodyage" onClick={go} type="button">
|
||||
<div className="bodyage-main">
|
||||
<span className="bodyage-value">{data.value}</span>
|
||||
<span className="bodyage-unit">岁</span>
|
||||
</div>
|
||||
<div className="bodyage-side">
|
||||
<span className={`bodyage-delta ${delta < 0 ? 'good' : delta > 0 ? 'warn' : ''}`}>
|
||||
{delta === 0
|
||||
? '与实际年龄相当'
|
||||
: `比实际年龄${delta < 0 ? '年轻' : '大'} ${Math.abs(delta)} 岁`}
|
||||
</span>
|
||||
<span className="bodyage-note">
|
||||
实际 {data.chronologicalAge} 岁 · 看推算过程
|
||||
</span>
|
||||
</div>
|
||||
<span className="mcard-chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthPage() {
|
||||
const [days, setDays] = useState<HealthDay[]>([]);
|
||||
const [bodyAge, setBodyAge] = useState<FitnessAge | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -110,12 +88,14 @@ function HealthPage() {
|
||||
}
|
||||
};
|
||||
load();
|
||||
// Body age is a separate, optional read: an empty profile must not stop
|
||||
// the rest of the page from rendering.
|
||||
apiClient.getFitnessAge().then(setBodyAge).catch(() => setBodyAge(null));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间" right={<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />}>
|
||||
<h2>健康</h2>
|
||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||
<Skeleton count={8} />
|
||||
</Screen>
|
||||
);
|
||||
@@ -123,8 +103,7 @@ function HealthPage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间" right={<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />}>
|
||||
<h2>健康</h2>
|
||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||
<div className="screen-error">{error}</div>
|
||||
</Screen>
|
||||
);
|
||||
@@ -132,11 +111,12 @@ function HealthPage() {
|
||||
|
||||
if (days.length === 0) {
|
||||
return (
|
||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间" right={<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />}>
|
||||
<h2>健康</h2>
|
||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||
<div className="screen-empty">
|
||||
<p>还没有任何健康数据。</p>
|
||||
<Link href="/sync" className="btn btn-primary">去同步 Garmin 数据</Link>
|
||||
<Link href="/sync/" className="button button-fill button-round">
|
||||
去同步 Garmin 数据
|
||||
</Link>
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
@@ -149,29 +129,33 @@ function HealthPage() {
|
||||
const v = pick(days[i]);
|
||||
if (v != null) return { value: v, date: days[i].date };
|
||||
}
|
||||
return { value: null, date: null };
|
||||
return { value: null as number | null, date: null as string | null };
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间" right={<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />}>
|
||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||
<BodyAge data={bodyAge} />
|
||||
|
||||
{SECTIONS.map((section) => (
|
||||
<section className="sec" key={section.title}>
|
||||
<h3 className="sec-title">{section.title}</h3>
|
||||
<div className="mcard-grid">
|
||||
{section.items.map((item) => {
|
||||
const { value, date } = latest(item.pick);
|
||||
{section.items.map((id) => {
|
||||
const def = METRICS[id];
|
||||
if (!def) return null;
|
||||
const { value, date } = latest(def.pick);
|
||||
const stale = date != null && date !== days[days.length - 1].date;
|
||||
return (
|
||||
<MetricCard
|
||||
key={item.label}
|
||||
metric={item.metric}
|
||||
label={item.label}
|
||||
key={id}
|
||||
metric={def.range}
|
||||
label={def.label}
|
||||
value={value}
|
||||
unit={item.unit}
|
||||
decimals={item.decimals}
|
||||
trend={days.map(item.pick)}
|
||||
unit={def.unit}
|
||||
decimals={def.decimals}
|
||||
trend={days.map(def.pick)}
|
||||
detail={stale ? `最近记录 ${date!.slice(5)}` : undefined}
|
||||
onClick={() => f7.views.current.router.navigate(metricHref(id))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user