[阶段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

@@ -3,15 +3,86 @@ import axios, { AxiosInstance } from 'axios';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
const TOKEN_KEY = 'ghl_token';
// The Flask backend returns bare JSON (an array, or the object itself) and
// signals failure with `{ error: "..." }` plus a non-2xx status. There is no
// {success, data} envelope, so responses are read as `response.data` directly.
export interface AuthResponse {
id: string;
email: string;
token: string;
}
export interface HealthDay {
date: string;
steps: number | null;
heartRate: number | null;
heartRateVariability: number | null;
sleep: { duration: number; quality: number | null } | null;
stress: number | null;
caloriesBurned: number | null;
}
export interface SyncStatus {
status: 'idle' | 'syncing' | 'error';
lastSyncTime: string | null;
recordsSynced: number;
lastError: string | null;
}
export interface SyncResult {
status: 'success' | 'error';
recordsSynced: number;
message: string;
lastSyncTime: string;
}
export interface Recommendation {
id: string;
category: string;
recommendation: string;
priority: 'high' | 'medium' | 'low';
basedOn: string[];
source?: 'ai';
}
export interface AiRecommendations {
recommendations: Recommendation[];
meta: {
source: 'ai' | 'rules';
model: string | null;
provider?: string;
days?: number;
fallbackFrom?: string[];
reason?: string;
};
}
export interface ModelInfo {
id: string;
model: string;
provider: string;
contextWindow: number;
configured: boolean;
default: boolean;
}
export interface TrendPoint {
date: string;
value: number;
}
/** Pull a human-readable message out of an axios error. */
export function errorMessage(err: any, fallback = '请求失败'): string {
return err?.response?.data?.error || err?.message || fallback;
}
class ApiClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
});
// Attach the saved JWT to every request.
@@ -24,36 +95,23 @@ class ApiClient {
return config;
});
// On 401, drop the stored session so the UI can redirect to login.
// On 401 drop the session and bounce to login, so an expired token does
// not leave the user staring at empty pages.
this.client.interceptors.response.use(
(resp) => resp,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem(TOKEN_KEY);
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
}
// --- Auth ---
register(email: string, garminEmail: string, garminPassword: string) {
return this.client.post('/auth/register', { email, garminEmail, garminPassword });
}
login(email: string, password: string) {
return this.client.post('/auth/login', { email, password });
}
logout() {
return this.client.post('/auth/logout');
}
refresh() {
return this.client.post('/auth/refresh');
}
// Persist the JWT returned by register/login.
// --- session ---
setSession(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
@@ -62,47 +120,99 @@ class ApiClient {
localStorage.removeItem(TOKEN_KEY);
}
// --- Garmin ---
syncGarminData(garminEmail?: string, garminPassword?: string) {
const body =
garminEmail || garminPassword ? { garminEmail, garminPassword } : {};
return this.client.post('/garmin/sync', body);
isAuthenticated(): boolean {
return Boolean(localStorage.getItem(TOKEN_KEY));
}
getGarminSyncStatus() {
return this.client.get('/garmin/status');
// --- auth ---
async register(email: string, garminEmail: string, garminPassword: string) {
const { data } = await this.client.post<AuthResponse>('/auth/register', {
email,
garminEmail,
garminPassword,
});
return data;
}
// --- Health ---
getHealthSummary(startDate?: string, endDate?: string) {
return this.client.get('/health/summary', { params: { startDate, endDate } });
async login(email: string, password: string) {
const { data } = await this.client.post<AuthResponse>('/auth/login', {
email,
password,
});
return data;
}
getStepsData(startDate?: string, endDate?: string) {
return this.client.get('/health/steps', { params: { startDate, endDate } });
async logout() {
try {
await this.client.post('/auth/logout');
} finally {
this.clearSession();
}
}
getHeartRateData(startDate?: string, endDate?: string) {
return this.client.get('/health/heart-rate', { params: { startDate, endDate } });
// --- garmin ---
/**
* The backend stores only a hash of the Garmin password, so a live sync
* needs the plaintext password supplied here each time.
*/
async syncGarminData(garminPassword: string, garminEmail?: string) {
const { data } = await this.client.post<SyncResult>('/garmin/sync', {
garminPassword,
...(garminEmail ? { garminEmail } : {}),
});
return data;
}
getSleepData(startDate?: string, endDate?: string) {
return this.client.get('/health/sleep', { params: { startDate, endDate } });
async getGarminSyncStatus() {
const { data } = await this.client.get<SyncStatus>('/garmin/status');
return data;
}
getActivities(startDate?: string, endDate?: string) {
return this.client.get('/health/activities', { params: { startDate, endDate } });
// --- health ---
private range(startDate?: string, endDate?: string) {
return { params: { startDate, endDate } };
}
// --- Analysis ---
getTrends(metricType?: string, startDate?: string, endDate?: string) {
return this.client.get('/analysis/trends', {
async getHealthSummary(startDate?: string, endDate?: string) {
const { data } = await this.client.get<HealthDay[]>(
'/health/summary',
this.range(startDate, endDate)
);
return data;
}
async getActivities(startDate?: string, endDate?: string) {
const { data } = await this.client.get<any[]>(
'/health/activities',
this.range(startDate, endDate)
);
return data;
}
// --- analysis ---
async getTrends(metricType: string, startDate?: string, endDate?: string) {
const { data } = await this.client.get<TrendPoint[]>('/analysis/trends', {
params: { metricType, startDate, endDate },
});
return data;
}
getRecommendations() {
return this.client.get('/analysis/recommendations');
async getRecommendations() {
const { data } = await this.client.get<Recommendation[]>('/analysis/recommendations');
return data;
}
async getModels() {
const { data } = await this.client.get<ModelInfo[]>('/analysis/models');
return data;
}
async getAiRecommendations(model?: string, days?: number) {
const { data } = await this.client.get<AiRecommendations>(
'/analysis/ai-recommendations',
{ params: { model, days } }
);
return data;
}
}