diff --git a/client/src/components/Chart.tsx b/client/src/components/Chart.tsx new file mode 100644 index 0000000..773e7f4 --- /dev/null +++ b/client/src/components/Chart.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; + +interface ChartProps { + title: string; + data: any[]; + type: 'line' | 'bar'; + dataKey: string; + stroke?: string; + fill?: string; + height?: number; +} + +function Chart({ + title, + data, + type, + dataKey, + stroke = '#667eea', + fill = '#667eea', + height = 300, +}: ChartProps) { + if (!data || data.length === 0) { + return ( +
+

{title}

+
暂无数据
+
+ ); + } + + return ( +
+

{title}

+ + {type === 'line' ? ( + + + + + + + + + ) : ( + + + + + + + + + )} + +
+ ); +} + +export default Chart; diff --git a/client/src/pages/Dashboard.css b/client/src/pages/Dashboard.css new file mode 100644 index 0000000..456f608 --- /dev/null +++ b/client/src/pages/Dashboard.css @@ -0,0 +1,162 @@ +.today-summary, +.statistics { + margin-bottom: 2rem; +} + +.today-summary h3, +.statistics h3, +.charts-section h3 { + font-size: 1.2rem; + color: #333; + margin-bottom: 1rem; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.stat-card { + display: flex; + align-items: center; + gap: 1rem; + background: white; + border: 1px solid #eee; + border-radius: 8px; + padding: 1rem; + transition: all 0.3s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.stat-card:hover { + border-color: #667eea; + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1); + transform: translateY(-2px); +} + +.stat-icon { + font-size: 2rem; + flex-shrink: 0; +} + +.stat-content { + flex: 1; +} + +.stat-label { + font-size: 0.85rem; + color: #999; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 0.25rem; +} + +.stat-value { + font-size: 1.4rem; + font-weight: 700; + color: #333; +} + +.charts-section { + margin-top: 2rem; +} + +.charts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.chart-container { + background: white; + border: 1px solid #eee; + border-radius: 8px; + padding: 1rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.chart-container h4 { + margin-top: 0; + margin-bottom: 1rem; + font-size: 1rem; + color: #333; +} + +.chart-placeholder { + height: 300px; + display: flex; + align-items: center; + justify-content: center; + color: #ccc; + font-size: 1rem; + background: #f9f9f9; + border-radius: 4px; +} + +.page-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; + font-size: 1.2rem; + color: #666; +} + +.error-message { + background: #fee; + border: 1px solid #fcc; + color: #c33; + padding: 1rem; + border-radius: 8px; + margin-bottom: 1rem; + font-size: 0.95rem; +} + +.placeholder { + text-align: center; + padding: 2rem; + color: #999; + background: #f9f9f9; + border-radius: 8px; + font-size: 1rem; +} + +@media (max-width: 768px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .charts-grid { + grid-template-columns: 1fr; + } + + .stat-value { + font-size: 1.2rem; + } +} + +@media (max-width: 480px) { + .stats-grid { + grid-template-columns: 1fr; + } + + .stat-card { + padding: 0.75rem; + } + + .stat-icon { + font-size: 1.5rem; + } + + .stat-label { + font-size: 0.75rem; + } + + .stat-value { + font-size: 1rem; + } +} diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 2164422..364406a 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -1,33 +1,216 @@ import React, { useEffect, useState } from 'react'; import { apiClient } from '../services/api'; -import './Pages.css'; +import Chart from '../components/Chart'; +import './Dashboard.css'; + +interface HealthData { + date: string; + steps?: number; + heartRate?: number; + sleep?: { + duration: number; + quality: number; + }; + caloriesBurned?: number; +} function Dashboard() { const [loading, setLoading] = useState(true); - const [summary, setSummary] = useState(null); + const [error, setError] = useState(''); + const [summary, setSummary] = useState([]); + const [today, setToday] = useState(null); + const [stats, setStats] = useState({ + avgSteps: 0, + avgHeartRate: 0, + avgSleep: 0, + totalCalories: 0, + }); useEffect(() => { - const loadData = async () => { - try { - // TODO: Fetch health summary data - setLoading(false); - } catch (error) { - console.error('Failed to load summary:', error); - setLoading(false); - } - }; - loadData(); }, []); + const loadData = async () => { + try { + setLoading(true); + setError(''); + + // Load 30-day summary + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + const startDate = thirtyDaysAgo.toISOString().split('T')[0]; + const endDate = now.toISOString().split('T')[0]; + + const response = await apiClient.getHealthSummary(startDate, endDate); + const data = response.data.data || []; + + setSummary(data); + + // Get today's data + const todayData = data.find((d: HealthData) => d.date === endDate); + setToday(todayData || null); + + // Calculate stats + const validData = data.filter((d: HealthData) => d.steps || d.heartRate); + if (validData.length > 0) { + const avgSteps = + Math.round( + validData.reduce((sum: number, d: HealthData) => sum + (d.steps || 0), 0) / + validData.length + ) || 0; + + const heartRates = validData + .map((d: HealthData) => d.heartRate) + .filter((hr) => hr); + const avgHeartRate = + heartRates.length > 0 + ? Math.round(heartRates.reduce((a, b) => a + b, 0) / heartRates.length) + : 0; + + const sleeps = validData + .map((d: HealthData) => d.sleep?.duration) + .filter((s) => s); + const avgSleep = + sleeps.length > 0 + ? Math.round((sleeps.reduce((a, b) => a + b, 0) / sleeps.length) * 10) / 10 + : 0; + + const totalCalories = Math.round( + validData.reduce((sum: number, d: HealthData) => sum + (d.caloriesBurned || 0), 0) + ); + + setStats({ avgSteps, avgHeartRate, avgSleep, totalCalories }); + } + + setLoading(false); + } catch (err: any) { + const errorMsg = err.response?.data?.error?.message || 'Failed to load data'; + setError(errorMsg); + setLoading(false); + } + }; + if (loading) { - return
加载中...
; + return
⏳ 加载中...
; } return (
-

健康仪表板

-

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

+

📊 健康仪表板

+ + {error &&
{error}
} + + {/* Today's Summary */} + {today ? ( +
+

今日概览

+
+
+
🚶
+
+
步数
+
{today.steps || '-'}
+
+
+ +
+
❤️
+
+
心率
+
{today.heartRate || '-'} bpm
+
+
+ +
+
😴
+
+
睡眠
+
{today.sleep?.duration || '-'} 小时
+
+
+ +
+
🔥
+
+
卡路里
+
{today.caloriesBurned || '-'} kcal
+
+
+
+
+ ) : ( +
暂无今日数据
+ )} + + {/* 30-Day Statistics */} +
+

30 日统计

+
+
+
📈
+
+
平均步数
+
{stats.avgSteps.toLocaleString()}
+
+
+ +
+
❤️
+
+
平均心率
+
{stats.avgHeartRate} bpm
+
+
+ +
+
😴
+
+
平均睡眠
+
{stats.avgSleep}h
+
+
+ +
+
🔥
+
+
总卡路里消耗
+
{stats.totalCalories.toLocaleString()}
+
+
+
+
+ + {/* Charts */} + {summary.length > 0 && ( +
+

数据趋势

+
+ + d.heartRate)} + type="line" + dataKey="heartRate" + stroke="#ff6b9d" + /> + d.sleep)} + type="line" + dataKey={(d: any) => d.sleep?.duration} + stroke="#ffd93d" + /> + +
+
+ )}
); } diff --git a/client/src/pages/DataSync.css b/client/src/pages/DataSync.css new file mode 100644 index 0000000..ce83b6e --- /dev/null +++ b/client/src/pages/DataSync.css @@ -0,0 +1,155 @@ +.sync-container { + display: grid; + gap: 2rem; + max-width: 600px; + margin: 0 auto; +} + +.subtitle { + color: #666; + font-size: 1rem; + margin-top: -0.5rem; + margin-bottom: 1.5rem; +} + +.status-card { + background: white; + border: 1px solid #eee; + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.status-card h3 { + margin-top: 0; + margin-bottom: 1rem; + color: #333; + font-size: 1.1rem; +} + +.status-info { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.status-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem; + background: #f9f9f9; + border-radius: 6px; + font-size: 0.95rem; +} + +.status-item.error { + background: #fee; + border: 1px solid #fcc; +} + +.status-item .label { + font-weight: 600; + color: #666; +} + +.status-item .value { + color: #333; + font-weight: 500; +} + +.sync-actions { + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; +} + +.btn-large { + width: 100%; + max-width: 300px; + padding: 1rem 2rem; + font-size: 1.1rem; +} + +.help-text { + text-align: center; + color: #999; + font-size: 0.9rem; + max-width: 400px; + line-height: 1.5; +} + +.error-message, +.success-message { + padding: 1rem; + border-radius: 8px; + font-size: 0.95rem; + animation: slideIn 0.3s ease-out; +} + +.error-message { + background: #fee; + border: 1px solid #fcc; + color: #c33; +} + +.success-message { + background: #efe; + border: 1px solid #cfc; + color: #333; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.info-box { + background: #f0f7ff; + border: 1px solid #d0e8ff; + border-radius: 8px; + padding: 1.5rem; +} + +.info-box h4 { + margin-top: 0; + margin-bottom: 1rem; + color: #333; + font-size: 1rem; +} + +.info-box ul { + margin: 0; + padding-left: 1.5rem; + list-style: disc; + color: #666; + line-height: 1.8; +} + +.info-box li { + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +@media (max-width: 768px) { + .sync-container { + gap: 1.5rem; + } + + .status-item { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .btn-large { + max-width: 100%; + } +} diff --git a/client/src/pages/DataSync.tsx b/client/src/pages/DataSync.tsx index 2d56e9f..89c2d7a 100644 --- a/client/src/pages/DataSync.tsx +++ b/client/src/pages/DataSync.tsx @@ -1,18 +1,161 @@ -import React from 'react'; -import './Pages.css'; +import React, { useEffect, useState } from 'react'; +import { apiClient } 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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); + + // Load sync status on component mount + useEffect(() => { + loadSyncStatus(); + }, []); + + const loadSyncStatus = async () => { + try { + const response = await apiClient.getGarminSyncStatus(); + setSyncStatus(response.data.data); + } catch (err: any) { + console.error('Failed to load sync status:', err); + } + }; + const handleSync = async () => { - // TODO: Implement data sync + setLoading(true); + 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}`); + } + } catch (err: any) { + const errorMsg = err.response?.data?.error?.message || 'Sync failed'; + setError(`❌ 同步失败:${errorMsg}`); + } 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 getStatusText = (status: string) => { + switch (status) { + case 'idle': + return '就绪'; + case 'syncing': + return '正在同步...'; + case 'error': + return '同步失败'; + default: + return '未知'; + } }; return (
-

数据同步

-

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

- +

📱 数据同步

+

从 Garmin Connect 同步你的健康数据

+ +
+ {/* Status Card */} +
+

同步状态

+ + {syncStatus ? ( +
+
+ 状态 + + {getStatusText(syncStatus.status)} + +
+ + {syncStatus.lastSyncTime && ( +
+ 最后同步 + + {new Date(syncStatus.lastSyncTime).toLocaleString('zh-CN')} + +
+ )} + +
+ 已同步数据 + {syncStatus.recordsSynced} 天 +
+ + {syncStatus.lastError && ( +
+ 错误信息 + {syncStatus.lastError} +
+ )} +
+ ) : ( +

加载中...

+ )} +
+ + {/* Sync Button */} +
+ + +

+ 数据同步会获取你最近 7 天的 Garmin 健康数据,包括步数、心率、睡眠、运动等信息。 +

+
+ + {/* Messages */} + {error &&
{error}
} + {message &&
{message}
} + + {/* Info Box */} +
+

💡 关于数据同步

+
    +
  • 首次同步会获取你最近 7 天的数据
  • +
  • 之后同步会增量更新最新的数据
  • +
  • 数据会保存到本地数据库
  • +
  • 同步频率受 Garmin API 限制
  • +
+
+
); }