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:
ericwyuan
2026-08-24 00:39:33 +08:00
parent 6b4c5375dc
commit b0e0799a97
17 changed files with 1677 additions and 171 deletions

View File

@@ -234,59 +234,95 @@
.metric-empty { color: var(--text-muted); font-size: 0.8rem; }
/* Metric picker (趋势) ----------------------------------------------------- */
.metric-picker {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 14px;
padding: 0.85rem 1rem;
.trend-tools {
display: grid;
gap: 0.5rem;
margin-bottom: 1.1rem;
}
.picker-head {
.picker-open {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.6rem;
gap: 0.6rem;
width: 100%;
padding: 0.7rem 0.95rem;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text-primary);
font-family: inherit;
font-size: 0.88rem;
cursor: pointer;
text-align: left;
text-decoration: none;
transition: background 0.15s var(--ease);
}
.picker-actions { display: flex; gap: 0.85rem; }
.picker-open:active { background: var(--surface-2); }
.picker-open-label { flex: 1; font-weight: 550; }
.picker-open-count {
font-size: 0.79rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.picker-open-link { color: var(--text-primary); }
.picker-body { padding: 1rem; }
.picker-actions {
display: flex;
gap: 1.1rem;
justify-content: flex-end;
margin-bottom: 0.7rem;
}
.link-button {
background: none;
border: none;
color: var(--accent);
font-size: 0.78rem;
font-size: 0.83rem;
cursor: pointer;
padding: 0;
font-family: inherit;
}
.picker-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.32rem 0.72rem;
border-radius: 999px;
font-size: 0.79rem;
cursor: pointer;
font-family: inherit;
.picker-rows {
border: 1px solid var(--border);
transition: all 0.15s var(--ease);
border-radius: 14px;
overflow: hidden;
background: var(--surface-1);
}
.chip.on {
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
.picker-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
width: 100%;
padding: 0.8rem 1rem;
background: none;
border: none;
border-bottom: 1px solid var(--border);
font-family: inherit;
font-size: 0.9rem;
color: var(--text-secondary);
cursor: pointer;
text-align: left;
}
.picker-row:last-child { border-bottom: none; }
.picker-row.on { color: var(--text-primary); font-weight: 550; }
.picker-row:active { background: var(--surface-2); }
.picker-row-mark {
color: var(--accent);
font-weight: 600;
font-size: 0.95rem;
width: 1em;
text-align: center;
}
.chip.off { background: var(--surface-0); color: var(--text-muted); }
.chip:active { transform: scale(0.96); }
.chip-mark { font-size: 0.72em; opacity: 0.85; }
.chart-grid {
display: grid;
grid-template-columns: 1fr;

142
client/src/lib/metrics.ts Normal file
View File

@@ -0,0 +1,142 @@
import { HealthDay } from '../services/api';
/**
* One registry for every metric the app can show.
*
* The label, unit and accessor used to live separately in 今日, 健康 and 趋势,
* which meant three places to edit and three chances to disagree. A card can
* now hand its id to the detail route and the detail page knows the rest.
*/
export interface MetricDef {
id: string;
label: string;
unit?: string;
decimals?: number;
pick: (d: HealthDay) => number | null;
/** Key into RANGES; absent when the metric has no reference band. */
range?: string;
/** Cumulative over a period (summed), rather than a level (averaged). */
cumulative?: boolean;
/** What the number is, in one sentence. Shown on the detail screen. */
about: string;
/** A screen to open instead of the generic detail page. */
route?: string;
}
const share = (part: number | null | undefined, hours: number | null) =>
part != null && hours ? (part / 3600 / hours) * 100 : null;
export const METRICS: Record<string, MetricDef> = {
steps: {
id: 'steps', label: '步数', unit: '步', range: 'steps', cumulative: true,
pick: (d) => d.steps,
about: '一天走过的步数,由手表的加速度计计数。',
},
distance: {
id: 'distance', label: '距离', unit: 'km', decimals: 2, cumulative: true,
pick: (d) => (d.distanceMeters != null ? d.distanceMeters / 1000 : null),
about: '一天移动的总距离,包含步行、跑步与骑行。',
},
caloriesBurned: {
id: 'caloriesBurned', label: '总消耗', unit: 'kcal', cumulative: true,
pick: (d) => d.caloriesBurned,
about: '基础代谢与活动消耗之和。',
},
activeCalories: {
id: 'activeCalories', label: '活动消耗', unit: 'kcal', cumulative: true,
pick: (d) => d.activeCalories,
about: '扣除基础代谢后,由活动产生的消耗。',
},
bmrCalories: {
id: 'bmrCalories', label: '基础代谢', unit: 'kcal', cumulative: true,
pick: (d) => d.bmrCalories,
about: '维持生命活动所需的最低能量,由身高体重年龄估算。',
},
floorsAscended: {
id: 'floorsAscended', label: '爬楼', unit: '层', range: 'floorsAscended',
cumulative: true, pick: (d) => d.floorsAscended,
about: '由气压计推算的爬升层数,约每 3 米记 1 层。',
},
intensityMinutes: {
id: 'intensityMinutes', label: '强度分钟', unit: '分钟',
range: 'intensityMinutes', cumulative: true, pick: (d) => d.intensityMinutes,
about: '中等及以上强度活动的时长,高强度按双倍计。',
},
sedentary: {
id: 'sedentary', label: '久坐', unit: '小时', decimals: 1, cumulative: true,
pick: (d) => (d.sedentarySeconds != null ? d.sedentarySeconds / 3600 : null),
about: '清醒但几乎没有移动的时长。',
},
heartRate: {
id: 'heartRate', label: '静息心率', unit: 'bpm', range: 'heartRate',
pick: (d) => d.heartRate,
about: '一天中最低的稳定心率,通常出现在睡眠时。规律有氧训练会让它长期下降。',
},
heartRateMax: {
id: 'heartRateMax', label: '最高心率', unit: 'bpm', pick: (d) => d.heartRateMax,
about: '当天记录到的最高心率。',
},
heartRateVariability: {
id: 'heartRateVariability', label: '心率变异性', unit: 'ms', decimals: 1,
range: 'heartRateVariability', pick: (d) => d.heartRateVariability,
about: '相邻心跳间隔的波动幅度(夜间 RMSSD。个体差异极大'
+ '和自己的基线比才有意义,和别人比没有意义。',
},
stress: {
id: 'stress', label: '平均压力', range: 'stress', pick: (d) => d.stress,
about: 'Garmin 由心率变异性推算的 0100 压力值,反映自主神经的负荷。',
},
bodyBatteryHigh: {
id: 'bodyBatteryHigh', label: '身体电量峰值', range: 'bodyBatteryHigh',
pick: (d) => d.bodyBatteryHigh,
about: '当天身体电量的最高点,通常是睡醒时。恢复得越好,起点越高。',
},
spo2Avg: {
id: 'spo2Avg', label: '血氧', unit: '%', range: 'spo2Avg', pick: (d) => d.spo2Avg,
about: '血氧饱和度。腕表用光学方式测量,误差比指夹式大,看趋势为主。',
},
respirationAvg: {
id: 'respirationAvg', label: '呼吸频率', unit: '次/分', decimals: 1,
range: 'respirationAvg', pick: (d) => d.respirationAvg,
about: '每分钟呼吸次数的平均值。',
},
sleepDuration: {
id: 'sleepDuration', label: '睡眠时长', unit: '小时', decimals: 1,
range: 'sleepDuration', pick: (d) => d.sleepDuration, route: '/sleep/',
about: '实际入睡时长,不含卧床清醒的时间。',
},
sleepQuality: {
id: 'sleepQuality', label: '睡眠评分', unit: '/100', range: 'sleepQuality',
pick: (d) => d.sleepQuality, route: '/sleep/',
about: 'Garmin 综合时长、深睡比例、夜醒次数与静息心率给出的评分。',
},
deepShare: {
id: 'deepShare', label: '深睡占比', unit: '%',
pick: (d) => share(d.sleep?.deepSeconds, d.sleepDuration), route: '/sleep/',
about: '深睡时长占总睡眠的比例,成人常见范围约 13%23%。',
},
remShare: {
id: 'remShare', label: 'REM 占比', unit: '%',
pick: (d) => share(d.sleep?.remSeconds, d.sleepDuration), route: '/sleep/',
about: 'REM 睡眠占比,成人常见范围约 20%25%。',
},
trainingReadiness: {
id: 'trainingReadiness', label: '训练准备度', unit: '/100',
range: 'trainingReadiness', pick: (d) => d.trainingReadiness,
about: 'Garmin 综合睡眠、恢复时间、HRV 状态与近期负荷给出的当日训练建议分。',
},
enduranceScore: {
id: 'enduranceScore', label: '耐力分', pick: (d) => d.enduranceScore,
about: 'Garmin 由长期训练负荷与 VO₂max 推算的耐力水平,变化很慢。',
},
vo2max: {
id: 'vo2max', label: 'VO₂max', unit: 'ml/kg/min', pick: (d) => d.vo2max,
about: '最大摄氧量,心肺适能的核心指标。只在户外跑步或骑行后才会更新。',
},
};
export const metric = (id: string): MetricDef | undefined => METRICS[id];
/** Where a card should navigate when tapped. */
export const metricHref = (id: string) =>
METRICS[id]?.route ?? `/metric/${id}/`;

View File

@@ -0,0 +1,158 @@
/* Activity detail -------------------------------------------------------- */
.ad-hero {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 16px;
padding: 1.4rem 1.1rem;
text-align: center;
margin-bottom: 0.85rem;
}
.ad-hero-main { display: inline-flex; align-items: baseline; gap: 0.3rem; }
.ad-hero-value {
font-size: 2.9rem;
font-weight: 700;
line-height: 1;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
letter-spacing: -0.03em;
}
.ad-hero-unit { font-size: 1rem; color: var(--text-muted); }
.ad-hero-label { margin-top: 0.45rem; font-size: 0.78rem; color: var(--text-muted); }
.ad-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
margin-bottom: 1.3rem;
}
.ad-tile {
background: var(--surface-1);
padding: 0.8rem 0.6rem;
display: flex;
flex-direction: column;
gap: 0.22rem;
align-items: center;
text-align: center;
}
.ad-tile-value {
font-size: 1.02rem;
font-weight: 640;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.ad-tile-label { font-size: 0.72rem; color: var(--text-muted); }
/* Stats rows */
.ad-rows {
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
background: var(--surface-1);
}
.ad-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding: 0.62rem 0.95rem;
border-bottom: 1px solid var(--border);
}
.ad-row:last-child { border-bottom: none; }
.ad-row-label { font-size: 0.85rem; color: var(--text-secondary); }
.ad-row-value {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ad-eval {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 14px;
padding: 0.9rem 1rem;
}
.ad-eval-name { font-size: 1rem; font-weight: 640; color: var(--text-primary); }
.ad-eval-note { margin-top: 0.2rem; font-size: 0.75rem; color: var(--text-muted); }
.ad-gear { display: flex; flex-wrap: wrap; gap: 0.45rem; }
.ad-gear-item {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.35rem 0.8rem;
font-size: 0.82rem;
color: var(--text-secondary);
}
.ad-total th, .ad-total td {
background: var(--surface-2);
font-weight: 640;
color: var(--text-primary);
}
/* Heart-rate zones ------------------------------------------------------- */
.zones {
display: flex;
flex-direction: column;
gap: 0.7rem;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 14px;
padding: 0.95rem 1rem;
}
.zone-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.3rem;
}
.zone-name { font-size: 0.85rem; font-weight: 600; color: var(--text-primary); }
.zone-range { font-weight: 400; font-size: 0.74rem; color: var(--text-muted); margin-left: 0.4rem; }
.zone-time {
font-size: 0.83rem;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.zone-pct { color: var(--text-muted); margin-left: 0.5rem; font-size: 0.76rem; }
.zone-bar {
height: 6px;
background: var(--surface-0);
border-radius: 999px;
overflow: hidden;
}
/* Zones are an ordered scale, so they take one hue getting darker rather
than five unrelated categorical colours. */
.zone-fill { height: 100%; border-radius: 999px; transition: width 0.5s var(--ease); }
.zone-fill.z1 { background: var(--seq-1); }
.zone-fill.z2 { background: var(--seq-2); }
.zone-fill.z3 { background: var(--seq-3); }
.zone-fill.z4 { background: var(--seq-4); }
.zone-fill.z5 { background: var(--seq-5); }
@media (prefers-reduced-motion: reduce) {
.zone-fill { transition: none; }
}

View File

@@ -0,0 +1,421 @@
import { useEffect, useMemo, useState } from 'react';
import { apiClient, ActivityDetail, errorMessage } from '../services/api';
import Screen from '../components/Screen';
import Chart from '../components/charts/Chart';
import Skeleton from '../components/Skeleton';
import './ActivityDetail.css';
type Tab = 'overview' | 'stats' | 'laps' | 'charts';
type Axis = 'time' | 'distance';
const TABS: Array<[Tab, string]> = [
['overview', '概览'], ['stats', '数据'], ['laps', '分段'], ['charts', '图表'],
];
const TYPE_LABEL: Record<string, string> = {
running: '跑步', cycling: '骑行', walking: '步行', hiking: '徒步',
swimming: '游泳', table_tennis: '乒乓球', strength_training: '力量训练',
indoor_cycling: '室内骑行', treadmill_running: '跑步机', badminton: '羽毛球',
yoga: '瑜伽', mountaineering: '登山', elliptical: '椭圆机', rowing: '划船',
open_water_swimming: '公开水域游泳', stair_climbing: '爬楼梯',
fitness_equipment: '健身器械',
};
/* --- formatting ---------------------------------------------------------- */
const hms = (seconds?: number | null) => {
if (seconds == null) return '—';
const s = Math.round(seconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n: number) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
};
/** Pace from speed in m/s, as mm:ss per km — the reading runners actually use. */
const pace = (metresPerSecond?: number | null) => {
if (!metresPerSecond) return '—';
const secondsPerKm = 1000 / metresPerSecond;
const m = Math.floor(secondsPerKm / 60);
const s = Math.round(secondsPerKm % 60);
return `${m}:${String(s).padStart(2, '0')} /km`;
};
const kmh = (metresPerSecond?: number | null) =>
metresPerSecond == null ? '—' : `${(metresPerSecond * 3.6).toFixed(1)} km/h`;
const num = (v?: number | null, digits = 0, unit = '') =>
v == null ? '—' : `${v.toLocaleString(undefined, {
minimumFractionDigits: digits, maximumFractionDigits: digits,
})}${unit ? ` ${unit}` : ''}`;
const km = (metres?: number | null) =>
metres == null ? '—' : `${(metres / 1000).toFixed(2)} km`;
/* Groups mirror the watch app's Stats tab, so a value sits where it is
expected. A group with nothing recorded is dropped rather than shown empty. */
function statGroups(s: Record<string, any>) {
return [
['配速', [
['平均配速', pace(s.averageSpeed)],
['移动平均配速', pace(s.averageMovingSpeed)],
['最快配速', pace(s.maxSpeed)],
]],
['速度', [
['平均速度', kmh(s.averageSpeed)],
['移动平均速度', kmh(s.averageMovingSpeed)],
['最高速度', kmh(s.maxSpeed)],
]],
['计时', [
['总时间', hms(s.duration)],
['移动时间', hms(s.movingDuration)],
['流逝时间', hms(s.elapsedDuration)],
]],
['心率', [
['平均心率', num(s.averageHR, 0, 'bpm')],
['最高心率', num(s.maxHR, 0, 'bpm')],
]],
['训练效果', [
['主要收益', s.trainingEffectLabel ?? '—'],
['有氧', num(s.trainingEffect, 1)],
['无氧', num(s.anaerobicTrainingEffect, 1)],
['运动负荷', num(s.activityTrainingLoad, 0)],
]],
['营养与补水', [
['静息消耗', num(s.bmrCalories, 0, 'kcal')],
['活动消耗', num(
s.calories != null && s.bmrCalories != null
? s.calories - s.bmrCalories : null, 0, 'kcal')],
['总消耗', num(s.calories, 0, 'kcal')],
['预估出汗量', num(s.waterEstimated, 0, 'ml')],
]],
['温度', [
['平均温度', num(s.averageTemperature, 0, '°C')],
['最低温度', num(s.minTemperature, 0, '°C')],
['最高温度', num(s.maxTemperature, 0, '°C')],
]],
['强度分钟', [
['中等', num(s.moderateIntensityMinutes, 0, '分钟')],
['高强度', num(s.vigorousIntensityMinutes, 0, '分钟')],
['合计', num(
(s.moderateIntensityMinutes ?? 0) + (s.vigorousIntensityMinutes ?? 0) || null,
0, '分钟')],
]],
['海拔', [
['总爬升', num(s.elevationGain, 0, 'm')],
['总下降', num(s.elevationLoss, 0, 'm')],
['最低海拔', num(s.minElevation, 0, 'm')],
['最高海拔', num(s.maxElevation, 0, 'm')],
]],
['步频', [
['平均步频', num(s.averageRunCadence ?? s.averageBikeCadence, 0, 'spm')],
['最高步频', num(s.maxRunCadence ?? s.maxBikeCadence, 0, 'spm')],
]],
['功率', [
['平均功率', num(s.averagePower, 0, 'W')],
['最大功率', num(s.maxPower, 0, 'W')],
['标准化功率', num(s.normPower, 0, 'W')],
]],
] as Array<[string, Array<[string, string]>]>;
}
/* --- heart-rate zones ---------------------------------------------------- */
const ZONE_NAME = ['', '热身', '轻松', '有氧', '阈值', '最大'];
function Zones({ zones }: { zones: ActivityDetail['hrZones'] }) {
const total = zones.reduce((sum, z) => sum + (z.seconds || 0), 0);
if (!total) return null;
return (
<section className="sec">
<h3 className="sec-title"></h3>
<div className="zones">
{[...zones].reverse().map((z) => {
const share = total ? (z.seconds / total) * 100 : 0;
return (
<div className="zone" key={z.zone}>
<div className="zone-head">
<span className="zone-name">
{z.zone}
<span className="zone-range">
{z.lowBoundary != null ? `${Math.round(z.lowBoundary)} bpm` : ''}
{ZONE_NAME[z.zone] ? ` · ${ZONE_NAME[z.zone]}` : ''}
</span>
</span>
<span className="zone-time">
{hms(z.seconds)}<span className="zone-pct">{Math.round(share)}%</span>
</span>
</div>
<div className="zone-bar">
<div
className={`zone-fill z${z.zone}`}
style={{ width: `${share}%` }}
/>
</div>
</div>
);
})}
</div>
</section>
);
}
/* --- page ---------------------------------------------------------------- */
interface Props {
id?: string;
f7route?: { params: { id: string } };
}
function ActivityDetailPage({ id, f7route }: Props) {
const activityId = id ?? f7route?.params.id ?? '';
const [detail, setDetail] = useState<ActivityDetail | null>(null);
const [tab, setTab] = useState<Tab>('overview');
const [axis, setAxis] = useState<Axis>('time');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
apiClient
.getActivityDetail(activityId)
.then((d) => { if (!cancelled) setDetail(d); })
.catch((err) => { if (!cancelled) setError(errorMessage(err, '加载失败')); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [activityId]);
/* The series arrive as parallel arrays; recharts wants one row per sample.
The x value is whichever axis is selected, formatted for reading. */
const chartRows = useMemo(() => {
if (!detail) return [];
const s = detail.series;
const length = Math.max(...Object.values(s).map((a) => a.length), 0);
if (!length) return [];
const elapsed = s.elapsed ?? s.duration;
return Array.from({ length }, (_, i) => ({
x: axis === 'distance'
? s.distance?.[i] != null ? +(s.distance[i]! / 1000).toFixed(2) : null
: elapsed?.[i] != null ? Math.round(elapsed[i]! / 60) : i,
heartRate: s.heartRate?.[i] ?? null,
speed: s.speed?.[i] != null ? +(s.speed[i]! * 3.6).toFixed(1) : null,
elevation: s.elevation?.[i] ?? null,
cadence: s.cadence?.[i] ?? null,
temperature: s.temperature?.[i] ?? null,
power: s.power?.[i] ?? null,
})).filter((row) => row.x != null);
}, [detail, axis]);
const title = detail
? detail.activityName
|| TYPE_LABEL[detail.activityType ?? '']
|| detail.activityType
|| '运动'
: '运动';
if (loading) {
return (
<Screen title="运动详情" backLink>
<p className="screen-note"> Garmin </p>
<Skeleton count={4} />
</Screen>
);
}
if (error || !detail) {
return (
<Screen title="运动详情" backLink>
<div className="screen-error">{error || '加载失败'}</div>
</Screen>
);
}
const s = detail.summary as Record<string, any>;
const hasDistance = !!detail.series.distance?.some((v) => v != null);
const charts: Array<[string, string, string, 'area' | 'line', number]> = [
['heartRate', '心率', 'bpm', 'area', 1],
['speed', '速度', 'km/h', 'area', 2],
['elevation', '海拔', 'm', 'area', 3],
['cadence', '步频', 'spm', 'line', 4],
['power', '功率', 'W', 'line', 5],
['temperature', '温度', '°C', 'line', 6],
];
return (
<Screen title={title} subtitle={s.startTimeLocal?.slice(0, 16).replace('T', ' ')} backLink>
<div className="metric-tabs">
{TABS.map(([tabId, text]) => (
<button
key={tabId}
className={`metric-tab ${tab === tabId ? 'active' : ''}`}
onClick={() => setTab(tabId)}
>
{text}
</button>
))}
</div>
{tab === 'overview' && (
<>
<section className="ad-hero">
<div className="ad-hero-main">
<span className="ad-hero-value">
{s.distance ? (s.distance / 1000).toFixed(2) : hms(s.duration)}
</span>
<span className="ad-hero-unit">{s.distance ? 'km' : ''}</span>
</div>
<div className="ad-hero-label">{s.distance ? '距离' : '总时间'}</div>
</section>
<div className="ad-grid">
{[
['总时间', hms(s.duration)],
['平均心率', num(s.averageHR, 0, 'bpm')],
['平均速度', kmh(s.averageSpeed)],
['总消耗', num(s.calories, 0, 'kcal')],
['总爬升', num(s.elevationGain, 0, 'm')],
['有氧效果', num(s.trainingEffect, 1)],
].map(([label, value]) => (
<div className="ad-tile" key={label}>
<span className="ad-tile-value">{value}</span>
<span className="ad-tile-label">{label}</span>
</div>
))}
</div>
{s.trainingEffectLabel && (
<section className="sec">
<h3 className="sec-title"></h3>
<div className="ad-eval">
<div className="ad-eval-name">{s.trainingEffectLabel}</div>
<div className="ad-eval-note"></div>
</div>
</section>
)}
<Zones zones={detail.hrZones} />
{!!detail.gear.length && (
<section className="sec">
<h3 className="sec-title"></h3>
<div className="ad-gear">
{detail.gear.map((g, i) => (
<div className="ad-gear-item" key={g.uuid ?? i}>
{g.displayName ?? g.customMakeModel ?? '装备'}
</div>
))}
</div>
</section>
)}
</>
)}
{tab === 'stats' && (
<div className="ad-stats">
{statGroups(s)
.filter(([, rows]) => rows.some(([, v]) => v !== '—'))
.map(([group, rows]) => (
<section className="sec" key={group}>
<h3 className="sec-title">{group}</h3>
<div className="ad-rows">
{rows.filter(([, v]) => v !== '—').map(([label, value]) => (
<div className="ad-row" key={label}>
<span className="ad-row-label">{label}</span>
<span className="ad-row-value">{value}</span>
</div>
))}
</div>
</section>
))}
</div>
)}
{tab === 'laps' && (
detail.laps.length === 0 ? (
<p className="screen-note"></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>
</tr>
</thead>
<tbody>
{detail.laps.map((lap) => (
<tr key={lap.index}>
<th scope="row">{lap.index}</th>
<td>{hms(lap.duration)}</td>
<td className="num">{km(lap.distance)}</td>
<td className="num">{kmh(lap.averageSpeed)}</td>
<td className="num">{num(lap.averageHR, 0)}</td>
</tr>
))}
<tr className="ad-total">
<th scope="row"></th>
<td>{hms(s.duration)}</td>
<td className="num">{km(s.distance)}</td>
<td className="num">{kmh(s.averageSpeed)}</td>
<td className="num">{num(s.averageHR, 0)}</td>
</tr>
</tbody>
</table>
</div>
)
)}
{tab === 'charts' && (
chartRows.length === 0 ? (
<p className="screen-note">线</p>
) : (
<>
{hasDistance && (
<div className="segmented-row">
<span className="segmented-label"></span>
<div className="segmented">
<button className={axis === 'time' ? 'on' : ''} onClick={() => setAxis('time')}>
</button>
<button className={axis === 'distance' ? 'on' : ''} onClick={() => setAxis('distance')}>
</button>
</div>
</div>
)}
<div className="chart-grid">
{charts
.filter(([key]) => chartRows.some((r) => (r as any)[key] != null))
.map(([key, label, unit, type, slot]) => (
<Chart
key={key}
title={label}
unit={unit}
data={chartRows}
xKey="x"
type={type}
height={190}
series={[{
key, label, slot: slot as 1 | 2 | 3 | 4 | 5 | 6,
unit, decimals: key === 'speed' ? 1 : 0,
}]}
footer={axis === 'time' ? '横轴:分钟' : '横轴:公里'}
/>
))}
</div>
</>
)
)}
</Screen>
);
}
export default ActivityDetailPage;

View File

@@ -0,0 +1,84 @@
.ba-hero {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 16px;
padding: 1.6rem 1.1rem;
text-align: center;
margin-bottom: 1.2rem;
}
.ba-value {
font-size: 3.4rem;
font-weight: 700;
line-height: 1;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
letter-spacing: -0.03em;
}
.ba-unit { font-size: 1.1rem; font-weight: 500; color: var(--text-muted); margin-left: 0.25rem; letter-spacing: 0; }
.ba-delta { margin-top: 0.7rem; font-size: 0.95rem; font-weight: 600; color: var(--text-primary); }
.ba-delta.good { color: var(--status-good); }
.ba-delta.warn { color: var(--status-warning); }
.ba-actual { margin-top: 0.3rem; font-size: 0.78rem; color: var(--text-muted); }
.ba-steps {
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
background: var(--surface-1);
}
.ba-step {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
padding: 0.72rem 0.95rem;
border-bottom: 1px solid var(--border);
}
.ba-step:last-child { border-bottom: none; }
.ba-step-name {
font-size: 0.87rem;
color: var(--text-secondary);
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.ba-step-input { font-size: 0.73rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.ba-step-years {
font-size: 0.95rem;
font-weight: 620;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ba-step-total { background: var(--surface-2); }
.ba-step-total .ba-step-name { color: var(--text-primary); font-weight: 620; }
.ba-note, .ba-summary {
margin: 0.8rem 0 0;
font-size: 0.8rem;
line-height: 1.75;
color: var(--text-muted);
}
.ba-summary { margin: 0 0 0.9rem; color: var(--text-secondary); font-size: 0.86rem; }
.ba-basis { display: flex; flex-direction: column; gap: 0.6rem; }
.ba-basis-item {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.8rem 0.95rem;
}
.ba-basis-name { font-size: 0.86rem; font-weight: 620; color: var(--text-primary); margin-bottom: 0.3rem; }
.ba-basis-detail { font-size: 0.82rem; line-height: 1.7; color: var(--text-secondary); }
.ba-basis-source { margin-top: 0.35rem; font-size: 0.74rem; color: var(--text-muted); }

View File

@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { Link } from 'framework7-react';
import { apiClient, errorMessage, FitnessAge } from '../services/api';
import Screen from '../components/Screen';
import Skeleton from '../components/Skeleton';
import './BodyAge.css';
function BodyAgePage() {
const [data, setData] = useState<FitnessAge | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
apiClient
.getFitnessAge()
.then(setData)
.catch((err) => setError(errorMessage(err, '加载失败')))
.finally(() => setLoading(false));
}, []);
if (loading) {
return <Screen title="身体年龄" backLink><Skeleton count={3} /></Screen>;
}
if (error || !data) {
return (
<Screen title="身体年龄" backLink>
<div className="screen-error">{error || '加载失败'}</div>
</Screen>
);
}
const delta = data.delta ?? 0;
return (
<Screen title="身体年龄" backLink>
{data.value == null ? (
<div className="screen-empty">
<p> {data.missing.join('、')} </p>
<Link href="/settings/" className="button button-fill button-round">
</Link>
</div>
) : (
<>
<section className="ba-hero">
<div className="ba-value">
{data.value}<span className="ba-unit"></span>
</div>
<div className={`ba-delta ${delta < 0 ? 'good' : delta > 0 ? 'warn' : ''}`}>
{delta === 0
? '与实际年龄相当'
: `比实际年龄${delta < 0 ? '年轻' : '大'} ${Math.abs(delta)}`}
</div>
<div className="ba-actual"> {data.chronologicalAge} </div>
</section>
<section className="sec">
<h3 className="sec-title"></h3>
<div className="ba-steps">
{(data.steps ?? []).map((s) => (
<div className="ba-step" key={s.label}>
<div className="ba-step-name">
{s.label}
<span className="ba-step-input">{s.input}</span>
</div>
<div className="ba-step-years">
{s.kind === 'base'
? `${s.years}`
: s.years === 0
? '不修正'
: `${s.years > 0 ? '+' : ''}${s.years}`}
</div>
</div>
))}
<div className="ba-step ba-step-total">
<div className="ba-step-name"></div>
<div className="ba-step-years">{data.value} </div>
</div>
</div>
{data.clamped && (
<p className="ba-note">
±20
</p>
)}
</section>
</>
)}
<section className="sec">
<h3 className="sec-title">{data.basis.title}</h3>
<p className="ba-summary">{data.basis.summary}</p>
<div className="ba-basis">
{data.basis.steps.map((s) => (
<div className="ba-basis-item" key={s.name}>
<div className="ba-basis-name">{s.name}</div>
<div className="ba-basis-detail">{s.detail}</div>
<div className="ba-basis-source">{s.source}</div>
</div>
))}
</div>
</section>
<p className="screen-disclaimer">{data.basis.caveat}</p>
</Screen>
);
}
export default BodyAgePage;

View File

@@ -61,10 +61,24 @@
border-radius: 12px;
padding: 0.7rem 0.85rem;
transition: border-color 0.15s var(--ease), transform 0.15s var(--ease);
/* Now a <button>: reset the control defaults Framework7 would otherwise
impose (full width stretch, centred text, its own font). */
width: 100%;
text-align: left;
font-family: inherit;
cursor: pointer;
}
.act:active { transform: scale(0.99); }
.act-chevron {
color: var(--text-muted);
font-size: 1.15rem;
line-height: 1;
flex-shrink: 0;
margin-left: 0.1rem;
}
.act-icon { font-size: 1.2rem; line-height: 1; flex-shrink: 0; }
.act-body { flex: 1; min-width: 0; }
.act-name { font-size: 0.88rem; font-weight: 600; color: var(--text-primary); }

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'framework7-react';
import { Link, f7 } from 'framework7-react';
import {
apiClient, Activity, Badge, errorMessage, HealthDay, PersonalRecord,
} from '../services/api';
@@ -202,7 +202,12 @@ function ExercisePage() {
) : (
<div className="act-list">
{activities.map((a) => (
<div className="act" key={a.id}>
<button
className="act"
key={a.id}
type="button"
onClick={() => f7.views.current.router.navigate(`/activity/${a.id}/`)}
>
<span className="act-icon" aria-hidden="true">{icon(a.activity_type)}</span>
<div className="act-body">
<div className="act-name">{label(a.activity_type)}</div>
@@ -215,7 +220,8 @@ function ExercisePage() {
{a.distance ? <span>{(a.distance / 1000).toFixed(2)} km</span> : null}
{a.calories != null ? <span>{Math.round(a.calories)} kcal</span> : null}
</div>
</div>
<span className="act-chevron" aria-hidden="true"></span>
</button>
))}
</div>
)

View File

@@ -0,0 +1,51 @@
/* Body age card ---------------------------------------------------------- */
.bodyage {
display: flex;
align-items: center;
gap: 0.9rem;
width: 100%;
text-align: left;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 16px;
padding: 1rem 1.05rem;
cursor: pointer;
font-family: inherit;
transition: transform 0.15s var(--ease), background 0.15s var(--ease);
}
.bodyage:active { transform: scale(0.985); background: var(--surface-2); }
.bodyage-main { display: flex; align-items: baseline; gap: 0.2rem; }
.bodyage-value {
font-size: 2.4rem;
font-weight: 700;
line-height: 1;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.bodyage-unit { font-size: 0.9rem; color: var(--text-muted); }
.bodyage-side {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.bodyage-delta { font-size: 0.88rem; font-weight: 600; color: var(--text-primary); }
.bodyage-delta.good { color: var(--status-good); }
.bodyage-delta.warn { color: var(--status-warning); }
.bodyage-note { font-size: 0.74rem; color: var(--text-muted); }
.bodyage-empty { justify-content: space-between; }
.bodyage-missing { font-size: 0.85rem; color: var(--text-secondary); line-height: 1.6; }
@media (prefers-reduced-motion: reduce) {
.bodyage { transition: none; }
.bodyage:active { transform: none; }
}

View File

@@ -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;
/* 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 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),
},
],
},
];
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))}
/>
);
})}

View File

@@ -0,0 +1,90 @@
/* Metric detail --------------------------------------------------------- */
.md-hero {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 16px;
padding: 1.2rem 1.1rem 1rem;
margin-bottom: 1rem;
}
.md-value {
font-size: 2.6rem;
font-weight: 700;
line-height: 1;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.md-unit {
font-size: 0.95rem;
font-weight: 500;
color: var(--text-muted);
margin-left: 0.35rem;
letter-spacing: 0;
}
.md-verdict {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin: 0.7rem 0 0.55rem;
font-size: 0.86rem;
}
/* Status is icon + word, never colour alone. */
.md-tone { display: inline-flex; align-items: center; gap: 0.3rem; font-weight: 600; }
.md-tone.tone-good { color: var(--status-good); }
.md-tone.tone-warning { color: var(--status-warning); }
.md-tone.tone-serious { color: var(--status-serious); }
.md-tone.tone-critical { color: var(--status-critical); }
.md-target { color: var(--text-muted); font-size: 0.79rem; }
.md-when { margin-top: 0.6rem; font-size: 0.75rem; color: var(--text-muted); }
.md-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(78px, 1fr));
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
margin-bottom: 1.1rem;
}
.md-stat {
background: var(--surface-1);
padding: 0.7rem 0.6rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
align-items: center;
}
.md-stat-label { font-size: 0.72rem; color: var(--text-muted); }
.md-stat-value {
font-size: 1.02rem;
font-weight: 640;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.md-about {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem 1.1rem 0.4rem;
}
.md-about p {
margin: 0 0 1rem;
font-size: 0.86rem;
line-height: 1.75;
color: var(--text-secondary);
}
.md-about .sec-title { margin-bottom: 0.5rem; }
.md-bands { font-variant-numeric: tabular-nums; }
.md-source { color: var(--text-muted) !important; font-size: 0.79rem !important; }

View File

@@ -0,0 +1,209 @@
import { useEffect, useMemo, useState } from 'react';
import { apiClient, errorMessage, HealthDay, RatingBasis } from '../services/api';
import { METRICS } from '../lib/metrics';
import { classify, formatTarget, RANGES } from '../lib/ranges';
import Screen from '../components/Screen';
import Chart from '../components/charts/Chart';
import BandBar from '../components/charts/BandBar';
import Skeleton from '../components/Skeleton';
import { useCountUp } from '../lib/motion';
import './MetricDetail.css';
const WINDOWS = [7, 30, 90, 365];
const TONE_ICON: Record<string, string> = {
good: '✓', warning: '!', serious: '↓', critical: '!',
};
interface Props {
/** Framework7 passes route params to the page component. */
id?: string;
f7route?: { params: { id: string } };
}
function MetricDetailPage({ id, f7route }: Props) {
const key = id ?? f7route?.params.id ?? '';
const def = METRICS[key];
const [days, setDays] = useState<HealthDay[]>([]);
const [basis, setBasis] = useState<RatingBasis | null>(null);
const [window, setWindow] = useState(30);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
if (!def) return;
let cancelled = false;
setLoading(true);
const load = async () => {
try {
const end = new Date();
const start = new Date(end.getTime() - (window - 1) * 86400000);
const rows = await apiClient.getHealthSummary(
start.toISOString().slice(0, 10), end.toISOString().slice(0, 10)
);
if (!cancelled) setDays(rows);
} catch (err: any) {
if (!cancelled) setError(errorMessage(err, '加载失败'));
} finally {
if (!cancelled) setLoading(false);
}
};
load();
return () => { cancelled = true; };
}, [def, window]);
useEffect(() => {
apiClient.getRatingBasis().then(setBasis).catch(() => setBasis(null));
}, []);
const values = useMemo(
() => (def ? days.map(def.pick).filter((v): v is number => v != null) : []),
[days, def]
);
const latest = useMemo(() => {
if (!def) return { value: null as number | null, date: null as string | null };
for (let i = days.length - 1; i >= 0; i--) {
const v = def.pick(days[i]);
if (v != null) return { value: v, date: days[i].date };
}
return { value: null, date: null };
}, [days, def]);
const animated = useCountUp(latest.value);
if (!def) {
return (
<Screen title="指标" backLink>
<p className="screen-note"></p>
</Screen>
);
}
const verdict = def.range ? classify(def.range, latest.value) : null;
const range = def.range ? RANGES[def.range] : undefined;
const avg = values.length
? values.reduce((a, b) => a + b, 0) / values.length : null;
const max = values.length ? Math.max(...values) : null;
const min = values.length ? Math.min(...values) : null;
const inBand = range
? days.filter((d) => {
const v = def.pick(d);
return v != null && v >= range.goodFrom && v <= range.goodTo;
}).length
: null;
const fmt = (v: number | null) =>
v == null ? '—' : v.toLocaleString(undefined, {
minimumFractionDigits: 0, maximumFractionDigits: def.decimals ?? 0,
});
const rows = days.map((d) => ({ date: d.date.slice(5), value: def.pick(d) }));
const source = basis?.bands.find((b) => b.metric === def.label);
return (
<Screen title={def.label} backLink>
{loading && days.length === 0 ? (
<Skeleton count={4} />
) : error ? (
<div className="screen-error">{error}</div>
) : (
<>
<section className="md-hero">
<div className="md-value">
{latest.value == null ? '—' : fmt(animated ?? latest.value)}
{def.unit && latest.value != null && (
<span className="md-unit">{def.unit}</span>
)}
</div>
{verdict && latest.value != null && (
<div className="md-verdict">
<span className={`md-tone tone-${verdict.band.tone}`}>
<span aria-hidden="true">{TONE_ICON[verdict.band.tone]}</span>
{verdict.band.label}
</span>
<span className="md-target"> {formatTarget(verdict.range)}</span>
</div>
)}
{verdict && latest.value != null && (
<BandBar range={verdict.range} value={latest.value} />
)}
<div className="md-when">
{latest.date ? `最近记录 ${latest.date}` : '暂无数据'}
</div>
</section>
<div className="segmented-row">
<span className="segmented-label"></span>
<div className="segmented">
{WINDOWS.map((w) => (
<button
key={w}
className={w === window ? 'on' : ''}
onClick={() => setWindow(w)}
>
{w === 365 ? '1 年' : `${w}`}
</button>
))}
</div>
</div>
<div className="md-stats">
{[
['平均', avg], ['最高', max], ['最低', min],
].map(([label, value]) => (
<div className="md-stat" key={label as string}>
<span className="md-stat-label">{label}</span>
<span className="md-stat-value">{fmt(value as number | null)}</span>
</div>
))}
{inBand != null && (
<div className="md-stat">
<span className="md-stat-label"></span>
<span className="md-stat-value">{inBand}/{days.length}</span>
</div>
)}
</div>
<section className="sec">
<Chart
title={`${def.label}趋势`}
unit={def.unit}
data={rows}
type={def.cumulative ? 'bar' : 'line'}
height={220}
series={[{
key: 'value', label: def.label, slot: 1,
unit: def.unit, decimals: def.decimals,
}]}
/>
</section>
<section className="md-about">
<h3 className="sec-title"></h3>
<p>{def.about}</p>
{source && (
<>
<h3 className="sec-title"></h3>
<p className="md-bands">{source.bands}</p>
<p className="md-source">{source.source}</p>
</>
)}
</section>
<p className="screen-disclaimer">
</p>
</>
)}
</Screen>
);
}
export default MetricDetailPage;

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'framework7-react';
import { Link, Popup, Page, Navbar, NavRight, Link as F7Link } from 'framework7-react';
import { apiClient, errorMessage, HealthDay } from '../services/api';
import Chart, { Series } from '../components/charts/Chart';
import Skeleton from '../components/Skeleton';
@@ -148,6 +148,7 @@ function TrendsPage() {
const [days, setDays] = useState<HealthDay[]>([]);
const [range, setRange] = useState(365);
const [granularity, setGranularity] = useState<Granularity | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -221,7 +222,7 @@ function TrendsPage() {
GRANULARITIES.find((g) => g.id === effective)?.label.replace('每', '') ?? '';
return (
<Screen title="趋势" right={<Link href="/daily/" iconIos="f7:calendar" tooltip="每日数据" />}>
<Screen title="趋势">
<div className="segmented-row">
<span className="segmented-label"></span>
@@ -243,9 +244,41 @@ function TrendsPage() {
</div>
</div>
<section className="metric-picker">
<div className="picker-head">
<span className="control-label"></span>
<div className="trend-tools">
<button
className="picker-open"
type="button"
onClick={() => setPickerOpen(true)}
>
<span className="picker-open-label"></span>
<span className="picker-open-count">
{visible.length} / {GROUPS.length}
</span>
<span className="mcard-chevron" aria-hidden="true"></span>
</button>
<Link href="/daily/" className="picker-open picker-open-link">
<span className="picker-open-label"></span>
<span className="mcard-chevron" aria-hidden="true"></span>
</Link>
</div>
{/* A wall of fifteen chips pushed the charts off the screen and looked
like a form. The choice lives in a sheet now; the row above says what
is on without spending the space. */}
<Popup
className="picker-popup"
opened={pickerOpen}
onPopupClosed={() => setPickerOpen(false)}
>
<Page>
<Navbar title="显示指标">
<NavRight>
<F7Link popupClose></F7Link>
</NavRight>
</Navbar>
<div className="picker-body">
<div className="picker-actions">
<button className="link-button" onClick={() => setHidden(new Set())}>
@@ -257,31 +290,36 @@ function TrendsPage() {
</button>
</div>
</div>
<div className="picker-chips">
<div className="picker-rows">
{GROUPS.map((g) => {
const on = !hidden.has(g.id);
return (
<button
key={g.id}
className={`chip ${on ? 'on' : 'off'}`}
className={`picker-row ${on ? 'on' : ''}`}
onClick={() => toggle(g.id)}
aria-pressed={on}
type="button"
>
<span className="picker-row-label">{g.label}</span>
{/* A mark, not colour alone, carries the on/off state. */}
<span className="chip-mark" aria-hidden="true">{on ? '✓' : '+'}</span>
{g.label}
<span className="picker-row-mark" aria-hidden="true">
{on ? '✓' : ''}
</span>
</button>
);
})}
</div>
</section>
</div>
</Page>
</Popup>
{error && <div className="screen-error">{error}</div>}
{loading && <Skeleton count={6} variant="chart" />}
{!loading && !error && visible.length === 0 && (
<p className="screen-note"></p>
<p className="screen-note"></p>
)}
{!loading && !error && visible.length > 0 && (

View File

@@ -4,6 +4,9 @@ import TodayPage from './pages/TodayPage';
import HealthPage from './pages/HealthPage';
import DailyPage from './pages/DailyPage';
import TrendsPage from './pages/TrendsPage';
import MetricDetailPage from './pages/MetricDetailPage';
import ActivityDetailPage from './pages/ActivityDetailPage';
import BodyAgePage from './pages/BodyAgePage';
import ExercisePage from './pages/ExercisePage';
import SleepPage from './pages/SleepPage';
import SyncPage from './pages/SyncPage';
@@ -26,6 +29,9 @@ const routes: Router.RouteParameters[] = [
{ path: '/trends/', component: TrendsPage },
{ path: '/exercise/', component: ExercisePage },
{ path: '/sleep/', component: SleepPage },
{ path: '/metric/:id/', component: MetricDetailPage },
{ path: '/activity/:id/', component: ActivityDetailPage },
{ path: '/body-age/', component: BodyAgePage },
{ path: '/sync/', component: SyncPage },
{ path: '/settings/', component: SettingsPage },
{ path: '/login/', component: LoginPage },

View File

@@ -157,6 +157,98 @@ export interface ModelInfo {
default: boolean;
}
export interface UserSettings {
heightCm: number | null;
weightKg: number | null;
birthDate: string | null;
sex: 'male' | 'female' | 'other' | null;
units: 'metric' | 'imperial';
autoSync: boolean;
autoSyncMinutes: number;
/** 0 means "everything Garmin has". */
historyDays: number;
age: number | null;
bmi: number | null;
}
export interface SettingsOptions {
sexes: string[];
units: string[];
autoSyncMinutes: number[];
historyDays: number[];
}
export interface BasisStep {
name: string;
detail: string;
source: string;
}
export interface RatingBasis {
fitnessAge: {
title: string;
summary: string;
steps: BasisStep[];
caveat: string;
};
bands: Array<{ metric: string; bands: string; source: string }>;
note: string;
}
export interface FitnessAge {
value: number | null;
chronologicalAge?: number;
delta?: number;
clamped?: boolean;
steps?: Array<{ label: string; input: string; years: number; kind: string }>;
missing: string[];
basis: RatingBasis['fitnessAge'];
}
export interface AutoSyncStatus {
enabled: boolean;
intervalSeconds: number;
tickSeconds: number;
days: number;
lastRunAt: string | null;
nextRunAt: string | null;
running: boolean;
account?: {
autoSync: boolean;
intervalMinutes: number;
dueAt: string | null;
};
}
/** One activity in full. Shapes mirror Garmin's own payload, which is why the
* summary is left loosely typed — it carries dozens of optional fields that
* differ by sport. */
export interface ActivityDetail {
activityId: string;
activityName: string | null;
activityType: string | null;
summary: Record<string, number | string | null>;
laps: Array<{
index: number;
duration: number | null;
movingDuration: number | null;
distance: number | null;
averageSpeed: number | null;
maxSpeed: number | null;
calories: number | null;
averageHR: number | null;
maxHR: number | null;
elevationGain: number | null;
elevationLoss: number | null;
}>;
hrZones: Array<{ zone: number; seconds: number; lowBoundary: number | null }>;
weather: Record<string, any>;
gear: Array<Record<string, any>>;
exerciseSets: Array<Record<string, any>>;
series: Record<string, Array<number | null>>;
cached: boolean;
}
export interface TrendPoint {
date: string;
value: number;
@@ -349,6 +441,53 @@ class ApiClient {
return data;
}
// --- settings ---
async getSettings() {
const { data } = await this.client.get<UserSettings>('/settings');
return data;
}
async saveSettings(patch: Partial<UserSettings>) {
const { data } = await this.client.put<UserSettings>('/settings', patch);
return data;
}
async getSettingsOptions() {
const { data } = await this.client.get<SettingsOptions>('/settings/options');
return data;
}
async getRatingBasis() {
const { data } = await this.client.get<RatingBasis>('/settings/rating-basis');
return data;
}
async getFitnessAge() {
const { data } = await this.client.get<FitnessAge>('/health/fitness-age');
return data;
}
async getAutoSyncStatus() {
const { data } = await this.client.get<AutoSyncStatus>('/garmin/auto-sync');
return data;
}
/** Pull the last few days inline — fast enough to await, unlike a backfill. */
async syncLatest(days = 2) {
const { data } = await this.client.post<SyncResult>(
'/garmin/sync-latest', { days }
);
return data;
}
async getActivityDetail(activityId: string, refresh = false) {
const { data } = await this.client.get<ActivityDetail>(
`/garmin/activities/${activityId}/detail`,
{ params: refresh ? { refresh: 1 } : {}, timeout: 60000 }
);
return data;
}
async getBadges() {
const { data } = await this.client.get<Badge[]>('/health/badges');
return data;

View File

@@ -32,6 +32,14 @@
--series-5: #e87ba4; /* magenta */
--series-6: #008300; /* green */
/* Sequential ramp, one hue light->dark. Heart-rate zones are an ordered
scale, not categories, so they must not take categorical hues. */
--seq-1: #c2d9f7;
--seq-2: #8ab6ec;
--seq-3: #5090de;
--seq-4: #2a6fc4;
--seq-5: #17497f;
/* status — reserved, never reused as a series */
--status-good: #0ca30c;
--status-warning: #fab219;
@@ -78,6 +86,11 @@
--text-muted: #78818f;
--series-1: #3987e5;
--seq-1: #1d3f6b;
--seq-2: #2a5f9e;
--seq-3: #3987e5;
--seq-4: #6fa9ee;
--seq-5: #a8c9f6;
--series-2: #d95926;
--series-3: #199e70;
--series-4: #c98500;
@@ -112,6 +125,11 @@
--text-muted: #78818f;
--series-1: #3987e5;
--seq-1: #1d3f6b;
--seq-2: #2a5f9e;
--seq-3: #3987e5;
--seq-4: #6fa9ee;
--seq-5: #a8c9f6;
--series-2: #d95926;
--series-3: #199e70;
--series-4: #c98500;

View File

@@ -39,8 +39,9 @@
| 3.2 | Tab今日 / 健康 / 趋势 / 运动 / 设置 | 每日是趋势的子页 | ✅ |
| 3.3 | 首页改三圆环:步数 / 睡眠 / HRV | 并列三环,满环=进入参考区间 | ✅ |
| 3.4 | 交互流畅、有动画 | 数字滚动、入场揭示、`prefers-reduced-motion` | ✅ |
| 3.5 | 删掉各页右上角的半透明椭圆 | 导航栏右侧 `Link`(带 tooltip渲染出来的 | 📋 |
| 3.6 | 睡眠入口不放右上角,改为点健康页睡眠卡片进入 | 入口跟着内容走 | 📋 |
| 3.7 | 主界面切到子界面加 loading 动画 | 路由切换时的过渡指示 | 📋 |
| 3.5 | 删掉各页右上角的半透明椭圆 | 导航栏右侧 `Link`(带 tooltip渲染出来的已移除 | |
| 3.6 | 睡眠入口不放右上角,改为点健康页睡眠卡片进入 | 入口跟着内容走;每日入口同样移进趋势页内容 | ✅ |
## 四、数据展示
@@ -49,12 +50,12 @@
| 4.1 | 每日详情模块,看某天所有数据 | 每日页 | ✅ |
| 4.2 | 趋势模块7 天 / 月 / 季 / 年 周期 | 按周期取日均,桶长不同也可比 | ✅ |
| 4.3 | 趋势覆盖全部 15 组指标,可自选显示/隐藏 | 指标选择器 | ✅ |
| 4.4 | 指标选择器太丑,改成选择弹窗 | F7 Popup/Sheet 承载 | 📋 |
| 4.5 | 所有卡片可点击进入详情 | 每个指标一个详情页:历史曲线 + 参考区间 + 说明 | 📋 |
| 4.4 | 指标选择器太丑,改成选择弹窗 | 改为 F7 Popup,页面上只留一行「显示指标 n/15」 | |
| 4.5 | 所有卡片可点击进入详情 | `/metric/:id/`:大数值 + 参考区间 + 趋势图 + 统计 + 依据;新增 `lib/metrics.ts` 统一指标注册表 | ✅ 健康页完成,🚧 今日页 |
| 4.6 | 今日页左右箭头切换前一天/后一天 | | 📋 |
| 4.7 | 今日页顶部日期选择控件,可看历史任一天 | | 📋 |
| 4.8 | 点击每个运动看本次运动详情,数据全展示 | 概览/数据/分段/图表:配速、速度、计时、心率、训练效果、营养补水、温度、强度分钟、海拔、心率区间、分段表、采样曲线 | ✅ 接口完成,🚧 界面 |
| 4.9 | 健康页增加身体年龄 | 本地按公开常模推算,展示推算过程 | ✅ 接口完成,🚧 界面 |
| 4.8 | 点击每个运动看本次运动详情,数据全展示 | 概览/数据/分段/图表四个 Tab含心率区间条与时间/距离横轴切换 | ✅ |
| 4.9 | 健康页增加身体年龄 | 健康页卡片 + `/body-age/` 展示每一步推算过程与出处 | ✅ |
## 五、设置