feat(ui): 今日页日期切换 + 卡片可点 + 路由切换进度条

需求 3.7 / 4.5 / 4.6 / 4.7

今日页
- 左右箭头切换日期,中间点开日历选任意一天,范围限定在已有数据内
- 没有记录的日期直接跳过,不会停在一个空白页上
- 一次读一年:往回翻一天不该产生一次请求,卡片的迷你曲线本来也需要前后几天
- 曲线窗口跟着所看的那天结束,卡片上的走势总是通向它上面那个数字
- 卡片改为从 lib/metrics.ts 渲染,点击进入指标详情

路由切换进度条
- 切换本身是瞬时的,用户等的是新页面的第一次请求,没有指示就像点了没反应
- 跑到 90% 停住等页面就位,不谎报完成
- 有最短显示时长,快速跳转时不会闪一下

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-24 00:49:38 +08:00
parent 0c7fecc124
commit 8430ff335e
5 changed files with 308 additions and 66 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Link } from 'framework7-react';
import { Link, f7 } from 'framework7-react';
import { apiClient, errorMessage, HealthDay } from '../services/api';
import Screen from '../components/Screen';
import Ring from '../components/charts/Ring';
@@ -8,9 +8,11 @@ import MetricStrip from '../components/charts/MetricStrip';
import Skeleton from '../components/Skeleton';
import { useCountUp } from '../lib/motion';
import { RANGES } from '../lib/ranges';
import { METRICS, metricHref } from '../lib/metrics';
import './Today.css';
const DAYS = 30;
/* A year is loaded up front so stepping back a day costs no request. */
const HISTORY_DAYS = 365;
interface RingSpec {
label: string;
@@ -116,19 +118,31 @@ function RingCell({ spec }: { spec: RingSpec }) {
);
}
/* Which cards each section shows, by registry id. */
const SECTIONS: Array<{ title: string; items: string[] }> = [
{ title: '活动', items: ['steps', 'intensityMinutes', 'floorsAscended', 'distance'] },
{ title: '心率与压力', items: ['heartRate', 'heartRateVariability', 'stress', 'trainingReadiness'] },
{ title: '睡眠', items: ['sleepDuration', 'sleepQuality'] },
];
const iso = (d: Date) => d.toISOString().slice(0, 10);
const shift = (date: string, days: number) =>
iso(new Date(new Date(`${date}T12:00:00`).getTime() + days * 86400000));
function TodayPage() {
const [days, setDays] = useState<HealthDay[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
// A year in one read: browsing back a day should not cost a request,
// and the cards' sparklines need the surrounding days anyway.
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)
));
const start = new Date(end.getTime() - (HISTORY_DAYS - 1) * 86400000);
setDays(await apiClient.getHealthSummary(iso(start), iso(end)));
} catch (err: any) {
setError(errorMessage(err, '加载数据失败'));
} finally {
@@ -138,10 +152,61 @@ function TodayPage() {
load();
}, []);
const today = days[days.length - 1];
const newest = days.length ? days[days.length - 1].date : null;
const date = selected ?? newest;
const index = date ? days.findIndex((d) => d.date === date) : -1;
const today = index >= 0 ? days[index] : undefined;
// The window feeding the sparklines ends at the day being viewed, so the
// trend on a card always leads up to the number above it.
const history = index >= 0 ? days.slice(Math.max(0, index - 13), index + 1) : [];
const oldest = days.length ? days[0].date : null;
const canPrev = !!(date && oldest && date > oldest);
const canNext = !!(date && newest && date < newest);
const go = (delta: number) => {
if (!date) return;
const target = shift(date, delta);
// Days with no record at all are skipped over rather than shown blank.
const nearest = delta < 0
? [...days].reverse().find((d) => d.date <= target)
: days.find((d) => d.date >= target);
if (nearest) setSelected(nearest.date);
};
const openCalendar = () => {
if (!date) return;
const calendar = f7.calendar.create({
value: [new Date(`${date}T12:00:00`)],
minDate: oldest ? new Date(`${oldest}T12:00:00`) : undefined,
maxDate: newest ? new Date(`${newest}T12:00:00`) : undefined,
closeOnSelect: true,
on: {
change(_c: any, value: unknown) {
const values = value as Date[];
if (!values?.length) return;
const picked = iso(values[0]);
const match = days.find((d) => d.date === picked);
setSelected(match ? match.date : picked);
},
closed(c: any) { c.destroy(); },
},
});
calendar.open();
};
const isToday = date === iso(new Date());
const weekday = date
? ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][
new Date(`${date}T12:00:00`).getDay()
]
: '';
const open = (id: string) => f7.views.current.router.navigate(metricHref(id));
return (
<Screen title="今日" subtitle={today?.date}>
<Screen title={isToday ? '今日' : '每日数据'} subtitle={date ?? undefined}>
{loading && (
<>
<div className="hero-skeleton" aria-hidden="true" />
@@ -151,7 +216,7 @@ function TodayPage() {
{!loading && error && <div className="screen-error">{error}</div>}
{!loading && !error && !today && (
{!loading && !error && !days.length && (
<div className="screen-empty">
<p></p>
<Link href="/sync/" className="button button-fill button-round">
@@ -160,63 +225,84 @@ function TodayPage() {
</div>
)}
{!loading && !error && today && (
{!loading && !error && !!days.length && date && (
<>
<RingRow today={today} history={days} />
<div className="date-nav">
<button
className="date-arrow"
onClick={() => go(-1)}
disabled={!canPrev}
aria-label="前一天"
>
</button>
<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>
<button className="date-current" onClick={openCalendar}>
<span className="date-main">
{isToday ? '今天' : date.slice(5).replace('-', ' / ')}
</span>
<span className="date-sub">
{weekday}
<span className="date-cal" aria-hidden="true"></span>
</span>
</button>
<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>
<button
className="date-arrow"
onClick={() => go(1)}
disabled={!canNext}
aria-label="后一天"
>
</button>
</div>
<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>
{!today ? (
<p className="screen-note"></p>
) : (
<>
<RingRow today={today} history={history} />
<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>
{SECTIONS.map((section) => (
<section className="sec" key={section.title}>
<h3 className="sec-title">{section.title}</h3>
<div className="mcard-grid">
{section.items.map((id) => {
const def = METRICS[id];
if (!def) return null;
return (
<MetricCard
key={id}
metric={def.range}
label={def.label}
value={def.pick(today)}
unit={def.unit}
decimals={def.decimals}
trend={history.map(def.pick)}
detail={
id === 'distance' && today.caloriesBurned != null
? `消耗 ${Math.round(today.caloriesBurned).toLocaleString()} kcal`
: undefined
}
onClick={() => open(id)}
/>
);
})}
</div>
</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>