Files
GarminHealthLab/client/src/pages/DataSync.tsx
ericwyuan cbbff61082 [阶段6] 同步 Garmin 全量数据:31 项日指标 + 奖励 + 个人纪录
原来每天只存 7 个指标,而 get_user_summary 一次就返回 60+ 字段,
另有睡眠分期、训练准备度、耐力分等独立端点从未被调用。

db.py:
- health_data 新增 31 列(距离/活动卡路里/基础代谢/爬楼/强度分钟/
  久坐时长/最高最低心率/最大压力/身体电量四项/血氧/呼吸/
  睡眠深浅REM清醒分期/睡眠血氧/睡眠呼吸/睡眠压力/训练准备度/
  VO2max/耐力分)
- 新增 badges 与 personal_records 两张表,均以 (user_id, garmin_id)
  为主键,重复同步更新而非累积
- 新增增量迁移: CREATE TABLE IF NOT EXISTS 对已存在的表不生效,
  新列必须显式 ALTER,否则生产库上永远不会出现。按列名比对后
  逐个补齐,SQLite 与 MariaDB 都幂等

services/garmin.py:
- _extract_daily 改为汇总 user_summary + sleep + hrv +
  training_readiness + training_status + endurance_score 五个端点
- 每个可选端点用 _safe 包裹:某项设备不记录时留 NULL,不影响当天其余数据
- 新增 sync_badges / sync_personal_records(账号级,每次同步取一次)

fix(garmin): 个人纪录整批写入失败
- Garmin 在同一份数据里混用 ISO 字符串和 Unix 毫秒时间戳,
  prStartTimeGmt 是 1570961412000,写进 DATETIME 列被 MariaDB
  以 1292 拒绝,导致 11 项个人纪录一条都没存进去
- 新增 _to_datetime 统一处理 ISO / 毫秒 / 秒三种形状,并优先取
  Garmin 自己提供的 *Formatted 字段

services/ai.py:
- 送给模型的 CSV 从 7 列扩到 23 列,纳入身体电量、血氧、呼吸、
  训练准备度、耐力分和睡眠分期

接口: GET /api/health/badges、/api/health/personal-records

tests (+13, 共 292):
- 徽章/纪录的往返、重复同步不累积、按用户隔离
- 两个用户可持有同一个 Garmin 徽章 id 而不冲突
- 时间戳三种形状的归一化及无效值不抛异常

NAS 实测: 7 天数据每天 31 项指标、65 个奖励、11 项个人纪录

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 20:48:49 +08:00

312 lines
10 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 {
apiClient, errorMessage, GarminLoginStatus, parseUtc, SyncStatus,
} from '../services/api';
import './DataSync.css';
const POLL_MS = 2000;
function DataSync() {
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [hasToken, setHasToken] = useState<boolean | null>(null);
// Garmin login (only needed until a token is stored)
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 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<string, string> = {
idle: '就绪',
syncing: '正在同步…',
error: '上次同步失败',
};
const busy = loading || syncStatus?.status === 'syncing';
const awaitingCode = loginState === 'awaiting_code' || codeSubmitted;
return (
<div className="page">
<h2></h2>
<p className="subtitle"> Garmin Connect 7 </p>
<div className="sync-container">
<section className="status-card">
<h3></h3>
{syncStatus ? (
<div className="status-info">
<div className="status-item">
<span className="label"></span>
<span className={`value status-${syncStatus.status}`}>
{statusLabel[syncStatus.status] ?? syncStatus.status}
</span>
</div>
<div className="status-item">
<span className="label"></span>
<span className="value">
{parseUtc(syncStatus.lastSyncTime)?.toLocaleString('zh-CN')
?? '从未同步'}
</span>
</div>
<div className="status-item">
<span className="label"></span>
<span className="value">{syncStatus.recordsSynced}</span>
</div>
{syncStatus.lastError && (
<div className="status-item error">
<span className="label"></span>
<span className="value">{syncStatus.lastError}</span>
</div>
)}
</div>
) : (
<p className="placeholder"></p>
)}
</section>
{/* Step 1 — link the Garmin account, once. */}
{hasToken === false && !session && (
<form className="sync-actions" onSubmit={startLogin}>
<div className="form-group">
<label htmlFor="garmin-password">Garmin </label>
<input
id="garmin-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
disabled={loading}
/>
<p className="field-hint">
Garmin
</p>
</div>
<button type="submit" className="btn btn-primary btn-large" disabled={loading}>
{loading ? '正在连接…' : '绑定 Garmin 账号'}
</button>
</form>
)}
{/* Step 2 — the two-factor code. */}
{session && (
<section className="status-card mfa-card">
{loginState === 'starting' && !codeSubmitted && (
<p className="placeholder"> Garmin</p>
)}
{awaitingCode && (
<form onSubmit={submitCode}>
<h3></h3>
<p className="field-hint" style={{ marginBottom: '1rem' }}>
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="placeholder"></p>
)}
</section>
)}
{/* 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>
)}
{error && <div className="error-message">{error}</div>}
{message && <div className="success-message">{message}</div>}
<section className="info-box">
<h4></h4>
<ul>
<li> 7 </li>
<li></li>
<li></li>
<li>Garmin </li>
</ul>
</section>
</div>
</div>
);
}
export default DataSync;