[阶段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

View File

@@ -0,0 +1,189 @@
import { useEffect, useState } from 'react';
import { Link } from 'framework7-react';
import { apiClient, errorMessage, HealthDay } from '../services/api';
import MetricCard from '../components/charts/MetricCard';
import Skeleton from '../components/Skeleton';
import Screen from '../components/Screen';
const WINDOW_DAYS = 30;
interface Item {
metric?: string;
label: string;
pick: (d: HealthDay) => number | null;
unit?: string;
decimals?: number;
detail?: (d: HealthDay) => string | undefined;
}
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 HealthPage() {
const [days, setDays] = useState<HealthDay[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
const end = new Date();
const start = new Date(end.getTime() - (WINDOW_DAYS - 1) * 86400000);
setDays(
await apiClient.getHealthSummary(
start.toISOString().slice(0, 10),
end.toISOString().slice(0, 10)
)
);
} catch (err: any) {
setError(errorMessage(err, '加载失败'));
} finally {
setLoading(false);
}
};
load();
}, []);
if (loading) {
return (
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
<h2></h2>
<Skeleton count={8} />
</Screen>
);
}
if (error) {
return (
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
<h2></h2>
<div className="screen-error">{error}</div>
</Screen>
);
}
if (days.length === 0) {
return (
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
<h2></h2>
<div className="screen-empty">
<p></p>
<Link href="/sync" className="btn btn-primary"> Garmin </Link>
</div>
</Screen>
);
}
// The most recent day that actually recorded a given metric — showing "—"
// because today's sleep has not synced yet would hide data that exists.
const latest = (pick: (d: HealthDay) => number | null) => {
for (let i = days.length - 1; i >= 0; i--) {
const v = pick(days[i]);
if (v != null) return { value: v, date: days[i].date };
}
return { value: null, date: null };
};
return (
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
{SECTIONS.map((section) => (
<section className="sec" key={section.title}>
<h3 className="sec-title">{section.title}</h3>
<div className="mcard-grid">
{section.items.map((item) => {
const { value, date } = latest(item.pick);
const stale = date != null && date !== days[days.length - 1].date;
return (
<MetricCard
key={item.label}
metric={item.metric}
label={item.label}
value={value}
unit={item.unit}
decimals={item.decimals}
trend={days.map(item.pick)}
detail={stale ? `最近记录 ${date!.slice(5)}` : undefined}
/>
);
})}
</div>
</section>
))}
<p className="screen-disclaimer">
</p>
</Screen>
);
}
export default HealthPage;