diff --git a/client/src/pages/DataSync.css b/client/src/pages/DataSync.css index 3edc7f5..d8ca12d 100644 --- a/client/src/pages/DataSync.css +++ b/client/src/pages/DataSync.css @@ -182,3 +182,59 @@ .status-item .value { text-align: left; } .sync-choices .btn { flex: 1; } } + +/* Simplified sync page ---------------------------------------------------- */ +.sync-card { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 14px; + padding: 1.1rem; + margin-bottom: 1rem; +} + +.sync-card h3 { + margin: 0 0 0.4rem; + font-size: 0.95rem; + font-weight: 650; + color: var(--text-primary); +} + +.sync-card .field-hint { margin-bottom: 0.9rem; } +.sync-card .form-group { margin-bottom: 0.8rem; } + +.sync-actions-row { + display: grid; + gap: 0.55rem; + margin-bottom: 1rem; +} + +.sync-facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + border-radius: 14px; + overflow: hidden; +} + +.sync-fact { + background: var(--surface-1); + padding: 0.75rem 0.6rem; + display: flex; + flex-direction: column; + gap: 0.22rem; + align-items: center; + text-align: center; +} + +.sync-fact-label { font-size: 0.72rem; color: var(--text-muted); } +.sync-fact-value { font-size: 0.92rem; font-weight: 620; color: var(--text-primary); } + +.sync-lasterror { + margin: 0.9rem 0 0; + font-size: 0.78rem; + line-height: 1.7; + color: var(--status-critical); + word-break: break-word; +} diff --git a/client/src/pages/RatingBasisPage.tsx b/client/src/pages/RatingBasisPage.tsx new file mode 100644 index 0000000..8868c8f --- /dev/null +++ b/client/src/pages/RatingBasisPage.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react'; +import { apiClient, errorMessage, RatingBasis } from '../services/api'; +import Screen from '../components/Screen'; +import Skeleton from '../components/Skeleton'; +import './BodyAge.css'; + +/** + * Where every rating in the app comes from. + * + * A band that paints a number 偏低 is making a claim about the user's health. + * This screen is the receipt for that claim — one row per metric, with the + * source named, including the ones whose honest source is "general guidance, + * no authoritative standard". + */ +function RatingBasisPage() { + const [basis, setBasis] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + apiClient + .getRatingBasis() + .then(setBasis) + .catch((err) => setError(errorMessage(err, '加载失败'))) + .finally(() => setLoading(false)); + }, []); + + if (loading) return ; + if (error || !basis) { + return ( + +
{error || '加载失败'}
+
+ ); + } + + return ( + +

{basis.note}

+ +
+

各指标参考区间

+
+ {basis.bands.map((b) => ( +
+
{b.metric}
+
{b.bands}
+
来源:{b.source}
+
+ ))} +
+
+ +
+

{basis.fitnessAge.title}

+

{basis.fitnessAge.summary}

+
+ {basis.fitnessAge.steps.map((s) => ( +
+
{s.name}
+
{s.detail}
+
来源:{s.source}
+
+ ))} +
+
+ +

{basis.fitnessAge.caveat}

+
+ ); +} + +export default RatingBasisPage; diff --git a/client/src/pages/Settings.css b/client/src/pages/Settings.css index 9341cd7..4558ab1 100644 --- a/client/src/pages/Settings.css +++ b/client/src/pages/Settings.css @@ -135,3 +135,118 @@ padding: 0.45rem 0.5rem; } } + +/* Settings rows ----------------------------------------------------------- */ +.set-rows { + border: 1px solid var(--border); + border-radius: 14px; + overflow: hidden; + background: var(--surface-1); +} + +.set-row { + display: flex; + align-items: center; + gap: 0.8rem; + width: 100%; + padding: 0.78rem 1rem; + background: none; + border: none; + border-bottom: 1px solid var(--border); + font-family: inherit; + font-size: 0.9rem; + color: var(--text-primary); + text-align: left; + text-decoration: none; + cursor: pointer; +} + +.set-row:last-child { border-bottom: none; } +.set-row:active:not(:disabled) { background: var(--surface-2); } +.set-row:disabled { opacity: 0.45; cursor: default; } +.set-row-static { cursor: default; } + +.set-label { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.set-sub { font-size: 0.73rem; color: var(--text-muted); font-weight: 400; } + +.set-value { + color: var(--text-secondary); + font-size: 0.85rem; + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 55%; +} + +.set-chevron { color: var(--text-muted); font-size: 1.1rem; line-height: 1; flex-shrink: 0; } + +.set-input { + background: none; + border: none; + color: var(--text-secondary); + font-family: inherit; + font-size: 0.85rem; + text-align: right; + padding: 0; +} + +.set-input:focus { outline: none; color: var(--accent); } + +/* Switch. The native checkbox stays in the DOM (and keeps keyboard and + screen-reader behaviour); only its painting is replaced. */ +.toggle { position: relative; flex-shrink: 0; width: 46px; height: 28px; } + +.toggle input { + position: absolute; + inset: 0; + opacity: 0; + margin: 0; + width: 100%; + height: 100%; + cursor: pointer; + z-index: 1; +} + +.toggle-track { + position: absolute; + inset: 0; + border-radius: 999px; + background: var(--surface-2); + border: 1px solid var(--border-strong); + transition: background 0.2s var(--ease), border-color 0.2s var(--ease); +} + +.toggle-track::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 22px; + height: 22px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + transition: transform 0.2s var(--ease); +} + +.toggle input:checked + .toggle-track { + background: var(--accent-solid); + border-color: var(--accent-solid); +} + +.toggle input:checked + .toggle-track::after { transform: translateX(18px); } +.toggle input:focus-visible + .toggle-track { outline: 2px solid var(--accent); outline-offset: 2px; } + +.rb-bands { font-variant-numeric: tabular-nums; } + +@media (prefers-reduced-motion: reduce) { + .toggle-track, .toggle-track::after { transition: none; } +} diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index a348218..d7e235c 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -1,90 +1,291 @@ -import Screen from '../components/Screen'; import { useEffect, useState } from 'react'; import { Link, f7 } from 'framework7-react'; -import { apiClient, ModelInfo } from '../services/api'; +import { + apiClient, errorMessage, ModelInfo, SettingsOptions, UserSettings, +} from '../services/api'; import { FEATURES } from '../features'; +import Screen from '../components/Screen'; +import Skeleton from '../components/Skeleton'; import './Settings.css'; +const SEX_LABEL: Record = { + male: '男', female: '女', other: '其他', +}; + +const UNIT_LABEL: Record = { + metric: '公制(km / kg / °C)', imperial: '英制(mi / lb / °F)', +}; + +const intervalLabel = (minutes: number) => + minutes < 60 ? `${minutes} 分钟` + : minutes === 1440 ? '每天一次' + : `${minutes / 60} 小时`; + +const historyLabel = (days: number) => + days === 0 ? '全部历史' + : days >= 365 ? `${days / 365} 年` + : `${days} 天`; + function SettingsPage() { + const [settings, setSettings] = useState(null); + const [options, setOptions] = useState(null); const [models, setModels] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [saved, setSaved] = useState(''); useEffect(() => { - if (!FEATURES.ai) { - setLoading(false); - return; - } - apiClient - .getModels() - .then(setModels) - .catch(() => setModels([])) + Promise.all([apiClient.getSettings(), apiClient.getSettingsOptions()]) + .then(([s, o]) => { setSettings(s); setOptions(o); }) + .catch((err) => setError(errorMessage(err, '加载设置失败'))) .finally(() => setLoading(false)); + + if (FEATURES.ai) { + apiClient.getModels().then(setModels).catch(() => setModels([])); + } }, []); + /* Saved on change rather than behind a 保存 button: every field here is a + single value with an obvious effect, and a form that can be left dirty + is a form that silently loses edits. */ + const save = async (patch: Partial) => { + setError(''); + try { + const next = await apiClient.saveSettings(patch); + setSettings(next); + setSaved('已保存'); + window.setTimeout(() => setSaved(''), 1600); + } catch (err: any) { + setError(errorMessage(err, '保存失败')); + } + }; + + const pick = ( + title: string, + values: Array, + label: (v: any) => string, + current: string | number, + onPick: (v: any) => void + ) => { + f7.dialog.create({ + title, + buttons: [ + ...values.map((v) => ({ + text: label(v) + (v === current ? ' ✓' : ''), + onClick: () => onPick(v), + })), + { text: '取消', color: 'gray' }, + ], + verticalButtons: true, + }).open(); + }; + + const promptNumber = ( + title: string, unit: string, current: number | null, + onDone: (v: number | null) => void + ) => { + f7.dialog.prompt(`${title}(${unit})`, title, (value) => { + const trimmed = value.trim(); + onDone(trimmed === '' ? null : Number(trimmed)); + }, undefined, current == null ? '' : String(current)); + }; + const handleLogout = async () => { await apiClient.logout(); f7.views.current.router.navigate('/login/', { reloadAll: true }); }; + if (loading) { + return ; + } + + const s = settings; + return ( -

设置

+ {error &&
{error}
} + {saved &&
{saved}
} - {FEATURES.ai && ( -
-

AI 模型

-

- 模型清单与优先级由后端 backend/.env 决定。 - 填入对应厂商的密钥后,模型会自动变为可用; - AI_MODEL_CHAIN 控制自动模式下的尝试顺序。 -

+ {s && ( + <> +
+

个人资料

+

+ 身高、体重、年龄与性别用于计算 BMI、基础代谢与身体年龄。 + 留空不影响其他功能,只是这几项会显示为「—」。 +

+
+ - {loading ? ( -

加载中…

- ) : models.length === 0 ? ( -

无法获取模型列表。

- ) : ( - - - - - - - - - - - {models.map((m) => ( - - - - - - - ))} - -
ID模型上下文状态
- {m.id} - {m.default && 默认} - {m.model}{(m.contextWindow / 1000).toLocaleString()}k - - {m.configured ? '已配置' : '缺少密钥'} - -
- )} -
+ + + + + + +
+ BMI + + {s.bmi ?? '—'} + {s.age != null && · {s.age} 岁} + +
+ +
+ +
+

单位

+
+ +
+
+ +
+

同步

+
+ + + + + + + + 数据同步 + 绑定账号 · 手动同步 + + +
+
+ )} -
-

数据

-

从 Garmin Connect 拉取健康数据,或重新绑定账号。

- - 前往数据同步 - +
+

评分依据

+

+ 应用里每个「偏低 / 正常 / 达标」的判断都基于公开参考值, + 不是模型现编的。AI 只负责解读结果,不参与设定任何阈值。 +

+
+ + 查看全部阈值与出处 + + + + 身体年龄怎么算的 + + +
-
-

数据与隐私

+ {FEATURES.ai && models.length > 0 && ( +
+

AI 模型

+
+ + + + + + + + {models.map((m) => ( + + + + + + ))} + +
ID上下文状态
{m.id}{(m.contextWindow / 1000).toLocaleString()}k{m.configured ? '已配置' : '缺少密钥'}
+
+
+ )} + +
+

数据与隐私

  • 健康数据保存在自建数据库中,不上传第三方服务。
  • @@ -92,15 +293,11 @@ function SettingsPage() { 届时在「同步」页重新绑定一次即可。
  • 本站登录密码以 PBKDF2 加盐哈希存储,无法还原。
  • -
-
-

账户

- +
+
); diff --git a/client/src/pages/SyncPage.tsx b/client/src/pages/SyncPage.tsx index dd7fa48..2906176 100644 --- a/client/src/pages/SyncPage.tsx +++ b/client/src/pages/SyncPage.tsx @@ -1,14 +1,30 @@ -import Screen from '../components/Screen'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { - apiClient, errorMessage, GarminLoginStatus, parseUtc, SyncStatus, + apiClient, AutoSyncStatus, errorMessage, GarminLoginStatus, parseUtc, + SyncStatus, UserSettings, } from '../services/api'; +import Screen from '../components/Screen'; import './DataSync.css'; const POLL_MS = 2000; +const when = (value: string | null | undefined) => { + const d = parseUtc(value); + if (!d) return '从未'; + const mins = Math.round((Date.now() - d.getTime()) / 60000); + if (mins < 1) return '刚刚'; + if (mins < 60) return `${mins} 分钟前`; + if (mins < 60 * 24) return `${Math.round(mins / 60)} 小时前`; + return d.toLocaleDateString('zh-CN'); +}; + +const historyLabel = (days: number) => + days === 0 ? '全部历史' : days >= 365 ? `${days / 365} 年` : `${days} 天`; + function SyncPage() { const [syncStatus, setSyncStatus] = useState(null); + const [auto, setAuto] = useState(null); + const [settings, setSettings] = useState(null); const [hasToken, setHasToken] = useState(null); // Garmin login (only needed until a token is stored) @@ -31,22 +47,28 @@ function SyncPage() { } }, []); - const loadSyncStatus = useCallback(async () => { + const refresh = useCallback(async () => { try { - setSyncStatus(await apiClient.getGarminSyncStatus()); - } catch (err) { + const [s, a] = await Promise.all([ + apiClient.getGarminSyncStatus(), + apiClient.getAutoSyncStatus().catch(() => null), + ]); + setSyncStatus(s); + if (a) setAuto(a); + return s; + } catch { // A failed status poll should not blank the page. - console.error('Failed to load sync status:', err); + return null; } }, []); useEffect(() => { // A backfill outlives the page, so a reload must pick the progress back up. - apiClient.getGarminSyncStatus().then((s) => { - setSyncStatus(s); - if (s.status === 'syncing') beginSyncPolling(); - }).catch(() => undefined); + refresh().then((s) => { + if (s?.status === 'syncing') beginSyncPolling(); + }); apiClient.getGarminAuthStatus().then(setHasToken).catch(() => setHasToken(false)); + apiClient.getSettings().then(setSettings).catch(() => setSettings(null)); return stopPolling; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -88,7 +110,7 @@ function SyncPage() { stopPolling(); setSession(null); setHasToken(true); - setMessage('Garmin 登录成功,之后同步不再需要密码或验证码。'); + setMessage('绑定成功,之后同步不再需要密码或验证码。'); } else if (status === 'failed') { stopPolling(); setSession(null); @@ -140,13 +162,32 @@ function SyncPage() { }; // --- sync --------------------------------------------------------------- - const handleSync = async (days: number) => { + + /** The last couple of days, awaited inline — seconds, not minutes. */ + const syncLatest = async () => { setError(''); setMessage(''); setLoading(true); try { - await apiClient.syncGarminData(days); - await loadSyncStatus(); + const result = await apiClient.syncLatest(2); + await refresh(); + setMessage(result.message || `已更新最近 ${result.recordsSynced ?? 2} 天`); + } catch (err: any) { + setError(errorMessage(err, '同步失败')); + } finally { + setLoading(false); + } + }; + + /** The configured history window, in the background with progress. */ + const syncHistory = async () => { + setError(''); + setMessage(''); + setLoading(true); + try { + // 0 means "everything"; the backend caps it at what Garmin will serve. + await apiClient.syncGarminData(settings?.historyDays || 3650); + await refresh(); beginSyncPolling(); } catch (err: any) { setError(errorMessage(err, '同步失败')); @@ -155,29 +196,19 @@ function SyncPage() { } }; - // The sync runs in the background, so the page follows it by polling - // rather than by holding a request open for the whole backfill. + // The backfill runs in the background, so the page follows it by polling + // rather than by holding a request open for the whole thing. const beginSyncPolling = () => { stopPolling(); pollRef.current = window.setInterval(async () => { - try { - const s = await apiClient.getGarminSyncStatus(); - setSyncStatus(s); - if (s.status !== 'syncing') { - stopPolling(); - if (s.status === 'error') setError(s.lastError || '同步失败'); - else setMessage(`同步完成,已更新 ${s.recordsSynced} 天数据`); - } - } catch { + const s = await refresh(); + if (!s) { stopPolling(); return; } + if (s.status !== 'syncing') { stopPolling(); + if (s.status === 'error') setError(s.lastError || '同步失败'); + else setMessage(`同步完成,已更新 ${s.recordsSynced} 天数据`); } - }, 2000); - }; - - const statusLabel: Record = { - idle: '就绪', - syncing: '正在同步…', - error: '上次同步失败', + }, POLL_MS); }; const syncing = syncStatus?.status === 'syncing'; @@ -189,176 +220,150 @@ function SyncPage() { return ( -

数据同步

-

从 Garmin Connect 拉取最近 7 天的健康数据

+ {error &&
{error}
} + {message &&
{message}
} -
-
-

同步状态

- {syncStatus ? ( -
-
- 状态 - - {statusLabel[syncStatus.status] ?? syncStatus.status} - + {/* Step 1 — link the Garmin account, once. */} + {hasToken === false && !session && ( +
+

绑定 Garmin 账号

+

+ 只需一次。保存的是授权令牌(约一年有效),密码不会被存储。 + 开启了两步验证的话,下一步会让你填验证码。 +

+
+ setPassword(e.target.value)} + placeholder="Garmin 密码" + autoComplete="current-password" + disabled={loading} + /> +
+ +
+ )} + + {/* Step 2 — the two-factor code. */} + {session && ( +
+ {loginState === 'starting' && !codeSubmitted && ( +

正在连接 Garmin…

+ )} + + {awaitingCode && ( +
+

输入验证码

+

Garmin 已向你的手机或邮箱发送了 6 位验证码。

+
+ setCode(e.target.value)} + placeholder="6 位数字" + className="code-input" + disabled={loading || codeSubmitted} + autoFocus + />
-
- 最后同步 - - {parseUtc(syncStatus.lastSyncTime)?.toLocaleString('zh-CN') - ?? '从未同步'} - +
+ +
-
- 已同步天数 - {syncStatus.recordsSynced} -
- {syncStatus.lastError && ( -
- 错误 - {syncStatus.lastError} -
- )} -
- ) : ( -

加载中…

+
+ )} + + {loginState === 'finishing' && ( +

验证通过,正在完成登录…

)}
+ )} - {/* Step 1 — link the Garmin account, once. */} - {hasToken === false && !session && ( -
-
- - setPassword(e.target.value)} - placeholder="••••••••" - autoComplete="current-password" - disabled={loading} - /> + {/* Step 3 — the whole page, once linked. Status on top, two actions + under it; the old version buried both under four paragraphs. */} + {hasToken === true && ( + <> + {syncing ? ( +
+
+ 正在同步… + {current} / {total} 天 +
+
+
+

- 只需绑定一次。登录成功后保存的是 Garmin 授权令牌(有效期约一年), - 密码不会被存储。若账号开启了两步验证,下一步会让你填验证码。 + 在后台运行,可以离开本页 + {total > 60 ? `,预计还需 ${Math.ceil(((total - current) * 3) / 60)} 分钟` : ''}。

+
+ ) : ( +
+ +
- - - )} + )} - {/* Step 2 — the two-factor code. */} - {session && ( -
- {loginState === 'starting' && !codeSubmitted && ( -

正在连接 Garmin…

- )} +
+
+ 上次同步 + + {when(syncStatus?.lastSyncTime)} + +
+
+ 自动同步 + + {auto?.account?.autoSync === false + ? '已关闭' + : auto?.account + ? `每 ${auto.account.intervalMinutes >= 60 + ? `${auto.account.intervalMinutes / 60} 小时` + : `${auto.account.intervalMinutes} 分钟`}` + : '—'} + +
+
+ 已同步天数 + {syncStatus?.recordsSynced ?? 0} +
+
- {awaitingCode && ( -
-

输入验证码

-

- Garmin 已向你的手机或邮箱发送了 6 位验证码,请在下方填写。 -

-
- setCode(e.target.value)} - placeholder="6 位数字" - className="code-input" - disabled={loading || codeSubmitted} - autoFocus - /> -
-
- - -
-
- )} - - {loginState === 'finishing' && ( -

验证通过,正在完成登录…

- )} -
- )} - - {/* Step 3 — sync, once linked. */} - {hasToken === true && ( -
-

拉取数据

-

- 已绑定 Garmin 账号,同步无需密码。首次建议回补一段历史, - 之后日常只需拉最近 7 天。 -

- - {syncing && total > 0 ? ( -
-
- 正在同步… - {current} / {total} 天 -
-
-
-
-

- 在后台运行,可以离开本页。约每天 3 秒, - {total > 60 ? `预计 ${Math.ceil((total * 3) / 60)} 分钟左右。` : ''} -

-
- ) : ( -
- {[7, 30, 90, 365].map((d) => ( - - ))} -
- )} -
- )} - - {error &&
{error}
} - {message &&
{message}
} - -
-

关于数据同步

-
    -
  • 每次同步获取最近 7 天的每日汇总与运动记录
  • -
  • 同一天重复同步会更新原有记录,不会产生重复数据
  • -
  • 数据保存在本机数据库,不经过第三方服务
  • -
  • Garmin 授权令牌约一年过期,届时重新绑定一次即可
  • -
-
-
+ {syncStatus?.lastError && !syncing && ( +

上次错误:{syncStatus.lastError}

+ )} + + )} ); } diff --git a/client/src/routes.ts b/client/src/routes.ts index 7e79b92..c6f1bbd 100644 --- a/client/src/routes.ts +++ b/client/src/routes.ts @@ -7,6 +7,7 @@ import TrendsPage from './pages/TrendsPage'; import MetricDetailPage from './pages/MetricDetailPage'; import ActivityDetailPage from './pages/ActivityDetailPage'; import BodyAgePage from './pages/BodyAgePage'; +import RatingBasisPage from './pages/RatingBasisPage'; import ExercisePage from './pages/ExercisePage'; import SleepPage from './pages/SleepPage'; import SyncPage from './pages/SyncPage'; @@ -32,6 +33,7 @@ const routes: Router.RouteParameters[] = [ { path: '/metric/:id/', component: MetricDetailPage }, { path: '/activity/:id/', component: ActivityDetailPage }, { path: '/body-age/', component: BodyAgePage }, + { path: '/rating-basis/', component: RatingBasisPage }, { path: '/sync/', component: SyncPage }, { path: '/settings/', component: SettingsPage }, { path: '/login/', component: LoginPage }, diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 466b8a3..7d03f44 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -26,10 +26,10 @@ | 2.2 | 验证码在前端输入(人不在电脑前) | Web MFA:后台线程阻塞在 `prompt_mfa`,验证码经数据库跨 worker 交接 | ✅ | | 2.3 | 同步改为用户手动触发,不自动跑 | 同步是界面动作 | ✅ | | 2.4 | 每小时后台同步最新数据 | `job_locks` 抢占,多 worker 只跑一次 | ✅ | -| 2.5 | 「同步最新数据」按钮 | `POST /api/garmin/sync-latest`,窗口 1–7 天,同步返回 | ✅ 接口完成,🚧 界面按钮 | -| 2.6 | 同步频率可设置 | 30 分 / 1 时 / 3 时 / 6 时 / 12 时 / 1 天 | ✅ 接口完成,🚧 界面 | -| 2.7 | 同步历史范围可设置(多久以前 / 全部) | 7/30/90/180/365/730 天,或全部 | ✅ 接口完成,🚧 界面 | -| 2.8 | 同步界面简化设计 | 去掉冗长说明,状态 + 两个动作为主 | 📋 | +| 2.5 | 「同步最新数据」按钮 | 同步页首要按钮,等待即返回结果 | ✅ | +| 2.6 | 同步频率可设置 | 设置页选择,调度器按各账号自己的频率判断是否该同步 | ✅ | +| 2.7 | 同步历史范围可设置(多久以前 / 全部) | 设置页选择,同步页「同步历史」按此范围拉取 | ✅ | +| 2.8 | 同步界面简化设计 | 两个按钮 + 三格状态,去掉原来四段说明文字 | ✅ | ## 三、界面框架 @@ -39,9 +39,9 @@ | 3.2 | Tab:今日 / 健康 / 趋势 / 运动 / 设置 | 每日是趋势的子页 | ✅ | | 3.3 | 首页改三圆环:步数 / 睡眠 / HRV | 并列三环,满环=进入参考区间 | ✅ | | 3.4 | 交互流畅、有动画 | 数字滚动、入场揭示、`prefers-reduced-motion` | ✅ | -| 3.7 | 主界面切到子界面加 loading 动画 | 路由切换时的过渡指示 | 📋 | | 3.5 | 删掉各页右上角的半透明椭圆 | 导航栏右侧 `Link`(带 tooltip)渲染出来的,已移除 | ✅ | | 3.6 | 睡眠入口不放右上角,改为点健康页睡眠卡片进入 | 入口跟着内容走;每日入口同样移进趋势页内容 | ✅ | +| 3.7 | 主界面切到子界面加 loading 动画 | 路由切换时的过渡指示 | 📋 | ## 四、数据展示 @@ -61,9 +61,9 @@ | # | 需求 | 理解 | 状态 | |---|---|---|---| -| 5.1 | 设置项太少:身高/体重/年龄/性别 | `user_settings` 表 | ✅ 接口完成,🚧 界面 | -| 5.2 | 单位设置 | 公制 / 英制 | ✅ 接口完成,🚧 界面 | -| 5.3 | 评分依据要在设置说明中显示 | `GET /api/settings/rating-basis`,逐条列出每个参考区间的出处 | ✅ 接口完成,🚧 界面 | +| 5.1 | 设置项太少:身高/体重/年龄/性别 | 设置页个人资料分组,改动即存,另显示 BMI 与年龄 | ✅ | +| 5.2 | 单位设置 | 公制 / 英制,已存储;各页按此显示待接入 | ✅ 设置完成,🚧 全局套用 | +| 5.3 | 评分依据要在设置说明中显示 | 设置 → 评分依据 `/rating-basis/`,11 项区间逐条列出处;指标详情页也各带一份 | ✅ | ## 六、AI