feat: 补齐 Garmin 未同步的数据,并各自配上界面
审计 57 个接口后,把账号里真有数据却从未入库的部分补上。全部走同步模块, 界面只读本地库。 新增数据 - 体重与身体成分(体脂率/肌肉量/体水分/骨量/内脏脂肪/代谢年龄) - 血压(接口通,账号暂无记录) - 跑步成绩预测(5 公里 / 10 公里 / 半马 / 全马) - 爬坡分、饮水量、出汗量 → health_data 新增七列 - 全天曲线:心率 / 压力 / 身体电量 / 呼吸 / 血氧 - 挑战赛(徽章挑战与好友挑战,与一次性的徽章不同,有周期和进度) - 已配对设备 新增界面 - /body/ 身体成分:体重大数字 + BMI 分级 + 体脂肌肉曲线 + 血压表格 - /race/ 成绩预测:四个距离的预测成绩与配速,以及预测随时间的变化 - /challenges/ 挑战赛:按类型筛选,有目标的显示进度条 - /devices/ 已配对设备 - 每日页新增「全天曲线」,这是存日内采样的主要目的 - 健康页新增「身体成分」分组与「更多」入口,运动页加挑战赛与成绩预测入口 同步开销 - 日内曲线每天五个请求,14 天以内的同步顺带拉,更长的历史交给后台 「补齐详细数据」,否则一年的同步会多出约 1800 个请求 - 原来的「补齐运动详情」扩展为统一的补齐任务,分阶段上报进度 日内采样抽稀到每天 240 点:手机图表分辨不出更多,只会把行撑大。 全量 446 项测试通过。 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -129,6 +129,30 @@ export const METRICS: Record<string, MetricDef> = {
|
||||
id: 'enduranceScore', label: '耐力分', pick: (d) => d.enduranceScore,
|
||||
about: 'Garmin 由长期训练负荷与 VO₂max 推算的耐力水平,变化很慢。',
|
||||
},
|
||||
hillScore: {
|
||||
id: 'hillScore', label: '爬坡分', pick: (d) => d.hillScore,
|
||||
about: 'Garmin 根据爬坡时的输出功率与耐力评估的爬坡能力,只在有爬升的活动后更新。',
|
||||
},
|
||||
hydration: {
|
||||
id: 'hydration', label: '饮水量', unit: 'ml', cumulative: true,
|
||||
pick: (d) => d.hydrationMl,
|
||||
about: '当天记录的饮水量,需要在 Garmin Connect 或手表上手动记录。',
|
||||
},
|
||||
sweatLoss: {
|
||||
id: 'sweatLoss', label: '出汗量', unit: 'ml', cumulative: true,
|
||||
pick: (d) => d.sweatLossMl,
|
||||
about: '运动中的预估出汗量,由时长、强度与温度推算。',
|
||||
},
|
||||
weight: {
|
||||
id: 'weight', label: '体重', unit: 'kg', decimals: 1,
|
||||
pick: (d) => d.weightKg, route: '/body/',
|
||||
about: '体脂秤或手动记录的体重。',
|
||||
},
|
||||
bodyFat: {
|
||||
id: 'bodyFat', label: '体脂率', unit: '%', decimals: 1,
|
||||
pick: (d) => d.bodyFatPct, route: '/body/',
|
||||
about: '体脂秤用生物电阻抗估算,绝对值误差较大,看趋势更有意义。',
|
||||
},
|
||||
vo2max: {
|
||||
id: 'vo2max', label: 'VO₂max', unit: 'ml/kg/min', pick: (d) => d.vo2max,
|
||||
about: '最大摄氧量,心肺适能的核心指标。只在户外跑步或骑行后才会更新。',
|
||||
|
||||
190
client/src/pages/BodyPage.tsx
Normal file
190
client/src/pages/BodyPage.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'framework7-react';
|
||||
import {
|
||||
apiClient, BloodPressureReading, BodyCompositionDay, errorMessage,
|
||||
} from '../services/api';
|
||||
import Screen from '../components/Screen';
|
||||
import Chart from '../components/charts/Chart';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import { daysAgo, today as todayIso } from '../lib/day';
|
||||
import './MetricDetail.css';
|
||||
|
||||
const RANGES = [90, 180, 365, 730];
|
||||
|
||||
/** WHO adult BMI classes. Shown as words, never as a colour alone. */
|
||||
function bmiClass(bmi: number | null) {
|
||||
if (bmi == null) return null;
|
||||
if (bmi < 18.5) return { label: '偏瘦', tone: 'warning' };
|
||||
if (bmi < 25) return { label: '正常', tone: 'good' };
|
||||
if (bmi < 30) return { label: '超重', tone: 'warning' };
|
||||
return { label: '肥胖', tone: 'serious' };
|
||||
}
|
||||
|
||||
function BodyPage() {
|
||||
const [rows, setRows] = useState<BodyCompositionDay[]>([]);
|
||||
const [pressure, setPressure] = useState<BloodPressureReading[]>([]);
|
||||
const [range, setRange] = useState(365);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
apiClient
|
||||
.getBodyComposition(daysAgo(range - 1), todayIso())
|
||||
.then((r) => { if (!cancelled) setRows(r); })
|
||||
.catch((err) => { if (!cancelled) setError(errorMessage(err, '加载失败')); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [range]);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.getBloodPressure().then(setPressure).catch(() => setPressure([]));
|
||||
}, []);
|
||||
|
||||
const latest = useMemo(
|
||||
() => [...rows].reverse().find((r) => r.weightKg != null) ?? null,
|
||||
[rows]
|
||||
);
|
||||
|
||||
const chartRows = rows.map((r) => ({
|
||||
date: r.date.slice(5),
|
||||
weightKg: r.weightKg,
|
||||
bodyFatPct: r.bodyFatPct,
|
||||
muscleMassKg: r.muscleMassKg,
|
||||
}));
|
||||
|
||||
const verdict = bmiClass(latest?.bmi ?? null);
|
||||
|
||||
if (loading && rows.length === 0) {
|
||||
return <Screen title="身体成分" backLink><Skeleton count={4} /></Screen>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen title="身体成分" backLink>
|
||||
{error && <div className="screen-error">{error}</div>}
|
||||
|
||||
{rows.length === 0 && !error ? (
|
||||
<div className="screen-empty">
|
||||
<p>还没有体重记录。用体脂秤同步到 Garmin Connect,或在 Connect 里手动记录后再同步。</p>
|
||||
<Link href="/sync/" className="button button-fill button-round">去同步</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<section className="md-hero">
|
||||
<div className="md-value">
|
||||
{latest?.weightKg != null ? latest.weightKg.toFixed(1) : '—'}
|
||||
<span className="md-unit">kg</span>
|
||||
</div>
|
||||
{verdict && (
|
||||
<div className="md-verdict">
|
||||
<span className={`md-tone tone-${verdict.tone}`}>{verdict.label}</span>
|
||||
<span className="md-target">BMI {latest?.bmi?.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="md-when">
|
||||
{latest ? `最近记录 ${latest.date}` : '暂无数据'}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="segmented-row">
|
||||
<span className="segmented-label">范围</span>
|
||||
<div className="segmented">
|
||||
{RANGES.map((d) => (
|
||||
<button key={d} className={d === range ? 'on' : ''}
|
||||
onClick={() => setRange(d)}>
|
||||
{d >= 365 ? `${d / 365} 年` : `${d} 天`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{latest && (
|
||||
<div className="md-stats">
|
||||
{([
|
||||
['体脂率', latest.bodyFatPct, '%'],
|
||||
['肌肉量', latest.muscleMassKg, 'kg'],
|
||||
['体水分', latest.bodyWaterPct, '%'],
|
||||
['骨量', latest.boneMassKg, 'kg'],
|
||||
['内脏脂肪', latest.visceralFat, ''],
|
||||
['代谢年龄', latest.metabolicAge, '岁'],
|
||||
] as Array<[string, number | null, string]>)
|
||||
.filter(([, v]) => v != null)
|
||||
.map(([label, value, unit]) => (
|
||||
<div className="md-stat" key={label}>
|
||||
<span className="md-stat-label">{label}</span>
|
||||
<span className="md-stat-value">
|
||||
{value!.toFixed(1)}{unit}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="sec">
|
||||
<Chart
|
||||
title="体重"
|
||||
unit="kg"
|
||||
data={chartRows}
|
||||
type="line"
|
||||
height={210}
|
||||
series={[{ key: 'weightKg', label: '体重', slot: 1, unit: 'kg', decimals: 1 }]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{chartRows.some((r) => r.bodyFatPct != null) && (
|
||||
<section className="sec">
|
||||
<Chart
|
||||
title="体脂率与肌肉量"
|
||||
data={chartRows}
|
||||
type="line"
|
||||
height={200}
|
||||
series={[
|
||||
{ key: 'bodyFatPct', label: '体脂率', slot: 2, unit: '%', decimals: 1 },
|
||||
{ key: 'muscleMassKg', label: '肌肉量', slot: 3, unit: 'kg', decimals: 1 },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<section className="sec">
|
||||
<h3 className="sec-title">血压</h3>
|
||||
{pressure.length === 0 ? (
|
||||
<p className="screen-note">
|
||||
没有血压记录。Garmin 的血压数据只能在 Connect 里手动录入,
|
||||
或由兼容的血压计上传。
|
||||
</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pressure.map((r) => (
|
||||
<tr key={r.measuredAt}>
|
||||
<th scope="row">{r.measuredAt.slice(0, 16)}</th>
|
||||
<td className="num">{r.systolic ?? '—'}</td>
|
||||
<td className="num">{r.diastolic ?? '—'}</td>
|
||||
<td className="num">{r.pulse ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<p className="screen-disclaimer">
|
||||
体脂率等身体成分由体脂秤的生物电阻抗估算,绝对值误差较大,看趋势比看单次更有意义。
|
||||
</p>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
export default BodyPage;
|
||||
114
client/src/pages/Challenges.css
Normal file
114
client/src/pages/Challenges.css
Normal file
@@ -0,0 +1,114 @@
|
||||
.chal-list { display: grid; gap: 0.6rem; }
|
||||
|
||||
.chal {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 0.8rem 0.95rem;
|
||||
}
|
||||
|
||||
.chal-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.chal-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.chal-kind {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chal-dates {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.chal-bar {
|
||||
margin-top: 0.55rem;
|
||||
height: 6px;
|
||||
background: var(--surface-0);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chal-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
transition: width 0.5s var(--ease);
|
||||
}
|
||||
|
||||
.chal-pct {
|
||||
margin-top: 0.22rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Race predictions ---------------------------------------------------------- */
|
||||
.race-list {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: var(--surface-1);
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.race-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.race-row:last-child { border-bottom: none; }
|
||||
|
||||
.race-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.race-pace { font-size: 0.72rem; color: var(--text-muted); font-weight: 400; }
|
||||
|
||||
.race-time {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 660;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Devices ------------------------------------------------------------------- */
|
||||
.dev-list { display: grid; gap: 0.6rem; }
|
||||
|
||||
.dev {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 0.8rem 0.95rem;
|
||||
}
|
||||
|
||||
.dev-name { font-size: 0.9rem; font-weight: 620; color: var(--text-primary); }
|
||||
.dev-meta { margin-top: 0.3rem; font-size: 0.74rem; color: var(--text-muted); line-height: 1.7; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chal-fill { transition: none; }
|
||||
}
|
||||
103
client/src/pages/ChallengesPage.tsx
Normal file
103
client/src/pages/ChallengesPage.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'framework7-react';
|
||||
import { apiClient, Challenge, errorMessage } from '../services/api';
|
||||
import Screen from '../components/Screen';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './Challenges.css';
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
badge: '徽章挑战',
|
||||
adhoc: '好友挑战',
|
||||
available: '可参加',
|
||||
inprogress: '进行中',
|
||||
};
|
||||
|
||||
/** Percentage complete, if the payload carries a target and a total. */
|
||||
function progressOf(c: Challenge): number | null {
|
||||
const p = c.payload || {};
|
||||
const target = p.badgeTargetValue ?? p.targetValue ?? p.challengeTargetValue;
|
||||
const current = p.userRankValue ?? p.badgeProgressValue ?? p.currentValue;
|
||||
if (!target || current == null) return null;
|
||||
return Math.min(100, Math.round((Number(current) / Number(target)) * 100));
|
||||
}
|
||||
|
||||
function ChallengesPage() {
|
||||
const [rows, setRows] = useState<Challenge[]>([]);
|
||||
const [kind, setKind] = useState<string>('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.getChallenges()
|
||||
.then(setRows)
|
||||
.catch((err) => setError(errorMessage(err, '加载失败')))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const kinds = useMemo(
|
||||
() => ['all', ...Array.from(new Set(rows.map((r) => r.kind)))],
|
||||
[rows]
|
||||
);
|
||||
const shown = kind === 'all' ? rows : rows.filter((r) => r.kind === kind);
|
||||
|
||||
if (loading) return <Screen title="挑战赛" backLink><Skeleton count={5} /></Screen>;
|
||||
|
||||
return (
|
||||
<Screen title="挑战赛" backLink subtitle={`共 ${rows.length} 项`}>
|
||||
{error && <div className="screen-error">{error}</div>}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="screen-empty">
|
||||
<p>还没有挑战赛数据。</p>
|
||||
<Link href="/sync/" className="button button-fill button-round">去同步</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{kinds.length > 2 && (
|
||||
<div className="metric-tabs">
|
||||
{kinds.map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
className={`metric-tab ${k === kind ? 'active' : ''}`}
|
||||
onClick={() => setKind(k)}
|
||||
>
|
||||
{k === 'all' ? '全部' : KIND_LABEL[k] ?? k}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="chal-list">
|
||||
{shown.map((c, i) => {
|
||||
const pct = progressOf(c);
|
||||
return (
|
||||
<div className="chal" key={`${c.uuid}-${i}`}>
|
||||
<div className="chal-head">
|
||||
<span className="chal-name">{c.name || '未命名挑战'}</span>
|
||||
<span className="chal-kind">{KIND_LABEL[c.kind] ?? c.kind}</span>
|
||||
</div>
|
||||
{(c.startDate || c.endDate) && (
|
||||
<div className="chal-dates">
|
||||
{c.startDate} {c.endDate ? `→ ${c.endDate}` : ''}
|
||||
</div>
|
||||
)}
|
||||
{pct != null && (
|
||||
<>
|
||||
<div className="chal-bar">
|
||||
<div className="chal-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div className="chal-pct">{pct}%</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChallengesPage;
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { apiClient, Activity, errorMessage, HealthDay } from '../services/api';
|
||||
import {
|
||||
apiClient, Activity, DailySeries, errorMessage, HealthDay,
|
||||
} from '../services/api';
|
||||
import Chart from '../components/charts/Chart';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './Daily.css';
|
||||
import Screen from '../components/Screen';
|
||||
@@ -117,17 +120,22 @@ function DailyPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [onlyRecorded, setOnlyRecorded] = useState(true);
|
||||
const [series, setSeries] = useState<DailySeries>({});
|
||||
|
||||
const load = useCallback(async (target: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [summary, acts] = await Promise.all([
|
||||
const [summary, acts, curves] = await Promise.all([
|
||||
apiClient.getHealthSummary(target, target),
|
||||
apiClient.getActivities(target, target),
|
||||
// Curves are optional: a day synced before they were stored simply
|
||||
// has none, and the rest of the screen must still render.
|
||||
apiClient.getDailySeries(target).catch(() => ({} as DailySeries)),
|
||||
]);
|
||||
setDay(summary[0] ?? null);
|
||||
setActivities(acts);
|
||||
setSeries(curves);
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '加载失败'));
|
||||
} finally {
|
||||
@@ -163,6 +171,22 @@ function DailyPage() {
|
||||
});
|
||||
};
|
||||
|
||||
/* The stored curves are [timestamp, value] pairs; recharts wants rows, and
|
||||
the axis reads better as clock time than as a full timestamp. */
|
||||
const curveRows = (points: Array<[string, number]> | undefined) =>
|
||||
(points ?? []).map(([at, value]) => ({
|
||||
date: String(at).slice(11, 16),
|
||||
value,
|
||||
}));
|
||||
|
||||
const CURVES: Array<[string, string, string, 1 | 2 | 3 | 4 | 5]> = [
|
||||
['heartRate', '心率', 'bpm', 1],
|
||||
['stress', '压力', '', 2],
|
||||
['bodyBattery', '身体电量', '', 3],
|
||||
['respiration', '呼吸频率', '次/分', 4],
|
||||
['spo2', '血氧', '%', 5],
|
||||
];
|
||||
|
||||
return (
|
||||
<Screen title="每日数据" backLink>
|
||||
|
||||
@@ -245,6 +269,29 @@ function DailyPage() {
|
||||
);
|
||||
})}
|
||||
|
||||
{CURVES.some(([key]) => (series[key] ?? []).length > 0) && (
|
||||
<section className="sec">
|
||||
<h3 className="sec-title">全天曲线</h3>
|
||||
<div className="chart-grid">
|
||||
{CURVES.filter(([key]) => (series[key] ?? []).length > 0).map(
|
||||
([key, label, unit, slot]) => (
|
||||
<Chart
|
||||
key={key}
|
||||
title={label}
|
||||
unit={unit || undefined}
|
||||
data={curveRows(series[key])}
|
||||
type="area"
|
||||
height={180}
|
||||
series={[{
|
||||
key: 'value', label, slot, unit: unit || undefined,
|
||||
}]}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="sec">
|
||||
<h3 className="sec-title">
|
||||
运动记录
|
||||
|
||||
51
client/src/pages/DevicesPage.tsx
Normal file
51
client/src/pages/DevicesPage.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'framework7-react';
|
||||
import { apiClient, Device, errorMessage } from '../services/api';
|
||||
import Screen from '../components/Screen';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './Challenges.css';
|
||||
|
||||
function DevicesPage() {
|
||||
const [rows, setRows] = useState<Device[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.getDevices()
|
||||
.then(setRows)
|
||||
.catch((err) => setError(errorMessage(err, '加载失败')))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <Screen title="设备" backLink><Skeleton count={3} /></Screen>;
|
||||
|
||||
return (
|
||||
<Screen title="设备" backLink>
|
||||
{error && <div className="screen-error">{error}</div>}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="screen-empty">
|
||||
<p>还没有设备信息。</p>
|
||||
<Link href="/sync/" className="button button-fill button-round">去同步</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dev-list">
|
||||
{rows.map((d) => (
|
||||
<div className="dev" key={d.deviceId}>
|
||||
<div className="dev-name">{d.name || d.model || '未知设备'}</div>
|
||||
<div className="dev-meta">
|
||||
{d.model && <>型号 {d.model}<br /></>}
|
||||
{d.softwareVersion && <>固件 {d.softwareVersion}<br /></>}
|
||||
{d.serial && <>序列号 {d.serial}<br /></>}
|
||||
{d.lastUsedAt && <>最后同步 {d.lastUsedAt.slice(0, 16)}</>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
export default DevicesPage;
|
||||
@@ -175,6 +175,19 @@ function ExercisePage() {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="sec">
|
||||
<div className="trend-tools">
|
||||
<Link href="/challenges/" className="picker-open picker-open-link">
|
||||
<span className="picker-open-label">挑战赛</span>
|
||||
<span className="mcard-chevron" aria-hidden="true">›</span>
|
||||
</Link>
|
||||
<Link href="/race/" className="picker-open picker-open-link">
|
||||
<span className="picker-open-label">成绩预测</span>
|
||||
<span className="mcard-chevron" aria-hidden="true">›</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="metric-tabs">
|
||||
{([
|
||||
['activities', `记录 (${activities.length})`],
|
||||
|
||||
@@ -7,6 +7,7 @@ import Skeleton from '../components/Skeleton';
|
||||
import Screen from '../components/Screen';
|
||||
import { daysAgo, today as todayIso } from '../lib/day';
|
||||
import './Health.css';
|
||||
import './Settings.css';
|
||||
|
||||
const WINDOW_DAYS = 30;
|
||||
|
||||
@@ -18,6 +19,14 @@ const SECTIONS: Array<{ title: string; items: string[] }> = [
|
||||
{ title: '睡眠', items: ['sleepDuration', 'sleepQuality', 'deepShare', 'remShare'] },
|
||||
{ title: '活动', items: ['steps', 'intensityMinutes', 'floorsAscended', 'distance'] },
|
||||
{ title: '能量', items: ['caloriesBurned', 'activeCalories', 'bmrCalories', 'sedentary'] },
|
||||
{ title: '身体成分', items: ['weight', 'bodyFat', 'hydration', 'hillScore'] },
|
||||
];
|
||||
|
||||
/* Screens that are not a single metric, so they get their own entries. */
|
||||
const LINKS: Array<[string, string, string]> = [
|
||||
['/body/', '身体成分与血压', '体重、体脂、肌肉量、血压记录'],
|
||||
['/race/', '成绩预测', '5 公里到全马的预测完赛时间'],
|
||||
['/challenges/', '挑战赛', '徽章挑战与好友挑战'],
|
||||
];
|
||||
|
||||
function BodyAge({ data }: { data: FitnessAge | null }) {
|
||||
@@ -161,6 +170,21 @@ function HealthPage() {
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section className="sec">
|
||||
<h3 className="sec-title">更多</h3>
|
||||
<div className="set-rows">
|
||||
{LINKS.map(([href, label, sub]) => (
|
||||
<Link href={href} className="set-row" key={href}>
|
||||
<span className="set-label">
|
||||
{label}
|
||||
<span className="set-sub">{sub}</span>
|
||||
</span>
|
||||
<span className="set-chevron" aria-hidden="true">›</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="screen-disclaimer">
|
||||
参考区间为一般人群的定位范围,非诊断标准。如有健康疑问请咨询专业医师。
|
||||
</p>
|
||||
|
||||
117
client/src/pages/RacePage.tsx
Normal file
117
client/src/pages/RacePage.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'framework7-react';
|
||||
import { apiClient, errorMessage, RacePrediction } from '../services/api';
|
||||
import Screen from '../components/Screen';
|
||||
import Chart from '../components/charts/Chart';
|
||||
import Skeleton from '../components/Skeleton';
|
||||
import './MetricDetail.css';
|
||||
|
||||
const DISTANCES: Array<[keyof RacePrediction, string, number]> = [
|
||||
['time5k', '5 公里', 1],
|
||||
['time10k', '10 公里', 2],
|
||||
['timeHalf', '半程马拉松', 3],
|
||||
['timeMarathon', '全程马拉松', 4],
|
||||
];
|
||||
|
||||
const hms = (seconds?: number | null) => {
|
||||
if (!seconds) return '—';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||
};
|
||||
|
||||
/** Pace per kilometre for a predicted finish. */
|
||||
const pace = (seconds: number | null | undefined, km: number) => {
|
||||
if (!seconds) return '—';
|
||||
const perKm = seconds / km;
|
||||
return `${Math.floor(perKm / 60)}:${String(Math.round(perKm % 60)).padStart(2, '0')} /km`;
|
||||
};
|
||||
|
||||
const KM: Record<string, number> = {
|
||||
time5k: 5, time10k: 10, timeHalf: 21.0975, timeMarathon: 42.195,
|
||||
};
|
||||
|
||||
function RacePage() {
|
||||
const [rows, setRows] = useState<RacePrediction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.getRacePredictions()
|
||||
.then(setRows)
|
||||
.catch((err) => setError(errorMessage(err, '加载失败')))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <Screen title="成绩预测" backLink><Skeleton count={3} /></Screen>;
|
||||
|
||||
const latest = rows[rows.length - 1];
|
||||
|
||||
if (!latest) {
|
||||
return (
|
||||
<Screen title="成绩预测" backLink>
|
||||
<div className="screen-empty">
|
||||
<p>还没有成绩预测。Garmin 需要几次户外跑步才会给出预测。</p>
|
||||
<Link href="/sync/" className="button button-fill button-round">去同步</Link>
|
||||
</div>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
// Minutes rather than seconds on the axis: a marathon in seconds is a
|
||||
// five-digit number that tells the reader nothing at a glance.
|
||||
const chartRows = rows.map((r) => ({
|
||||
date: r.date.slice(5),
|
||||
time5k: r.time5k ? +(r.time5k / 60).toFixed(1) : null,
|
||||
time10k: r.time10k ? +(r.time10k / 60).toFixed(1) : null,
|
||||
timeHalf: r.timeHalf ? +(r.timeHalf / 60).toFixed(1) : null,
|
||||
timeMarathon: r.timeMarathon ? +(r.timeMarathon / 60).toFixed(1) : null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Screen title="成绩预测" backLink subtitle={latest.date}>
|
||||
{error && <div className="screen-error">{error}</div>}
|
||||
|
||||
<div className="race-list">
|
||||
{DISTANCES.map(([key, label]) => (
|
||||
<div className="race-row" key={key}>
|
||||
<div className="race-name">
|
||||
{label}
|
||||
<span className="race-pace">
|
||||
{pace(latest[key] as number | null, KM[key as string])}
|
||||
</span>
|
||||
</div>
|
||||
<div className="race-time">{hms(latest[key] as number | null)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{rows.length > 1 && (
|
||||
<section className="sec">
|
||||
<Chart
|
||||
title="预测成绩变化"
|
||||
unit="分钟"
|
||||
data={chartRows}
|
||||
type="line"
|
||||
height={220}
|
||||
series={DISTANCES.map(([key, label, slot]) => ({
|
||||
key: key as string, label, slot: slot as 1 | 2 | 3 | 4,
|
||||
unit: '分钟', decimals: 1,
|
||||
}))}
|
||||
footer="向下走表示预测成绩在变快。"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<p className="screen-disclaimer">
|
||||
预测由 Garmin 根据 VO₂max 与近期训练负荷推算,假设你按对应距离完成了针对性训练,
|
||||
实际成绩受配速策略、天气与赛道影响。
|
||||
</p>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
export default RacePage;
|
||||
@@ -261,6 +261,16 @@ function SettingsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="sec">
|
||||
<h3 className="sec-title">设备</h3>
|
||||
<div className="set-rows">
|
||||
<Link href="/devices/" className="set-row">
|
||||
<span className="set-label">已配对设备</span>
|
||||
<span className="set-chevron" aria-hidden="true">›</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{FEATURES.ai && models.length > 0 && (
|
||||
<section className="sec">
|
||||
<h3 className="sec-title">AI 模型</h3>
|
||||
|
||||
@@ -8,6 +8,10 @@ import MetricDetailPage from './pages/MetricDetailPage';
|
||||
import ActivityDetailPage from './pages/ActivityDetailPage';
|
||||
import BodyAgePage from './pages/BodyAgePage';
|
||||
import RatingBasisPage from './pages/RatingBasisPage';
|
||||
import BodyPage from './pages/BodyPage';
|
||||
import RacePage from './pages/RacePage';
|
||||
import ChallengesPage from './pages/ChallengesPage';
|
||||
import DevicesPage from './pages/DevicesPage';
|
||||
import ExercisePage from './pages/ExercisePage';
|
||||
import SleepPage from './pages/SleepPage';
|
||||
import SyncPage from './pages/SyncPage';
|
||||
@@ -34,6 +38,10 @@ const SCREENS: Router.RouteParameters[] = [
|
||||
{ path: '/activity/:id/', component: ActivityDetailPage },
|
||||
{ path: '/body-age/', component: BodyAgePage },
|
||||
{ path: '/rating-basis/', component: RatingBasisPage },
|
||||
{ path: '/body/', component: BodyPage },
|
||||
{ path: '/race/', component: RacePage },
|
||||
{ path: '/challenges/', component: ChallengesPage },
|
||||
{ path: '/devices/', component: DevicesPage },
|
||||
{ path: '/sync/', component: SyncPage },
|
||||
{ path: '/settings/', component: SettingsPage },
|
||||
{ path: '/login/', component: LoginPage },
|
||||
|
||||
@@ -58,6 +58,13 @@ export interface HealthDay {
|
||||
sleepStressAvg: number | null;
|
||||
trainingReadiness: number | null;
|
||||
vo2max: number | null;
|
||||
hillScore: number | null;
|
||||
hydrationMl: number | null;
|
||||
hydrationGoalMl: number | null;
|
||||
sweatLossMl: number | null;
|
||||
weightKg: number | null;
|
||||
bodyFatPct: number | null;
|
||||
bmi: number | null;
|
||||
enduranceScore: number | null;
|
||||
sleep: SleepDetail | null;
|
||||
}
|
||||
@@ -211,11 +218,65 @@ export interface FitnessAge {
|
||||
|
||||
export interface DetailSyncStatus {
|
||||
running: boolean;
|
||||
/** Which part of the backfill is running: 运动详情 / 每日曲线. */
|
||||
stage?: string | null;
|
||||
done: number;
|
||||
total: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface BodyCompositionDay {
|
||||
date: string;
|
||||
weightKg: number | null;
|
||||
bmi: number | null;
|
||||
bodyFatPct: number | null;
|
||||
bodyWaterPct: number | null;
|
||||
boneMassKg: number | null;
|
||||
muscleMassKg: number | null;
|
||||
physiqueRating: number | null;
|
||||
visceralFat: number | null;
|
||||
metabolicAge: number | null;
|
||||
}
|
||||
|
||||
export interface BloodPressureReading {
|
||||
measuredAt: string;
|
||||
systolic: number | null;
|
||||
diastolic: number | null;
|
||||
pulse: number | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/** Predicted finishing times, in seconds. */
|
||||
export interface RacePrediction {
|
||||
date: string;
|
||||
time5k: number | null;
|
||||
time10k: number | null;
|
||||
timeHalf: number | null;
|
||||
timeMarathon: number | null;
|
||||
}
|
||||
|
||||
/** [timestamp, value] pairs, thinned to at most 240 points per day. */
|
||||
export type DailySeries = Record<string, Array<[string, number]>>;
|
||||
|
||||
export interface Challenge {
|
||||
uuid: string;
|
||||
kind: string;
|
||||
name: string | null;
|
||||
status: string | null;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
payload: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
deviceId: string;
|
||||
name: string | null;
|
||||
model: string | null;
|
||||
serial: string | null;
|
||||
softwareVersion: string | null;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
export interface AutoSyncStatus {
|
||||
enabled: boolean;
|
||||
intervalSeconds: number;
|
||||
@@ -511,6 +572,44 @@ class ApiClient {
|
||||
return data;
|
||||
}
|
||||
|
||||
async getBodyComposition(startDate?: string, endDate?: string) {
|
||||
const { data } = await this.client.get<BodyCompositionDay[]>(
|
||||
'/health/body-composition', this.range(startDate, endDate)
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getBloodPressure() {
|
||||
const { data } = await this.client.get<BloodPressureReading[]>(
|
||||
'/health/blood-pressure'
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getRacePredictions() {
|
||||
const { data } = await this.client.get<RacePrediction[]>(
|
||||
'/health/race-predictions'
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getDailySeries(date: string) {
|
||||
const { data } = await this.client.get<DailySeries>(
|
||||
'/health/series', { params: { date } }
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
async getChallenges() {
|
||||
const { data } = await this.client.get<Challenge[]>('/health/challenges');
|
||||
return data;
|
||||
}
|
||||
|
||||
async getDevices() {
|
||||
const { data } = await this.client.get<Device[]>('/health/devices');
|
||||
return data;
|
||||
}
|
||||
|
||||
async getBadges() {
|
||||
const { data } = await this.client.get<Badge[]>('/health/badges');
|
||||
return data;
|
||||
|
||||
Reference in New Issue
Block a user