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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user