From 8430ff335e1d1624a6258e60ef682f0f5c731b80 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Mon, 24 Aug 2026 00:49:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E4=BB=8A=E6=97=A5=E9=A1=B5?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E5=88=87=E6=8D=A2=20+=20=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=8F=AF=E7=82=B9=20+=20=E8=B7=AF=E7=94=B1=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求 3.7 / 4.5 / 4.6 / 4.7 今日页 - 左右箭头切换日期,中间点开日历选任意一天,范围限定在已有数据内 - 没有记录的日期直接跳过,不会停在一个空白页上 - 一次读一年:往回翻一天不该产生一次请求,卡片的迷你曲线本来也需要前后几天 - 曲线窗口跟着所看的那天结束,卡片上的走势总是通向它上面那个数字 - 卡片改为从 lib/metrics.ts 渲染,点击进入指标详情 路由切换进度条 - 切换本身是瞬时的,用户等的是新页面的第一次请求,没有指示就像点了没反应 - 跑到 90% 停住等页面就位,不谎报完成 - 有最短显示时长,快速跳转时不会闪一下 Co-Authored-By: Claude Haiku 4.5 --- client/src/App.tsx | 52 ++++++++- client/src/f7theme.css | 41 +++++++ client/src/pages/Today.css | 65 +++++++++++ client/src/pages/TodayPage.tsx | 208 +++++++++++++++++++++++---------- docs/REQUIREMENTS.md | 8 +- 5 files changed, 308 insertions(+), 66 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 9506461..3f86142 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 ( +
+ +
+ {TABS.map((tab) => ( diff --git a/client/src/f7theme.css b/client/src/f7theme.css index 1ed0473..c252b74 100644 --- a/client/src/f7theme.css +++ b/client/src/f7theme.css @@ -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); } +} diff --git a/client/src/pages/Today.css b/client/src/pages/Today.css index 7d22b2b..afc735d 100644 --- a/client/src/pages/Today.css +++ b/client/src/pages/Today.css @@ -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; } +} diff --git a/client/src/pages/TodayPage.tsx b/client/src/pages/TodayPage.tsx index 986a27c..481cefd 100644 --- a/client/src/pages/TodayPage.tsx +++ b/client/src/pages/TodayPage.tsx @@ -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([]); + const [selected, setSelected] = useState(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 ( - + {loading && ( <>