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:
@@ -234,59 +234,95 @@
|
|||||||
.metric-empty { color: var(--text-muted); font-size: 0.8rem; }
|
.metric-empty { color: var(--text-muted); font-size: 0.8rem; }
|
||||||
|
|
||||||
/* Metric picker (趋势) ----------------------------------------------------- */
|
/* Metric picker (趋势) ----------------------------------------------------- */
|
||||||
.metric-picker {
|
.trend-tools {
|
||||||
background: var(--surface-1);
|
display: grid;
|
||||||
border: 1px solid var(--border);
|
gap: 0.5rem;
|
||||||
border-radius: 14px;
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
margin-bottom: 1.1rem;
|
margin-bottom: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-head {
|
.picker-open {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 0.6rem;
|
||||||
margin-bottom: 0.6rem;
|
width: 100%;
|
||||||
|
padding: 0.7rem 0.95rem;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-actions { display: flex; gap: 0.85rem; }
|
.picker-open:active { background: var(--surface-2); }
|
||||||
|
.picker-open-label { flex: 1; font-weight: 550; }
|
||||||
|
|
||||||
|
.picker-open-count {
|
||||||
|
font-size: 0.79rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-open-link { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.picker-body { padding: 1rem; }
|
||||||
|
|
||||||
|
.picker-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
.link-button {
|
.link-button {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-size: 0.78rem;
|
font-size: 0.83rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
.picker-rows {
|
||||||
|
|
||||||
.chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.3rem;
|
|
||||||
padding: 0.32rem 0.72rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.79rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
transition: all 0.15s var(--ease);
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip.on {
|
.picker-row {
|
||||||
background: var(--accent-soft);
|
display: flex;
|
||||||
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-row:last-child { border-bottom: none; }
|
||||||
|
.picker-row.on { color: var(--text-primary); font-weight: 550; }
|
||||||
|
.picker-row:active { background: var(--surface-2); }
|
||||||
|
|
||||||
|
.picker-row-mark {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-size: 0.95rem;
|
||||||
|
width: 1em;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip.off { background: var(--surface-0); color: var(--text-muted); }
|
|
||||||
.chip:active { transform: scale(0.96); }
|
|
||||||
.chip-mark { font-size: 0.72em; opacity: 0.85; }
|
|
||||||
|
|
||||||
.chart-grid {
|
.chart-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
142
client/src/lib/metrics.ts
Normal file
142
client/src/lib/metrics.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { HealthDay } from '../services/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One registry for every metric the app can show.
|
||||||
|
*
|
||||||
|
* The label, unit and accessor used to live separately in 今日, 健康 and 趋势,
|
||||||
|
* which meant three places to edit and three chances to disagree. A card can
|
||||||
|
* now hand its id to the detail route and the detail page knows the rest.
|
||||||
|
*/
|
||||||
|
export interface MetricDef {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
unit?: string;
|
||||||
|
decimals?: number;
|
||||||
|
pick: (d: HealthDay) => number | null;
|
||||||
|
/** Key into RANGES; absent when the metric has no reference band. */
|
||||||
|
range?: string;
|
||||||
|
/** Cumulative over a period (summed), rather than a level (averaged). */
|
||||||
|
cumulative?: boolean;
|
||||||
|
/** What the number is, in one sentence. Shown on the detail screen. */
|
||||||
|
about: string;
|
||||||
|
/** A screen to open instead of the generic detail page. */
|
||||||
|
route?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const share = (part: number | null | undefined, hours: number | null) =>
|
||||||
|
part != null && hours ? (part / 3600 / hours) * 100 : null;
|
||||||
|
|
||||||
|
export const METRICS: Record<string, MetricDef> = {
|
||||||
|
steps: {
|
||||||
|
id: 'steps', label: '步数', unit: '步', range: 'steps', cumulative: true,
|
||||||
|
pick: (d) => d.steps,
|
||||||
|
about: '一天走过的步数,由手表的加速度计计数。',
|
||||||
|
},
|
||||||
|
distance: {
|
||||||
|
id: 'distance', label: '距离', unit: 'km', decimals: 2, cumulative: true,
|
||||||
|
pick: (d) => (d.distanceMeters != null ? d.distanceMeters / 1000 : null),
|
||||||
|
about: '一天移动的总距离,包含步行、跑步与骑行。',
|
||||||
|
},
|
||||||
|
caloriesBurned: {
|
||||||
|
id: 'caloriesBurned', label: '总消耗', unit: 'kcal', cumulative: true,
|
||||||
|
pick: (d) => d.caloriesBurned,
|
||||||
|
about: '基础代谢与活动消耗之和。',
|
||||||
|
},
|
||||||
|
activeCalories: {
|
||||||
|
id: 'activeCalories', label: '活动消耗', unit: 'kcal', cumulative: true,
|
||||||
|
pick: (d) => d.activeCalories,
|
||||||
|
about: '扣除基础代谢后,由活动产生的消耗。',
|
||||||
|
},
|
||||||
|
bmrCalories: {
|
||||||
|
id: 'bmrCalories', label: '基础代谢', unit: 'kcal', cumulative: true,
|
||||||
|
pick: (d) => d.bmrCalories,
|
||||||
|
about: '维持生命活动所需的最低能量,由身高体重年龄估算。',
|
||||||
|
},
|
||||||
|
floorsAscended: {
|
||||||
|
id: 'floorsAscended', label: '爬楼', unit: '层', range: 'floorsAscended',
|
||||||
|
cumulative: true, pick: (d) => d.floorsAscended,
|
||||||
|
about: '由气压计推算的爬升层数,约每 3 米记 1 层。',
|
||||||
|
},
|
||||||
|
intensityMinutes: {
|
||||||
|
id: 'intensityMinutes', label: '强度分钟', unit: '分钟',
|
||||||
|
range: 'intensityMinutes', cumulative: true, pick: (d) => d.intensityMinutes,
|
||||||
|
about: '中等及以上强度活动的时长,高强度按双倍计。',
|
||||||
|
},
|
||||||
|
sedentary: {
|
||||||
|
id: 'sedentary', label: '久坐', unit: '小时', decimals: 1, cumulative: true,
|
||||||
|
pick: (d) => (d.sedentarySeconds != null ? d.sedentarySeconds / 3600 : null),
|
||||||
|
about: '清醒但几乎没有移动的时长。',
|
||||||
|
},
|
||||||
|
heartRate: {
|
||||||
|
id: 'heartRate', label: '静息心率', unit: 'bpm', range: 'heartRate',
|
||||||
|
pick: (d) => d.heartRate,
|
||||||
|
about: '一天中最低的稳定心率,通常出现在睡眠时。规律有氧训练会让它长期下降。',
|
||||||
|
},
|
||||||
|
heartRateMax: {
|
||||||
|
id: 'heartRateMax', label: '最高心率', unit: 'bpm', pick: (d) => d.heartRateMax,
|
||||||
|
about: '当天记录到的最高心率。',
|
||||||
|
},
|
||||||
|
heartRateVariability: {
|
||||||
|
id: 'heartRateVariability', label: '心率变异性', unit: 'ms', decimals: 1,
|
||||||
|
range: 'heartRateVariability', pick: (d) => d.heartRateVariability,
|
||||||
|
about: '相邻心跳间隔的波动幅度(夜间 RMSSD)。个体差异极大,'
|
||||||
|
+ '和自己的基线比才有意义,和别人比没有意义。',
|
||||||
|
},
|
||||||
|
stress: {
|
||||||
|
id: 'stress', label: '平均压力', range: 'stress', pick: (d) => d.stress,
|
||||||
|
about: 'Garmin 由心率变异性推算的 0–100 压力值,反映自主神经的负荷。',
|
||||||
|
},
|
||||||
|
bodyBatteryHigh: {
|
||||||
|
id: 'bodyBatteryHigh', label: '身体电量峰值', range: 'bodyBatteryHigh',
|
||||||
|
pick: (d) => d.bodyBatteryHigh,
|
||||||
|
about: '当天身体电量的最高点,通常是睡醒时。恢复得越好,起点越高。',
|
||||||
|
},
|
||||||
|
spo2Avg: {
|
||||||
|
id: 'spo2Avg', label: '血氧', unit: '%', range: 'spo2Avg', pick: (d) => d.spo2Avg,
|
||||||
|
about: '血氧饱和度。腕表用光学方式测量,误差比指夹式大,看趋势为主。',
|
||||||
|
},
|
||||||
|
respirationAvg: {
|
||||||
|
id: 'respirationAvg', label: '呼吸频率', unit: '次/分', decimals: 1,
|
||||||
|
range: 'respirationAvg', pick: (d) => d.respirationAvg,
|
||||||
|
about: '每分钟呼吸次数的平均值。',
|
||||||
|
},
|
||||||
|
sleepDuration: {
|
||||||
|
id: 'sleepDuration', label: '睡眠时长', unit: '小时', decimals: 1,
|
||||||
|
range: 'sleepDuration', pick: (d) => d.sleepDuration, route: '/sleep/',
|
||||||
|
about: '实际入睡时长,不含卧床清醒的时间。',
|
||||||
|
},
|
||||||
|
sleepQuality: {
|
||||||
|
id: 'sleepQuality', label: '睡眠评分', unit: '/100', range: 'sleepQuality',
|
||||||
|
pick: (d) => d.sleepQuality, route: '/sleep/',
|
||||||
|
about: 'Garmin 综合时长、深睡比例、夜醒次数与静息心率给出的评分。',
|
||||||
|
},
|
||||||
|
deepShare: {
|
||||||
|
id: 'deepShare', label: '深睡占比', unit: '%',
|
||||||
|
pick: (d) => share(d.sleep?.deepSeconds, d.sleepDuration), route: '/sleep/',
|
||||||
|
about: '深睡时长占总睡眠的比例,成人常见范围约 13%–23%。',
|
||||||
|
},
|
||||||
|
remShare: {
|
||||||
|
id: 'remShare', label: 'REM 占比', unit: '%',
|
||||||
|
pick: (d) => share(d.sleep?.remSeconds, d.sleepDuration), route: '/sleep/',
|
||||||
|
about: 'REM 睡眠占比,成人常见范围约 20%–25%。',
|
||||||
|
},
|
||||||
|
trainingReadiness: {
|
||||||
|
id: 'trainingReadiness', label: '训练准备度', unit: '/100',
|
||||||
|
range: 'trainingReadiness', pick: (d) => d.trainingReadiness,
|
||||||
|
about: 'Garmin 综合睡眠、恢复时间、HRV 状态与近期负荷给出的当日训练建议分。',
|
||||||
|
},
|
||||||
|
enduranceScore: {
|
||||||
|
id: 'enduranceScore', label: '耐力分', pick: (d) => d.enduranceScore,
|
||||||
|
about: 'Garmin 由长期训练负荷与 VO₂max 推算的耐力水平,变化很慢。',
|
||||||
|
},
|
||||||
|
vo2max: {
|
||||||
|
id: 'vo2max', label: 'VO₂max', unit: 'ml/kg/min', pick: (d) => d.vo2max,
|
||||||
|
about: '最大摄氧量,心肺适能的核心指标。只在户外跑步或骑行后才会更新。',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const metric = (id: string): MetricDef | undefined => METRICS[id];
|
||||||
|
|
||||||
|
/** Where a card should navigate when tapped. */
|
||||||
|
export const metricHref = (id: string) =>
|
||||||
|
METRICS[id]?.route ?? `/metric/${id}/`;
|
||||||
158
client/src/pages/ActivityDetail.css
Normal file
158
client/src/pages/ActivityDetail.css
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/* Activity detail -------------------------------------------------------- */
|
||||||
|
.ad-hero {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.4rem 1.1rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-hero-main { display: inline-flex; align-items: baseline; gap: 0.3rem; }
|
||||||
|
|
||||||
|
.ad-hero-value {
|
||||||
|
font-size: 2.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-hero-unit { font-size: 1rem; color: var(--text-muted); }
|
||||||
|
.ad-hero-label { margin-top: 0.45rem; font-size: 0.78rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.ad-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-tile {
|
||||||
|
background: var(--surface-1);
|
||||||
|
padding: 0.8rem 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.22rem;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-tile-value {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
font-weight: 640;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-tile-label { font-size: 0.72rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Stats rows */
|
||||||
|
.ad-rows {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.62rem 0.95rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-row:last-child { border-bottom: none; }
|
||||||
|
.ad-row-label { font-size: 0.85rem; color: var(--text-secondary); }
|
||||||
|
.ad-row-value {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-eval {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-eval-name { font-size: 1rem; font-weight: 640; color: var(--text-primary); }
|
||||||
|
.ad-eval-note { margin-top: 0.2rem; font-size: 0.75rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.ad-gear { display: flex; flex-wrap: wrap; gap: 0.45rem; }
|
||||||
|
|
||||||
|
.ad-gear-item {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.35rem 0.8rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-total th, .ad-total td {
|
||||||
|
background: var(--surface-2);
|
||||||
|
font-weight: 640;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Heart-rate zones ------------------------------------------------------- */
|
||||||
|
.zones {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.7rem;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.95rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-name { font-size: 0.85rem; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.zone-range { font-weight: 400; font-size: 0.74rem; color: var(--text-muted); margin-left: 0.4rem; }
|
||||||
|
|
||||||
|
.zone-time {
|
||||||
|
font-size: 0.83rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-pct { color: var(--text-muted); margin-left: 0.5rem; font-size: 0.76rem; }
|
||||||
|
|
||||||
|
.zone-bar {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--surface-0);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zones are an ordered scale, so they take one hue getting darker rather
|
||||||
|
than five unrelated categorical colours. */
|
||||||
|
.zone-fill { height: 100%; border-radius: 999px; transition: width 0.5s var(--ease); }
|
||||||
|
.zone-fill.z1 { background: var(--seq-1); }
|
||||||
|
.zone-fill.z2 { background: var(--seq-2); }
|
||||||
|
.zone-fill.z3 { background: var(--seq-3); }
|
||||||
|
.zone-fill.z4 { background: var(--seq-4); }
|
||||||
|
.zone-fill.z5 { background: var(--seq-5); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.zone-fill { transition: none; }
|
||||||
|
}
|
||||||
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;
|
||||||
84
client/src/pages/BodyAge.css
Normal file
84
client/src/pages/BodyAge.css
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
.ba-hero {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.6rem 1.1rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ba-value {
|
||||||
|
font-size: 3.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ba-unit { font-size: 1.1rem; font-weight: 500; color: var(--text-muted); margin-left: 0.25rem; letter-spacing: 0; }
|
||||||
|
.ba-delta { margin-top: 0.7rem; font-size: 0.95rem; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.ba-delta.good { color: var(--status-good); }
|
||||||
|
.ba-delta.warn { color: var(--status-warning); }
|
||||||
|
.ba-actual { margin-top: 0.3rem; font-size: 0.78rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.ba-steps {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ba-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.72rem 0.95rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ba-step:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.ba-step-name {
|
||||||
|
font-size: 0.87rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ba-step-input { font-size: 0.73rem; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.ba-step-years {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 620;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ba-step-total { background: var(--surface-2); }
|
||||||
|
.ba-step-total .ba-step-name { color: var(--text-primary); font-weight: 620; }
|
||||||
|
|
||||||
|
.ba-note, .ba-summary {
|
||||||
|
margin: 0.8rem 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ba-summary { margin: 0 0 0.9rem; color: var(--text-secondary); font-size: 0.86rem; }
|
||||||
|
|
||||||
|
.ba-basis { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||||
|
|
||||||
|
.ba-basis-item {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.8rem 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ba-basis-name { font-size: 0.86rem; font-weight: 620; color: var(--text-primary); margin-bottom: 0.3rem; }
|
||||||
|
.ba-basis-detail { font-size: 0.82rem; line-height: 1.7; color: var(--text-secondary); }
|
||||||
|
.ba-basis-source { margin-top: 0.35rem; font-size: 0.74rem; color: var(--text-muted); }
|
||||||
109
client/src/pages/BodyAgePage.tsx
Normal file
109
client/src/pages/BodyAgePage.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'framework7-react';
|
||||||
|
import { apiClient, errorMessage, FitnessAge } from '../services/api';
|
||||||
|
import Screen from '../components/Screen';
|
||||||
|
import Skeleton from '../components/Skeleton';
|
||||||
|
import './BodyAge.css';
|
||||||
|
|
||||||
|
function BodyAgePage() {
|
||||||
|
const [data, setData] = useState<FitnessAge | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient
|
||||||
|
.getFitnessAge()
|
||||||
|
.then(setData)
|
||||||
|
.catch((err) => setError(errorMessage(err, '加载失败')))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Screen title="身体年龄" backLink><Skeleton count={3} /></Screen>;
|
||||||
|
}
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<Screen title="身体年龄" backLink>
|
||||||
|
<div className="screen-error">{error || '加载失败'}</div>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = data.delta ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen title="身体年龄" backLink>
|
||||||
|
{data.value == null ? (
|
||||||
|
<div className="screen-empty">
|
||||||
|
<p>还差 {data.missing.join('、')} 才能推算。</p>
|
||||||
|
<Link href="/settings/" className="button button-fill button-round">
|
||||||
|
去补全资料
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="ba-hero">
|
||||||
|
<div className="ba-value">
|
||||||
|
{data.value}<span className="ba-unit">岁</span>
|
||||||
|
</div>
|
||||||
|
<div className={`ba-delta ${delta < 0 ? 'good' : delta > 0 ? 'warn' : ''}`}>
|
||||||
|
{delta === 0
|
||||||
|
? '与实际年龄相当'
|
||||||
|
: `比实际年龄${delta < 0 ? '年轻' : '大'} ${Math.abs(delta)} 岁`}
|
||||||
|
</div>
|
||||||
|
<div className="ba-actual">实际年龄 {data.chronologicalAge} 岁</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">推算过程</h3>
|
||||||
|
<div className="ba-steps">
|
||||||
|
{(data.steps ?? []).map((s) => (
|
||||||
|
<div className="ba-step" key={s.label}>
|
||||||
|
<div className="ba-step-name">
|
||||||
|
{s.label}
|
||||||
|
<span className="ba-step-input">{s.input}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ba-step-years">
|
||||||
|
{s.kind === 'base'
|
||||||
|
? `${s.years} 岁`
|
||||||
|
: s.years === 0
|
||||||
|
? '不修正'
|
||||||
|
: `${s.years > 0 ? '+' : ''}${s.years} 岁`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="ba-step ba-step-total">
|
||||||
|
<div className="ba-step-name">身体年龄</div>
|
||||||
|
<div className="ba-step-years">{data.value} 岁</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{data.clamped && (
|
||||||
|
<p className="ba-note">
|
||||||
|
原始结果超出了实际年龄 ±20 岁,已收敛到边界——参考表以外的外推
|
||||||
|
说明的是表的边界,不是你。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">{data.basis.title}</h3>
|
||||||
|
<p className="ba-summary">{data.basis.summary}</p>
|
||||||
|
<div className="ba-basis">
|
||||||
|
{data.basis.steps.map((s) => (
|
||||||
|
<div className="ba-basis-item" key={s.name}>
|
||||||
|
<div className="ba-basis-name">{s.name}</div>
|
||||||
|
<div className="ba-basis-detail">{s.detail}</div>
|
||||||
|
<div className="ba-basis-source">来源:{s.source}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="screen-disclaimer">{data.basis.caveat}</p>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BodyAgePage;
|
||||||
@@ -61,10 +61,24 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.7rem 0.85rem;
|
padding: 0.7rem 0.85rem;
|
||||||
transition: border-color 0.15s var(--ease), transform 0.15s var(--ease);
|
transition: border-color 0.15s var(--ease), transform 0.15s var(--ease);
|
||||||
|
/* Now a <button>: reset the control defaults Framework7 would otherwise
|
||||||
|
impose (full width stretch, centred text, its own font). */
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.act:active { transform: scale(0.99); }
|
.act:active { transform: scale(0.99); }
|
||||||
|
|
||||||
|
.act-chevron {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.act-icon { font-size: 1.2rem; line-height: 1; flex-shrink: 0; }
|
.act-icon { font-size: 1.2rem; line-height: 1; flex-shrink: 0; }
|
||||||
.act-body { flex: 1; min-width: 0; }
|
.act-body { flex: 1; min-width: 0; }
|
||||||
.act-name { font-size: 0.88rem; font-weight: 600; color: var(--text-primary); }
|
.act-name { font-size: 0.88rem; font-weight: 600; color: var(--text-primary); }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link } from 'framework7-react';
|
import { Link, f7 } from 'framework7-react';
|
||||||
import {
|
import {
|
||||||
apiClient, Activity, Badge, errorMessage, HealthDay, PersonalRecord,
|
apiClient, Activity, Badge, errorMessage, HealthDay, PersonalRecord,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
@@ -202,7 +202,12 @@ function ExercisePage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="act-list">
|
<div className="act-list">
|
||||||
{activities.map((a) => (
|
{activities.map((a) => (
|
||||||
<div className="act" key={a.id}>
|
<button
|
||||||
|
className="act"
|
||||||
|
key={a.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => f7.views.current.router.navigate(`/activity/${a.id}/`)}
|
||||||
|
>
|
||||||
<span className="act-icon" aria-hidden="true">{icon(a.activity_type)}</span>
|
<span className="act-icon" aria-hidden="true">{icon(a.activity_type)}</span>
|
||||||
<div className="act-body">
|
<div className="act-body">
|
||||||
<div className="act-name">{label(a.activity_type)}</div>
|
<div className="act-name">{label(a.activity_type)}</div>
|
||||||
@@ -215,7 +220,8 @@ function ExercisePage() {
|
|||||||
{a.distance ? <span>{(a.distance / 1000).toFixed(2)} km</span> : null}
|
{a.distance ? <span>{(a.distance / 1000).toFixed(2)} km</span> : null}
|
||||||
{a.calories != null ? <span>{Math.round(a.calories)} kcal</span> : null}
|
{a.calories != null ? <span>{Math.round(a.calories)} kcal</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<span className="act-chevron" aria-hidden="true">›</span>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
51
client/src/pages/Health.css
Normal file
51
client/src/pages/Health.css
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/* Body age card ---------------------------------------------------------- */
|
||||||
|
.bodyage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1rem 1.05rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: transform 0.15s var(--ease), background 0.15s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodyage:active { transform: scale(0.985); background: var(--surface-2); }
|
||||||
|
|
||||||
|
.bodyage-main { display: flex; align-items: baseline; gap: 0.2rem; }
|
||||||
|
|
||||||
|
.bodyage-value {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodyage-unit { font-size: 0.9rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.bodyage-side {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bodyage-delta { font-size: 0.88rem; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.bodyage-delta.good { color: var(--status-good); }
|
||||||
|
.bodyage-delta.warn { color: var(--status-warning); }
|
||||||
|
.bodyage-note { font-size: 0.74rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.bodyage-empty { justify-content: space-between; }
|
||||||
|
.bodyage-missing { font-size: 0.85rem; color: var(--text-secondary); line-height: 1.6; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bodyage { transition: none; }
|
||||||
|
.bodyage:active { transform: none; }
|
||||||
|
}
|
||||||
@@ -1,94 +1,72 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'framework7-react';
|
import { Link, f7 } from 'framework7-react';
|
||||||
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
import { apiClient, errorMessage, FitnessAge, HealthDay } from '../services/api';
|
||||||
|
import { METRICS, metricHref } from '../lib/metrics';
|
||||||
import MetricCard from '../components/charts/MetricCard';
|
import MetricCard from '../components/charts/MetricCard';
|
||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
import Screen from '../components/Screen';
|
import Screen from '../components/Screen';
|
||||||
|
import './Health.css';
|
||||||
|
|
||||||
const WINDOW_DAYS = 30;
|
const WINDOW_DAYS = 30;
|
||||||
|
|
||||||
interface Item {
|
/* Sections name metric ids; the labels, units and accessors come from the
|
||||||
metric?: string;
|
registry so 今日 / 健康 / 趋势 cannot disagree about what a metric is. */
|
||||||
label: string;
|
const SECTIONS: Array<{ title: string; items: string[] }> = [
|
||||||
pick: (d: HealthDay) => number | null;
|
{ title: '身体指标', items: ['heartRate', 'heartRateVariability', 'respirationAvg', 'spo2Avg'] },
|
||||||
unit?: string;
|
{ title: '恢复', items: ['bodyBatteryHigh', 'stress', 'trainingReadiness', 'enduranceScore'] },
|
||||||
decimals?: number;
|
{ title: '睡眠', items: ['sleepDuration', 'sleepQuality', 'deepShare', 'remShare'] },
|
||||||
detail?: (d: HealthDay) => string | undefined;
|
{ title: '活动', items: ['steps', 'intensityMinutes', 'floorsAscended', 'distance'] },
|
||||||
}
|
{ title: '能量', items: ['caloriesBurned', 'activeCalories', 'bmrCalories', 'sedentary'] },
|
||||||
|
|
||||||
const SECTIONS: Array<{ title: string; items: Item[] }> = [
|
|
||||||
{
|
|
||||||
title: '身体指标',
|
|
||||||
items: [
|
|
||||||
{ metric: 'heartRate', label: '静息心率', pick: (d) => d.heartRate, unit: 'bpm' },
|
|
||||||
{
|
|
||||||
metric: 'heartRateVariability', label: '心率变异性',
|
|
||||||
pick: (d) => d.heartRateVariability, unit: 'ms', decimals: 1,
|
|
||||||
},
|
|
||||||
{ metric: 'respirationAvg', label: '呼吸频率', pick: (d) => d.respirationAvg, unit: '次/分', decimals: 1 },
|
|
||||||
{ metric: 'spo2Avg', label: '血氧', pick: (d) => d.spo2Avg, unit: '%' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '恢复',
|
|
||||||
items: [
|
|
||||||
{ metric: 'bodyBatteryHigh', label: '身体电量峰值', pick: (d) => d.bodyBatteryHigh },
|
|
||||||
{ metric: 'stress', label: '平均压力', pick: (d) => d.stress },
|
|
||||||
{ metric: 'trainingReadiness', label: '训练准备度', pick: (d) => d.trainingReadiness, unit: '/100' },
|
|
||||||
{ label: '耐力分', pick: (d) => d.enduranceScore },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '睡眠',
|
|
||||||
items: [
|
|
||||||
{ metric: 'sleepDuration', label: '睡眠时长', pick: (d) => d.sleepDuration, unit: '小时', decimals: 1 },
|
|
||||||
{ metric: 'sleepQuality', label: '睡眠评分', pick: (d) => d.sleepQuality, unit: '/100' },
|
|
||||||
{
|
|
||||||
label: '深睡占比', unit: '%',
|
|
||||||
pick: (d) =>
|
|
||||||
d.sleep?.deepSeconds != null && d.sleepDuration
|
|
||||||
? (d.sleep.deepSeconds / 3600 / d.sleepDuration) * 100
|
|
||||||
: null,
|
|
||||||
decimals: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'REM 占比', unit: '%',
|
|
||||||
pick: (d) =>
|
|
||||||
d.sleep?.remSeconds != null && d.sleepDuration
|
|
||||||
? (d.sleep.remSeconds / 3600 / d.sleepDuration) * 100
|
|
||||||
: null,
|
|
||||||
decimals: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '活动',
|
|
||||||
items: [
|
|
||||||
{ metric: 'steps', label: '步数', pick: (d) => d.steps, unit: '步' },
|
|
||||||
{ metric: 'intensityMinutes', label: '强度分钟', pick: (d) => d.intensityMinutes, unit: '分钟' },
|
|
||||||
{ metric: 'floorsAscended', label: '爬楼', pick: (d) => d.floorsAscended, unit: '层' },
|
|
||||||
{
|
|
||||||
label: '距离', unit: 'km', decimals: 2,
|
|
||||||
pick: (d) => (d.distanceMeters != null ? d.distanceMeters / 1000 : null),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '能量',
|
|
||||||
items: [
|
|
||||||
{ label: '总消耗', pick: (d) => d.caloriesBurned, unit: 'kcal' },
|
|
||||||
{ label: '活动消耗', pick: (d) => d.activeCalories, unit: 'kcal' },
|
|
||||||
{ label: '基础代谢', pick: (d) => d.bmrCalories, unit: 'kcal' },
|
|
||||||
{
|
|
||||||
label: '久坐', unit: '小时', decimals: 1,
|
|
||||||
pick: (d) => (d.sedentarySeconds != null ? d.sedentarySeconds / 3600 : null),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function BodyAge({ data }: { data: FitnessAge | null }) {
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const go = () => f7.views.current.router.navigate('/body-age/');
|
||||||
|
|
||||||
|
if (data.value == null) {
|
||||||
|
return (
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">身体年龄</h3>
|
||||||
|
<button className="bodyage bodyage-empty" onClick={go} type="button">
|
||||||
|
<span className="bodyage-missing">
|
||||||
|
还差 {data.missing.join('、')} 才能推算
|
||||||
|
</span>
|
||||||
|
<span className="mcard-chevron" aria-hidden="true">›</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = data.delta ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="sec">
|
||||||
|
<h3 className="sec-title">身体年龄</h3>
|
||||||
|
<button className="bodyage" onClick={go} type="button">
|
||||||
|
<div className="bodyage-main">
|
||||||
|
<span className="bodyage-value">{data.value}</span>
|
||||||
|
<span className="bodyage-unit">岁</span>
|
||||||
|
</div>
|
||||||
|
<div className="bodyage-side">
|
||||||
|
<span className={`bodyage-delta ${delta < 0 ? 'good' : delta > 0 ? 'warn' : ''}`}>
|
||||||
|
{delta === 0
|
||||||
|
? '与实际年龄相当'
|
||||||
|
: `比实际年龄${delta < 0 ? '年轻' : '大'} ${Math.abs(delta)} 岁`}
|
||||||
|
</span>
|
||||||
|
<span className="bodyage-note">
|
||||||
|
实际 {data.chronologicalAge} 岁 · 看推算过程
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="mcard-chevron" aria-hidden="true">›</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HealthPage() {
|
function HealthPage() {
|
||||||
const [days, setDays] = useState<HealthDay[]>([]);
|
const [days, setDays] = useState<HealthDay[]>([]);
|
||||||
|
const [bodyAge, setBodyAge] = useState<FitnessAge | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
@@ -110,12 +88,14 @@ function HealthPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
|
// Body age is a separate, optional read: an empty profile must not stop
|
||||||
|
// the rest of the page from rendering.
|
||||||
|
apiClient.getFitnessAge().then(setBodyAge).catch(() => setBodyAge(null));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间" right={<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />}>
|
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||||
<h2>健康</h2>
|
|
||||||
<Skeleton count={8} />
|
<Skeleton count={8} />
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
@@ -123,8 +103,7 @@ function HealthPage() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间" right={<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />}>
|
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||||
<h2>健康</h2>
|
|
||||||
<div className="screen-error">{error}</div>
|
<div className="screen-error">{error}</div>
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
@@ -132,11 +111,12 @@ function HealthPage() {
|
|||||||
|
|
||||||
if (days.length === 0) {
|
if (days.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间" right={<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />}>
|
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||||
<h2>健康</h2>
|
|
||||||
<div className="screen-empty">
|
<div className="screen-empty">
|
||||||
<p>还没有任何健康数据。</p>
|
<p>还没有任何健康数据。</p>
|
||||||
<Link href="/sync" className="btn btn-primary">去同步 Garmin 数据</Link>
|
<Link href="/sync/" className="button button-fill button-round">
|
||||||
|
去同步 Garmin 数据
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
@@ -149,29 +129,33 @@ function HealthPage() {
|
|||||||
const v = pick(days[i]);
|
const v = pick(days[i]);
|
||||||
if (v != null) return { value: v, date: days[i].date };
|
if (v != null) return { value: v, date: days[i].date };
|
||||||
}
|
}
|
||||||
return { value: null, date: null };
|
return { value: null as number | null, date: null as string | null };
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen title="健康" subtitle="每项指标的最新值与参考区间" right={<Link href="/sleep/" iconIos="f7:moon_stars" tooltip="睡眠" />}>
|
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
|
||||||
|
<BodyAge data={bodyAge} />
|
||||||
|
|
||||||
{SECTIONS.map((section) => (
|
{SECTIONS.map((section) => (
|
||||||
<section className="sec" key={section.title}>
|
<section className="sec" key={section.title}>
|
||||||
<h3 className="sec-title">{section.title}</h3>
|
<h3 className="sec-title">{section.title}</h3>
|
||||||
<div className="mcard-grid">
|
<div className="mcard-grid">
|
||||||
{section.items.map((item) => {
|
{section.items.map((id) => {
|
||||||
const { value, date } = latest(item.pick);
|
const def = METRICS[id];
|
||||||
|
if (!def) return null;
|
||||||
|
const { value, date } = latest(def.pick);
|
||||||
const stale = date != null && date !== days[days.length - 1].date;
|
const stale = date != null && date !== days[days.length - 1].date;
|
||||||
return (
|
return (
|
||||||
<MetricCard
|
<MetricCard
|
||||||
key={item.label}
|
key={id}
|
||||||
metric={item.metric}
|
metric={def.range}
|
||||||
label={item.label}
|
label={def.label}
|
||||||
value={value}
|
value={value}
|
||||||
unit={item.unit}
|
unit={def.unit}
|
||||||
decimals={item.decimals}
|
decimals={def.decimals}
|
||||||
trend={days.map(item.pick)}
|
trend={days.map(def.pick)}
|
||||||
detail={stale ? `最近记录 ${date!.slice(5)}` : undefined}
|
detail={stale ? `最近记录 ${date!.slice(5)}` : undefined}
|
||||||
|
onClick={() => f7.views.current.router.navigate(metricHref(id))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
90
client/src/pages/MetricDetail.css
Normal file
90
client/src/pages/MetricDetail.css
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/* Metric detail --------------------------------------------------------- */
|
||||||
|
.md-hero {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.2rem 1.1rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-value {
|
||||||
|
font-size: 2.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-unit {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-verdict {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 0.7rem 0 0.55rem;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status is icon + word, never colour alone. */
|
||||||
|
.md-tone { display: inline-flex; align-items: center; gap: 0.3rem; font-weight: 600; }
|
||||||
|
.md-tone.tone-good { color: var(--status-good); }
|
||||||
|
.md-tone.tone-warning { color: var(--status-warning); }
|
||||||
|
.md-tone.tone-serious { color: var(--status-serious); }
|
||||||
|
.md-tone.tone-critical { color: var(--status-critical); }
|
||||||
|
|
||||||
|
.md-target { color: var(--text-muted); font-size: 0.79rem; }
|
||||||
|
.md-when { margin-top: 0.6rem; font-size: 0.75rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.md-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(78px, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-stat {
|
||||||
|
background: var(--surface-1);
|
||||||
|
padding: 0.7rem 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-stat-label { font-size: 0.72rem; color: var(--text-muted); }
|
||||||
|
.md-stat-value {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
font-weight: 640;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-about {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1rem 1.1rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-about p {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-about .sec-title { margin-bottom: 0.5rem; }
|
||||||
|
.md-bands { font-variant-numeric: tabular-nums; }
|
||||||
|
.md-source { color: var(--text-muted) !important; font-size: 0.79rem !important; }
|
||||||
209
client/src/pages/MetricDetailPage.tsx
Normal file
209
client/src/pages/MetricDetailPage.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { apiClient, errorMessage, HealthDay, RatingBasis } from '../services/api';
|
||||||
|
import { METRICS } from '../lib/metrics';
|
||||||
|
import { classify, formatTarget, RANGES } from '../lib/ranges';
|
||||||
|
import Screen from '../components/Screen';
|
||||||
|
import Chart from '../components/charts/Chart';
|
||||||
|
import BandBar from '../components/charts/BandBar';
|
||||||
|
import Skeleton from '../components/Skeleton';
|
||||||
|
import { useCountUp } from '../lib/motion';
|
||||||
|
import './MetricDetail.css';
|
||||||
|
|
||||||
|
const WINDOWS = [7, 30, 90, 365];
|
||||||
|
|
||||||
|
const TONE_ICON: Record<string, string> = {
|
||||||
|
good: '✓', warning: '!', serious: '↓', critical: '!',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Framework7 passes route params to the page component. */
|
||||||
|
id?: string;
|
||||||
|
f7route?: { params: { id: string } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricDetailPage({ id, f7route }: Props) {
|
||||||
|
const key = id ?? f7route?.params.id ?? '';
|
||||||
|
const def = METRICS[key];
|
||||||
|
|
||||||
|
const [days, setDays] = useState<HealthDay[]>([]);
|
||||||
|
const [basis, setBasis] = useState<RatingBasis | null>(null);
|
||||||
|
const [window, setWindow] = useState(30);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!def) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const end = new Date();
|
||||||
|
const start = new Date(end.getTime() - (window - 1) * 86400000);
|
||||||
|
const rows = await apiClient.getHealthSummary(
|
||||||
|
start.toISOString().slice(0, 10), end.toISOString().slice(0, 10)
|
||||||
|
);
|
||||||
|
if (!cancelled) setDays(rows);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!cancelled) setError(errorMessage(err, '加载失败'));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [def, window]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.getRatingBasis().then(setBasis).catch(() => setBasis(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const values = useMemo(
|
||||||
|
() => (def ? days.map(def.pick).filter((v): v is number => v != null) : []),
|
||||||
|
[days, def]
|
||||||
|
);
|
||||||
|
|
||||||
|
const latest = useMemo(() => {
|
||||||
|
if (!def) return { value: null as number | null, date: null as string | null };
|
||||||
|
for (let i = days.length - 1; i >= 0; i--) {
|
||||||
|
const v = def.pick(days[i]);
|
||||||
|
if (v != null) return { value: v, date: days[i].date };
|
||||||
|
}
|
||||||
|
return { value: null, date: null };
|
||||||
|
}, [days, def]);
|
||||||
|
|
||||||
|
const animated = useCountUp(latest.value);
|
||||||
|
|
||||||
|
if (!def) {
|
||||||
|
return (
|
||||||
|
<Screen title="指标" backLink>
|
||||||
|
<p className="screen-note">没有这个指标。</p>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verdict = def.range ? classify(def.range, latest.value) : null;
|
||||||
|
const range = def.range ? RANGES[def.range] : undefined;
|
||||||
|
|
||||||
|
const avg = values.length
|
||||||
|
? values.reduce((a, b) => a + b, 0) / values.length : null;
|
||||||
|
const max = values.length ? Math.max(...values) : null;
|
||||||
|
const min = values.length ? Math.min(...values) : null;
|
||||||
|
const inBand = range
|
||||||
|
? days.filter((d) => {
|
||||||
|
const v = def.pick(d);
|
||||||
|
return v != null && v >= range.goodFrom && v <= range.goodTo;
|
||||||
|
}).length
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const fmt = (v: number | null) =>
|
||||||
|
v == null ? '—' : v.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 0, maximumFractionDigits: def.decimals ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = days.map((d) => ({ date: d.date.slice(5), value: def.pick(d) }));
|
||||||
|
const source = basis?.bands.find((b) => b.metric === def.label);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen title={def.label} backLink>
|
||||||
|
{loading && days.length === 0 ? (
|
||||||
|
<Skeleton count={4} />
|
||||||
|
) : error ? (
|
||||||
|
<div className="screen-error">{error}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="md-hero">
|
||||||
|
<div className="md-value">
|
||||||
|
{latest.value == null ? '—' : fmt(animated ?? latest.value)}
|
||||||
|
{def.unit && latest.value != null && (
|
||||||
|
<span className="md-unit">{def.unit}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{verdict && latest.value != null && (
|
||||||
|
<div className="md-verdict">
|
||||||
|
<span className={`md-tone tone-${verdict.band.tone}`}>
|
||||||
|
<span aria-hidden="true">{TONE_ICON[verdict.band.tone]}</span>
|
||||||
|
{verdict.band.label}
|
||||||
|
</span>
|
||||||
|
<span className="md-target">参考 {formatTarget(verdict.range)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verdict && latest.value != null && (
|
||||||
|
<BandBar range={verdict.range} value={latest.value} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="md-when">
|
||||||
|
{latest.date ? `最近记录 ${latest.date}` : '暂无数据'}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="segmented-row">
|
||||||
|
<span className="segmented-label">范围</span>
|
||||||
|
<div className="segmented">
|
||||||
|
{WINDOWS.map((w) => (
|
||||||
|
<button
|
||||||
|
key={w}
|
||||||
|
className={w === window ? 'on' : ''}
|
||||||
|
onClick={() => setWindow(w)}
|
||||||
|
>
|
||||||
|
{w === 365 ? '1 年' : `${w} 天`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md-stats">
|
||||||
|
{[
|
||||||
|
['平均', avg], ['最高', max], ['最低', min],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<div className="md-stat" key={label as string}>
|
||||||
|
<span className="md-stat-label">{label}</span>
|
||||||
|
<span className="md-stat-value">{fmt(value as number | null)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{inBand != null && (
|
||||||
|
<div className="md-stat">
|
||||||
|
<span className="md-stat-label">达标天数</span>
|
||||||
|
<span className="md-stat-value">{inBand}/{days.length}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="sec">
|
||||||
|
<Chart
|
||||||
|
title={`${def.label}趋势`}
|
||||||
|
unit={def.unit}
|
||||||
|
data={rows}
|
||||||
|
type={def.cumulative ? 'bar' : 'line'}
|
||||||
|
height={220}
|
||||||
|
series={[{
|
||||||
|
key: 'value', label: def.label, slot: 1,
|
||||||
|
unit: def.unit, decimals: def.decimals,
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="md-about">
|
||||||
|
<h3 className="sec-title">这个指标是什么</h3>
|
||||||
|
<p>{def.about}</p>
|
||||||
|
|
||||||
|
{source && (
|
||||||
|
<>
|
||||||
|
<h3 className="sec-title">评分依据</h3>
|
||||||
|
<p className="md-bands">{source.bands}</p>
|
||||||
|
<p className="md-source">来源:{source.source}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="screen-disclaimer">
|
||||||
|
参考区间为一般人群的定位范围,非诊断标准。如有健康疑问请咨询专业医师。
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MetricDetailPage;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link } from 'framework7-react';
|
import { Link, Popup, Page, Navbar, NavRight, Link as F7Link } from 'framework7-react';
|
||||||
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
import { apiClient, errorMessage, HealthDay } from '../services/api';
|
||||||
import Chart, { Series } from '../components/charts/Chart';
|
import Chart, { Series } from '../components/charts/Chart';
|
||||||
import Skeleton from '../components/Skeleton';
|
import Skeleton from '../components/Skeleton';
|
||||||
@@ -148,6 +148,7 @@ function TrendsPage() {
|
|||||||
const [days, setDays] = useState<HealthDay[]>([]);
|
const [days, setDays] = useState<HealthDay[]>([]);
|
||||||
const [range, setRange] = useState(365);
|
const [range, setRange] = useState(365);
|
||||||
const [granularity, setGranularity] = useState<Granularity | null>(null);
|
const [granularity, setGranularity] = useState<Granularity | null>(null);
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
@@ -221,7 +222,7 @@ function TrendsPage() {
|
|||||||
GRANULARITIES.find((g) => g.id === effective)?.label.replace('每', '') ?? '';
|
GRANULARITIES.find((g) => g.id === effective)?.label.replace('每', '') ?? '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen title="趋势" right={<Link href="/daily/" iconIos="f7:calendar" tooltip="每日数据" />}>
|
<Screen title="趋势">
|
||||||
|
|
||||||
<div className="segmented-row">
|
<div className="segmented-row">
|
||||||
<span className="segmented-label">范围</span>
|
<span className="segmented-label">范围</span>
|
||||||
@@ -243,9 +244,41 @@ function TrendsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="metric-picker">
|
<div className="trend-tools">
|
||||||
<div className="picker-head">
|
<button
|
||||||
<span className="control-label">显示</span>
|
className="picker-open"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
>
|
||||||
|
<span className="picker-open-label">显示指标</span>
|
||||||
|
<span className="picker-open-count">
|
||||||
|
{visible.length} / {GROUPS.length}
|
||||||
|
</span>
|
||||||
|
<span className="mcard-chevron" aria-hidden="true">›</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Link href="/daily/" className="picker-open picker-open-link">
|
||||||
|
<span className="picker-open-label">每日数据</span>
|
||||||
|
<span className="mcard-chevron" aria-hidden="true">›</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* A wall of fifteen chips pushed the charts off the screen and looked
|
||||||
|
like a form. The choice lives in a sheet now; the row above says what
|
||||||
|
is on without spending the space. */}
|
||||||
|
<Popup
|
||||||
|
className="picker-popup"
|
||||||
|
opened={pickerOpen}
|
||||||
|
onPopupClosed={() => setPickerOpen(false)}
|
||||||
|
>
|
||||||
|
<Page>
|
||||||
|
<Navbar title="显示指标">
|
||||||
|
<NavRight>
|
||||||
|
<F7Link popupClose>完成</F7Link>
|
||||||
|
</NavRight>
|
||||||
|
</Navbar>
|
||||||
|
|
||||||
|
<div className="picker-body">
|
||||||
<div className="picker-actions">
|
<div className="picker-actions">
|
||||||
<button className="link-button" onClick={() => setHidden(new Set())}>
|
<button className="link-button" onClick={() => setHidden(new Set())}>
|
||||||
全选
|
全选
|
||||||
@@ -257,31 +290,36 @@ function TrendsPage() {
|
|||||||
全不选
|
全不选
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="picker-chips">
|
<div className="picker-rows">
|
||||||
{GROUPS.map((g) => {
|
{GROUPS.map((g) => {
|
||||||
const on = !hidden.has(g.id);
|
const on = !hidden.has(g.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={g.id}
|
key={g.id}
|
||||||
className={`chip ${on ? 'on' : 'off'}`}
|
className={`picker-row ${on ? 'on' : ''}`}
|
||||||
onClick={() => toggle(g.id)}
|
onClick={() => toggle(g.id)}
|
||||||
aria-pressed={on}
|
aria-pressed={on}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
|
<span className="picker-row-label">{g.label}</span>
|
||||||
{/* A mark, not colour alone, carries the on/off state. */}
|
{/* A mark, not colour alone, carries the on/off state. */}
|
||||||
<span className="chip-mark" aria-hidden="true">{on ? '✓' : '+'}</span>
|
<span className="picker-row-mark" aria-hidden="true">
|
||||||
{g.label}
|
{on ? '✓' : ''}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
</Page>
|
||||||
|
</Popup>
|
||||||
|
|
||||||
{error && <div className="screen-error">{error}</div>}
|
{error && <div className="screen-error">{error}</div>}
|
||||||
{loading && <Skeleton count={6} variant="chart" />}
|
{loading && <Skeleton count={6} variant="chart" />}
|
||||||
|
|
||||||
{!loading && !error && visible.length === 0 && (
|
{!loading && !error && visible.length === 0 && (
|
||||||
<p className="screen-note">所有指标都已隐藏,点上面的标签重新显示。</p>
|
<p className="screen-note">所有指标都已隐藏,点上面的「显示指标」重新打开。</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && visible.length > 0 && (
|
{!loading && !error && visible.length > 0 && (
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import TodayPage from './pages/TodayPage';
|
|||||||
import HealthPage from './pages/HealthPage';
|
import HealthPage from './pages/HealthPage';
|
||||||
import DailyPage from './pages/DailyPage';
|
import DailyPage from './pages/DailyPage';
|
||||||
import TrendsPage from './pages/TrendsPage';
|
import TrendsPage from './pages/TrendsPage';
|
||||||
|
import MetricDetailPage from './pages/MetricDetailPage';
|
||||||
|
import ActivityDetailPage from './pages/ActivityDetailPage';
|
||||||
|
import BodyAgePage from './pages/BodyAgePage';
|
||||||
import ExercisePage from './pages/ExercisePage';
|
import ExercisePage from './pages/ExercisePage';
|
||||||
import SleepPage from './pages/SleepPage';
|
import SleepPage from './pages/SleepPage';
|
||||||
import SyncPage from './pages/SyncPage';
|
import SyncPage from './pages/SyncPage';
|
||||||
@@ -26,6 +29,9 @@ const routes: Router.RouteParameters[] = [
|
|||||||
{ path: '/trends/', component: TrendsPage },
|
{ path: '/trends/', component: TrendsPage },
|
||||||
{ path: '/exercise/', component: ExercisePage },
|
{ path: '/exercise/', component: ExercisePage },
|
||||||
{ path: '/sleep/', component: SleepPage },
|
{ path: '/sleep/', component: SleepPage },
|
||||||
|
{ path: '/metric/:id/', component: MetricDetailPage },
|
||||||
|
{ path: '/activity/:id/', component: ActivityDetailPage },
|
||||||
|
{ path: '/body-age/', component: BodyAgePage },
|
||||||
{ path: '/sync/', component: SyncPage },
|
{ path: '/sync/', component: SyncPage },
|
||||||
{ path: '/settings/', component: SettingsPage },
|
{ path: '/settings/', component: SettingsPage },
|
||||||
{ path: '/login/', component: LoginPage },
|
{ path: '/login/', component: LoginPage },
|
||||||
|
|||||||
@@ -157,6 +157,98 @@ export interface ModelInfo {
|
|||||||
default: boolean;
|
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 {
|
export interface TrendPoint {
|
||||||
date: string;
|
date: string;
|
||||||
value: number;
|
value: number;
|
||||||
@@ -349,6 +441,53 @@ class ApiClient {
|
|||||||
return data;
|
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() {
|
async getBadges() {
|
||||||
const { data } = await this.client.get<Badge[]>('/health/badges');
|
const { data } = await this.client.get<Badge[]>('/health/badges');
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -32,6 +32,14 @@
|
|||||||
--series-5: #e87ba4; /* magenta */
|
--series-5: #e87ba4; /* magenta */
|
||||||
--series-6: #008300; /* green */
|
--series-6: #008300; /* green */
|
||||||
|
|
||||||
|
/* Sequential ramp, one hue light->dark. Heart-rate zones are an ordered
|
||||||
|
scale, not categories, so they must not take categorical hues. */
|
||||||
|
--seq-1: #c2d9f7;
|
||||||
|
--seq-2: #8ab6ec;
|
||||||
|
--seq-3: #5090de;
|
||||||
|
--seq-4: #2a6fc4;
|
||||||
|
--seq-5: #17497f;
|
||||||
|
|
||||||
/* status — reserved, never reused as a series */
|
/* status — reserved, never reused as a series */
|
||||||
--status-good: #0ca30c;
|
--status-good: #0ca30c;
|
||||||
--status-warning: #fab219;
|
--status-warning: #fab219;
|
||||||
@@ -78,6 +86,11 @@
|
|||||||
--text-muted: #78818f;
|
--text-muted: #78818f;
|
||||||
|
|
||||||
--series-1: #3987e5;
|
--series-1: #3987e5;
|
||||||
|
--seq-1: #1d3f6b;
|
||||||
|
--seq-2: #2a5f9e;
|
||||||
|
--seq-3: #3987e5;
|
||||||
|
--seq-4: #6fa9ee;
|
||||||
|
--seq-5: #a8c9f6;
|
||||||
--series-2: #d95926;
|
--series-2: #d95926;
|
||||||
--series-3: #199e70;
|
--series-3: #199e70;
|
||||||
--series-4: #c98500;
|
--series-4: #c98500;
|
||||||
@@ -112,6 +125,11 @@
|
|||||||
--text-muted: #78818f;
|
--text-muted: #78818f;
|
||||||
|
|
||||||
--series-1: #3987e5;
|
--series-1: #3987e5;
|
||||||
|
--seq-1: #1d3f6b;
|
||||||
|
--seq-2: #2a5f9e;
|
||||||
|
--seq-3: #3987e5;
|
||||||
|
--seq-4: #6fa9ee;
|
||||||
|
--seq-5: #a8c9f6;
|
||||||
--series-2: #d95926;
|
--series-2: #d95926;
|
||||||
--series-3: #199e70;
|
--series-3: #199e70;
|
||||||
--series-4: #c98500;
|
--series-4: #c98500;
|
||||||
|
|||||||
@@ -39,8 +39,9 @@
|
|||||||
| 3.2 | Tab:今日 / 健康 / 趋势 / 运动 / 设置 | 每日是趋势的子页 | ✅ |
|
| 3.2 | Tab:今日 / 健康 / 趋势 / 运动 / 设置 | 每日是趋势的子页 | ✅ |
|
||||||
| 3.3 | 首页改三圆环:步数 / 睡眠 / HRV | 并列三环,满环=进入参考区间 | ✅ |
|
| 3.3 | 首页改三圆环:步数 / 睡眠 / HRV | 并列三环,满环=进入参考区间 | ✅ |
|
||||||
| 3.4 | 交互流畅、有动画 | 数字滚动、入场揭示、`prefers-reduced-motion` | ✅ |
|
| 3.4 | 交互流畅、有动画 | 数字滚动、入场揭示、`prefers-reduced-motion` | ✅ |
|
||||||
| 3.5 | 删掉各页右上角的半透明椭圆 | 导航栏右侧 `Link`(带 tooltip)渲染出来的 | 📋 |
|
| 3.7 | 主界面切到子界面加 loading 动画 | 路由切换时的过渡指示 | 📋 |
|
||||||
| 3.6 | 睡眠入口不放右上角,改为点健康页睡眠卡片进入 | 入口跟着内容走 | 📋 |
|
| 3.5 | 删掉各页右上角的半透明椭圆 | 导航栏右侧 `Link`(带 tooltip)渲染出来的,已移除 | ✅ |
|
||||||
|
| 3.6 | 睡眠入口不放右上角,改为点健康页睡眠卡片进入 | 入口跟着内容走;每日入口同样移进趋势页内容 | ✅ |
|
||||||
|
|
||||||
## 四、数据展示
|
## 四、数据展示
|
||||||
|
|
||||||
@@ -49,12 +50,12 @@
|
|||||||
| 4.1 | 每日详情模块,看某天所有数据 | 每日页 | ✅ |
|
| 4.1 | 每日详情模块,看某天所有数据 | 每日页 | ✅ |
|
||||||
| 4.2 | 趋势模块,7 天 / 月 / 季 / 年 周期 | 按周期取日均,桶长不同也可比 | ✅ |
|
| 4.2 | 趋势模块,7 天 / 月 / 季 / 年 周期 | 按周期取日均,桶长不同也可比 | ✅ |
|
||||||
| 4.3 | 趋势覆盖全部 15 组指标,可自选显示/隐藏 | 指标选择器 | ✅ |
|
| 4.3 | 趋势覆盖全部 15 组指标,可自选显示/隐藏 | 指标选择器 | ✅ |
|
||||||
| 4.4 | 指标选择器太丑,改成选择弹窗 | 用 F7 Popup/Sheet 承载 | 📋 |
|
| 4.4 | 指标选择器太丑,改成选择弹窗 | 改为 F7 Popup,页面上只留一行「显示指标 n/15」 | ✅ |
|
||||||
| 4.5 | 所有卡片可点击进入详情 | 每个指标一个详情页:历史曲线 + 参考区间 + 说明 | 📋 |
|
| 4.5 | 所有卡片可点击进入详情 | `/metric/:id/`:大数值 + 参考区间 + 趋势图 + 统计 + 依据;新增 `lib/metrics.ts` 统一指标注册表 | ✅ 健康页完成,🚧 今日页 |
|
||||||
| 4.6 | 今日页左右箭头切换前一天/后一天 | | 📋 |
|
| 4.6 | 今日页左右箭头切换前一天/后一天 | | 📋 |
|
||||||
| 4.7 | 今日页顶部日期选择控件,可看历史任一天 | | 📋 |
|
| 4.7 | 今日页顶部日期选择控件,可看历史任一天 | | 📋 |
|
||||||
| 4.8 | 点击每个运动看本次运动详情,数据全展示 | 概览/数据/分段/图表:配速、速度、计时、心率、训练效果、营养补水、温度、强度分钟、海拔、心率区间、分段表、采样曲线 | ✅ 接口完成,🚧 界面 |
|
| 4.8 | 点击每个运动看本次运动详情,数据全展示 | 概览/数据/分段/图表四个 Tab,含心率区间条与时间/距离横轴切换 | ✅ |
|
||||||
| 4.9 | 健康页增加身体年龄 | 本地按公开常模推算,展示推算过程 | ✅ 接口完成,🚧 界面 |
|
| 4.9 | 健康页增加身体年龄 | 健康页卡片 + `/body-age/` 展示每一步推算过程与出处 | ✅ |
|
||||||
|
|
||||||
## 五、设置
|
## 五、设置
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user