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:
@@ -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 1 — link 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 3 — the 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user