[阶段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,218 +1,142 @@
import React, { useEffect, useState } from 'react';
import { apiClient } from '../services/api';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { apiClient, errorMessage, HealthDay } from '../services/api';
import Chart from '../components/Chart';
import './Dashboard.css';
interface HealthData {
/** Flattened row so every chart can address its metric with a plain key. */
interface ChartRow {
date: string;
steps?: number;
heartRate?: number;
sleep?: {
duration: number;
quality: number;
};
caloriesBurned?: number;
steps: number | null;
heartRate: number | null;
sleepHours: number | null;
calories: number | null;
}
function average(values: Array<number | null | undefined>): number {
const present = values.filter((v): v is number => v != null);
if (present.length === 0) return 0;
return present.reduce((a, b) => a + b, 0) / present.length;
}
function Dashboard() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>('');
const [summary, setSummary] = useState<HealthData[]>([]);
const [today, setToday] = useState<HealthData | null>(null);
const [stats, setStats] = useState({
avgSteps: 0,
avgHeartRate: 0,
avgSleep: 0,
totalCalories: 0,
});
const [error, setError] = useState('');
const [rows, setRows] = useState<ChartRow[]>([]);
const [today, setToday] = useState<HealthDay | null>(null);
useEffect(() => {
loadData();
}, []);
const load = async () => {
try {
const end = new Date();
const start = new Date(end.getTime() - 29 * 24 * 60 * 60 * 1000);
const endStr = end.toISOString().slice(0, 10);
const loadData = async () => {
try {
setLoading(true);
setError('');
// Load 30-day summary
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const startDate = thirtyDaysAgo.toISOString().split('T')[0];
const endDate = now.toISOString().split('T')[0];
const response = await apiClient.getHealthSummary(startDate, endDate);
const data = response.data.data || [];
setSummary(data);
// Get today's data
const todayData = data.find((d: HealthData) => d.date === endDate);
setToday(todayData || null);
// Calculate stats
const validData = data.filter((d: HealthData) => d.steps || d.heartRate);
if (validData.length > 0) {
const avgSteps =
Math.round(
validData.reduce((sum: number, d: HealthData) => sum + (d.steps || 0), 0) /
validData.length
) || 0;
const heartRates = validData
.map((d: HealthData) => d.heartRate)
.filter((hr) => hr);
const avgHeartRate =
heartRates.length > 0
? Math.round(heartRates.reduce((a, b) => a + b, 0) / heartRates.length)
: 0;
const sleeps = validData
.map((d: HealthData) => d.sleep?.duration)
.filter((s) => s);
const avgSleep =
sleeps.length > 0
? Math.round((sleeps.reduce((a, b) => a + b, 0) / sleeps.length) * 10) / 10
: 0;
const totalCalories = Math.round(
validData.reduce((sum: number, d: HealthData) => sum + (d.caloriesBurned || 0), 0)
const summary = await apiClient.getHealthSummary(
start.toISOString().slice(0, 10),
endStr
);
setStats({ avgSteps, avgHeartRate, avgSleep, totalCalories });
setRows(
summary.map((d) => ({
date: d.date.slice(5), // MM-DD keeps the axis readable
steps: d.steps,
heartRate: d.heartRate,
sleepHours: d.sleep?.duration ?? null,
calories: d.caloriesBurned,
}))
);
setToday(summary.find((d) => d.date === endStr) ?? null);
} catch (err: any) {
setError(errorMessage(err, '加载数据失败'));
} finally {
setLoading(false);
}
};
load();
}, []);
setLoading(false);
} catch (err: any) {
const errorMsg = err.response?.data?.error?.message || 'Failed to load data';
setError(errorMsg);
setLoading(false);
}
if (loading) return <div className="page-loading"></div>;
const hasData = rows.length > 0;
const stats = {
steps: Math.round(average(rows.map((r) => r.steps))),
heartRate: Math.round(average(rows.map((r) => r.heartRate))),
sleep: Math.round(average(rows.map((r) => r.sleepHours)) * 10) / 10,
calories: Math.round(
rows.reduce((sum, r) => sum + (r.calories ?? 0), 0)
),
};
if (loading) {
return <div className="page-loading"> ...</div>;
}
const show = (v: number | null | undefined, suffix = '') =>
v == null ? '—' : `${v.toLocaleString()}${suffix}`;
return (
<div className="page">
<h2>📊 </h2>
<h2></h2>
{error && <div className="error-message">{error}</div>}
{/* Today's Summary */}
{today ? (
<div className="today-summary">
<h3></h3>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon">🚶</div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{today.steps || '-'}</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{today.heartRate || '-'} bpm</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">😴</div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{today.sleep?.duration || '-'} </div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">🔥</div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{today.caloriesBurned || '-'} kcal</div>
</div>
</div>
</div>
{!hasData && !error && (
<div className="empty-state">
<p></p>
<Link to="/sync" className="btn btn-primary">
Garmin
</Link>
</div>
) : (
<div className="placeholder"></div>
)}
{/* 30-Day Statistics */}
<div className="statistics">
<h3>30 </h3>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon">📈</div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{stats.avgSteps.toLocaleString()}</div>
</div>
</div>
{hasData && (
<>
<section className="today-summary">
<h3></h3>
{today ? (
<div className="stats-grid">
<StatCard icon="🚶" label="步数" value={show(today.steps)} />
<StatCard icon="❤️" label="静息心率" value={show(today.heartRate, ' bpm')} />
<StatCard icon="😴" label="睡眠" value={show(today.sleep?.duration, ' 小时')} />
<StatCard icon="🔥" label="消耗" value={show(today.caloriesBurned, ' kcal')} />
</div>
) : (
<p className="placeholder"></p>
)}
</section>
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{stats.avgHeartRate} bpm</div>
<section className="statistics">
<h3> 30 </h3>
<div className="stats-grid">
<StatCard icon="📈" label="日均步数" value={show(stats.steps)} />
<StatCard icon="❤️" label="平均静息心率" value={show(stats.heartRate, ' bpm')} />
<StatCard icon="😴" label="日均睡眠" value={show(stats.sleep, ' 小时')} />
<StatCard icon="🔥" label="累计消耗" value={show(stats.calories, ' kcal')} />
</div>
</div>
</section>
<div className="stat-card">
<div className="stat-icon">😴</div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{stats.avgSleep}h</div>
<section className="charts-section">
<h3></h3>
<div className="charts-grid">
<Chart title="步数" data={rows} type="bar" dataKey="steps" fill="#667eea" />
<Chart title="静息心率 (bpm)" data={rows} type="line" dataKey="heartRate" stroke="#ff6b9d" />
<Chart title="睡眠时长 (小时)" data={rows} type="line" dataKey="sleepHours" stroke="#f0a500" />
<Chart title="卡路里消耗" data={rows} type="bar" dataKey="calories" fill="#4caf50" />
</div>
</div>
<div className="stat-card">
<div className="stat-icon">🔥</div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{stats.totalCalories.toLocaleString()}</div>
</div>
</div>
</div>
</div>
{/* Charts */}
{summary.length > 0 && (
<div className="charts-section">
<h3></h3>
<div className="charts-grid">
<Chart title="步数" data={summary} type="bar" dataKey="steps" fill="#667eea" />
<Chart
title="心率"
data={summary.filter((d) => d.heartRate)}
type="line"
dataKey="heartRate"
stroke="#ff6b9d"
/>
<Chart
title="睡眠时长"
data={summary.filter((d) => d.sleep)}
type="line"
dataKey={(d: any) => d.sleep?.duration}
stroke="#ffd93d"
/>
<Chart
title="卡路里消耗"
data={summary}
type="bar"
dataKey="caloriesBurned"
fill="#6bcf7f"
/>
</div>
</div>
</section>
</>
)}
</div>
);
}
function StatCard({ icon, label, value }: { icon: string; label: string; value: string }) {
return (
<div className="stat-card">
<div className="stat-icon">{icon}</div>
<div className="stat-content">
<div className="stat-label">{label}</div>
<div className="stat-value">{value}</div>
</div>
</div>
);
}
export default Dashboard;