[阶段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 loadData = async () => {
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - 29 * 24 * 60 * 60 * 1000);
|
||||
const endStr = end.toISOString().slice(0, 10);
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
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) {
|
||||
const errorMsg = err.response?.data?.error?.message || 'Failed to load data';
|
||||
setError(errorMsg);
|
||||
setError(errorMessage(err, '加载数据失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="page-loading">⏳ 加载中...</div>;
|
||||
}
|
||||
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)
|
||||
),
|
||||
};
|
||||
|
||||
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 */}
|
||||
{!hasData && !error && (
|
||||
<div className="empty-state">
|
||||
<p>还没有任何健康数据。</p>
|
||||
<Link to="/sync" className="btn btn-primary">
|
||||
去同步 Garmin 数据
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasData && (
|
||||
<>
|
||||
<section className="today-summary">
|
||||
<h3>今日</h3>
|
||||
{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>
|
||||
<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>
|
||||
) : (
|
||||
<div className="placeholder">暂无今日数据</div>
|
||||
<p className="placeholder">今日暂无数据,最近一次记录见下方趋势。</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 30-Day Statistics */}
|
||||
<div className="statistics">
|
||||
<h3>30 日统计</h3>
|
||||
<section 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>
|
||||
<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>
|
||||
</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>
|
||||
</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.avgSleep}h</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">{stats.totalCalories.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
{summary.length > 0 && (
|
||||
<div className="charts-section">
|
||||
<h3>数据趋势</h3>
|
||||
<section 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>
|
||||
<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>
|
||||
</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 (response.data.success) {
|
||||
setMessage(`✅ 同步完成!新增/更新 ${response.data.data.recordsSynced} 天数据`);
|
||||
// Reload sync status
|
||||
loadSyncStatus();
|
||||
} else {
|
||||
setError(`❌ 同步失败:${response.data.data.message}`);
|
||||
if (!garminPassword) {
|
||||
setError('请输入 Garmin 密码');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await apiClient.syncGarminData(garminPassword);
|
||||
if (result.status === 'success') {
|
||||
setMessage(`同步完成,新增/更新 ${result.recordsSynced} 天数据`);
|
||||
} else {
|
||||
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')}
|
||||
{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>
|
||||
<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' ? '✋ 正在同步...' : '🔄 立即同步'}
|
||||
</button>
|
||||
|
||||
<p className="help-text">
|
||||
数据同步会获取你最近 7 天的 Garmin 健康数据,包括步数、心率、睡眠、运动等信息。
|
||||
<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>
|
||||
|
||||
{/* Messages */}
|
||||
<button type="submit" className="btn btn-primary btn-large" disabled={busy}>
|
||||
{busy ? '正在同步…' : '立即同步'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
21
package.json
21
package.json
@@ -1,21 +1,20 @@
|
||||
{
|
||||
"name": "garmin-health-lab",
|
||||
"version": "0.1.0",
|
||||
"description": "佳明健康数据分析平台",
|
||||
"description": "佳明健康数据分析平台 - React 前端 + Flask 后端",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
||||
"dev:server": "cd server && npm run dev",
|
||||
"dev:client": "cd client && npm start",
|
||||
"build": "npm run build:server && npm run build:client",
|
||||
"build:server": "cd server && npm run build",
|
||||
"build:client": "cd client && npm run build",
|
||||
"start": "node server/dist/index.js"
|
||||
},
|
||||
"workspaces": [
|
||||
"server",
|
||||
"client"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently -n backend,client -c blue,green \"npm run dev:backend\" \"npm run dev:client\"",
|
||||
"dev:backend": "cd backend && .venv/bin/python app.py",
|
||||
"dev:client": "npm start --workspace=client",
|
||||
"build": "npm run build --workspace=client",
|
||||
"typecheck": "npm run typecheck --workspace=client",
|
||||
"test": "cd backend && .venv/bin/python -m pytest",
|
||||
"setup:backend": "cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.0"
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# App
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
JWT_SECRET=generate_a_random_string
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# --- Database ---
|
||||
# Local/dev: SQLite (zero config)
|
||||
# DB_TYPE=sqlite
|
||||
# DATABASE_PATH=./data/health.db
|
||||
#
|
||||
# Production: MariaDB on the NAS
|
||||
DB_TYPE=mariadb
|
||||
MARIADB_SOCKET=/run/mysqld/mysqld10.sock
|
||||
MARIADB_USER=root
|
||||
MARIADB_PASSWORD=your_nas_mariadb_root_password
|
||||
MARIADB_DATABASE=garmin_health_lab
|
||||
|
||||
# Garmin Connect
|
||||
GARMIN_CONNECT_USER=your_garmin_email
|
||||
GARMIN_CONNECT_PASSWORD=your_garmin_password
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"name": "garmin-health-lab-server",
|
||||
"version": "0.1.0",
|
||||
"description": "Garmin Health Lab Backend",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.5.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"garmin-connect": "^1.6.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mysql2": "^3.6.0",
|
||||
"sqlite3": "^5.1.6",
|
||||
"ts-node": "^10.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.13",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jsonwebtoken": "^9.0.2",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/sqlite3": "^3.1.8",
|
||||
"tsx": "^3.12.7",
|
||||
"typescript": "^5.1.3"
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import { initializeDatabase } from './utils/database';
|
||||
import authRoutes from './routes/auth';
|
||||
import garminRoutes from './routes/garmin';
|
||||
import healthRoutes from './routes/health';
|
||||
import analysisRoutes from './routes/analysis';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 5000;
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/garmin', garminRoutes);
|
||||
app.use('/api/health', healthRoutes);
|
||||
app.use('/api/analysis', analysisRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/api/health/status', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Error handling
|
||||
app.use(errorHandler);
|
||||
|
||||
// Initialize database, then start accepting requests
|
||||
initializeDatabase()
|
||||
.then(() => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[db] Failed to initialize database:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { verifyToken } from '../services/AuthService';
|
||||
import { AppError } from './errorHandler';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
}
|
||||
|
||||
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
status: 401,
|
||||
message: 'Missing authorization header',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
status: 401,
|
||||
message: 'Invalid authorization header format',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const token = parts[1];
|
||||
|
||||
try {
|
||||
const payload = verifyToken(token);
|
||||
req.userId = payload.userId;
|
||||
next();
|
||||
} catch (error: any) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
status: 401,
|
||||
message: 'Invalid or expired token',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function optionalAuthMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader) {
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length === 2 && parts[0] === 'Bearer') {
|
||||
const token = parts[1];
|
||||
try {
|
||||
const payload = verifyToken(token);
|
||||
req.userId = payload.userId;
|
||||
} catch (error) {
|
||||
// Silently fail - continue without authentication
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export function errorHandler(
|
||||
err: any,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
console.error('Error:', err);
|
||||
|
||||
const status = err.status || 500;
|
||||
const message = err.message || 'Internal server error';
|
||||
|
||||
res.status(status).json({
|
||||
error: {
|
||||
status,
|
||||
message,
|
||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// TODO: Implement analysis endpoints
|
||||
router.get('/trends', (req, res) => {
|
||||
res.json({ message: 'Trends analysis endpoint' });
|
||||
});
|
||||
|
||||
router.get('/recommendations', (req, res) => {
|
||||
res.json({ message: 'Recommendations endpoint' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,154 +0,0 @@
|
||||
import express from 'express';
|
||||
import { register, login, logout, getUserById } from '../services/AuthService';
|
||||
import { authMiddleware, AuthRequest } from '../middleware/authMiddleware';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* POST /api/auth/register
|
||||
* Register a new user
|
||||
*/
|
||||
router.post('/register', async (req, res, next) => {
|
||||
try {
|
||||
const { email, garminEmail, garminPassword } = req.body;
|
||||
|
||||
if (!email || !garminEmail || !garminPassword) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
status: 400,
|
||||
message: 'Missing required fields: email, garminEmail, garminPassword',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = await register({ email, garminEmail, garminPassword });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
userId: result.id,
|
||||
email: result.email,
|
||||
token: result.token,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.code === 'EMAIL_TAKEN') {
|
||||
return res.status(409).json({
|
||||
error: {
|
||||
status: 409,
|
||||
message: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Login with email and password
|
||||
*/
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
status: 400,
|
||||
message: 'Missing required fields: email, password',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = await login(email, password);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
userId: result.id,
|
||||
email: result.email,
|
||||
token: result.token,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.code === 'INVALID_CREDENTIALS') {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
status: 401,
|
||||
message: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Logout (requires authentication)
|
||||
*/
|
||||
router.post('/logout', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
status: 401,
|
||||
message: 'User not authenticated',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await logout(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Logged out successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Get current user info (requires authentication)
|
||||
*/
|
||||
router.get('/me', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
status: 401,
|
||||
message: 'User not authenticated',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const user = await getUserById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
status: 404,
|
||||
message: 'User not found',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
garminEmail: user.garmin_email,
|
||||
createdAt: user.created_at,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,76 +0,0 @@
|
||||
import express from 'express';
|
||||
import { syncData, getSyncStatus } from '../services/GarminService';
|
||||
import { getUserById } from '../services/AuthService';
|
||||
import { authMiddleware, AuthRequest } from '../middleware/authMiddleware';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* POST /api/garmin/sync
|
||||
* Trigger Garmin data synchronization (requires authentication)
|
||||
*/
|
||||
router.post('/sync', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
status: 401,
|
||||
message: 'User not authenticated',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const user = await getUserById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
status: 404,
|
||||
message: 'User not found',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Sync Garmin data using stored credentials
|
||||
const result = await syncData(userId, {
|
||||
garminEmail: user.garminEmail,
|
||||
garminPassword: req.body.garminPassword || '',
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: result.status === 'success',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/garmin/status
|
||||
* Get Garmin synchronization status (requires authentication)
|
||||
*/
|
||||
router.get('/status', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
status: 401,
|
||||
message: 'User not authenticated',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const status = await getSyncStatus(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: status,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,170 +0,0 @@
|
||||
import express from 'express';
|
||||
import { getSummary, getSteps, getHeartRate, getSleep, getActivities } from '../services/HealthService';
|
||||
import { authMiddleware, AuthRequest } from '../middleware/authMiddleware';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Apply authentication middleware to all health routes
|
||||
router.use(authMiddleware);
|
||||
|
||||
/**
|
||||
* GET /api/health/summary
|
||||
* Get health data summary for a date range
|
||||
* Query params: startDate, endDate (ISO 8601 format, e.g., 2024-08-23)
|
||||
*/
|
||||
router.get('/summary', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
status: 401,
|
||||
message: 'User not authenticated',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
const data = await getSummary(userId, {
|
||||
startDate: startDate as string | undefined,
|
||||
endDate: endDate as string | undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/health/steps
|
||||
* Get steps data for a date range
|
||||
* Query params: startDate, endDate
|
||||
*/
|
||||
router.get('/steps', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
status: 401,
|
||||
message: 'User not authenticated',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
const data = await getSteps(userId, {
|
||||
startDate: startDate as string | undefined,
|
||||
endDate: endDate as string | undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/health/heart-rate
|
||||
* Get heart rate data for a date range
|
||||
* Query params: startDate, endDate
|
||||
*/
|
||||
router.get('/heart-rate', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
status: 401,
|
||||
message: 'User not authenticated',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
const data = await getHeartRate(userId, {
|
||||
startDate: startDate as string | undefined,
|
||||
endDate: endDate as string | undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/health/sleep
|
||||
* Get sleep data for a date range
|
||||
* Query params: startDate, endDate
|
||||
*/
|
||||
router.get('/sleep', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
status: 401,
|
||||
message: 'User not authenticated',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
const data = await getSleep(userId, {
|
||||
startDate: startDate as string | undefined,
|
||||
endDate: endDate as string | undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/health/activities
|
||||
* Get activities for a date range
|
||||
* Query params: startDate, endDate
|
||||
*/
|
||||
router.get('/activities', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const userId = req.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
status: 401,
|
||||
message: 'User not authenticated',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
const data = await getActivities(userId, {
|
||||
startDate: startDate as string | undefined,
|
||||
endDate: endDate as string | undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,130 +0,0 @@
|
||||
import { allAsync } from '../utils/database';
|
||||
import { getSummary } from './HealthService';
|
||||
|
||||
const METRIC_COLUMNS: Record<string, string> = {
|
||||
steps: 'steps',
|
||||
heart_rate: 'heart_rate',
|
||||
sleep_duration: 'sleep_duration',
|
||||
sleep_quality: 'sleep_quality',
|
||||
stress: 'stress',
|
||||
calories_burned: 'calories_burned',
|
||||
};
|
||||
|
||||
export async function getTrends(metric: string, userId: string, startDate?: string, endDate?: string) {
|
||||
const column = METRIC_COLUMNS[metric] || 'steps';
|
||||
const params: any[] = [userId];
|
||||
let sql = 'WHERE user_id = ?';
|
||||
if (startDate) { sql += ' AND date >= ?'; params.push(startDate); }
|
||||
if (endDate) { sql += ' AND date <= ?'; params.push(endDate); }
|
||||
|
||||
const rows = await allAsync(
|
||||
`SELECT date, ${column} AS value FROM health_data ${sql}
|
||||
AND ${column} IS NOT NULL ORDER BY date ASC`,
|
||||
params
|
||||
);
|
||||
return rows.map((r: any) => ({ date: r.date, value: r.value }));
|
||||
}
|
||||
|
||||
export interface Recommendation {
|
||||
id: string;
|
||||
category: string;
|
||||
recommendation: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
basedOn: string[];
|
||||
}
|
||||
|
||||
export async function getRecommendations(userId: string): Promise<Recommendation[]> {
|
||||
// Look at the most recent 14 days of data.
|
||||
const recent = await getSummary(userId);
|
||||
const last14 = recent.slice(-14);
|
||||
const recs: Recommendation[] = [];
|
||||
|
||||
if (last14.length === 0) {
|
||||
return [
|
||||
{
|
||||
id: 'no-data',
|
||||
category: '数据',
|
||||
recommendation: '暂无健康数据,请先同步你的 Garmin 设备数据。',
|
||||
priority: 'low',
|
||||
basedOn: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const avg = (key: string) =>
|
||||
last14.reduce((sum, r) => sum + (r[key] ?? 0), 0) / last14.length;
|
||||
|
||||
const avgSteps = avg('steps');
|
||||
const avgSleep = last14.filter((r) => r.sleep).reduce(
|
||||
(s, r) => s + (r.sleep?.duration ?? 0),
|
||||
0
|
||||
) / Math.max(1, last14.filter((r) => r.sleep).length);
|
||||
const avgStress = avg('stress');
|
||||
const avgRestingHr = avg('heartRate');
|
||||
const avgHrv = avg('heartRateVariability');
|
||||
|
||||
if (avgSteps > 0 && avgSteps < 8000) {
|
||||
recs.push({
|
||||
id: 'steps',
|
||||
category: '运动',
|
||||
recommendation: `近 ${last14.length} 天日均步数约 ${Math.round(avgSteps)} 步,低于 8000 步目标,建议每天增加 20 分钟快走。`,
|
||||
priority: 'medium',
|
||||
basedOn: ['steps'],
|
||||
});
|
||||
}
|
||||
|
||||
if (avgSleep > 0 && avgSleep < 7) {
|
||||
recs.push({
|
||||
id: 'sleep',
|
||||
category: '睡眠',
|
||||
recommendation: `日均睡眠约 ${avgSleep.toFixed(1)} 小时,偏少。建议固定就寝时间,目标 7-8 小时。`,
|
||||
priority: 'high',
|
||||
basedOn: ['sleep_duration'],
|
||||
});
|
||||
}
|
||||
|
||||
if (avgStress > 0 && avgStress > 50) {
|
||||
recs.push({
|
||||
id: 'stress',
|
||||
category: '压力',
|
||||
recommendation: `平均压力指数 ${Math.round(avgStress)} 偏高,建议安排放松活动(冥想/散步)。`,
|
||||
priority: 'high',
|
||||
basedOn: ['stress'],
|
||||
});
|
||||
}
|
||||
|
||||
if (avgRestingHr > 0 && avgRestingHr > 65) {
|
||||
recs.push({
|
||||
id: 'rhr',
|
||||
category: '心肺',
|
||||
recommendation: `静息心率约 ${Math.round(avgRestingHr)} bpm 偏高,规律有氧运动有助于改善心肺功能。`,
|
||||
priority: 'medium',
|
||||
basedOn: ['heart_rate'],
|
||||
});
|
||||
}
|
||||
|
||||
if (avgHrv > 0 && avgHrv < 40) {
|
||||
recs.push({
|
||||
id: 'hrv',
|
||||
category: '恢复',
|
||||
recommendation: `心率变异性(HRV)约 ${Math.round(avgHrv)} ms 偏低,注意恢复与休息,避免过度训练。`,
|
||||
priority: 'low',
|
||||
basedOn: ['heart_rate_variability'],
|
||||
});
|
||||
}
|
||||
|
||||
if (recs.length === 0) {
|
||||
recs.push({
|
||||
id: 'good',
|
||||
category: '状态',
|
||||
recommendation: '近期各项指标良好,保持当前作息与运动习惯即可。',
|
||||
priority: 'low',
|
||||
basedOn: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by priority
|
||||
const order = { high: 0, medium: 1, low: 2 } as const;
|
||||
recs.sort((a, b) => order[a.priority] - order[b.priority]);
|
||||
return recs;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { runAsync, getAsync } from '../utils/database';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_change_me';
|
||||
const TOKEN_EXPIRY = '7d';
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(public code: string, message: string) {
|
||||
super(message);
|
||||
this.name = 'AuthError';
|
||||
}
|
||||
}
|
||||
|
||||
function hashPassword(password: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
crypto.scrypt(password, salt, 64, (err, derived) => {
|
||||
if (err) reject(err);
|
||||
else resolve(`${salt}:${derived.toString('hex')}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const [salt, hash] = stored.split(':');
|
||||
if (!salt || !hash) return resolve(false);
|
||||
crypto.scrypt(password, salt, 64, (err, derived) => {
|
||||
if (err) reject(err);
|
||||
else resolve(derived.toString('hex') === hash);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function signToken(userId: string): string {
|
||||
return jwt.sign({ sub: userId }, JWT_SECRET, { expiresIn: TOKEN_EXPIRY });
|
||||
}
|
||||
|
||||
export async function register(input: {
|
||||
email: string;
|
||||
garminEmail: string;
|
||||
garminPassword: string;
|
||||
}) {
|
||||
const existing = await getAsync('SELECT id FROM users WHERE email = ?', [input.email]);
|
||||
if (existing) {
|
||||
throw new AuthError('EMAIL_TAKEN', '该邮箱已注册');
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const garminPasswordHash = await hashPassword(input.garminPassword);
|
||||
const token = signToken(id);
|
||||
await runAsync(
|
||||
`INSERT INTO users (id, email, garmin_email, garmin_password_hash, jwt_token)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[id, input.email, input.garminEmail, garminPasswordHash, token]
|
||||
);
|
||||
return { id, email: input.email, token };
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string) {
|
||||
const user = await getAsync('SELECT * FROM users WHERE email = ?', [email]);
|
||||
if (!user) {
|
||||
throw new AuthError('INVALID_CREDENTIALS', '邮箱或密码错误');
|
||||
}
|
||||
const ok = await verifyPassword(password, user.garmin_password_hash);
|
||||
if (!ok) {
|
||||
throw new AuthError('INVALID_CREDENTIALS', '邮箱或密码错误');
|
||||
}
|
||||
const token = signToken(user.id);
|
||||
await runAsync('UPDATE users SET jwt_token = ? WHERE id = ?', [token, user.id]);
|
||||
return { id: user.id, email: user.email, token };
|
||||
}
|
||||
|
||||
export async function logout(userId: string) {
|
||||
await runAsync('UPDATE users SET jwt_token = NULL WHERE id = ?', [userId]);
|
||||
}
|
||||
|
||||
export async function getUserById(userId: string) {
|
||||
const user = await getAsync(
|
||||
'SELECT id, email, garmin_email, created_at FROM users WHERE id = ?',
|
||||
[userId]
|
||||
);
|
||||
return user || null;
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): { userId: string } {
|
||||
const payload = jwt.verify(token, JWT_SECRET) as { sub: string };
|
||||
return { userId: payload.sub };
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { getAsync, runAsync } from '../utils/database';
|
||||
import { upsertHealthDaily, insertActivity } from './HealthService';
|
||||
|
||||
export interface SyncResult {
|
||||
status: 'success' | 'error';
|
||||
recordsSynced: number;
|
||||
message: string;
|
||||
lastSyncTime: string;
|
||||
}
|
||||
|
||||
export async function syncData(
|
||||
userId: string,
|
||||
creds: { garminEmail: string; garminPassword: string }
|
||||
): Promise<SyncResult> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await runAsync(
|
||||
`INSERT INTO sync_status (user_id, status, last_sync_time, records_synced)
|
||||
VALUES (?, 'syncing', ?, 0)
|
||||
ON DUPLICATE KEY UPDATE status='syncing', last_sync_time=?, records_synced=0`,
|
||||
[userId, now, now]
|
||||
);
|
||||
|
||||
try {
|
||||
let GarminConnect: any;
|
||||
try {
|
||||
GarminConnect = require('garmin-connect');
|
||||
} catch {
|
||||
throw new Error('GARMIN_LIB_MISSING: 请先运行 `npm install garmin-connect` 以启用同步');
|
||||
}
|
||||
|
||||
const Client = GarminConnect.default || GarminConnect.GarminConnect || GarminConnect;
|
||||
const client = new Client({ username: creds.garminEmail, password: creds.garminPassword });
|
||||
await client.login();
|
||||
|
||||
let recordsSynced = 0;
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
const dateStr = d.toISOString().slice(0, 10);
|
||||
try {
|
||||
const daily =
|
||||
(await client.getUserDailySummary?.(dateStr)) ||
|
||||
(await client.getDailySummary?.(dateStr)) ||
|
||||
(await client.getStats?.(dateStr));
|
||||
|
||||
if (daily) {
|
||||
const sleepSec = daily.sleep?.sleepingSeconds ?? daily.sleepingSeconds;
|
||||
await upsertHealthDaily(userId, {
|
||||
date: dateStr,
|
||||
steps: daily.steps ?? null,
|
||||
heartRate: daily.restingHeartRate ?? daily.averageHeartRate ?? null,
|
||||
heartRateVariability: daily.hrv ?? daily.heartRateVariability ?? null,
|
||||
sleepDuration: sleepSec ? Math.round(sleepSec / 3600 * 10) / 10 : null,
|
||||
sleepQuality: daily.sleep?.sleepQuality ?? null,
|
||||
stress: daily.stress?.average ?? daily.averageStress ?? null,
|
||||
caloriesBurned: daily.calories?.total ?? daily.totalCalories ?? null,
|
||||
});
|
||||
recordsSynced++;
|
||||
}
|
||||
|
||||
const activities =
|
||||
(await client.getActivities?.(dateStr)) ||
|
||||
(await client.getActivitiesByDate?.(dateStr)) ||
|
||||
[];
|
||||
for (const a of activities || []) {
|
||||
const start = a.startTimeLocal || a.startTime;
|
||||
const startMs = start ? new Date(start).getTime() : NaN;
|
||||
await insertActivity(userId, {
|
||||
activityType: a.activityType?.typeKey || a.type || 'unknown',
|
||||
startTime: start,
|
||||
endTime:
|
||||
isNaN(startMs) || !a.duration ? start : new Date(startMs + (a.duration || 0) * 1000).toISOString(),
|
||||
duration: a.duration ?? null,
|
||||
distance: a.distance ?? null,
|
||||
calories: a.calories ?? null,
|
||||
heartRateAverage: a.averageHR ?? null,
|
||||
heartRateMax: a.maxHR ?? null,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// skip a single bad day and continue
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await runAsync(
|
||||
`INSERT INTO sync_status (user_id, status, last_sync_time, records_synced)
|
||||
VALUES (?, 'idle', ?, ?)
|
||||
ON DUPLICATE KEY UPDATE status='idle', last_sync_time=?, records_synced=?`,
|
||||
[userId, now, recordsSynced, now, recordsSynced]
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
recordsSynced,
|
||||
message: `同步完成,新增/更新 ${recordsSynced} 天数据`,
|
||||
lastSyncTime: now,
|
||||
};
|
||||
} catch (err: any) {
|
||||
const message = err?.message || String(err);
|
||||
await runAsync(
|
||||
`INSERT INTO sync_status (user_id, status, last_sync_time, last_error, records_synced)
|
||||
VALUES (?, 'error', ?, ?, 0)
|
||||
ON DUPLICATE KEY UPDATE status='error', last_sync_time=?, last_error=?, records_synced=0`,
|
||||
[userId, now, message, now, message]
|
||||
);
|
||||
return { status: 'error', recordsSynced: 0, message, lastSyncTime: now };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSyncStatus(userId: string) {
|
||||
const row = await getAsync('SELECT * FROM sync_status WHERE user_id = ?', [userId]);
|
||||
if (!row) return { status: 'idle', lastSyncTime: null, recordsSynced: 0, lastError: null };
|
||||
return {
|
||||
status: row.status,
|
||||
lastSyncTime: row.last_sync_time,
|
||||
recordsSynced: row.records_synced,
|
||||
lastError: row.last_error,
|
||||
};
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import crypto from 'crypto';
|
||||
import { allAsync, runAsync } from '../utils/database';
|
||||
|
||||
interface DateRange {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
function rangeParams(userId: string, range?: DateRange): { sql: string; params: any[] } {
|
||||
const params: any[] = [userId];
|
||||
let sql = 'WHERE user_id = ?';
|
||||
if (range?.startDate) {
|
||||
sql += ' AND date >= ?';
|
||||
params.push(range.startDate);
|
||||
}
|
||||
if (range?.endDate) {
|
||||
sql += ' AND date <= ?';
|
||||
params.push(range.endDate);
|
||||
}
|
||||
return { sql, params };
|
||||
}
|
||||
|
||||
export async function getSummary(userId: string, range?: DateRange) {
|
||||
const { sql, params } = rangeParams(userId, range);
|
||||
const rows = await allAsync(
|
||||
`SELECT date, steps, heart_rate, heart_rate_variability,
|
||||
sleep_duration, sleep_quality, stress, calories_burned
|
||||
FROM health_data ${sql} ORDER BY date ASC`,
|
||||
params
|
||||
);
|
||||
return rows.map((r: any) => ({
|
||||
date: r.date,
|
||||
steps: r.steps,
|
||||
heartRate: r.heart_rate,
|
||||
heartRateVariability: r.heart_rate_variability,
|
||||
sleep: r.sleep_duration != null ? { duration: r.sleep_duration, quality: r.sleep_quality } : null,
|
||||
stress: r.stress,
|
||||
caloriesBurned: r.calories_burned,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getSteps(userId: string, range?: DateRange) {
|
||||
const { sql, params } = rangeParams(userId, range);
|
||||
const rows = await allAsync(
|
||||
`SELECT date, steps FROM health_data ${sql} AND steps IS NOT NULL ORDER BY date ASC`,
|
||||
params
|
||||
);
|
||||
return rows.map((r: any) => ({ date: r.date, steps: r.steps }));
|
||||
}
|
||||
|
||||
export async function getHeartRate(userId: string, range?: DateRange) {
|
||||
const { sql, params } = rangeParams(userId, range);
|
||||
const rows = await allAsync(
|
||||
`SELECT date, heart_rate, heart_rate_variability FROM health_data ${sql}
|
||||
AND heart_rate IS NOT NULL ORDER BY date ASC`,
|
||||
params
|
||||
);
|
||||
return rows.map((r: any) => ({
|
||||
date: r.date,
|
||||
heartRate: r.heart_rate,
|
||||
heartRateVariability: r.heart_rate_variability,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getSleep(userId: string, range?: DateRange) {
|
||||
const { sql, params } = rangeParams(userId, range);
|
||||
const rows = await allAsync(
|
||||
`SELECT date, sleep_duration, sleep_quality FROM health_data ${sql}
|
||||
AND sleep_duration IS NOT NULL ORDER BY date ASC`,
|
||||
params
|
||||
);
|
||||
return rows.map((r: any) => ({
|
||||
date: r.date,
|
||||
duration: r.sleep_duration,
|
||||
quality: r.sleep_quality,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getActivities(userId: string, range?: DateRange) {
|
||||
const { sql, params } = rangeParams(userId, range);
|
||||
const rows = await allAsync(
|
||||
`SELECT id, activity_type, start_time, end_time, duration, distance,
|
||||
calories, heart_rate_average, heart_rate_max
|
||||
FROM activities ${sql} ORDER BY start_time DESC`,
|
||||
params
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Used by GarminService to persist daily health records (upsert by user+date).
|
||||
export async function upsertHealthDaily(userId: string, record: any) {
|
||||
const id = `${userId}-${record.date}`;
|
||||
await runAsync(
|
||||
`INSERT INTO health_data
|
||||
(id, user_id, date, steps, heart_rate, heart_rate_variability,
|
||||
blood_pressure_systolic, blood_pressure_diastolic, sleep_duration,
|
||||
sleep_quality, stress, calories_burned)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
steps = VALUES(steps),
|
||||
heart_rate = VALUES(heart_rate),
|
||||
heart_rate_variability = VALUES(heart_rate_variability),
|
||||
blood_pressure_systolic = VALUES(blood_pressure_systolic),
|
||||
blood_pressure_diastolic = VALUES(blood_pressure_diastolic),
|
||||
sleep_duration = VALUES(sleep_duration),
|
||||
sleep_quality = VALUES(sleep_quality),
|
||||
stress = VALUES(stress),
|
||||
calories_burned = VALUES(calories_burned),
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
id, userId, record.date, record.steps ?? null, record.heartRate ?? null,
|
||||
record.heartRateVariability ?? null, record.bloodPressureSystolic ?? null,
|
||||
record.bloodPressureDiastolic ?? null, record.sleepDuration ?? null,
|
||||
record.sleepQuality ?? null, record.stress ?? null, record.caloriesBurned ?? null,
|
||||
]
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function insertActivity(userId: string, activity: any) {
|
||||
const id = crypto.randomUUID();
|
||||
await runAsync(
|
||||
`INSERT INTO activities
|
||||
(id, user_id, activity_type, start_time, end_time, duration, distance,
|
||||
calories, heart_rate_average, heart_rate_max)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id, userId, activity.activityType, activity.startTime, activity.endTime,
|
||||
activity.duration ?? null, activity.distance ?? null, activity.calories ?? null,
|
||||
activity.heartRateAverage ?? null, activity.heartRateMax ?? null,
|
||||
]
|
||||
);
|
||||
return id;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
garminEmail: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface HealthData {
|
||||
id: string;
|
||||
userId: string;
|
||||
date: Date;
|
||||
steps: number;
|
||||
heartRate?: number;
|
||||
heartRateVariability?: number;
|
||||
bloodPressure?: {
|
||||
systolic: number;
|
||||
diastolic: number;
|
||||
};
|
||||
sleep?: {
|
||||
duration: number;
|
||||
quality: number;
|
||||
};
|
||||
stress?: number;
|
||||
caloriesBurned?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
userId: string;
|
||||
activityType: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
duration: number;
|
||||
distance?: number;
|
||||
calories?: number;
|
||||
heartRateAverage?: number;
|
||||
heartRateMax?: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface HealthRecommendation {
|
||||
id: string;
|
||||
userId: string;
|
||||
category: string;
|
||||
recommendation: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
basedOn: string[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
lastSyncTime: Date;
|
||||
status: 'idle' | 'syncing' | 'error';
|
||||
lastError?: string;
|
||||
recordsSynced: number;
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const DB_TYPE = (process.env.DB_TYPE || 'sqlite').toLowerCase();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQLite (local / development)
|
||||
// Loaded lazily so the MariaDB build never depends on the native sqlite3 module.
|
||||
// ---------------------------------------------------------------------------
|
||||
let sqliteDb: any = null;
|
||||
|
||||
const SQLITE_SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
garmin_email TEXT NOT NULL,
|
||||
garmin_password_hash TEXT NOT NULL,
|
||||
jwt_token TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS health_data (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
steps INTEGER,
|
||||
heart_rate INTEGER,
|
||||
heart_rate_variability REAL,
|
||||
blood_pressure_systolic INTEGER,
|
||||
blood_pressure_diastolic INTEGER,
|
||||
sleep_duration INTEGER,
|
||||
sleep_quality REAL,
|
||||
stress INTEGER,
|
||||
calories_burned REAL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
UNIQUE(user_id, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
activity_type TEXT NOT NULL,
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME NOT NULL,
|
||||
duration INTEGER,
|
||||
distance REAL,
|
||||
calories REAL,
|
||||
heart_rate_average INTEGER,
|
||||
heart_rate_max INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_status (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
last_sync_time DATETIME,
|
||||
status TEXT DEFAULT 'idle',
|
||||
last_error TEXT,
|
||||
records_synced INTEGER DEFAULT 0,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MariaDB (production, runs on the NAS)
|
||||
// NOTE: TEXT cannot be a PRIMARY KEY in MariaDB, so ids use VARCHAR(64).
|
||||
// ---------------------------------------------------------------------------
|
||||
let mariadbPool: mysql.Pool | null = null;
|
||||
|
||||
const MARIADB_SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
garmin_email VARCHAR(255) NOT NULL,
|
||||
garmin_password_hash TEXT NOT NULL,
|
||||
jwt_token TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS health_data (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
steps INT,
|
||||
heart_rate INT,
|
||||
heart_rate_variability DOUBLE,
|
||||
blood_pressure_systolic INT,
|
||||
blood_pressure_diastolic INT,
|
||||
sleep_duration INT,
|
||||
sleep_quality DOUBLE,
|
||||
stress INT,
|
||||
calories_burned DOUBLE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, date),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
activity_type VARCHAR(255) NOT NULL,
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME NOT NULL,
|
||||
duration INT,
|
||||
distance DOUBLE,
|
||||
calories DOUBLE,
|
||||
heart_rate_average INT,
|
||||
heart_rate_max INT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_status (
|
||||
user_id VARCHAR(64) PRIMARY KEY,
|
||||
last_sync_time DATETIME,
|
||||
status VARCHAR(32) DEFAULT 'idle',
|
||||
last_error TEXT,
|
||||
records_synced INT DEFAULT 0,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
`;
|
||||
|
||||
function getMariadbConfig(): mysql.PoolOptions {
|
||||
const config: mysql.PoolOptions = {
|
||||
user: process.env.MARIADB_USER || 'root',
|
||||
password: process.env.MARIADB_PASSWORD || '',
|
||||
database: process.env.MARIADB_DATABASE || 'garmin_health_lab',
|
||||
connectionLimit: 10,
|
||||
waitForConnections: true,
|
||||
};
|
||||
if (process.env.MARIADB_SOCKET) {
|
||||
config.socketPath = process.env.MARIADB_SOCKET;
|
||||
} else {
|
||||
config.host = process.env.MARIADB_HOST || '127.0.0.1';
|
||||
config.port = process.env.MARIADB_PORT ? Number(process.env.MARIADB_PORT) : 3306;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function initializeDatabase(): Promise<void> {
|
||||
if (DB_TYPE === 'mariadb') {
|
||||
mariadbPool = mysql.createPool(getMariadbConfig());
|
||||
const conn = await mariadbPool.getConnection();
|
||||
try {
|
||||
const statements = MARIADB_SCHEMA
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
for (const stmt of statements) {
|
||||
await conn.query(stmt);
|
||||
}
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
console.log('[db] MariaDB database initialized successfully');
|
||||
} else {
|
||||
const sqlitePath = process.env.DATABASE_PATH || './data/health.db';
|
||||
const dataDir = path.dirname(sqlitePath);
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
const sqlite3 = await import('sqlite3');
|
||||
const SqliteDb = (sqlite3 as any).default?.Database || (sqlite3 as any).Database;
|
||||
sqliteDb = new SqliteDb(sqlitePath);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sqliteDb.serialize(() => {
|
||||
sqliteDb.exec(SQLITE_SCHEMA, (err: Error | null) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
console.log('[db] SQLite database initialized successfully');
|
||||
}
|
||||
}
|
||||
|
||||
// Unified query helpers — same signatures for both backends.
|
||||
// `?` placeholders are supported by both sqlite3 and mysql2.
|
||||
|
||||
export async function runAsync(sql: string, params: any[] = []): Promise<any> {
|
||||
if (DB_TYPE === 'mariadb') {
|
||||
const [result] = await mariadbPool!.execute(sql, params);
|
||||
const r = result as any;
|
||||
return { id: r.insertId, changes: r.affectedRows };
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
sqliteDb.run(sql, params, function (this: any, err: Error | null) {
|
||||
if (err) reject(err);
|
||||
else resolve({ id: this.lastID, changes: this.changes });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAsync(sql: string, params: any[] = []): Promise<any> {
|
||||
if (DB_TYPE === 'mariadb') {
|
||||
const [rows] = await mariadbPool!.execute(sql, params);
|
||||
return Array.isArray(rows) ? (rows as any[])[0] : undefined;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
sqliteDb.get(sql, params, (err: Error | null, row: any) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function allAsync(sql: string, params: any[] = []): Promise<any[]> {
|
||||
if (DB_TYPE === 'mariadb') {
|
||||
const [rows] = await mariadbPool!.execute(sql, params);
|
||||
return (rows as any[]) || [];
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
sqliteDb.all(sql, params, (err: Error | null, rows: any[]) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user