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:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { App as F7App, View, Views, Toolbar, Link } from 'framework7-react';
|
||||
import { App as F7App, View, Views, Toolbar, Link, f7ready } from 'framework7-react';
|
||||
import Framework7 from 'framework7/lite-bundle';
|
||||
import Framework7React from 'framework7-react';
|
||||
|
||||
@@ -55,8 +55,49 @@ function useTheme() {
|
||||
return [theme, setTheme] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* A progress line across the top while a screen is being pushed.
|
||||
*
|
||||
* The transition itself is instant; what the user waits on is the new page's
|
||||
* first fetch, which without this reads as a dead tap. Shown on route change
|
||||
* and cleared once the incoming page has settled, with a floor on how briefly
|
||||
* it can appear so a fast navigation does not produce a flash.
|
||||
*/
|
||||
function useNavProgress() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let shownAt = 0;
|
||||
let timer: number | undefined;
|
||||
|
||||
const show = () => {
|
||||
shownAt = Date.now();
|
||||
window.clearTimeout(timer);
|
||||
setBusy(true);
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
const elapsed = Date.now() - shownAt;
|
||||
const wait = Math.max(0, 260 - elapsed);
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => setBusy(false), wait);
|
||||
};
|
||||
|
||||
f7ready((app) => {
|
||||
app.on('routeChange', show);
|
||||
app.on('pageAfterIn', hide);
|
||||
app.on('pageBeforeRemove', hide);
|
||||
});
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return busy;
|
||||
}
|
||||
|
||||
function App() {
|
||||
useTheme();
|
||||
const navigating = useNavProgress();
|
||||
|
||||
return (
|
||||
<F7App
|
||||
@@ -68,6 +109,15 @@ function App() {
|
||||
routes={routes}
|
||||
touch={{ tapHold: true }}
|
||||
>
|
||||
<div
|
||||
className={`nav-progress ${navigating ? 'on' : ''}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={navigating ? '正在打开' : ''}
|
||||
>
|
||||
<span className="nav-progress-bar" />
|
||||
</div>
|
||||
|
||||
<Views tabs className="safe-areas">
|
||||
<Toolbar tabbar icons bottom>
|
||||
{TABS.map((tab) => (
|
||||
|
||||
@@ -103,3 +103,44 @@
|
||||
border-radius: 16px;
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
/* Route-change progress ---------------------------------------------------- */
|
||||
.nav-progress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2.5px;
|
||||
z-index: 20000;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s var(--ease);
|
||||
}
|
||||
|
||||
.nav-progress.on { opacity: 1; }
|
||||
|
||||
.nav-progress-bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--accent);
|
||||
transform-origin: left center;
|
||||
transform: scaleX(0);
|
||||
}
|
||||
|
||||
/* Runs to 90% and waits: the remaining 10% is the page arriving, so the bar
|
||||
never claims to be finished before it is. */
|
||||
.nav-progress.on .nav-progress-bar {
|
||||
animation: nav-progress-run 1.4s var(--ease) forwards;
|
||||
}
|
||||
|
||||
@keyframes nav-progress-run {
|
||||
0% { transform: scaleX(0); }
|
||||
40% { transform: scaleX(0.6); }
|
||||
100% { transform: scaleX(0.9); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nav-progress { transition: none; }
|
||||
.nav-progress.on .nav-progress-bar { animation: none; transform: scaleX(0.9); }
|
||||
}
|
||||
|
||||
@@ -121,3 +121,68 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero { animation: none; }
|
||||
}
|
||||
|
||||
/* Date navigation --------------------------------------------------------- */
|
||||
.date-nav {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.date-arrow {
|
||||
width: 46px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-1);
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s var(--ease), transform 0.15s var(--ease);
|
||||
}
|
||||
|
||||
.date-arrow:active:not(:disabled) { transform: scale(0.93); background: var(--surface-2); }
|
||||
.date-arrow:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.date-current {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.12rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-1);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s var(--ease);
|
||||
}
|
||||
|
||||
.date-current:active { background: var(--surface-2); }
|
||||
|
||||
.date-main {
|
||||
font-size: 1rem;
|
||||
font-weight: 640;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.date-sub {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.date-cal { font-size: 0.65rem; opacity: 0.8; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.date-arrow, .date-current { transition: none; }
|
||||
.date-arrow:active:not(:disabled) { transform: none; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
| 3.4 | 交互流畅、有动画 | 数字滚动、入场揭示、`prefers-reduced-motion` | ✅ |
|
||||
| 3.5 | 删掉各页右上角的半透明椭圆 | 导航栏右侧 `Link`(带 tooltip)渲染出来的,已移除 | ✅ |
|
||||
| 3.6 | 睡眠入口不放右上角,改为点健康页睡眠卡片进入 | 入口跟着内容走;每日入口同样移进趋势页内容 | ✅ |
|
||||
| 3.7 | 主界面切到子界面加 loading 动画 | 路由切换时的过渡指示 | 📋 |
|
||||
| 3.7 | 主界面切到子界面加 loading 动画 | 顶部进度条,跑到 90% 等页面就位;有最短显示时长,避免快速跳转时闪一下 | ✅ |
|
||||
|
||||
## 四、数据展示
|
||||
|
||||
@@ -51,9 +51,9 @@
|
||||
| 4.2 | 趋势模块,7 天 / 月 / 季 / 年 周期 | 按周期取日均,桶长不同也可比 | ✅ |
|
||||
| 4.3 | 趋势覆盖全部 15 组指标,可自选显示/隐藏 | 指标选择器 | ✅ |
|
||||
| 4.4 | 指标选择器太丑,改成选择弹窗 | 改为 F7 Popup,页面上只留一行「显示指标 n/15」 | ✅ |
|
||||
| 4.5 | 所有卡片可点击进入详情 | `/metric/:id/`:大数值 + 参考区间 + 趋势图 + 统计 + 依据;新增 `lib/metrics.ts` 统一指标注册表 | ✅ 健康页完成,🚧 今日页 |
|
||||
| 4.6 | 今日页左右箭头切换前一天/后一天 | | 📋 |
|
||||
| 4.7 | 今日页顶部日期选择控件,可看历史任一天 | | 📋 |
|
||||
| 4.5 | 所有卡片可点击进入详情 | `/metric/:id/`:大数值 + 参考区间 + 趋势图 + 统计 + 依据;新增 `lib/metrics.ts` 统一指标注册表 | ✅ |
|
||||
| 4.6 | 今日页左右箭头切换前一天/后一天 | 无记录的日期自动跳过;到边界置灰 | ✅ |
|
||||
| 4.7 | 今日页顶部日期选择控件,可看历史任一天 | 点中间日期开 F7 日历,范围限定在已有数据内 | ✅ |
|
||||
| 4.8 | 点击每个运动看本次运动详情,数据全展示 | 概览/数据/分段/图表四个 Tab,含心率区间条与时间/距离横轴切换 | ✅ |
|
||||
| 4.9 | 健康页增加身体年龄 | 健康页卡片 + `/body-age/` 展示每一步推算过程与出处 | ✅ |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user