后端: - 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日), 触发类型标签, 范围与耗时 - 同步页与设置页同步区块均加入口链接
544 lines
19 KiB
TypeScript
544 lines
19 KiB
TypeScript
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;
|