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:
421
client/src/pages/ActivityDetailPage.tsx
Normal file
421
client/src/pages/ActivityDetailPage.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user