[阶段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:
189
client/src/pages/HealthPage.tsx
Normal file
189
client/src/pages/HealthPage.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user