Files
GarminHealthLab/client/src/pages/TodayPage.tsx
ericwyuan 7ab150537d [阶段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>
2026-08-23 23:38:21 +08:00

164 lines
6.6 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Link } from 'framework7-react';
import { apiClient, errorMessage, HealthDay } from '../services/api';
import Screen from '../components/Screen';
import Ring from '../components/charts/Ring';
import MetricCard from '../components/charts/MetricCard';
import MetricStrip from '../components/charts/MetricStrip';
import Skeleton from '../components/Skeleton';
import { useCountUp } from '../lib/motion';
import './Today.css';
const DAYS = 30;
function StepHero({ today, history }: { today: HealthDay; history: HealthDay[] }) {
const goal = today.stepGoal ?? null;
const steps = today.steps ?? null;
const progress = steps != null && goal ? steps / goal : null;
const animated = useCountUp(steps);
const week = history.slice(-7).map((d) => d.steps).filter((v): v is number => v != null);
const weekAvg = week.length
? Math.round(week.reduce((a, b) => a + b, 0) / week.length)
: null;
const remaining = steps != null && goal ? goal - steps : null;
return (
<section className="hero">
<Ring
progress={progress}
label={`步数完成度 ${progress != null ? Math.round(progress * 100) : 0}%`}
>
<span className="hero-value">
{steps == null ? '—' : Math.round(animated ?? steps).toLocaleString()}
</span>
<span className="hero-caption"></span>
</Ring>
<div className="hero-facts">
<div className="hero-headline">
{progress == null
? '今日暂无步数记录'
: progress >= 1
? '今日目标已完成'
: `距目标还差 ${remaining!.toLocaleString()}`}
</div>
<dl className="hero-list">
<div><dt></dt><dd>{goal ? goal.toLocaleString() : '—'}</dd></div>
<div><dt> 7 </dt><dd>{weekAvg ? weekAvg.toLocaleString() : '—'}</dd></div>
<div><dt></dt><dd>{progress != null ? `${Math.round(progress * 100)}%` : '—'}</dd></div>
</dl>
</div>
</section>
);
}
function TodayPage() {
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() - (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();
}, []);
const today = days[days.length - 1];
return (
<Screen title="今日" subtitle={today?.date}>
{loading && (
<>
<div className="hero-skeleton" aria-hidden="true" />
<Skeleton count={4} />
</>
)}
{!loading && error && <div className="screen-error">{error}</div>}
{!loading && !error && !today && (
<div className="screen-empty">
<p></p>
<Link href="/sync/" className="button button-fill button-round">
Garmin
</Link>
</div>
)}
{!loading && !error && today && (
<>
<StepHero today={today} history={days} />
<section className="sec">
<h3 className="sec-title"></h3>
<div className="mcard-grid">
<MetricCard metric="steps" label="步数" value={today.steps} unit="步"
trend={days.map((d) => d.steps)} />
<MetricCard metric="intensityMinutes" label="强度分钟" value={today.intensityMinutes}
unit="分钟" trend={days.map((d) => d.intensityMinutes)} />
<MetricCard metric="floorsAscended" label="爬楼" value={today.floorsAscended}
unit="层" trend={days.map((d) => d.floorsAscended)} />
<MetricCard label="距离" unit="km" decimals={2}
value={today.distanceMeters != null ? today.distanceMeters / 1000 : null}
trend={days.map((d) => d.distanceMeters)}
detail={today.caloriesBurned != null
? `消耗 ${Math.round(today.caloriesBurned).toLocaleString()} kcal` : undefined} />
</div>
</section>
<section className="sec">
<h3 className="sec-title"></h3>
<div className="mcard-grid">
<MetricCard metric="heartRate" label="静息心率" value={today.heartRate} unit="bpm"
trend={days.map((d) => d.heartRate)} />
<MetricCard metric="heartRateVariability" label="心率变异性"
value={today.heartRateVariability} unit="ms" decimals={1}
trend={days.map((d) => d.heartRateVariability)} />
<MetricCard metric="stress" label="平均压力" value={today.stress}
trend={days.map((d) => d.stress)} />
<MetricCard metric="trainingReadiness" label="训练准备度"
value={today.trainingReadiness} unit="/100"
trend={days.map((d) => d.trainingReadiness)} />
</div>
</section>
<section className="sec">
<h3 className="sec-title"></h3>
<div className="mcard-grid">
<MetricCard metric="sleepDuration" label="睡眠时长" value={today.sleepDuration}
unit="小时" decimals={1} trend={days.map((d) => d.sleepDuration)} />
<MetricCard metric="sleepQuality" label="睡眠评分" value={today.sleepQuality}
unit="/100" trend={days.map((d) => d.sleepQuality)} />
</div>
<p className="sec-link"><Link href="/sleep/"> </Link></p>
</section>
<section className="sec">
<MetricStrip title="身体指标" items={[
{ metric: 'heartRateVariability', icon: '💓', label: 'HRV', value: today.heartRateVariability, unit: 'ms' },
{ metric: 'heartRate', icon: '❤️', label: '静息心率', value: today.heartRate, unit: 'bpm' },
{ metric: 'respirationAvg', icon: '🫁', label: '呼吸', value: today.respirationAvg, unit: '次/分', decimals: 1 },
{ metric: 'spo2Avg', icon: '🩸', label: '血氧', value: today.spo2Avg, unit: '%' },
{ metric: 'bodyBatteryHigh', icon: '🔋', label: '身体电量', value: today.bodyBatteryHigh, unit: '峰值' },
]} />
</section>
</>
)}
</Screen>
);
}
export default TodayPage;