feat(ui): 设置页重做 + 同步页简化 + 评分依据页

需求 2.5 / 2.6 / 2.7 / 2.8 / 5.1 / 5.2 / 5.3

设置页
- 个人资料:身高/体重/出生日期/性别,另显示算出的 BMI 与年龄
- 单位:公制 / 英制
- 同步:自动同步开关、频率、历史范围
- 改动即存,不放「保存」按钮——每项都是单值且效果直观,
  而能保持脏状态的表单就是会悄悄丢编辑的表单
- 开关用原生 checkbox 只换绘制,键盘与读屏行为保持不变

/rating-basis/ 评分依据
- 11 项参考区间逐条列出边界与出处,包括「一般性参考,无权威标准」这种
  诚实的答案。判断从哪来必须能查到。

同步页
- 首要按钮「同步最新数据」,等几秒直接返回结果
- 次要按钮「同步历史」,按设置里的历史范围后台跑
- 状态压缩成三格:上次同步 / 自动同步 / 已同步天数
- 删掉原来四段说明文字,它们把两个动作埋在了下面

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-24 00:44:07 +08:00
parent b0e0799a97
commit 0c7fecc124
7 changed files with 714 additions and 266 deletions

View File

@@ -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;
}

View File

@@ -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<RatingBasis | null>(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 <Screen title="评分依据" backLink><Skeleton count={5} /></Screen>;
if (error || !basis) {
return (
<Screen title="评分依据" backLink>
<div className="screen-error">{error || '加载失败'}</div>
</Screen>
);
}
return (
<Screen title="评分依据" backLink>
<p className="ba-summary">{basis.note}</p>
<section className="sec">
<h3 className="sec-title"></h3>
<div className="ba-basis">
{basis.bands.map((b) => (
<div className="ba-basis-item" key={b.metric}>
<div className="ba-basis-name">{b.metric}</div>
<div className="ba-basis-detail rb-bands">{b.bands}</div>
<div className="ba-basis-source">{b.source}</div>
</div>
))}
</div>
</section>
<section className="sec">
<h3 className="sec-title">{basis.fitnessAge.title}</h3>
<p className="ba-summary">{basis.fitnessAge.summary}</p>
<div className="ba-basis">
{basis.fitnessAge.steps.map((s) => (
<div className="ba-basis-item" key={s.name}>
<div className="ba-basis-name">{s.name}</div>
<div className="ba-basis-detail">{s.detail}</div>
<div className="ba-basis-source">{s.source}</div>
</div>
))}
</div>
</section>
<p className="screen-disclaimer">{basis.fitnessAge.caveat}</p>
</Screen>
);
}
export default RatingBasisPage;

View File

@@ -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; }
}

View File

@@ -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<string, string> = {
male: '男', female: '女', other: '其他',
};
const UNIT_LABEL: Record<string, string> = {
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<UserSettings | null>(null);
const [options, setOptions] = useState<SettingsOptions | null>(null);
const [models, setModels] = useState<ModelInfo[]>([]);
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<UserSettings>) => {
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<string | number>,
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 <Screen title="设置"><Skeleton count={5} /></Screen>;
}
const s = settings;
return (
<Screen title="设置">
<h2></h2>
{error && <div className="screen-error">{error}</div>}
{saved && <div className="screen-ok">{saved}</div>}
{FEATURES.ai && (
<section className="settings-section">
<h3>AI </h3>
{s && (
<>
<section className="sec">
<h3 className="sec-title"></h3>
<p className="settings-hint">
<code>backend/.env</code>
<code>AI_MODEL_CHAIN</code>
BMI
</p>
<div className="set-rows">
<button
className="set-row"
onClick={() => promptNumber('身高', 'cm', s.heightCm,
(v) => save({ heightCm: v }))}
>
<span className="set-label"></span>
<span className="set-value">
{s.heightCm == null ? '未设置' : `${s.heightCm} cm`}
</span>
<span className="set-chevron" aria-hidden="true"></span>
</button>
{loading ? (
<p className="screen-note"></p>
) : models.length === 0 ? (
<p className="screen-note"></p>
) : (
<table className="model-table">
<button
className="set-row"
onClick={() => promptNumber('体重', 'kg', s.weightKg,
(v) => save({ weightKg: v }))}
>
<span className="set-label"></span>
<span className="set-value">
{s.weightKg == null ? '未设置' : `${s.weightKg} kg`}
</span>
<span className="set-chevron" aria-hidden="true"></span>
</button>
<label className="set-row">
<span className="set-label"></span>
<input
className="set-input"
type="date"
value={s.birthDate ?? ''}
max={new Date().toISOString().slice(0, 10)}
onChange={(e) => save({ birthDate: e.target.value || null })}
/>
</label>
<button
className="set-row"
onClick={() => options && pick('性别', options.sexes,
(v) => SEX_LABEL[v] ?? v, s.sex ?? '',
(v) => save({ sex: v }))}
>
<span className="set-label"></span>
<span className="set-value">
{s.sex ? SEX_LABEL[s.sex] : '未设置'}
</span>
<span className="set-chevron" aria-hidden="true"></span>
</button>
<div className="set-row set-row-static">
<span className="set-label">BMI</span>
<span className="set-value">
{s.bmi ?? '—'}
{s.age != null && <span className="set-sub"> · {s.age} </span>}
</span>
</div>
</div>
</section>
<section className="sec">
<h3 className="sec-title"></h3>
<div className="set-rows">
<button
className="set-row"
onClick={() => options && pick('单位', options.units,
(v) => UNIT_LABEL[v] ?? v, s.units, (v) => save({ units: v }))}
>
<span className="set-label"></span>
<span className="set-value">{UNIT_LABEL[s.units]}</span>
<span className="set-chevron" aria-hidden="true"></span>
</button>
</div>
</section>
<section className="sec">
<h3 className="sec-title"></h3>
<div className="set-rows">
<label className="set-row">
<span className="set-label">
<span className="set-sub"></span>
</span>
<span className="toggle">
<input
type="checkbox"
checked={s.autoSync}
onChange={(e) => save({ autoSync: e.target.checked })}
/>
<span className="toggle-track" aria-hidden="true" />
</span>
</label>
<button
className="set-row"
disabled={!s.autoSync}
onClick={() => options && pick('同步频率', options.autoSyncMinutes,
intervalLabel, s.autoSyncMinutes,
(v) => save({ autoSyncMinutes: v }))}
>
<span className="set-label"></span>
<span className="set-value">{intervalLabel(s.autoSyncMinutes)}</span>
<span className="set-chevron" aria-hidden="true"></span>
</button>
<button
className="set-row"
onClick={() => options && pick('历史范围', options.historyDays,
historyLabel, s.historyDays,
(v) => save({ historyDays: v }))}
>
<span className="set-label">
<span className="set-sub"></span>
</span>
<span className="set-value">{historyLabel(s.historyDays)}</span>
<span className="set-chevron" aria-hidden="true"></span>
</button>
<Link href="/sync/" className="set-row">
<span className="set-label"></span>
<span className="set-value"> · </span>
<span className="set-chevron" aria-hidden="true"></span>
</Link>
</div>
</section>
</>
)}
<section className="sec">
<h3 className="sec-title"></h3>
<p className="settings-hint">
/ /
AI
</p>
<div className="set-rows">
<Link href="/rating-basis/" className="set-row">
<span className="set-label"></span>
<span className="set-chevron" aria-hidden="true"></span>
</Link>
<Link href="/body-age/" className="set-row">
<span className="set-label"></span>
<span className="set-chevron" aria-hidden="true"></span>
</Link>
</div>
</section>
{FEATURES.ai && models.length > 0 && (
<section className="sec">
<h3 className="sec-title">AI </h3>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th scope="col">ID</th><th scope="col"></th><th scope="col"></th>
</tr>
</thead>
<tbody>
{models.map((m) => (
<tr key={m.id}>
<td>
<code>{m.id}</code>
{m.default && <span className="badge-default"></span>}
</td>
<td className="model-name">{m.model}</td>
<th scope="row"><code>{m.id}</code></th>
<td>{(m.contextWindow / 1000).toLocaleString()}k</td>
<td>
<span className={`badge ${m.configured ? 'ok' : 'off'}`}>
{m.configured ? '已配置' : '缺少密钥'}
</span>
</td>
<td>{m.configured ? '已配置' : '缺少密钥'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
)}
<section className="settings-section">
<h3></h3>
<p className="settings-hint"> Garmin Connect </p>
<Link href="/sync/" className="button button-fill button-round">
</Link>
</section>
<section className="settings-section">
<h3></h3>
<section className="sec">
<h3 className="sec-title"></h3>
<ul className="settings-list">
<li></li>
<li>
@@ -92,15 +293,11 @@ function SettingsPage() {
</li>
<li> PBKDF2 </li>
</ul>
</section>
<section className="settings-section">
<h3></h3>
<button className="btn btn-danger" onClick={handleLogout}>
退
</button>
<section className="sec">
<button className="btn btn-danger" onClick={handleLogout}>退</button>
</section>
</Screen>
);

View File

@@ -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<SyncStatus | null>(null);
const [auto, setAuto] = useState<AutoSyncStatus | null>(null);
const [settings, setSettings] = useState<UserSettings | null>(null);
const [hasToken, setHasToken] = useState<boolean | null>(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);
const s = await refresh();
if (!s) { stopPolling(); return; }
if (s.status !== 'syncing') {
stopPolling();
if (s.status === 'error') setError(s.lastError || '同步失败');
else setMessage(`同步完成,已更新 ${s.recordsSynced} 天数据`);
}
} catch {
stopPolling();
}
}, 2000);
};
const statusLabel: Record<string, string> = {
idle: '就绪',
syncing: '正在同步…',
error: '上次同步失败',
}, POLL_MS);
};
const syncing = syncStatus?.status === 'syncing';
@@ -189,71 +220,37 @@ function SyncPage() {
return (
<Screen title="数据同步" backLink>
<h2></h2>
<p className="subtitle"> Garmin Connect 7 </p>
<div className="sync-container">
<section className="status-card">
<h3></h3>
{syncStatus ? (
<div className="status-info">
<div className="status-item">
<span className="label"></span>
<span className={`value status-${syncStatus.status}`}>
{statusLabel[syncStatus.status] ?? syncStatus.status}
</span>
</div>
<div className="status-item">
<span className="label"></span>
<span className="value">
{parseUtc(syncStatus.lastSyncTime)?.toLocaleString('zh-CN')
?? '从未同步'}
</span>
</div>
<div className="status-item">
<span className="label"></span>
<span className="value">{syncStatus.recordsSynced}</span>
</div>
{syncStatus.lastError && (
<div className="status-item error">
<span className="label"></span>
<span className="value">{syncStatus.lastError}</span>
</div>
)}
</div>
) : (
<p className="screen-note"></p>
)}
</section>
{error && <div className="screen-error">{error}</div>}
{message && <div className="screen-ok">{message}</div>}
{/* Step 1 — link the Garmin account, once. */}
{hasToken === false && !session && (
<form className="sync-actions" onSubmit={startLogin}>
<form className="sync-card" onSubmit={startLogin}>
<h3> Garmin </h3>
<p className="field-hint">
</p>
<div className="form-group">
<label htmlFor="garmin-password">Garmin </label>
<input
id="garmin-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
placeholder="Garmin 密码"
autoComplete="current-password"
disabled={loading}
/>
<p className="field-hint">
Garmin
</p>
</div>
<button type="submit" className="btn btn-primary btn-large" disabled={loading}>
{loading ? '正在连接…' : '绑定 Garmin 账号'}
{loading ? '正在连接…' : '绑定'}
</button>
</form>
)}
{/* Step 2 — the two-factor code. */}
{session && (
<section className="status-card mfa-card">
<section className="sync-card mfa-card">
{loginState === 'starting' && !codeSubmitted && (
<p className="screen-note"> Garmin</p>
)}
@@ -261,9 +258,7 @@ function SyncPage() {
{awaitingCode && (
<form onSubmit={submitCode}>
<h3></h3>
<p className="field-hint" style={{ marginBottom: '1rem' }}>
Garmin 6
</p>
<p className="field-hint">Garmin 6 </p>
<div className="form-group">
<input
id="mfa-code"
@@ -285,7 +280,7 @@ function SyncPage() {
className="btn btn-primary"
disabled={loading || codeSubmitted || !code.trim()}
>
{codeSubmitted ? '正在验证…' : '提交验证码'}
{codeSubmitted ? '正在验证…' : '提交'}
</button>
<button type="button" className="btn btn-plain" onClick={cancelLogin}>
@@ -300,17 +295,12 @@ function SyncPage() {
</section>
)}
{/* Step 3 — sync, once linked. */}
{/* Step 3 — the whole page, once linked. Status on top, two actions
under it; the old version buried both under four paragraphs. */}
{hasToken === true && (
<section className="status-card">
<h3></h3>
<p className="field-hint" style={{ marginBottom: '0.9rem' }}>
Garmin
7
</p>
{syncing && total > 0 ? (
<div className="progress-block">
<>
{syncing ? (
<section className="sync-card">
<div className="progress-head">
<span></span>
<span className="progress-count">{current} / {total} </span>
@@ -325,40 +315,55 @@ function SyncPage() {
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
<p className="field-hint">
3
{total > 60 ? `预计 ${Math.ceil((total * 3) / 60)} 分钟左右。` : ''}
{total > 60 ? `预计还需 ${Math.ceil(((total - current) * 3) / 60)} 分钟` : ''}
</p>
</div>
</section>
) : (
<div className="sync-choices">
{[7, 30, 90, 365].map((d) => (
<div className="sync-actions-row">
<button
key={d}
onClick={() => handleSync(d)}
className={`btn ${d === 7 ? 'btn-primary' : 'btn-plain'}`}
className="btn btn-primary btn-large"
onClick={syncLatest}
disabled={busy}
>
{d === 365 ? '回补一年' : `最近 ${d}`}
{loading ? '同步中…' : '同步最新数据'}
</button>
<button className="btn btn-plain" onClick={syncHistory} disabled={busy}>
{historyLabel(settings?.historyDays ?? 365)}
</button>
))}
</div>
)}
</section>
)}
{error && <div className="screen-error">{error}</div>}
{message && <div className="screen-ok">{message}</div>}
<section className="info-box">
<h4></h4>
<ul>
<li> 7 </li>
<li></li>
<li></li>
<li>Garmin </li>
</ul>
</section>
<div className="sync-facts">
<div className="sync-fact">
<span className="sync-fact-label"></span>
<span className="sync-fact-value">
{when(syncStatus?.lastSyncTime)}
</span>
</div>
<div className="sync-fact">
<span className="sync-fact-label"></span>
<span className="sync-fact-value">
{auto?.account?.autoSync === false
? '已关闭'
: auto?.account
? `${auto.account.intervalMinutes >= 60
? `${auto.account.intervalMinutes / 60} 小时`
: `${auto.account.intervalMinutes} 分钟`}`
: '—'}
</span>
</div>
<div className="sync-fact">
<span className="sync-fact-label"></span>
<span className="sync-fact-value">{syncStatus?.recordsSynced ?? 0}</span>
</div>
</div>
{syncStatus?.lastError && !syncing && (
<p className="sync-lasterror">{syncStatus.lastError}</p>
)}
</>
)}
</Screen>
);
}

View File

@@ -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 },

View File

@@ -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`,窗口 17 天,同步返回 | ✅ 接口完成,🚧 界面按钮 |
| 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