[阶段7.1] 同步改为后台任务 + 进度上报,支持回补历史
趋势页提供了"一年"档,但库里只有 7 天数据,那一档形同虚设。 实测每天约 2.84 秒(一天要打 5 个端点),回补一年需要约 28 分钟, 远超任何 HTTP 超时能等的时间。 - sync_status 新增 progress_current / progress_total / started_at - start_sync() 起后台线程并立即返回,sync_data 每 5 天写一次进度 (写库便宜但不免费,而前端本来就是 2 秒一轮询) - POST /api/garmin/sync 改为 202 立即返回,接受 days 参数并 夹在 1..730;进度经 GET /status 轮询 - 一次新同步会清掉上一次的错误,避免旧错误一直挂在界面上 前端: - 同步页给出 7 / 30 / 90 / 365 天四个选项,日常与首次回补分开 - 进度条显示"第 N / 共 M 天"与预计耗时,并说明可以离开本页 - 页面挂载时若发现正在同步会接着轮询 —— 回补比页面存活时间长, 刷新后必须能接上进度 tests (+7, 共 299): - start_sync 在工作完成前就返回,且返回前已把 total 写好 - 进度随同步推进,结束时等于总天数 - days 超范围被夹到 730 - 新同步清除上一次的错误 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -199,3 +199,43 @@
|
||||
border-color: #bbb;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.sync-choices {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.progress-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.progress-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.progress-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: var(--surface-0);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
@@ -40,10 +40,15 @@ function DataSync() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSyncStatus();
|
||||
// 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);
|
||||
apiClient.getGarminAuthStatus().then(setHasToken).catch(() => setHasToken(false));
|
||||
return stopPolling;
|
||||
}, [loadSyncStatus, stopPolling]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// --- Garmin login -------------------------------------------------------
|
||||
const startLogin = async (e: React.FormEvent) => {
|
||||
@@ -134,20 +139,14 @@ function DataSync() {
|
||||
};
|
||||
|
||||
// --- sync ---------------------------------------------------------------
|
||||
const handleSync = async () => {
|
||||
const handleSync = async (days: number) => {
|
||||
setError('');
|
||||
setMessage('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await apiClient.syncGarminData();
|
||||
if (result.status === 'success') {
|
||||
const acts = result.activitiesSynced ?? 0;
|
||||
setMessage(`同步完成:${result.recordsSynced} 天数据、${acts} 条运动记录`);
|
||||
} else {
|
||||
setError(result.message);
|
||||
if (result.mfaRequired) setHasToken(false);
|
||||
}
|
||||
loadSyncStatus();
|
||||
await apiClient.syncGarminData(days);
|
||||
await loadSyncStatus();
|
||||
beginSyncPolling();
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '同步失败'));
|
||||
} finally {
|
||||
@@ -155,14 +154,37 @@ function DataSync() {
|
||||
}
|
||||
};
|
||||
|
||||
// The sync runs in the background, so the page follows it by polling
|
||||
// rather than by holding a request open for the whole backfill.
|
||||
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 {
|
||||
stopPolling();
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
idle: '就绪',
|
||||
syncing: '正在同步…',
|
||||
error: '上次同步失败',
|
||||
};
|
||||
|
||||
const busy = loading || syncStatus?.status === 'syncing';
|
||||
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 (
|
||||
<div className="page">
|
||||
@@ -279,16 +301,48 @@ function DataSync() {
|
||||
|
||||
{/* Step 3 — sync, once linked. */}
|
||||
{hasToken === true && (
|
||||
<div className="sync-actions">
|
||||
<p className="field-hint">已绑定 Garmin 账号,同步无需密码。</p>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
className="btn btn-primary btn-large"
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? '正在同步…' : '立即同步'}
|
||||
</button>
|
||||
</div>
|
||||
<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="error-message">{error}</div>}
|
||||
|
||||
@@ -100,6 +100,10 @@ export interface SyncStatus {
|
||||
lastSyncTime: string | null;
|
||||
recordsSynced: number;
|
||||
lastError: string | null;
|
||||
/** Days completed / requested. A backfill runs for many minutes. */
|
||||
progressCurrent: number | null;
|
||||
progressTotal: number | null;
|
||||
startedAt: string | null;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
@@ -266,15 +270,14 @@ class ApiClient {
|
||||
* plaintext password must be supplied because only a hash is kept — and an
|
||||
* MFA-protected account cannot log in this way at all (see garmin_login.py).
|
||||
*/
|
||||
async syncGarminData(garminPassword?: string, garminEmail?: string) {
|
||||
const { data } = await this.client.post<SyncResult>(
|
||||
/** Starts a sync in the background; poll getGarminSyncStatus for progress. */
|
||||
async syncGarminData(days?: number, garminPassword?: string) {
|
||||
const { data } = await this.client.post<{ status: string; days?: number }>(
|
||||
'/garmin/sync',
|
||||
{
|
||||
...(days ? { days } : {}),
|
||||
...(garminPassword ? { garminPassword } : {}),
|
||||
...(garminEmail ? { garminEmail } : {}),
|
||||
},
|
||||
// Pulling a week of days plus activities is many upstream calls.
|
||||
{ timeout: 180_000 }
|
||||
}
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user