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

@@ -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);
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<string, string> = {
idle: '就绪',
syncing: '正在同步…',
error: '上次同步失败',
}, POLL_MS);
};
const syncing = syncStatus?.status === 'syncing';
@@ -189,176 +220,150 @@ function SyncPage() {
return (
<Screen title="数据同步" backLink>
<h2></h2>
<p className="subtitle"> Garmin Connect 7 </p>
{error && <div className="screen-error">{error}</div>}
{message && <div className="screen-ok">{message}</div>}
<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>
{/* Step 1 — link the Garmin account, once. */}
{hasToken === false && !session && (
<form className="sync-card" onSubmit={startLogin}>
<h3> Garmin </h3>
<p className="field-hint">
</p>
<div className="form-group">
<input
id="garmin-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Garmin 密码"
autoComplete="current-password"
disabled={loading}
/>
</div>
<button type="submit" className="btn btn-primary btn-large" disabled={loading}>
{loading ? '正在连接…' : '绑定'}
</button>
</form>
)}
{/* Step 2 — the two-factor code. */}
{session && (
<section className="sync-card mfa-card">
{loginState === 'starting' && !codeSubmitted && (
<p className="screen-note"> Garmin</p>
)}
{awaitingCode && (
<form onSubmit={submitCode}>
<h3></h3>
<p className="field-hint">Garmin 6 </p>
<div className="form-group">
<input
id="mfa-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={10}
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="6 位数字"
className="code-input"
disabled={loading || codeSubmitted}
autoFocus
/>
</div>
<div className="status-item">
<span className="label"></span>
<span className="value">
{parseUtc(syncStatus.lastSyncTime)?.toLocaleString('zh-CN')
?? '从未同步'}
</span>
<div className="mfa-buttons">
<button
type="submit"
className="btn btn-primary"
disabled={loading || codeSubmitted || !code.trim()}
>
{codeSubmitted ? '正在验证…' : '提交'}
</button>
<button type="button" className="btn btn-plain" onClick={cancelLogin}>
</button>
</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>
</form>
)}
{loginState === 'finishing' && (
<p className="screen-note"></p>
)}
</section>
)}
{/* Step 1link the Garmin account, once. */}
{hasToken === false && !session && (
<form className="sync-actions" onSubmit={startLogin}>
<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="••••••••"
autoComplete="current-password"
disabled={loading}
/>
{/* Step 3the whole page, once linked. Status on top, two actions
under it; the old version buried both under four paragraphs. */}
{hasToken === true && (
<>
{syncing ? (
<section className="sync-card">
<div className="progress-head">
<span></span>
<span className="progress-count">{current} / {total} </span>
</div>
<div
className="progress-bar"
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
>
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
<p className="field-hint">
Garmin
{total > 60 ? `,预计还需 ${Math.ceil(((total - current) * 3) / 60)} 分钟` : ''}
</p>
</section>
) : (
<div className="sync-actions-row">
<button
className="btn btn-primary btn-large"
onClick={syncLatest}
disabled={busy}
>
{loading ? '同步中…' : '同步最新数据'}
</button>
<button className="btn btn-plain" onClick={syncHistory} disabled={busy}>
{historyLabel(settings?.historyDays ?? 365)}
</button>
</div>
<button type="submit" className="btn btn-primary btn-large" disabled={loading}>
{loading ? '正在连接…' : '绑定 Garmin 账号'}
</button>
</form>
)}
)}
{/* Step 2 — the two-factor code. */}
{session && (
<section className="status-card mfa-card">
{loginState === 'starting' && !codeSubmitted && (
<p className="screen-note"> Garmin</p>
)}
<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>
{awaitingCode && (
<form onSubmit={submitCode}>
<h3></h3>
<p className="field-hint" style={{ marginBottom: '1rem' }}>
Garmin 6
</p>
<div className="form-group">
<input
id="mfa-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={10}
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="6 位数字"
className="code-input"
disabled={loading || codeSubmitted}
autoFocus
/>
</div>
<div className="mfa-buttons">
<button
type="submit"
className="btn btn-primary"
disabled={loading || codeSubmitted || !code.trim()}
>
{codeSubmitted ? '正在验证…' : '提交验证码'}
</button>
<button type="button" className="btn btn-plain" onClick={cancelLogin}>
</button>
</div>
</form>
)}
{loginState === 'finishing' && (
<p className="screen-note"></p>
)}
</section>
)}
{/* Step 3 — sync, once linked. */}
{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">
<div className="progress-head">
<span></span>
<span className="progress-count">{current} / {total} </span>
</div>
<div
className="progress-bar"
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
>
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
<p className="field-hint">
3
{total > 60 ? `预计 ${Math.ceil((total * 3) / 60)} 分钟左右。` : ''}
</p>
</div>
) : (
<div className="sync-choices">
{[7, 30, 90, 365].map((d) => (
<button
key={d}
onClick={() => handleSync(d)}
className={`btn ${d === 7 ? 'btn-primary' : 'btn-plain'}`}
disabled={busy}
>
{d === 365 ? '回补一年' : `最近 ${d}`}
</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>
{syncStatus?.lastError && !syncing && (
<p className="sync-lasterror">{syncStatus.lastError}</p>
)}
</>
)}
</Screen>
);
}