[阶段10] 前端以 Framework7 重建,采用 iOS 原生形态

按要求废弃手写外壳,改用 Framework7 React(theme=ios)。参照 PeakWatch
的信息架构与卡片语言。

保留(这些是资产,不该重来):
- 数据层 services/api.ts、聚合 lib/aggregate.ts、参考区间 lib/ranges.ts
- 图表组件 Chart / Ring / Sparkline / BandBar / MetricCard / MetricStrip
- 经校验的配色令牌(色盲安全 + 对比度,浅深两档)

替换:
- 路由与外壳交给 F7:五个 Tab 各自独立导航栈,推入详情页不影响其他 Tab
- 页面转场、橡皮筋滚动、大标题折叠、半透明栏 —— 这些正是换框架的理由,
  手写做不像
- 底部标签栏改用 F7 Toolbar,触控目标与安全区由框架处理

配色接入:
- 新增 f7theme.css 把我们的令牌映射到 F7 的 CSS 变量,让它的导航栏/
  列表/面板与我们的图表同属一套设计,而不是两种视觉打架
- F7 的深色靠 .dark 类,我们的靠 data-theme,两者在 App 里同步切换

fix: 图标显示为原始名称(squ/hea/cale…)
- iconIos/iconMd 引用的是 framework7-icons 字体,没装就只会渲染出名字

其他:
- tsconfig moduleResolution 改为 bundler —— F7 用 exports 映射,
  node 解析方式找不到它的类型
- 登录页不套 Tab 外壳,未登录时不该出现导航

桌面与手机都要好看:内容在宽屏收进 1100px 居中列并加密卡片列数,
窄屏走底部标签栏;两档都已实机核对。

bundle 190KB -> 399KB,是换取原生手感的代价。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 23:38:21 +08:00
parent 757afdc941
commit 7ab150537d
37 changed files with 4266 additions and 356 deletions

154
client/src/lib/ranges.ts Normal file
View File

@@ -0,0 +1,154 @@
import { Status } from '../components/charts/StatTile';
/**
* Reference bands for each metric.
*
* These are general-population orientation ranges, not diagnostic thresholds —
* they exist so a number reads as "where does this sit" rather than as a bare
* figure, and the UI labels them 参考区间 and keeps the medical disclaimer.
* Where Garmin publishes its own banding (stress, body battery) that banding
* is used, so the app and the watch never disagree.
*/
export interface Band {
/** Upper bound of this band, inclusive. Infinity for the last one. */
max: number;
label: string;
tone: Status;
}
export interface Range {
bands: Band[];
/** The band considered the target, shown as "参考 x~y". */
goodFrom: number;
goodTo: number;
/** Higher values are better; drives which end of the meter reads as good. */
higherIsBetter?: boolean;
unit?: string;
/** Scale bounds for the meter; defaults to the outermost bands. */
min?: number;
max?: number;
}
export const RANGES: Record<string, Range> = {
steps: {
bands: [
{ max: 5000, label: '偏低', tone: 'serious' },
{ max: 8000, label: '一般', tone: 'warning' },
{ max: 12000, label: '达标', tone: 'good' },
{ max: Infinity, label: '优秀', tone: 'good' },
],
goodFrom: 8000, goodTo: 12000, higherIsBetter: true, min: 0, max: 16000,
},
heartRate: {
// Resting heart rate; lower is generally better in healthy adults.
bands: [
{ max: 50, label: '很低', tone: 'good' },
{ max: 65, label: '正常', tone: 'good' },
{ max: 75, label: '偏高', tone: 'warning' },
{ max: Infinity, label: '较高', tone: 'serious' },
],
goodFrom: 50, goodTo: 65, unit: 'bpm', min: 35, max: 95,
},
heartRateVariability: {
bands: [
{ max: 25, label: '偏低', tone: 'serious' },
{ max: 40, label: '一般', tone: 'warning' },
{ max: 70, label: '良好', tone: 'good' },
{ max: Infinity, label: '很好', tone: 'good' },
],
goodFrom: 40, goodTo: 70, higherIsBetter: true, unit: 'ms', min: 0, max: 100,
},
sleepDuration: {
bands: [
{ max: 6, label: '不足', tone: 'critical' },
{ max: 7, label: '偏少', tone: 'warning' },
{ max: 9, label: '充足', tone: 'good' },
{ max: Infinity, label: '偏多', tone: 'warning' },
],
goodFrom: 7, goodTo: 9, unit: '小时', min: 3, max: 11,
},
sleepQuality: {
bands: [
{ max: 50, label: '较差', tone: 'serious' },
{ max: 70, label: '一般', tone: 'warning' },
{ max: 85, label: '良好', tone: 'good' },
{ max: Infinity, label: '优秀', tone: 'good' },
],
goodFrom: 70, goodTo: 85, higherIsBetter: true, min: 0, max: 100,
},
// Garmin's own stress banding, so the app and the watch agree.
stress: {
bands: [
{ max: 25, label: '休息', tone: 'good' },
{ max: 50, label: '偏低', tone: 'good' },
{ max: 75, label: '中等', tone: 'warning' },
{ max: Infinity, label: '偏高', tone: 'serious' },
],
goodFrom: 0, goodTo: 50, min: 0, max: 100,
},
spo2Avg: {
bands: [
{ max: 90, label: '偏低', tone: 'critical' },
{ max: 94, label: '略低', tone: 'warning' },
{ max: Infinity, label: '正常', tone: 'good' },
],
goodFrom: 95, goodTo: 100, higherIsBetter: true, unit: '%', min: 85, max: 100,
},
respirationAvg: {
bands: [
{ max: 12, label: '偏低', tone: 'warning' },
{ max: 20, label: '正常', tone: 'good' },
{ max: Infinity, label: '偏高', tone: 'warning' },
],
goodFrom: 12, goodTo: 20, unit: '次/分', min: 6, max: 26,
},
bodyBatteryHigh: {
bands: [
{ max: 25, label: '很低', tone: 'critical' },
{ max: 50, label: '偏低', tone: 'warning' },
{ max: 75, label: '良好', tone: 'good' },
{ max: Infinity, label: '充足', tone: 'good' },
],
goodFrom: 50, goodTo: 100, higherIsBetter: true, min: 0, max: 100,
},
trainingReadiness: {
bands: [
{ max: 25, label: '很低', tone: 'critical' },
{ max: 50, label: '偏低', tone: 'warning' },
{ max: 75, label: '就绪', tone: 'good' },
{ max: Infinity, label: '很好', tone: 'good' },
],
goodFrom: 50, goodTo: 100, higherIsBetter: true, min: 0, max: 100,
},
intensityMinutes: {
// WHO recommends ~150 moderate minutes a week, i.e. ~21 a day.
bands: [
{ max: 10, label: '偏少', tone: 'serious' },
{ max: 21, label: '一般', tone: 'warning' },
{ max: Infinity, label: '达标', tone: 'good' },
],
goodFrom: 21, goodTo: 60, higherIsBetter: true, unit: '分钟', min: 0, max: 60,
},
floorsAscended: {
bands: [
{ max: 5, label: '偏少', tone: 'warning' },
{ max: 10, label: '达标', tone: 'good' },
{ max: Infinity, label: '优秀', tone: 'good' },
],
goodFrom: 5, goodTo: 10, higherIsBetter: true, unit: '层', min: 0, max: 20,
},
};
export function classify(metric: string, value: number | null) {
const range = RANGES[metric];
if (!range || value == null) return null;
const band = range.bands.find((b) => value <= b.max) ?? range.bands[range.bands.length - 1];
return { band, range };
}
/** Format the target band the way the watch shows it: "8k~12k". */
export function formatTarget(range: Range): string {
const compact = (v: number) =>
v >= 10000 ? `${Math.round(v / 1000)}k` : v >= 1000 ? `${v / 1000}k` : String(v);
return `${compact(range.goodFrom)}~${compact(range.goodTo)}`;
}