[阶段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:
@@ -22,6 +22,7 @@
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
84
client/src/pages/Analysis.css
Normal file
84
client/src/pages/Analysis.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
171
client/src/pages/Recommendations.css
Normal file
171
client/src/pages/Recommendations.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
123
client/src/pages/Settings.css
Normal file
123
client/src/pages/Settings.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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