[阶段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:
163
client/src/pages/TodayPage.tsx
Normal file
163
client/src/pages/TodayPage.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user