原来每天只存 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>
312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
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;
|