[阶段4.2] 前端对接 Flask API + 建议/分析/设置页面,移除废弃的 Node 后端
选型确认为 Python Flask 后,删除 server/ 整套 Node 实现,
根 package.json 改为只管理 client workspace,
npm run dev 同时拉起 Flask 与 React。
fix: 前端读取响应的方式与后端不符
- Flask 返回裸数组/对象,而前端读的是 response.data.data
(Node 那套 {success,data} 包装),登录后拿到 undefined 直接崩
- api.ts 重写为返回 response.data,并补齐全部端点的 TypeScript 类型
- 401 拦截器改为清除会话并跳转 /login,而不是留在空白页
fix: 同步页缺少 Garmin 密码输入
- 后端只存密码哈希、无法还原,/garmin/sync 要求请求体带明文密码,
原页面没有该输入框,点同步必然 400
- DataSync 增加密码字段,请求结束后立即清空,并说明为何每次都要输入
新增页面:
- Recommendations: 模型下拉切换(未配置密钥的模型置灰),
展示本次由哪个模型作答、是否发生了降级、分析了多少天数据;
按优先级配色,底部附免责声明
- Analysis: 6 个指标 × 4 个时间窗口,展示均值/中位数/极值/
前后半段差值,并绘制趋势图
- Settings: 模型清单与配置状态、数据隐私说明、退出登录
Dashboard:
- 修正响应结构,数据扁平化后再传给图表
(原先给 dataKey 传了函数,与 Chart 的 string 类型不符)
- 无数据时引导用户去同步,而不是显示一堆空图表
Chart:
- 按指标而非按行判断是否为空,某天缺某项指标不再让整张图判空
- 折线图 connectNulls,避免设备漏记的日子把线断成碎片
验证: 前端 tsc --noEmit 通过;后端 161 passed / 1 skipped;
实机启动 Flask 后 register/login/models/ai-recommendations 均正常,
未配置密钥时正确降级到规则引擎。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,139 @@
|
||||
import React from 'react';
|
||||
import './Pages.css';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
apiClient, errorMessage, AiRecommendations, ModelInfo,
|
||||
} 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 load = useCallback(async (model?: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
setResult(await apiClient.getAiRecommendations(model || undefined));
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '获取建议失败'));
|
||||
} finally {
|
||||
setLoading(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="placeholder">健康建议页面即将推出...</p>
|
||||
<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)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '生成中…' : '重新生成'}
|
||||
</button>
|
||||
</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.days} 天数据
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user