Files
GarminHealthLab/client/src/pages/SyncPage.tsx
ericwyuan 5e6fc01f76 feat(sync-history): 新增同步结果查询(每次自动/手动/立即同步的记录)
后端:
- db.py SCHEMA 新增 sync_history 表(不可变,每次同步尝试一行) + 索引
- garmin.py: _log_sync_history() 在 sync_data 全部出口记录; trigger 区分
  auto(调度器)/manual(同步页开始同步)/quick(设置页立即同步); start_sync
  限流拦截分支同样留档; 写入失败只告警不影响同步
- scheduler.py 自动同步传 trigger="auto"; routes 新增 GET /garmin/sync-history
  按时间倒序返回(默认 50 条,上限 200)

前端:
- api.ts 增加 SyncHistoryItem 类型 + getSyncHistory()
- 新页面 /sync-history/ 同步记录: 卡片列表, 状态(成功/失败/被限流)chip 配色,
  时间本地化(今天/昨天/X月X日), 触发类型标签, 范围与耗时
- 同步页与设置页同步区块均加入口链接
2026-09-02 20:42:26 +08:00

544 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { f7, Link } from 'framework7-react';
import {
apiClient, AutoSyncStatus, DetailSyncStatus, errorMessage, GarminLoginStatus,
parseUtc, SettingsOptions, SyncStatus,
} 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 === -1 ? '自上次同步'
: days === 0 ? '全部历史'
: days >= 365 ? `${days / 365}`
: `${days}`;
/** What each range actually costs, so the choice is made with eyes open. */
const rangeHint = (days: number) =>
days === -1 ? '只补上次同步之后缺的那几天,最快'
: days === 0 ? '一直回到账号最早的数据,可能要分几次才拉得完'
: days >= 365 ? `${days} 天,大约 ${Math.ceil((days * 3) / 60)} 分钟`
: `${days} 天,几分钟`;
function SyncPage() {
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [auto, setAuto] = useState<AutoSyncStatus | null>(null);
const [options, setOptions] = useState<SettingsOptions | null>(null);
// The manual sync range lives here now, not in 设置: the button that uses it
// is on this page, and a setting that only takes effect somewhere else is
// how 全部历史 came to look like it did nothing. It is still persisted, so
// the picker opens where it was left.
const [range, setRange] = useState(-1);
const [details, setDetails] = useState<DetailSyncStatus | null>(null);
const [hasToken, setHasToken] = useState<boolean | null>(null);
// Garmin login (only needed until a token is stored)
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [session, setSession] = useState<string | null>(null);
const [loginState, setLoginState] = useState<GarminLoginStatus | null>(null);
const [code, setCode] = useState('');
const [codeSubmitted, setCodeSubmitted] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const pollRef = useRef<number | null>(null);
const stopPolling = useCallback(() => {
if (pollRef.current) {
window.clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
const refresh = useCallback(async () => {
try {
const [s, a, d] = await Promise.all([
apiClient.getGarminSyncStatus(),
apiClient.getAutoSyncStatus().catch(() => null),
apiClient.getDetailSyncStatus().catch(() => null),
]);
setSyncStatus(s);
if (a) setAuto(a);
if (d) setDetails(d);
return s;
} catch {
// A failed status poll should not blank the page.
return null;
}
}, []);
useEffect(() => {
// A backfill outlives the page, so a reload must pick the progress back up.
refresh().then((s) => {
if (s?.status === 'syncing') beginSyncPolling();
});
apiClient.getGarminAuthStatus().then(setHasToken).catch(() => setHasToken(false));
apiClient.getSettings().then((s) => setRange(s.historyDays)).catch(() => {});
apiClient.getSettingsOptions().then(setOptions).catch(() => setOptions(null));
return stopPolling;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// --- Garmin login -------------------------------------------------------
const startLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setMessage('');
if (!email) {
setError('请输入 Garmin 邮箱');
return;
}
if (!password) {
setError('请输入 Garmin 密码');
return;
}
setLoading(true);
try {
const sid = await apiClient.startGarminLogin(password, email);
// The password is only ever needed for this one request.
setPassword('');
setSession(sid);
setLoginState('starting');
setCodeSubmitted(false);
beginPolling(sid);
} catch (err: any) {
setError(errorMessage(err, '登录失败'));
} finally {
setLoading(false);
}
};
const beginPolling = (sid: string) => {
stopPolling();
pollRef.current = window.setInterval(async () => {
try {
const { status, error: loginError } = await apiClient.getGarminLoginStatus(sid);
setLoginState(status);
if (status === 'done') {
stopPolling();
setSession(null);
setHasToken(true);
setMessage('绑定成功,之后同步不再需要密码或验证码。');
} else if (status === 'failed') {
stopPolling();
setSession(null);
setCodeSubmitted(false);
setError(loginError || '登录失败,请重试');
}
} catch (err: any) {
stopPolling();
setSession(null);
setError(errorMessage(err, '登录状态查询失败'));
}
}, POLL_MS);
};
const submitCode = async (e: React.FormEvent) => {
e.preventDefault();
if (!session || !code.trim()) return;
setError('');
setLoading(true);
try {
const { ok, message: msg } = await apiClient.submitGarminMfa(session, code.trim());
if (ok) {
setCodeSubmitted(true);
setCode('');
} else {
setError(msg);
}
} catch (err: any) {
setError(errorMessage(err, '验证码提交失败'));
} finally {
setLoading(false);
}
};
const cancelLogin = async () => {
if (session) {
try {
await apiClient.cancelGarminLogin(session);
} catch {
// Cancelling is best-effort; the session expires on its own anyway.
}
}
stopPolling();
setSession(null);
setLoginState(null);
setCode('');
setCodeSubmitted(false);
};
// --- account ------------------------------------------------------------
/** Delete the stored Garmin token so the next sync forces a fresh login. */
const disconnect = async () => {
if (
!window.confirm(
'退出 Garmin 账号会删除已保存的授权令牌,下次同步需重新登录。确定继续?'
)
) {
return;
}
setError('');
setMessage('');
setLoading(true);
try {
const res = await apiClient.disconnectGarmin();
setHasToken(false);
setMessage(res.message || '已退出 Garmin 账号,下次同步需重新登录获取新令牌。');
} catch (err: any) {
setError(errorMessage(err, '退出失败'));
} finally {
setLoading(false);
}
};
// --- sync ---------------------------------------------------------------
/** Choose how far back to pull, and remember it for next time. */
const pickRange = () => {
if (!options) return;
f7.dialog.create({
title: '同步范围',
buttons: [
...options.historyDays.map((v) => ({
text: historyLabel(v) + (v === range ? ' ✓' : ''),
onClick: () => {
setRange(v);
// Persisted, but nothing else reads it — auto-sync has its own
// fixed window and never looks at this.
apiClient.saveSettings({ historyDays: v }).catch(() => {});
},
})),
{ text: '取消', color: 'gray' },
],
verticalButtons: true,
}).open();
};
/** The one manual sync: the chosen range, in the background, with progress. */
const syncHistory = async () => {
setError('');
setMessage('');
setLoading(true);
try {
// 0 means "everything" and -1 "since the last sync"; the backend caps
// the former at what Garmin will actually serve.
const started = await apiClient.syncGarminData(range);
const s = await refresh();
// A refused start (Garmin is throttling us) used to be swallowed: the
// button did nothing, no progress bar appeared, and no reason was shown.
if (started.status === 'rate_limited' || s?.status === 'rate_limited') {
setError(started.message || s?.lastError || 'Garmin 正在限流,稍后会自动恢复。');
return;
}
beginSyncPolling();
} catch (err: any) {
setError(errorMessage(err, '同步失败'));
} finally {
setLoading(false);
}
};
/** Pull the per-activity detail for anything stored without it. */
const syncDetails = async () => {
setError('');
setMessage('');
try {
setDetails(await apiClient.startDetailSync());
beginDetailPolling();
} catch (err: any) {
setError(errorMessage(err, '同步失败'));
}
};
const beginDetailPolling = () => {
const timer = window.setInterval(async () => {
try {
const d = await apiClient.getDetailSyncStatus();
setDetails(d);
if (!d.running) {
window.clearInterval(timer);
if (d.error) setError(d.error);
else if (d.done) setMessage(`已补齐 ${d.done} 条运动的详细数据`);
}
} catch {
window.clearInterval(timer);
}
}, 2000);
};
// 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 () => {
const s = await refresh();
if (!s) { stopPolling(); return; }
if (s.status !== 'syncing') {
stopPolling();
// Only 'idle' means it finished; anything else reported "同步完成"
// while nothing had been synced.
if (s.status === 'idle') setMessage(`同步完成,已更新 ${s.recordsSynced} 天数据`);
else setError(s.lastError || '同步失败');
}
}, POLL_MS);
};
const syncing = syncStatus?.status === 'syncing';
const busy = loading || syncing;
const awaitingCode = loginState === 'awaiting_code' || codeSubmitted;
const current = syncStatus?.progressCurrent ?? 0;
const total = syncStatus?.progressTotal ?? 0;
const pct = total > 0 ? Math.round((current / total) * 100) : 0;
return (
<Screen title="数据同步" backLink>
{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-card" onSubmit={startLogin}>
<h3> Garmin </h3>
<p className="field-hint">
</p>
<div className="form-group">
<input
id="garmin-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Garmin 邮箱"
autoComplete="email"
disabled={loading}
/>
</div>
<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="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 — 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>{syncStatus?.stage || '正在同步…'}</span>
<span className="progress-count">
{total > 0 ? `${current} / ${total}` : `${current}`}
</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">
{total > 60 ? `,最多还需 ${Math.ceil(((total - current) * 3) / 60)} 分钟` : ''}
{total > 365 && '已经存过的日期会跳过,所以中途被打断也不用从头再来。'}
</p>
</section>
) : (
<>
{/* The range is chosen here, right above the button that uses
it. It used to live in 设置 while only taking effect on this
page, so picking 全部历史 there and pressing the (hardcoded
two-day) button here looked like the app ignoring you. */}
<div className="sync-secondary">
<button
className="btn btn-plain"
onClick={pickRange}
disabled={busy || !options}
>
<span className="sync-btn-label"></span>
<span className="sync-btn-sub">
{historyLabel(range)} · {rangeHint(range)}
</span>
</button>
</div>
<button
className="btn btn-primary btn-large sync-primary"
onClick={syncHistory}
disabled={busy}
>
{loading ? '正在启动…' : '开始同步'}
</button>
<div className="sync-secondary">
<button
className="btn btn-plain"
onClick={syncDetails}
disabled={busy || !!details?.running}
>
<span className="sync-btn-label">
{details?.running ? '补齐中…' : '补齐详细数据'}
</span>
<span className="sync-btn-sub">
{details?.running
? `${details.stage ?? ''} ${details.done}/${details.total || '…'}`
: '运动详情与全天曲线'}
</span>
</button>
</div>
<div className="sync-account">
<button
className="btn btn-plain sync-disconnect"
onClick={disconnect}
disabled={busy}
>
退 Garmin
</button>
<p className="field-hint">
</p>
</div>
</>
)}
<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?.totalDays ?? 0}</span>
</div>
</div>
<Link href="/sync-history/" className="sync-records-link">
<span>
<span className="sync-btn-label"></span>
<span className="sync-btn-sub">
</span>
</span>
<span className="set-chevron" aria-hidden="true"></span>
</Link>
<section className="sync-note">
<h3 className="sec-title"></h3>
<ul className="sync-list">
<li>HRV</li>
<li>线</li>
<li>线</li>
<li></li>
</ul>
<p className="field-hint">
Garmin
</p>
</section>
{syncStatus?.lastError && !syncing && (
<p className="sync-lasterror">{syncStatus.lastError}</p>
)}
</>
)}
</Screen>
);
}
export default SyncPage;