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