[阶段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:
154
client/src/lib/ranges.ts
Normal file
154
client/src/lib/ranges.ts
Normal 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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user