import React, { useCallback, useEffect, useRef, useState } from 'react'; import { apiClient, errorMessage, GarminLoginStatus, parseUtc, SyncStatus, } from '../services/api'; import './DataSync.css'; const POLL_MS = 2000; function DataSync() { const [syncStatus, setSyncStatus] = useState(null); const [hasToken, setHasToken] = useState(null); // Garmin login (only needed until a token is stored) 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 loadSyncStatus = useCallback(async () => { try { setSyncStatus(await apiClient.getGarminSyncStatus()); } catch (err) { // A failed status poll should not blank the page. console.error('Failed to load sync status:', err); } }, []); useEffect(() => { loadSyncStatus(); apiClient.getGarminAuthStatus().then(setHasToken).catch(() => setHasToken(false)); return stopPolling; }, [loadSyncStatus, stopPolling]); // --- Garmin login ------------------------------------------------------- const startLogin = async (e: React.FormEvent) => { e.preventDefault(); setError(''); setMessage(''); if (!password) { setError('请输入 Garmin 密码'); return; } setLoading(true); try { const sid = await apiClient.startGarminLogin(password); // 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('Garmin 登录成功,之后同步不再需要密码或验证码。'); } 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); }; // --- sync --------------------------------------------------------------- const handleSync = async () => { 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(); } catch (err: any) { setError(errorMessage(err, '同步失败')); } finally { setLoading(false); } }; const statusLabel: Record = { idle: '就绪', syncing: '正在同步…', error: '上次同步失败', }; const busy = loading || syncStatus?.status === 'syncing'; const awaitingCode = loginState === 'awaiting_code' || codeSubmitted; return (

数据同步

从 Garmin Connect 拉取最近 7 天的健康数据

同步状态

{syncStatus ? (
状态 {statusLabel[syncStatus.status] ?? syncStatus.status}
最后同步 {parseUtc(syncStatus.lastSyncTime)?.toLocaleString('zh-CN') ?? '从未同步'}
已同步天数 {syncStatus.recordsSynced}
{syncStatus.lastError && (
错误 {syncStatus.lastError}
)}
) : (

加载中…

)}
{/* Step 1 — link the Garmin account, once. */} {hasToken === false && !session && (
setPassword(e.target.value)} placeholder="••••••••" autoComplete="current-password" disabled={loading} />

只需绑定一次。登录成功后保存的是 Garmin 授权令牌(有效期约一年), 密码不会被存储。若账号开启了两步验证,下一步会让你填验证码。

)} {/* 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 — sync, once linked. */} {hasToken === true && (

已绑定 Garmin 账号,同步无需密码。

)} {error &&
{error}
} {message &&
{message}
}

关于数据同步

  • 每次同步获取最近 7 天的每日汇总与运动记录
  • 同一天重复同步会更新原有记录,不会产生重复数据
  • 数据保存在本机数据库,不经过第三方服务
  • Garmin 授权令牌约一年过期,届时重新绑定一次即可
); } export default DataSync;