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

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