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

@@ -22,6 +22,7 @@
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"typecheck": "tsc --noEmit",
"eject": "react-scripts eject"
},
"eslintConfig": {

View File

@@ -1,5 +1,4 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
import ProtectedRoute from './components/ProtectedRoute';
import Login from './pages/Login';

View File

@@ -1,9 +1,11 @@
import React from 'react';
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis,
CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts';
interface ChartProps {
title: string;
data: any[];
data: Array<Record<string, any>>;
type: 'line' | 'bar';
dataKey: string;
stroke?: string;
@@ -18,9 +20,13 @@ function Chart({
dataKey,
stroke = '#667eea',
fill = '#667eea',
height = 300,
height = 260,
}: ChartProps) {
if (!data || data.length === 0) {
// A row may carry some metrics and not others, so emptiness is decided per
// metric rather than by the length of `data`.
const hasValues = data.some((row) => row[dataKey] != null);
if (!hasValues) {
return (
<div className="chart-container">
<h4>{title}</h4>
@@ -29,27 +35,37 @@ function Chart({
);
}
const axisProps = { fontSize: 12, stroke: '#999' };
return (
<div className="chart-container">
<h4>{title}</h4>
<ResponsiveContainer width="100%" height={height}>
{type === 'line' ? (
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" fontSize={12} />
<YAxis fontSize={12} />
<LineChart data={data} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#eee" />
<XAxis dataKey="date" {...axisProps} />
<YAxis {...axisProps} domain={['auto', 'auto']} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey={dataKey} stroke={stroke} dot={{ r: 4 }} />
{/* connectNulls keeps the line continuous across days the device
did not record, instead of breaking it into fragments. */}
<Line
type="monotone"
dataKey={dataKey}
stroke={stroke}
strokeWidth={2}
dot={{ r: 3 }}
connectNulls
name={title}
/>
</LineChart>
) : (
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" fontSize={12} />
<YAxis fontSize={12} />
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#eee" />
<XAxis dataKey="date" {...axisProps} />
<YAxis {...axisProps} />
<Tooltip />
<Legend />
<Bar dataKey={dataKey} fill={fill} />
<Bar dataKey={dataKey} fill={fill} radius={[3, 3, 0, 0]} name={title} />
</BarChart>
)}
</ResponsiveContainer>

View File

@@ -0,0 +1,84 @@
.analysis-controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.metric-tabs,
.range-tabs {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.metric-tab,
.range-tab {
padding: 0.45rem 0.9rem;
border: 1px solid #ddd;
background: white;
border-radius: 999px;
font-size: 0.9rem;
color: #555;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.metric-tab:hover,
.range-tab:hover {
border-color: #667eea;
color: #667eea;
}
.metric-tab.active,
.range-tab.active {
background: #667eea;
border-color: #667eea;
color: white;
}
.range-tabs {
border-top: 1px solid #f0f0f0;
padding-top: 0.75rem;
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.stat-box {
background: #f9f9fb;
border: 1px solid #eee;
border-radius: 8px;
padding: 0.85rem 1rem;
}
.stat-box-label {
font-size: 0.78rem;
color: #999;
margin-bottom: 0.3rem;
}
.stat-box-value {
font-size: 1.15rem;
font-weight: 700;
color: #333;
}
.stat-box-value.up {
color: #2f8f4a;
}
.stat-box-value.down {
color: #c04747;
}
@media (max-width: 600px) {
.stats-row {
grid-template-columns: repeat(2, 1fr);
}
}

View File

@@ -1,11 +1,157 @@
import React from 'react';
import './Pages.css';
import { useCallback, useEffect, useState } from 'react';
import { apiClient, errorMessage, TrendPoint } from '../services/api';
import Chart from '../components/Chart';
import './Analysis.css';
const METRICS = [
{ id: 'steps', label: '步数', unit: '步', type: 'bar' as const },
{ id: 'heart_rate', label: '静息心率', unit: 'bpm', type: 'line' as const },
{ id: 'sleep_duration', label: '睡眠时长', unit: '小时', type: 'line' as const },
{ id: 'sleep_quality', label: '睡眠质量', unit: '分', type: 'line' as const },
{ id: 'stress', label: '压力指数', unit: '', type: 'line' as const },
{ id: 'calories_burned', label: '卡路里消耗', unit: 'kcal', type: 'bar' as const },
];
const RANGES = [
{ days: 7, label: '7 天' },
{ days: 30, label: '30 天' },
{ days: 90, label: '90 天' },
{ days: 365, label: '一年' },
];
function stats(points: TrendPoint[]) {
const values = points.map((p) => p.value).filter((v): v is number => v != null);
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
const mean = values.reduce((a, b) => a + b, 0) / values.length;
// Compare the two halves of the window to describe direction of travel.
const mid = Math.floor(values.length / 2);
const firstHalf = values.slice(0, mid);
const secondHalf = values.slice(mid);
const delta =
firstHalf.length && secondHalf.length
? secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length -
firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length
: 0;
return {
count: values.length,
mean,
min: sorted[0],
max: sorted[sorted.length - 1],
median: sorted[Math.floor(sorted.length / 2)],
delta,
};
}
function Analysis() {
const [metric, setMetric] = useState(METRICS[0]);
const [days, setDays] = useState(30);
const [points, setPoints] = useState<TrendPoint[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async (metricId: string, windowDays: number) => {
setLoading(true);
setError('');
try {
const end = new Date();
const start = new Date(end.getTime() - (windowDays - 1) * 86400000);
setPoints(
await apiClient.getTrends(
metricId,
start.toISOString().slice(0, 10),
end.toISOString().slice(0, 10)
)
);
} catch (err: any) {
setError(errorMessage(err, '加载趋势失败'));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load(metric.id, days);
}, [load, metric.id, days]);
const summary = stats(points);
const chartData = points.map((p) => ({ date: p.date.slice(5), value: p.value }));
const fmt = (v: number) => (Math.round(v * 10) / 10).toLocaleString();
return (
<div className="page">
<h2></h2>
<p className="placeholder">...</p>
<p className="subtitle"></p>
<div className="analysis-controls">
<div className="metric-tabs">
{METRICS.map((m) => (
<button
key={m.id}
className={`metric-tab ${m.id === metric.id ? 'active' : ''}`}
onClick={() => setMetric(m)}
>
{m.label}
</button>
))}
</div>
<div className="range-tabs">
{RANGES.map((r) => (
<button
key={r.days}
className={`range-tab ${r.days === days ? 'active' : ''}`}
onClick={() => setDays(r.days)}
>
{r.label}
</button>
))}
</div>
</div>
{error && <div className="error-message">{error}</div>}
{loading && <div className="page-loading"></div>}
{!loading && !error && (
<>
{summary ? (
<div className="stats-row">
<Stat label="平均" value={`${fmt(summary.mean)} ${metric.unit}`} />
<Stat label="中位数" value={`${fmt(summary.median)} ${metric.unit}`} />
<Stat label="最低" value={`${fmt(summary.min)} ${metric.unit}`} />
<Stat label="最高" value={`${fmt(summary.max)} ${metric.unit}`} />
<Stat
label="后半段较前半段"
value={`${summary.delta >= 0 ? '+' : ''}${fmt(summary.delta)} ${metric.unit}`}
tone={summary.delta >= 0 ? 'up' : 'down'}
/>
<Stat label="有效天数" value={`${summary.count}`} />
</div>
) : (
<p className="placeholder"></p>
)}
<Chart
title={`${metric.label}${metric.unit ? ` (${metric.unit})` : ''}`}
data={chartData}
type={metric.type}
dataKey="value"
height={320}
/>
</>
)}
</div>
);
}
function Stat({ label, value, tone }: { label: string; value: string; tone?: 'up' | 'down' }) {
return (
<div className="stat-box">
<div className="stat-box-label">{label}</div>
<div className={`stat-box-value ${tone ?? ''}`}>{value}</div>
</div>
);
}

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;

View File

@@ -1,160 +1,140 @@
import React, { useEffect, useState } from 'react';
import { apiClient } from '../services/api';
import React, { useCallback, useEffect, useState } from 'react';
import { apiClient, errorMessage, SyncStatus } from '../services/api';
import './DataSync.css';
interface SyncStatus {
status: 'idle' | 'syncing' | 'error';
lastSyncTime: string | null;
recordsSynced: number;
lastError?: string;
}
function DataSync() {
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [garminPassword, setGarminPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>('');
const [message, setMessage] = useState<string>('');
const [error, setError] = useState('');
const [message, setMessage] = useState('');
// Load sync status on component mount
useEffect(() => {
loadSyncStatus();
}, []);
const loadSyncStatus = async () => {
const loadSyncStatus = useCallback(async () => {
try {
const response = await apiClient.getGarminSyncStatus();
setSyncStatus(response.data.data);
} catch (err: any) {
setSyncStatus(await apiClient.getGarminSyncStatus());
} catch (err) {
// A failed status poll should not blank the page; the sync button
// remains usable and will surface its own errors.
console.error('Failed to load sync status:', err);
}
};
}, []);
const handleSync = async () => {
setLoading(true);
useEffect(() => {
loadSyncStatus();
}, [loadSyncStatus]);
const handleSync = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setMessage('');
try {
const response = await apiClient.syncGarminData();
if (!garminPassword) {
setError('请输入 Garmin 密码');
return;
}
if (response.data.success) {
setMessage(`✅ 同步完成!新增/更新 ${response.data.data.recordsSynced} 天数据`);
// Reload sync status
loadSyncStatus();
setLoading(true);
try {
const result = await apiClient.syncGarminData(garminPassword);
if (result.status === 'success') {
setMessage(`同步完成,新增/更新 ${result.recordsSynced} 天数据`);
} else {
setError(`❌ 同步失败:${response.data.data.message}`);
setError(result.message);
}
// Clear the password as soon as the request is done — it is only ever
// held in memory for the duration of the call.
setGarminPassword('');
loadSyncStatus();
} catch (err: any) {
const errorMsg = err.response?.data?.error?.message || 'Sync failed';
setError(`❌ 同步失败:${errorMsg}`);
setError(errorMessage(err, '同步失败'));
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'idle':
return '#999';
case 'syncing':
return '#667eea';
case 'error':
return '#c33';
default:
return '#999';
}
const statusLabel: Record<string, string> = {
idle: '就绪',
syncing: '正在同步…',
error: '上次同步失败',
};
const getStatusText = (status: string) => {
switch (status) {
case 'idle':
return '就绪';
case 'syncing':
return '正在同步...';
case 'error':
return '同步失败';
default:
return '未知';
}
};
const busy = loading || syncStatus?.status === 'syncing';
return (
<div className="page">
<h2>📱 </h2>
<p className="subtitle"> Garmin Connect </p>
<h2></h2>
<p className="subtitle"> Garmin Connect 7 </p>
<div className="sync-container">
{/* Status Card */}
<div className="status-card">
<section className="status-card">
<h3></h3>
{syncStatus ? (
<div className="status-info">
<div className="status-item">
<span className="label"></span>
<span
className="value"
style={{ color: getStatusColor(syncStatus.status) }}
>
{getStatusText(syncStatus.status)}
<span className={`value status-${syncStatus.status}`}>
{statusLabel[syncStatus.status] ?? syncStatus.status}
</span>
</div>
{syncStatus.lastSyncTime && (
<div className="status-item">
<span className="label"></span>
<span className="value">
{new Date(syncStatus.lastSyncTime).toLocaleString('zh-CN')}
</span>
</div>
)}
<div className="status-item">
<span className="label"></span>
<span className="value">{syncStatus.recordsSynced} </span>
<span className="label"></span>
<span className="value">
{syncStatus.lastSyncTime
? new Date(syncStatus.lastSyncTime).toLocaleString('zh-CN')
: '从未同步'}
</span>
</div>
<div className="status-item">
<span className="label"></span>
<span className="value">{syncStatus.recordsSynced}</span>
</div>
{syncStatus.lastError && (
<div className="status-item error">
<span className="label"></span>
<span className="label"></span>
<span className="value">{syncStatus.lastError}</span>
</div>
)}
</div>
) : (
<p className="placeholder">...</p>
<p className="placeholder"></p>
)}
</div>
</section>
{/* Sync Button */}
<div className="sync-actions">
<button
onClick={handleSync}
disabled={loading || (syncStatus?.status === 'syncing')}
className="btn btn-primary btn-large"
>
{loading || syncStatus?.status === 'syncing' ? '✋ 正在同步...' : '🔄 立即同步'}
<form className="sync-actions" onSubmit={handleSync}>
<div className="form-group">
<label htmlFor="garmin-password">Garmin </label>
<input
id="garmin-password"
type="password"
value={garminPassword}
onChange={(e) => setGarminPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
disabled={busy}
/>
<p className="field-hint">
Garmin
</p>
</div>
<button type="submit" className="btn btn-primary btn-large" disabled={busy}>
{busy ? '正在同步…' : '立即同步'}
</button>
</form>
<p className="help-text">
7 Garmin
</p>
</div>
{/* Messages */}
{error && <div className="error-message">{error}</div>}
{message && <div className="success-message">{message}</div>}
{/* Info Box */}
<div className="info-box">
<h4>💡 </h4>
<section className="info-box">
<h4></h4>
<ul>
<li> 7 </li>
<li></li>
<li></li>
<li> Garmin API </li>
<li> 7 </li>
<li></li>
<li></li>
<li> <code>garminconnect</code> </li>
</ul>
</div>
</section>
</div>
</div>
);

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiClient } from '../services/api';
import { apiClient, errorMessage } from '../services/api';
import './Login.css';
type TabType = 'login' | 'register';
@@ -47,17 +47,11 @@ function Login() {
setLoading(true);
try {
const response = await apiClient.login(loginEmail, loginPassword);
const { token } = response.data.data;
// Store token
const { token } = await apiClient.login(loginEmail, loginPassword);
apiClient.setSession(token);
// Redirect to dashboard
navigate('/');
} catch (err: any) {
const message = err.response?.data?.error?.message || 'Login failed';
setError(message);
setError(errorMessage(err, '登录失败'));
} finally {
setLoading(false);
}
@@ -90,17 +84,11 @@ function Login() {
setLoading(true);
try {
const response = await apiClient.register(regEmail, regGarminEmail, regPassword);
const { token } = response.data.data;
// Store token
const { token } = await apiClient.register(regEmail, regGarminEmail, regPassword);
apiClient.setSession(token);
// Redirect to dashboard
navigate('/');
} catch (err: any) {
const message = err.response?.data?.error?.message || 'Registration failed';
setError(message);
setError(errorMessage(err, '注册失败'));
} finally {
setLoading(false);
}

View File

@@ -0,0 +1,171 @@
.model-bar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 1rem;
background: #f9f9fb;
border: 1px solid #eee;
border-radius: 8px;
margin-bottom: 1rem;
}
.model-bar label {
font-weight: 600;
color: #555;
font-size: 0.9rem;
}
.model-bar select {
flex: 1;
min-width: 220px;
padding: 0.5rem 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.95rem;
font-family: inherit;
background: white;
}
.model-bar select:disabled {
background: #f0f0f0;
color: #999;
}
.meta-bar {
padding: 0.75rem 1rem;
border-radius: 6px;
font-size: 0.9rem;
margin-bottom: 1.25rem;
}
.meta-bar.ai {
background: #eef2ff;
border: 1px solid #d6dfff;
color: #3b4a94;
}
.meta-bar.rules {
background: #fff8e6;
border: 1px solid #ffe4a3;
color: #7a5a10;
}
.meta-bar .fallback {
opacity: 0.8;
margin-left: 0.4rem;
}
.notice {
padding: 1rem;
background: #fff8e6;
border: 1px solid #ffe4a3;
border-radius: 8px;
color: #7a5a10;
font-size: 0.9rem;
line-height: 1.7;
margin-bottom: 1rem;
}
.notice code {
background: rgba(0, 0, 0, 0.06);
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-size: 0.85em;
}
.rec-list {
display: grid;
gap: 1rem;
}
.rec-card {
background: white;
border: 1px solid #eee;
border-left: 4px solid #ccc;
border-radius: 8px;
padding: 1.1rem 1.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.rec-card.priority-high {
border-left-color: #e05252;
}
.rec-card.priority-medium {
border-left-color: #e0a052;
}
.rec-card.priority-low {
border-left-color: #6bbf7f;
}
.rec-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.5rem;
}
.rec-category {
font-weight: 700;
color: #333;
font-size: 1rem;
}
.rec-priority {
font-size: 0.75rem;
font-weight: 700;
padding: 0.15rem 0.6rem;
border-radius: 999px;
white-space: nowrap;
}
.rec-priority.priority-high {
background: #fdecec;
color: #b03030;
}
.rec-priority.priority-medium {
background: #fdf3e6;
color: #9a6412;
}
.rec-priority.priority-low {
background: #eaf6ed;
color: #35733f;
}
.rec-body {
color: #444;
line-height: 1.75;
margin: 0;
}
.rec-based-on {
margin-top: 0.7rem;
font-size: 0.8rem;
color: #999;
}
.disclaimer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #eee;
font-size: 0.82rem;
color: #999;
text-align: center;
}
@media (max-width: 600px) {
.model-bar {
flex-direction: column;
align-items: stretch;
}
.model-bar select,
.model-bar .btn {
width: 100%;
}
}

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

View File

@@ -0,0 +1,123 @@
.settings-section {
margin-bottom: 2.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #f0f0f0;
}
.settings-section:last-child {
border-bottom: none;
}
.settings-section h3 {
font-size: 1.1rem;
color: #333;
margin: 0 0 0.6rem 0;
}
.settings-hint {
color: #777;
font-size: 0.9rem;
line-height: 1.7;
margin-bottom: 1rem;
}
.settings-hint code,
.model-table code {
background: #f2f2f5;
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-size: 0.85em;
}
.model-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.model-table th {
text-align: left;
padding: 0.6rem 0.75rem;
border-bottom: 2px solid #eee;
color: #888;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.model-table td {
padding: 0.7rem 0.75rem;
border-bottom: 1px solid #f4f4f4;
color: #444;
}
.model-name {
color: #777;
font-size: 0.85rem;
}
.badge {
display: inline-block;
padding: 0.15rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.badge.ok {
background: #eaf6ed;
color: #35733f;
}
.badge.off {
background: #f2f2f2;
color: #999;
}
.badge-default {
margin-left: 0.4rem;
padding: 0.05rem 0.4rem;
background: #eef2ff;
color: #4a5aa8;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
}
.settings-list {
margin: 0;
padding-left: 1.3rem;
color: #555;
line-height: 1.9;
font-size: 0.9rem;
}
.btn-danger {
background: #fff;
color: #c04747;
border: 1px solid #e0b4b4;
padding: 0.6rem 1.2rem;
border-radius: 6px;
font-size: 0.95rem;
cursor: pointer;
font-family: inherit;
transition: all 0.2s ease;
}
.btn-danger:hover {
background: #fdecec;
border-color: #c04747;
}
@media (max-width: 600px) {
.model-table {
font-size: 0.8rem;
}
.model-table th,
.model-table td {
padding: 0.5rem 0.4rem;
}
}

View File

@@ -1,11 +1,91 @@
import React from 'react';
import './Pages.css';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiClient, ModelInfo } from '../services/api';
import './Settings.css';
function Settings() {
const navigate = useNavigate();
const [models, setModels] = useState<ModelInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
apiClient
.getModels()
.then(setModels)
.catch(() => setModels([]))
.finally(() => setLoading(false));
}, []);
const handleLogout = async () => {
await apiClient.logout();
navigate('/login');
};
return (
<div className="page">
<h2></h2>
<p className="placeholder">...</p>
<section className="settings-section">
<h3>AI </h3>
<p className="settings-hint">
<code>backend/.env</code>
<code>AI_MODEL_CHAIN</code>
</p>
{loading ? (
<p className="placeholder"></p>
) : models.length === 0 ? (
<p className="placeholder"></p>
) : (
<table className="model-table">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{models.map((m) => (
<tr key={m.id}>
<td>
<code>{m.id}</code>
{m.default && <span className="badge-default"></span>}
</td>
<td className="model-name">{m.model}</td>
<td>{(m.contextWindow / 1000).toLocaleString()}k</td>
<td>
<span className={`badge ${m.configured ? 'ok' : 'off'}`}>
{m.configured ? '已配置' : '缺少密钥'}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="settings-section">
<h3></h3>
<ul className="settings-list">
<li></li>
<li>Garmin </li>
<li>
使 AI CSV
</li>
</ul>
</section>
<section className="settings-section">
<h3></h3>
<button className="btn btn-danger" onClick={handleLogout}>
退
</button>
</section>
</div>
);
}

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