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(null); const [auto, setAuto] = useState(null); const [options, setOptions] = useState(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(null); const [hasToken, setHasToken] = useState(null); // Garmin login (only needed until a token is stored) const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [session, setSession] = useState(null); const [loginState, setLoginState] = useState(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(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 ( {error &&
{error}
} {message &&
{message}
} {/* Step 1 — link the Garmin account, once. */} {hasToken === false && !session && (

绑定 Garmin 账号

只需一次。保存的是授权令牌(约一年有效),密码不会被存储。 开启了两步验证的话,下一步会让你填验证码。

setEmail(e.target.value)} placeholder="Garmin 邮箱" autoComplete="email" disabled={loading} />
setPassword(e.target.value)} placeholder="Garmin 密码" autoComplete="current-password" disabled={loading} />
)} {/* Step 2 — the two-factor code. */} {session && (
{loginState === 'starting' && !codeSubmitted && (

正在连接 Garmin…

)} {awaitingCode && (

输入验证码

Garmin 已向你的手机或邮箱发送了 6 位验证码。

setCode(e.target.value)} placeholder="6 位数字" className="code-input" disabled={loading || codeSubmitted} autoFocus />
)} {loginState === 'finishing' && (

验证通过,正在完成登录…

)}
)} {/* 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 ? (
{syncStatus?.stage || '正在同步…'} {total > 0 ? `${current} / ${total} 天` : `${current} 天`}

在后台运行,可以离开本页 {total > 60 ? `,最多还需 ${Math.ceil(((total - current) * 3) / 60)} 分钟` : ''}。 {total > 365 && '已经存过的日期会跳过,所以中途被打断也不用从头再来。'}

) : ( <> {/* 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. */}

删除已保存的授权令牌,下次同步需重新登录获取新令牌;已同步的数据不会丢失。

)}
上次同步 {when(syncStatus?.lastSyncTime)}
自动同步(最近几天) {auto?.account?.autoSync === false ? '已关闭' : auto?.account ? auto.account.intervalMinutes >= 60 ? `每 ${auto.account.intervalMinutes / 60} 小时` : `每 ${auto.account.intervalMinutes} 分钟` : '—'}
已同步天数 {syncStatus?.totalDays ?? 0}
同步记录 每次自动、手动与立即同步的结果,按时间倒序

同步会取哪些数据

  • 每日指标:步数、心率、HRV、压力、身体电量、血氧、呼吸、睡眠、训练准备度
  • 运动记录与每次运动的完整详情(分段、心率区间、采样曲线)
  • 全天曲线:心率、压力、身体电量、呼吸、血氧
  • 身体成分、成绩预测、爬坡分、饮水、挑战赛、设备

数据只保存在自建数据库,打开页面时读的是本机,不会再回源 Garmin。

{syncStatus?.lastError && !syncing && (

上次错误:{syncStatus.lastError}

)} )}
); } export default SyncPage;