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

@@ -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;