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 (
+
+ );
+ }
+
+ 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 限制
+
+
+
);
}