[阶段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,343 @@
import { useEffect, useMemo, useState } from 'react';
import { apiClient, errorMessage, HealthDay } from '../services/api';
import Chart, { Series } from '../components/charts/Chart';
import Skeleton from '../components/Skeleton';
import {
aggregate, Granularity, GRANULARITIES, isCumulative, suggestGranularity,
} from '../lib/aggregate';
import Screen from '../components/Screen';
const RANGES = [
{ days: 30, label: '近一月' },
{ days: 91, label: '近一季' },
{ days: 182, label: '近半年' },
{ days: 365, label: '近一年' },
{ days: 730, label: '近两年' },
];
const HIDDEN_KEY = 'ghl_hidden_metrics';
/** Each group is one chart. Metrics only share a chart when they share a
* scale and a unit — a chart never carries two y-scales. */
interface MetricGroup {
id: string;
label: string;
unit?: string;
type: 'line' | 'bar' | 'area';
series: Series[];
/** Optional transform, e.g. metres to kilometres. */
scale?: Record<string, number>;
note?: string;
}
const GROUPS: MetricGroup[] = [
{
id: 'steps', label: '步数', unit: '步', type: 'bar',
series: [{ key: 'steps', label: '步数', slot: 1, unit: '步' }],
},
{
id: 'distance', label: '距离', unit: 'km', type: 'bar',
scale: { distanceMeters: 1 / 1000 },
series: [{ key: 'distanceMeters', label: '距离', slot: 1, unit: 'km', decimals: 2 }],
},
{
id: 'calories', label: '能量消耗', unit: 'kcal', type: 'bar',
series: [
{ key: 'bmrCalories', label: '基础代谢', slot: 1, unit: 'kcal' },
{ key: 'activeCalories', label: '活动消耗', slot: 2, unit: 'kcal' },
],
note: '两者相加即当日总消耗。',
},
{
id: 'heart', label: '心率', unit: 'bpm', type: 'line',
series: [
{ key: 'heartRate', label: '静息', slot: 1, unit: 'bpm' },
{ key: 'heartRateMax', label: '最高', slot: 2, unit: 'bpm' },
{ key: 'heartRateMin', label: '最低', slot: 3, unit: 'bpm' },
],
},
{
id: 'hrv', label: '心率变异性', unit: 'ms', type: 'area',
series: [{ key: 'heartRateVariability', label: 'HRV', slot: 1, unit: 'ms', decimals: 1 }],
note: 'HRV 反映自主神经恢复情况,持续偏低常与压力或训练过量相关。',
},
{
id: 'stress', label: '压力', type: 'line',
series: [
{ key: 'stress', label: '平均', slot: 1 },
{ key: 'stressMax', label: '峰值', slot: 2 },
],
},
{
id: 'battery', label: '身体电量', type: 'line',
series: [
{ key: 'bodyBatteryHigh', label: '最高', slot: 1 },
{ key: 'bodyBatteryLow', label: '最低', slot: 2 },
],
},
{
id: 'sleep', label: '睡眠时长', unit: '小时', type: 'area',
series: [{ key: 'sleepDuration', label: '时长', slot: 1, unit: '小时', decimals: 1 }],
},
{
id: 'spo2', label: '血氧', unit: '%', type: 'line',
series: [
{ key: 'spo2Avg', label: '平均', slot: 1, unit: '%', decimals: 1 },
{ key: 'spo2Min', label: '最低', slot: 2, unit: '%' },
],
},
{
id: 'respiration', label: '呼吸频率', unit: '次/分', type: 'line',
series: [
{ key: 'respirationAvg', label: '平均', slot: 1, unit: '次/分', decimals: 1 },
{ key: 'respirationMax', label: '最高', slot: 2, unit: '次/分', decimals: 1 },
{ key: 'respirationMin', label: '最低', slot: 3, unit: '次/分', decimals: 1 },
],
},
{
id: 'floors', label: '爬楼', unit: '层', type: 'bar',
series: [{ key: 'floorsAscended', label: '上行', slot: 1, unit: '层' }],
},
{
id: 'intensity', label: '强度分钟', unit: '分钟', type: 'bar',
series: [{ key: 'intensityMinutes', label: '强度分钟', slot: 1, unit: '分钟' }],
},
{
id: 'sedentary', label: '久坐与活动时长', unit: '小时', type: 'bar',
scale: { sedentarySeconds: 1 / 3600, activeSeconds: 1 / 3600 },
series: [
{ key: 'sedentarySeconds', label: '久坐', slot: 1, unit: '小时', decimals: 1 },
{ key: 'activeSeconds', label: '活动', slot: 2, unit: '小时', decimals: 1 },
],
},
{
id: 'training', label: '训练准备度', unit: '/100', type: 'area',
series: [{ key: 'trainingReadiness', label: '准备度', slot: 1 }],
},
{
id: 'endurance', label: '耐力分', type: 'area',
series: [{ key: 'enduranceScore', label: '耐力分', slot: 1 }],
},
];
function summarise(values: Array<number | null | undefined>) {
const present = values.filter((v): v is number => v != null);
if (!present.length) return null;
const sorted = [...present].sort((a, b) => a - b);
const mean = present.reduce((a, b) => a + b, 0) / present.length;
const mid = Math.floor(present.length / 2);
const delta =
present.length > 1
? present.slice(mid).reduce((a, b) => a + b, 0) / (present.length - mid) -
present.slice(0, mid).reduce((a, b) => a + b, 0) / Math.max(mid, 1)
: 0;
return { mean, min: sorted[0], max: sorted[sorted.length - 1], delta };
}
const fmt = (v: number) => {
const abs = Math.abs(v);
const decimals = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
return v.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
});
};
function TrendsPage() {
const [days, setDays] = useState<HealthDay[]>([]);
const [range, setRange] = useState(365);
const [granularity, setGranularity] = useState<Granularity | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Which charts are hidden. Persisted: a selection that resets on every
// reload is not really a preference.
const [hidden, setHidden] = useState<Set<string>>(() => {
try {
return new Set<string>(JSON.parse(localStorage.getItem(HIDDEN_KEY) || '[]'));
} catch {
return new Set<string>();
}
});
useEffect(() => {
localStorage.setItem(HIDDEN_KEY, JSON.stringify([...hidden]));
}, [hidden]);
useEffect(() => {
const load = async () => {
setLoading(true);
setError('');
try {
const end = new Date();
const start = new Date(end.getTime() - (range - 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();
}, [range]);
const effective = granularity ?? suggestGranularity(days.length);
const visible = GROUPS.filter((g) => !hidden.has(g.id));
// Aggregated once for every metric, so all charts read the same slice — a
// filter row that scoped only some of them would be misleading.
const allKeys = useMemo(() => GROUPS.flatMap((g) => g.series.map((s) => s.key)), []);
const buckets = useMemo(
() => aggregate(days, effective, allKeys),
[days, effective, allKeys]
);
const rowsFor = (group: MetricGroup) =>
buckets.map((b) => {
const row: Record<string, any> = { date: b.label };
for (const s of group.series) {
const raw = b.values[s.key];
const factor = group.scale?.[s.key];
row[s.key] = raw == null ? null : factor ? raw * factor : raw;
}
return row;
});
const toggle = (id: string) =>
setHidden((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const granLabel =
GRANULARITIES.find((g) => g.id === effective)?.label.replace('每', '') ?? '';
return (
<Screen title="趋势">
<div className="segmented-row">
<span className="segmented-label"></span>
<div className="segmented">
{RANGES.map((r) => (
<button key={r.days} className={r.days === range ? 'on' : ''}
onClick={() => setRange(r.days)}>{r.label}</button>
))}
</div>
</div>
<div className="segmented-row">
<span className="segmented-label"></span>
<div className="segmented">
{GRANULARITIES.filter((g) => g.days <= Math.max(range / 2, 1)).map((g) => (
<button key={g.id} className={g.id === effective ? 'on' : ''}
onClick={() => setGranularity(g.id)}>{g.label}</button>
))}
</div>
</div>
<section className="metric-picker">
<div className="picker-head">
<span className="control-label"></span>
<div className="picker-actions">
<button className="link-button" onClick={() => setHidden(new Set())}>
</button>
<button
className="link-button"
onClick={() => setHidden(new Set(GROUPS.map((g) => g.id)))}
>
</button>
</div>
</div>
<div className="picker-chips">
{GROUPS.map((g) => {
const on = !hidden.has(g.id);
return (
<button
key={g.id}
className={`chip ${on ? 'on' : 'off'}`}
onClick={() => toggle(g.id)}
aria-pressed={on}
>
{/* A mark, not colour alone, carries the on/off state. */}
<span className="chip-mark" aria-hidden="true">{on ? '✓' : '+'}</span>
{g.label}
</button>
);
})}
</div>
</section>
{error && <div className="screen-error">{error}</div>}
{loading && <Skeleton count={6} variant="chart" />}
{!loading && !error && visible.length === 0 && (
<p className="screen-note"></p>
)}
{!loading && !error && visible.length > 0 && (
<div className="chart-grid">
{visible.map((g) => {
const rows = rowsFor(g);
const primary = g.series[0];
const s = summarise(rows.map((r) => r[primary.key]));
/* Bars and areas both encode magnitude by extent, so both must
start at zero — which makes a year of monthly step averages,
all between 9.6k and 12.7k, render as near-identical shapes and
hides exactly the change the reader came for. Once days are
bucketed the question is "how is this trending", and that is a
line's job: it encodes position rather than extent, so a
non-zero axis is legitimate and the variation becomes visible.
Dense daily views switch for the same reason plus hit size. */
const aggregated = effective !== 'day';
const type =
aggregated || (g.type === 'bar' && rows.length > 90)
? ('line' as const)
: g.type;
const meanWord =
effective !== 'day' && isCumulative(primary.key) ? '日均' : '平均';
return (
<Chart
key={g.id}
title={g.label}
unit={g.unit}
subtitle={
effective === 'day'
? undefined
: `每点为一个${granLabel}周期的日均值,共 ${rows.length} 个周期`
}
data={rows}
type={type}
series={g.series}
height={210}
footer={
s ? (
<span className="chart-stats">
<span>{meanWord} <b>{fmt(s.mean)}</b></span>
<span> <b>{fmt(s.min)}</b></span>
<span> <b>{fmt(s.max)}</b></span>
<span> <b>{s.delta >= 0 ? '+' : ''}{fmt(s.delta)}</b></span>
</span>
) : (
g.note
)
}
/>
);
})}
</div>
)}
</Screen>
);
}
export default TrendsPage;