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