From d73405decbfb8dc0d9324f97dfa5d9d1d14238c2 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sun, 23 Aug 2026 11:11:29 +0800 Subject: [PATCH] Initial commit: Set up Garmin Health Lab project structure - Initialize monorepo with root workspace configuration - Set up Express.js backend with TypeScript - Set up React 18 frontend with TypeScript - Create database schema with SQLite - Implement project architecture and documentation - Add development and deployment guidelines Co-Authored-By: Claude Haiku 4.5 --- .claude/launch.json | 11 + .gitignore | 27 +++ CLAUDE.md | 88 ++++++++ README.md | 121 +++++++++++ client/package.json | 44 ++++ client/public/index.html | 12 ++ client/src/App.tsx | 26 +++ client/src/components/Layout.css | 129 ++++++++++++ client/src/components/Layout.tsx | 58 ++++++ client/src/index.css | 24 +++ client/src/index.tsx | 10 + client/src/pages/Analysis.tsx | 13 ++ client/src/pages/Dashboard.tsx | 35 ++++ client/src/pages/DataSync.tsx | 20 ++ client/src/pages/Pages.css | 59 ++++++ client/src/pages/Recommendations.tsx | 13 ++ client/src/pages/Settings.tsx | 13 ++ client/src/services/api.ts | 78 ++++++++ client/src/types/index.ts | 37 ++++ client/tsconfig.json | 21 ++ docs/ARCHITECTURE.md | 175 ++++++++++++++++ docs/DEVELOPMENT.md | 278 ++++++++++++++++++++++++++ package.json | 22 ++ server/.env.example | 20 ++ server/package.json | 29 +++ server/src/index.ts | 42 ++++ server/src/middleware/errorHandler.ts | 31 +++ server/src/routes/analysis.ts | 14 ++ server/src/routes/auth.ts | 18 ++ server/src/routes/garmin.ts | 14 ++ server/src/routes/health.ts | 26 +++ server/src/types/index.ts | 59 ++++++ server/src/utils/database.ts | 112 +++++++++++ server/tsconfig.json | 20 ++ 34 files changed, 1699 insertions(+) create mode 100644 .claude/launch.json create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 client/package.json create mode 100644 client/public/index.html create mode 100644 client/src/App.tsx create mode 100644 client/src/components/Layout.css create mode 100644 client/src/components/Layout.tsx create mode 100644 client/src/index.css create mode 100644 client/src/index.tsx create mode 100644 client/src/pages/Analysis.tsx create mode 100644 client/src/pages/Dashboard.tsx create mode 100644 client/src/pages/DataSync.tsx create mode 100644 client/src/pages/Pages.css create mode 100644 client/src/pages/Recommendations.tsx create mode 100644 client/src/pages/Settings.tsx create mode 100644 client/src/services/api.ts create mode 100644 client/src/types/index.ts create mode 100644 client/tsconfig.json create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DEVELOPMENT.md create mode 100644 package.json create mode 100644 server/.env.example create mode 100644 server/package.json create mode 100644 server/src/index.ts create mode 100644 server/src/middleware/errorHandler.ts create mode 100644 server/src/routes/analysis.ts create mode 100644 server/src/routes/auth.ts create mode 100644 server/src/routes/garmin.ts create mode 100644 server/src/routes/health.ts create mode 100644 server/src/types/index.ts create mode 100644 server/src/utils/database.ts create mode 100644 server/tsconfig.json diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..04ffa28 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ecce6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +node_modules/ +dist/ +build/ +*.log +npm-debug.log* +.DS_Store +.env +.env.local +.env.*.local +.idea/ +.vscode/ +*.swp +*.swo +~* + +# Database +*.db +*.sqlite +*.sqlite3 + +# API Keys and secrets +.env.production.local +config/secrets.json + +# Build artifacts +server/dist/ +client/build/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3b6dc22 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,88 @@ +# Garmin Health Lab - 项目指南 + +## 项目简介 + +Garmin Health Lab 是一个完整的健康数据分析平台,用于: +- 获取和同步佳明(Garmin)设备数据 +- 进行多维度的健康数据分析 +- 提供个性化的健康建议 +- 可视化健康趋势 + +## 技术栈 + +- **前端**: React 18 + TypeScript + Recharts +- **后端**: Node.js + Express + TypeScript +- **数据库**: SQLite3 +- **API 集成**: Garmin Connect API + +## 快速开始 + +### 安装 +```bash +npm install +``` + +### 配置 +```bash +cp server/.env.example server/.env +# 编辑 server/.env 填入 Garmin 凭证 +``` + +### 开发 +```bash +npm run dev +``` + +- 前端: http://localhost:3000 +- 后端: http://localhost:5000 + +## 项目结构 + +- `client/` - React 前端应用 +- `server/` - Node.js 后端服务 +- `docs/` - 文档 + +详见 [ARCHITECTURE.md](docs/ARCHITECTURE.md) 和 [DEVELOPMENT.md](docs/DEVELOPMENT.md) + +## 核心功能 + +1. **仪表板** - 健康数据概览和最近数据展示 +2. **数据同步** - 从 Garmin 同步最新健康数据 +3. **数据分析** - 趋势分析和数据可视化 +4. **健康建议** - 基于数据的个性化建议 +5. **设置** - 用户配置和偏好设置 + +## 关键特性 + +- ✅ Garmin API 集成 +- ✅ 多维度数据分析(步数、心率、睡眠等) +- ✅ 实时数据同步 +- ✅ 交互式数据可视化 +- ✅ JWT 认证安全 +- ✅ 本地数据存储 + +## 开发指南 + +- 详见 [DEVELOPMENT.md](docs/DEVELOPMENT.md) +- API 文档见 README.md + +## 常用命令 + +```bash +npm run dev # 开发模式 +npm run build # 构建项目 +npm run typecheck # 类型检查 +npm start # 生产模式 +``` + +## 下一步任务 + +- [ ] 实现 Garmin OAuth 认证 +- [ ] 完成 Garmin API 数据获取 +- [ ] 实现仪表板可视化 +- [ ] 添加数据分析算法 +- [ ] 部署和优化 + +## 联系方式 + +项目维护: ericwyuan.g@gmail.com diff --git a/README.md b/README.md new file mode 100644 index 0000000..a66f36e --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# 佳明健康数据分析平台 (Garmin Health Lab) + +一个完整的健康数据分析平台,用于获取、分析和可视化你的佳明(Garmin)设备数据。 + +## 📊 主要功能 + +- **数据同步**: 通过 Garmin API 自动同步你的健康数据 +- **综合分析**: 步数、心率、睡眠、运动、压力等多维度分析 +- **数据可视化**: 交互式图表和仪表板展示健康数据趋势 +- **智能建议**: 基于数据分析的个性化健康建议 +- **数据导出**: 支持数据导出为 CSV/JSON 格式 + +## 🛠 技术栈 + +### 前端 +- React 18 + TypeScript +- Recharts (数据可视化) +- Tailwind CSS (样式) +- Axios (API 请求) + +### 后端 +- Node.js + Express +- TypeScript +- SQLite3 (本地数据存储) +- garmin-connect (Garmin API 集成) + +## 📁 项目结构 + +``` +GarminHealthLab/ +├── client/ # 前端应用 +│ ├── src/ +│ │ ├── components/ # React 组件 +│ │ ├── pages/ # 页面 +│ │ ├── services/ # API 服务 +│ │ └── types/ # TypeScript 类型定义 +│ └── package.json +├── server/ # 后端应用 +│ ├── src/ +│ │ ├── routes/ # API 路由 +│ │ ├── services/ # 业务逻辑 +│ │ ├── models/ # 数据模型 +│ │ ├── middleware/ # 中间件 +│ │ └── utils/ # 工具函数 +│ └── package.json +├── docs/ # 文档 +├── package.json # 工作空间根配置 +└── README.md +``` + +## 🚀 快速开始 + +### 前置要求 +- Node.js 18+ +- npm 或 yarn +- Garmin Connect 账户 + +### 安装依赖 + +```bash +npm install +``` + +### 配置环境变量 + +创建 `server/.env` 文件: + +```env +PORT=5000 +NODE_ENV=development +GARMIN_CONNECT_USER=your_garmin_email +GARMIN_CONNECT_PASSWORD=your_garmin_password +DATABASE_PATH=./data/health.db +JWT_SECRET=your_jwt_secret_here +CORS_ORIGIN=http://localhost:3000 +``` + +### 启动开发服务器 + +```bash +npm run dev +``` + +- 前端: http://localhost:3000 +- 后端: http://localhost:5000 + +## 📚 API 文档 + +### 认证 +- `POST /api/auth/login` - 用户登录 +- `POST /api/auth/logout` - 用户登出 +- `POST /api/auth/refresh` - 刷新 Token + +### Garmin 数据同步 +- `POST /api/garmin/sync` - 同步 Garmin 数据 +- `GET /api/garmin/status` - 获取同步状态 + +### 健康数据 +- `GET /api/health/summary` - 获取健康摘要 +- `GET /api/health/steps` - 获取步数数据 +- `GET /api/health/heart-rate` - 获取心率数据 +- `GET /api/health/sleep` - 获取睡眠数据 +- `GET /api/health/activities` - 获取运动数据 + +### 分析与建议 +- `GET /api/analysis/trends` - 获取数据趋势 +- `GET /api/analysis/recommendations` - 获取健康建议 + +## 🔐 安全说明 + +- Garmin 账户密码使用加密存储 +- 所有 API 请求需要 JWT 认证 +- 敏感数据不在前端存储 + +## 📝 开发指南 + +详见 [DEVELOPMENT.md](./docs/DEVELOPMENT.md) + +## 📄 许可证 + +MIT diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..443611c --- /dev/null +++ b/client/package.json @@ -0,0 +1,44 @@ +{ + "name": "garmin-health-lab-client", + "version": "0.1.0", + "private": true, + "proxy": "http://localhost:5000", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.14.2", + "axios": "^1.5.0", + "recharts": "^2.8.0", + "tailwindcss": "^3.3.2", + "date-fns": "^2.30.0" + }, + "devDependencies": { + "@types/react": "^18.2.14", + "@types/react-dom": "^18.2.6", + "typescript": "^5.1.3", + "react-scripts": "5.0.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/client/public/index.html b/client/public/index.html new file mode 100644 index 0000000..618c22d --- /dev/null +++ b/client/public/index.html @@ -0,0 +1,12 @@ + + + + + + + Garmin Health Lab - 佳明健康数据分析 + + +
+ + diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..8d183dd --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import Layout from './components/Layout'; +import Dashboard from './pages/Dashboard'; +import DataSync from './pages/DataSync'; +import Analysis from './pages/Analysis'; +import Recommendations from './pages/Recommendations'; +import Settings from './pages/Settings'; + +function App() { + return ( + + + + } /> + } /> + } /> + } /> + } /> + + + + ); +} + +export default App; diff --git a/client/src/components/Layout.css b/client/src/components/Layout.css new file mode 100644 index 0000000..4341ed2 --- /dev/null +++ b/client/src/components/Layout.css @@ -0,0 +1,129 @@ +.layout { + display: flex; + flex-direction: column; + min-height: 100vh; + background-color: #f5f5f5; +} + +.header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 2rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.header-content { + max-width: 1400px; + margin: 0 auto; +} + +.logo { + font-size: 1.8rem; + font-weight: bold; + margin-bottom: 0.5rem; +} + +.tagline { + font-size: 0.9rem; + opacity: 0.9; +} + +.container { + display: flex; + flex: 1; + max-width: 1400px; + width: 100%; + margin: 0 auto; + gap: 2rem; + padding: 2rem; +} + +.sidebar { + width: 250px; + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + height: fit-content; + position: sticky; + top: 2rem; +} + +.nav-list { + list-style: none; + padding: 0; +} + +.nav-link { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.5rem; + text-decoration: none; + color: #333; + border-left: 3px solid transparent; + transition: all 0.3s ease; +} + +.nav-link:hover { + background-color: #f5f5f5; + border-left-color: #667eea; +} + +.nav-link.active { + background-color: #f0f0ff; + border-left-color: #667eea; + color: #667eea; + font-weight: 600; +} + +.icon { + font-size: 1.2rem; +} + +.label { + flex: 1; +} + +.content { + flex: 1; + background: white; + border-radius: 8px; + padding: 2rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.footer { + text-align: center; + padding: 2rem; + color: #666; + font-size: 0.9rem; + border-top: 1px solid #eee; +} + +@media (max-width: 768px) { + .container { + flex-direction: column; + padding: 1rem; + } + + .sidebar { + width: 100%; + position: static; + } + + .nav-list { + display: flex; + gap: 0.5rem; + overflow-x: auto; + } + + .nav-link { + flex: 1; + min-width: 100px; + font-size: 0.9rem; + } + + .logo { + font-size: 1.4rem; + } +} diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx new file mode 100644 index 0000000..72e2c39 --- /dev/null +++ b/client/src/components/Layout.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import './Layout.css'; + +interface LayoutProps { + children: React.ReactNode; +} + +function Layout({ children }: LayoutProps) { + const location = useLocation(); + + const navigationItems = [ + { path: '/', label: '仪表板', icon: '📊' }, + { path: '/sync', label: '数据同步', icon: '🔄' }, + { path: '/analysis', label: '数据分析', icon: '📈' }, + { path: '/recommendations', label: '健康建议', icon: '💡' }, + { path: '/settings', label: '设置', icon: '⚙️' }, + ]; + + return ( +
+
+
+

🏃 Garmin Health Lab

+

佳明健康数据分析平台

+
+
+ +
+ + +
+ {children} +
+
+ +
+

© 2024 Garmin Health Lab. All rights reserved.

+
+
+ ); +} + +export default Layout; diff --git a/client/src/index.css b/client/src/index.css new file mode 100644 index 0000000..eb3addf --- /dev/null +++ b/client/src/index.css @@ -0,0 +1,24 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: #f5f5f5; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} + +html, body, #root { + width: 100%; + height: 100%; +} diff --git a/client/src/index.tsx b/client/src/index.tsx new file mode 100644 index 0000000..2339d59 --- /dev/null +++ b/client/src/index.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/client/src/pages/Analysis.tsx b/client/src/pages/Analysis.tsx new file mode 100644 index 0000000..00dc8dd --- /dev/null +++ b/client/src/pages/Analysis.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import './Pages.css'; + +function Analysis() { + return ( +
+

数据分析

+

数据分析页面即将推出...

+
+ ); +} + +export default Analysis; diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx new file mode 100644 index 0000000..2164422 --- /dev/null +++ b/client/src/pages/Dashboard.tsx @@ -0,0 +1,35 @@ +import React, { useEffect, useState } from 'react'; +import { apiClient } from '../services/api'; +import './Pages.css'; + +function Dashboard() { + const [loading, setLoading] = useState(true); + const [summary, setSummary] = useState(null); + + useEffect(() => { + const loadData = async () => { + try { + // TODO: Fetch health summary data + setLoading(false); + } catch (error) { + console.error('Failed to load summary:', error); + setLoading(false); + } + }; + + loadData(); + }, []); + + if (loading) { + return
加载中...
; + } + + return ( +
+

健康仪表板

+

仪表板内容即将推出...

+
+ ); +} + +export default Dashboard; diff --git a/client/src/pages/DataSync.tsx b/client/src/pages/DataSync.tsx new file mode 100644 index 0000000..2d56e9f --- /dev/null +++ b/client/src/pages/DataSync.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import './Pages.css'; + +function DataSync() { + const handleSync = async () => { + // TODO: Implement data sync + }; + + return ( +
+

数据同步

+

数据同步功能即将推出...

+ +
+ ); +} + +export default DataSync; diff --git a/client/src/pages/Pages.css b/client/src/pages/Pages.css new file mode 100644 index 0000000..9ff34f8 --- /dev/null +++ b/client/src/pages/Pages.css @@ -0,0 +1,59 @@ +.page { + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.page h2 { + color: #333; + margin-bottom: 1.5rem; + font-size: 1.8rem; +} + +.page-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; + font-size: 1.2rem; + color: #666; +} + +.placeholder { + color: #999; + font-size: 1.1rem; + padding: 2rem; + background-color: #f9f9f9; + border-radius: 8px; + text-align: center; +} + +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 6px; + font-size: 1rem; + cursor: pointer; + transition: all 0.3s ease; + margin-top: 1rem; +} + +.btn-primary { + background-color: #667eea; + color: white; +} + +.btn-primary:hover { + background-color: #5568d3; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} diff --git a/client/src/pages/Recommendations.tsx b/client/src/pages/Recommendations.tsx new file mode 100644 index 0000000..ee61382 --- /dev/null +++ b/client/src/pages/Recommendations.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import './Pages.css'; + +function Recommendations() { + return ( +
+

健康建议

+

健康建议页面即将推出...

+
+ ); +} + +export default Recommendations; diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx new file mode 100644 index 0000000..de09326 --- /dev/null +++ b/client/src/pages/Settings.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import './Pages.css'; + +function Settings() { + return ( +
+

设置

+

设置页面即将推出...

+
+ ); +} + +export default Settings; diff --git a/client/src/services/api.ts b/client/src/services/api.ts new file mode 100644 index 0000000..ccb9e5f --- /dev/null +++ b/client/src/services/api.ts @@ -0,0 +1,78 @@ +import axios, { AxiosInstance } from 'axios'; + +const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api'; + +class ApiClient { + private client: AxiosInstance; + + constructor() { + this.client = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + // Auth endpoints + login(email: string, password: string) { + return this.client.post('/auth/login', { email, password }); + } + + logout() { + return this.client.post('/auth/logout'); + } + + // Garmin endpoints + syncGarminData() { + return this.client.post('/garmin/sync'); + } + + getGarminSyncStatus() { + return this.client.get('/garmin/status'); + } + + // Health endpoints + getHealthSummary(startDate?: string, endDate?: string) { + return this.client.get('/health/summary', { + params: { startDate, endDate } + }); + } + + getStepsData(startDate?: string, endDate?: string) { + return this.client.get('/health/steps', { + params: { startDate, endDate } + }); + } + + getHeartRateData(startDate?: string, endDate?: string) { + return this.client.get('/health/heart-rate', { + params: { startDate, endDate } + }); + } + + getSleepData(startDate?: string, endDate?: string) { + return this.client.get('/health/sleep', { + params: { startDate, endDate } + }); + } + + getActivities(startDate?: string, endDate?: string) { + return this.client.get('/health/activities', { + params: { startDate, endDate } + }); + } + + // Analysis endpoints + getTrends(metricType?: string) { + return this.client.get('/analysis/trends', { + params: { metricType } + }); + } + + getRecommendations() { + return this.client.get('/analysis/recommendations'); + } +} + +export const apiClient = new ApiClient(); diff --git a/client/src/types/index.ts b/client/src/types/index.ts new file mode 100644 index 0000000..034e9ea --- /dev/null +++ b/client/src/types/index.ts @@ -0,0 +1,37 @@ +export interface HealthSummary { + date: string; + steps: number; + heartRate?: number; + sleep?: { + duration: number; + quality: number; + }; + caloriesBurned?: number; + stress?: number; +} + +export interface Activity { + id: string; + activityType: string; + startTime: string; + endTime: string; + duration: number; + distance?: number; + calories?: number; + heartRateAverage?: number; + heartRateMax?: number; +} + +export interface Recommendation { + id: string; + category: string; + recommendation: string; + priority: 'high' | 'medium' | 'low'; + basedOn: string[]; +} + +export interface TrendData { + date: string; + value: number; + [key: string]: any; +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..72729fe --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..912996a --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,175 @@ +# 项目架构说明 + +## 整体架构 + +``` +┌─────────────────────────────────────────────────────┐ +│ 浏览器 (React 前端) │ +│ ┌────────────┬──────────────┬──────────┬──────────┐ │ +│ │ 仪表板 │ 数据同步 │ 数据分析 │ 设置 │ │ +│ └────────────┴──────────────┴──────────┴──────────┘ │ +└──────────────────────────┬──────────────────────────┘ + │ HTTP/REST + ▼ +┌──────────────────────────────────────────────────────┐ +│ Node.js/Express 后端服务器 │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ API 路由层 │ │ +│ │ ├─ /auth - 用户认证 │ │ +│ │ ├─ /garmin - Garmin 数据同步 │ │ +│ │ ├─ /health - 健康数据查询 │ │ +│ │ └─ /analysis - 数据分析 │ │ +│ └──────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ 服务层 │ │ +│ │ ├─ GarminService - Garmin API 集成 │ │ +│ │ ├─ HealthService - 健康数据业务逻辑 │ │ +│ │ ├─ AuthService - 认证授权 │ │ +│ │ └─ AnalysisService - 数据分析 │ │ +│ └──────────────────────────────────────────────┘ │ +└──────────────────────┬───────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ SQLite 数据库 │ + │ ├─ users │ + │ ├─ health_data │ + │ ├─ activities │ + │ └─ sync_status │ + └──────────────────────────────┘ + + + ┌──────────────────────────────┐ + │ Garmin Cloud API │ + │ ├─ 用户认证 │ + │ ├─ 数据获取 │ + │ └─ 数据同步 │ + └──────────────────────────────┘ +``` + +## 核心模块 + +### 1. 前端 (Client) +- **框架**: React 18 + TypeScript +- **路由**: React Router +- **UI 组件**: 自定义 + CSS +- **数据可视化**: Recharts +- **API 通信**: Axios + +**主要页面**: +- Dashboard (仪表板) +- DataSync (数据同步) +- Analysis (数据分析) +- Recommendations (健康建议) +- Settings (设置) + +### 2. 后端 (Server) +- **框架**: Express.js +- **语言**: TypeScript +- **数据库**: SQLite3 +- **认证**: JWT + +**主要服务**: +- **AuthService**: 用户认证和授权 +- **GarminService**: Garmin API 集成和数据获取 +- **HealthService**: 健康数据管理 +- **AnalysisService**: 数据分析和建议生成 + +### 3. 数据库 (SQLite) +**表结构**: +- `users`: 用户信息和 Garmin 凭证 +- `health_data`: 每日健康数据汇总 +- `activities`: 运动活动记录 +- `sync_status`: 数据同步状态追踪 + +## 数据流 + +### 1. Garmin 数据同步流程 +``` +用户点击"同步" + ↓ +POST /api/garmin/sync + ↓ +GarminService.syncData() + ↓ +获取 Garmin 授权 Token + ↓ +调用 Garmin API 获取数据 + ↓ +转换数据格式 + ↓ +存储到本地 SQLite + ↓ +更新 sync_status + ↓ +返回同步结果 +``` + +### 2. 数据分析流程 +``` +GET /api/health/summary (日期范围) + ↓ +从数据库查询健康数据 + ↓ +计算统计指标 + ↓ +生成趋势分析 + ↓ +返回分析结果 + ↓ +前端绘制图表 +``` + +### 3. 健康建议生成流程 +``` +GET /api/analysis/recommendations + ↓ +AnalysisService.generateRecommendations() + ↓ +分析历史数据 + ↓ +识别异常和趋势 + ↓ +根据规则引擎生成建议 + ↓ +按优先级排序 + ↓ +返回建议列表 +``` + +## 关键特性 + +### 安全性 +- Garmin 密码使用加密存储 +- JWT 令牌验证所有请求 +- CORS 配置限制来源 +- 环境变量管理敏感配置 + +### 扩展性 +- 模块化的服务设计 +- 易于添加新的分析算法 +- 支持数据导出功能 +- 可扩展的 API 端点 + +### 性能 +- 数据库索引优化查询 +- API 缓存策略 +- 异步处理长运行任务 +- 增量数据同步支持 + +## 部署架构 + +``` +┌─────────────────────────────────┐ +│ Development 本地开发 │ +│ └─ npm run dev (同时运行前后端) │ +└─────────────────────────────────┘ + +┌─────────────────────────────────┐ +│ Production 生产环境 │ +│ ├─ Docker 容器化 │ +│ ├─ Nginx 反向代理 │ +│ ├─ Node.js 后端服务 │ +│ └─ SQLite 数据持久化 │ +└─────────────────────────────────┘ +``` diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..866d6c8 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,278 @@ +# 开发指南 + +## 环境设置 + +### 前置要求 +- Node.js 18+ +- npm 或 yarn +- Git +- Garmin Connect 账户 + +### 安装步骤 + +1. **克隆项目** + ```bash + cd ~/Desktop/Work + git clone GarminHealthLab + cd GarminHealthLab + ``` + +2. **安装依赖** + ```bash + npm install + ``` + +3. **配置环境变量** + ```bash + # 复制示例文件 + cp server/.env.example server/.env + + # 编辑 server/.env,填入你的 Garmin 凭证 + # GARMIN_CONNECT_USER=your_email@example.com + # GARMIN_CONNECT_PASSWORD=your_password + # JWT_SECRET=generate_a_random_string + ``` + +4. **启动开发服务器** + ```bash + npm run dev + ``` + + - 前端: http://localhost:3000 + - 后端 API: http://localhost:5000/api + +## 项目命令 + +```bash +# 开发模式(同时运行前后端) +npm run dev + +# 仅运行后端服务器 +npm run dev:server + +# 仅运行前端应用 +npm run dev:client + +# 构建项目 +npm run build + +# 生产模式启动 +npm start + +# 类型检查 +npm run typecheck +``` + +## 代码结构 + +### 后端 (server/) + +``` +server/ +├── src/ +│ ├── index.ts # 入口文件 +│ ├── routes/ # API 路由 +│ │ ├── auth.ts +│ │ ├── garmin.ts +│ │ ├── health.ts +│ │ └── analysis.ts +│ ├── services/ # 业务逻辑服务 +│ │ ├── AuthService.ts +│ │ ├── GarminService.ts +│ │ ├── HealthService.ts +│ │ └── AnalysisService.ts +│ ├── models/ # 数据模型 +│ ├── middleware/ # 中间件 +│ ├── utils/ # 工具函数 +│ │ └── database.ts +│ └── types/ # TypeScript 类型定义 +├── dist/ # 编译输出 +├── tsconfig.json +└── package.json +``` + +### 前端 (client/) + +``` +client/ +├── src/ +│ ├── index.tsx # 入口文件 +│ ├── App.tsx # 主组件 +│ ├── components/ # 可复用组件 +│ ├── pages/ # 页面组件 +│ │ ├── Dashboard.tsx +│ │ ├── DataSync.tsx +│ │ ├── Analysis.tsx +│ │ ├── Recommendations.tsx +│ │ └── Settings.tsx +│ ├── services/ # API 服务 +│ │ └── api.ts +│ ├── types/ # TypeScript 类型定义 +│ └── index.css # 全局样式 +├── public/ # 静态资源 +└── package.json +``` + +## 开发工作流 + +### 添加新的 API 端点 + +1. **创建路由处理器** (`server/src/routes/yourFeature.ts`) + ```typescript + import express from 'express'; + + const router = express.Router(); + + router.get('/endpoint', (req, res) => { + // 业务逻辑 + }); + + export default router; + ``` + +2. **在主文件中注册路由** (`server/src/index.ts`) + ```typescript + import yourFeatureRoutes from './routes/yourFeature'; + app.use('/api/yourfeature', yourFeatureRoutes); + ``` + +3. **创建 API 客户端方法** (`client/src/services/api.ts`) + ```typescript + getYourEndpoint() { + return this.client.get('/yourfeature/endpoint'); + } + ``` + +### 添加新页面 + +1. **创建页面组件** (`client/src/pages/YourPage.tsx`) + ```typescript + import React from 'react'; + + function YourPage() { + return
Page content
; + } + + export default YourPage; + ``` + +2. **在 App.tsx 中添加路由** + ```typescript + } /> + ``` + +### 数据库操作 + +使用数据库工具函数简化操作: + +```typescript +import { runAsync, getAsync, allAsync } from '../utils/database'; + +// 插入数据 +await runAsync('INSERT INTO table (col1, col2) VALUES (?, ?)', [val1, val2]); + +// 查询单行 +const row = await getAsync('SELECT * FROM table WHERE id = ?', [id]); + +// 查询多行 +const rows = await allAsync('SELECT * FROM table WHERE status = ?', ['active']); +``` + +## 调试 + +### 后端调试 +```bash +# 启用详细日志 +DEBUG=* npm run dev:server + +# 使用 Node 调试器 +node --inspect server/dist/index.js +``` + +### 前端调试 +- 使用 React DevTools 浏览器扩展 +- 使用浏览器开发者工具 (F12) +- 检查网络请求和响应 + +## 测试 + +### 测试后端 API + +使用 curl 或 Postman: + +```bash +# 获取健康摘要 +curl http://localhost:5000/api/health/summary + +# 触发 Garmin 同步 +curl -X POST http://localhost:5000/api/garmin/sync + +# 获取健康建议 +curl http://localhost:5000/api/analysis/recommendations +``` + +## 常见问题 + +### 数据库连接失败 +```bash +# 检查数据库文件 +ls -la ./data/health.db + +# 重置数据库 +rm ./data/health.db +npm run dev +``` + +### 前端无法连接后端 +- 检查 CORS 配置 +- 确保后端运行在 5000 端口 +- 检查防火墙设置 + +### Garmin 认证失败 +- 验证 Garmin 邮箱和密码 +- 检查网络连接 +- 查看后端日志 + +## 性能优化建议 + +1. **数据库索引** + - 在频繁查询的字段上添加索引 + - 定期分析查询性能 + +2. **缓存策略** + - 实现 API 响应缓存 + - 使用浏览器缓存 + +3. **代码分割** + - React 懒加载路由 + - 按需加载 JavaScript + +## 代码规范 + +### TypeScript +- 使用严格模式 +- 为所有函数参数添加类型 +- 使用接口定义复杂对象 + +### CSS +- 使用 BEM 命名规范 +- 响应式设计优先 +- 避免内联样式 + +### 提交信息 +``` +feat: 添加新功能描述 +fix: 修复 bug 描述 +docs: 文档更新 +style: 代码格式调整 +refactor: 代码重构 +test: 测试相关 +``` + +## 资源链接 + +- [Express.js 文档](https://expressjs.com/) +- [React 文档](https://react.dev/) +- [TypeScript 文档](https://www.typescriptlang.org/) +- [SQLite 文档](https://www.sqlite.org/) +- [Garmin API](https://developer.garmin.com/) diff --git a/package.json b/package.json new file mode 100644 index 0000000..a09fd4d --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "garmin-health-lab", + "version": "0.1.0", + "description": "佳明健康数据分析平台", + "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" + ], + "devDependencies": { + "concurrently": "^8.2.0" + } +} diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..f93aff3 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,20 @@ +# Server Configuration +PORT=5000 +NODE_ENV=development + +# Database +DATABASE_PATH=./data/health.db + +# JWT +JWT_SECRET=your_jwt_secret_key_here_change_in_production + +# CORS +CORS_ORIGIN=http://localhost:3000 + +# Garmin Configuration +# Note: Consider using OAuth instead of storing credentials +GARMIN_CONNECT_USER=your_garmin_email@example.com +GARMIN_CONNECT_PASSWORD=your_password_here + +# Data Sync +SYNC_INTERVAL_HOURS=1 diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..921b979 --- /dev/null +++ b/server/package.json @@ -0,0 +1,29 @@ +{ + "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": { + "express": "^4.18.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "sqlite3": "^5.1.6", + "jsonwebtoken": "^9.0.2", + "axios": "^1.5.0", + "ts-node": "^10.9.1" + }, + "devDependencies": { + "@types/express": "^4.17.17", + "@types/node": "^20.3.1", + "@types/jsonwebtoken": "^9.0.2", + "typescript": "^5.1.3", + "tsx": "^3.12.7" + } +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..dc2d7cc --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,42 @@ +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 +})); + +// Initialize database +initializeDatabase(); + +// 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); + +app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); +}); diff --git a/server/src/middleware/errorHandler.ts b/server/src/middleware/errorHandler.ts new file mode 100644 index 0000000..8210117 --- /dev/null +++ b/server/src/middleware/errorHandler.ts @@ -0,0 +1,31 @@ +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; + } +} diff --git a/server/src/routes/analysis.ts b/server/src/routes/analysis.ts new file mode 100644 index 0000000..c144703 --- /dev/null +++ b/server/src/routes/analysis.ts @@ -0,0 +1,14 @@ +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; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..11da12c --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,18 @@ +import express from 'express'; + +const router = express.Router(); + +// TODO: Implement authentication routes +router.post('/login', (req, res) => { + res.json({ message: 'Login endpoint' }); +}); + +router.post('/logout', (req, res) => { + res.json({ message: 'Logout endpoint' }); +}); + +router.post('/refresh', (req, res) => { + res.json({ message: 'Refresh token endpoint' }); +}); + +export default router; diff --git a/server/src/routes/garmin.ts b/server/src/routes/garmin.ts new file mode 100644 index 0000000..06d9c7f --- /dev/null +++ b/server/src/routes/garmin.ts @@ -0,0 +1,14 @@ +import express from 'express'; + +const router = express.Router(); + +// TODO: Implement Garmin API integration +router.post('/sync', (req, res) => { + res.json({ message: 'Garmin sync endpoint' }); +}); + +router.get('/status', (req, res) => { + res.json({ message: 'Garmin sync status endpoint' }); +}); + +export default router; diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts new file mode 100644 index 0000000..9a21227 --- /dev/null +++ b/server/src/routes/health.ts @@ -0,0 +1,26 @@ +import express from 'express'; + +const router = express.Router(); + +// TODO: Implement health data endpoints +router.get('/summary', (req, res) => { + res.json({ message: 'Health summary endpoint' }); +}); + +router.get('/steps', (req, res) => { + res.json({ message: 'Steps data endpoint' }); +}); + +router.get('/heart-rate', (req, res) => { + res.json({ message: 'Heart rate data endpoint' }); +}); + +router.get('/sleep', (req, res) => { + res.json({ message: 'Sleep data endpoint' }); +}); + +router.get('/activities', (req, res) => { + res.json({ message: 'Activities endpoint' }); +}); + +export default router; diff --git a/server/src/types/index.ts b/server/src/types/index.ts new file mode 100644 index 0000000..17c2b6f --- /dev/null +++ b/server/src/types/index.ts @@ -0,0 +1,59 @@ +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; +} diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts new file mode 100644 index 0000000..f9ebc04 --- /dev/null +++ b/server/src/utils/database.ts @@ -0,0 +1,112 @@ +import sqlite3 from 'sqlite3'; +import path from 'path'; +import fs from 'fs'; + +const dbPath = process.env.DATABASE_PATH || './data/health.db'; + +// Ensure data directory exists +const dataDir = path.dirname(dbPath); +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +export const db = new sqlite3.Database(dbPath); + +export function initializeDatabase() { + db.serialize(() => { + // Users table + db.run(` + 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 + ) + `); + + // Health data table + db.run(` + 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) + ) + `); + + // Activities table + db.run(` + 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) + ) + `); + + // Sync status table + db.run(` + 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) + ) + `); + + console.log('Database initialized successfully'); + }); +} + +export function runAsync(sql: string, params: any[] = []): Promise { + return new Promise((resolve, reject) => { + db.run(sql, params, function(err) { + if (err) reject(err); + else resolve({ id: this.lastID, changes: this.changes }); + }); + }); +} + +export function getAsync(sql: string, params: any[] = []): Promise { + return new Promise((resolve, reject) => { + db.get(sql, params, (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); +} + +export function allAsync(sql: string, params: any[] = []): Promise { + return new Promise((resolve, reject) => { + db.all(sql, params, (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + }); + }); +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..c71688c --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "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"] +}