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