Files
GarminHealthLab/client/src/pages/Recommendations.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

162 lines
5.3 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 { useCallback, useEffect, useState } from 'react';
import {
apiClient, errorMessage, AiRecommendations, ModelInfo, parseUtc,
} from '../services/api';
import './Recommendations.css';
const PRIORITY_LABEL: Record<string, string> = {
high: '高',
medium: '中',
low: '低',
};
function Recommendations() {
const [models, setModels] = useState<ModelInfo[]>([]);
const [selected, setSelected] = useState<string>('');
const [result, setResult] = useState<AiRecommendations | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [regenerating, setRegenerating] = useState(false);
const load = useCallback(async (model?: string, refresh?: boolean) => {
setLoading(true);
setRegenerating(Boolean(refresh || model));
setError('');
try {
setResult(await apiClient.getAiRecommendations(model || undefined, refresh));
} catch (err: any) {
setError(errorMessage(err, '获取建议失败'));
} finally {
setLoading(false);
setRegenerating(false);
}
}, []);
useEffect(() => {
const init = async () => {
try {
const list = await apiClient.getModels();
setModels(list);
setSelected(list.find((m) => m.default && m.configured)?.id ?? '');
} catch {
// The model list is a convenience; recommendations still work without it.
}
load();
};
init();
}, [load]);
const configured = models.filter((m) => m.configured);
const meta = result?.meta;
return (
<div className="page">
<h2></h2>
<p className="subtitle"></p>
<div className="model-bar">
<label htmlFor="model-select"></label>
<select
id="model-select"
value={selected}
onChange={(e) => {
setSelected(e.target.value);
load(e.target.value);
}}
disabled={loading || configured.length === 0}
>
<option value=""></option>
{models.map((m) => (
<option key={m.id} value={m.id} disabled={!m.configured}>
{m.id} · {(m.contextWindow / 1000).toLocaleString()}k
{m.configured ? '' : '(未配置密钥)'}
</option>
))}
</select>
<button
className="btn btn-primary"
onClick={() => load(selected, true)}
disabled={loading}
>
{loading ? '生成中…' : '重新生成'}
</button>
</div>
{regenerating && (
<div className="notice">
1-3
</div>
)}
{configured.length === 0 && models.length > 0 && (
<div className="notice">
<code>backend/.env</code>
<code>GEMINI_API_KEY</code> <code>NVIDIA_API_KEY</code> AI
</div>
)}
{error && <div className="error-message">{error}</div>}
{meta && (
<div className={`meta-bar ${meta.source}`}>
{meta.source === 'ai' ? (
<>
<strong>AI </strong> · {meta.model}
{meta.upstream && `(上游 ${meta.upstream}`}
{' · '} {meta.days}
{meta.cached && (
<span className="fallback">
·
{meta.generatedAt &&
`,生成于 ${parseUtc(meta.generatedAt)?.toLocaleString('zh-CN')}`}
</span>
)}
{meta.fallbackFrom && meta.fallbackFrom.length > 0 && (
<span className="fallback">
{meta.fallbackFrom.join('、')}
</span>
)}
</>
) : (
<>
<strong></strong>
{meta.reason && <span className="fallback">· {meta.reason}</span>}
</>
)}
</div>
)}
{loading && !result && <div className="page-loading"></div>}
<div className="rec-list">
{result?.recommendations.map((rec) => (
<article key={rec.id} className={`rec-card priority-${rec.priority}`}>
<header className="rec-header">
<span className="rec-category">{rec.category}</span>
<span className={`rec-priority priority-${rec.priority}`}>
{PRIORITY_LABEL[rec.priority] ?? rec.priority}
</span>
</header>
<p className="rec-body">{rec.recommendation}</p>
{rec.basedOn.length > 0 && (
<footer className="rec-based-on">
{rec.basedOn.join('、')}
</footer>
)}
</article>
))}
</div>
<p className="disclaimer">
</p>
</div>
);
}
export default Recommendations;