[阶段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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user