[阶段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:
ericwyuan
2026-08-23 12:43:52 +08:00
parent c83340742c
commit 8616a13525
30 changed files with 1135 additions and 1777 deletions

View File

@@ -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>
);
}