[阶段2.3-2.4] 实现前端数据同步和仪表板页面

前端实现:
- 创建 DataSync 页面
  - 显示同步状态(就绪/正在同步/失败)
  - 手动触发同步按钮
  - 显示同步日志和错误信息
  - 显示同步帮助信息

- 创建 Dashboard 页面
  - 今日概览(步数、心率、睡眠、卡路里)
  - 30 日统计(平均值、总计)
  - 交互式图表展示(步数、心率、睡眠、卡路里)

- 创建 Chart 组件
  - 支持折线图和柱状图
  - 使用 Recharts 库
  - 响应式布局

前端样式:
- DataSync.css
- Dashboard.css
- 完整的响应式设计

验收标准已满足:
- 数据同步页面功能完整
- 仪表板显示健康数据
- 图表正确展示数据趋势
- 移动端响应式

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 12:27:43 +08:00
parent 637347082a
commit 0177758e1f
5 changed files with 726 additions and 23 deletions

View File

@@ -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 (
<div className="chart-container">
<h4>{title}</h4>
<div className="chart-placeholder"></div>
</div>
);
}
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} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey={dataKey} stroke={stroke} dot={{ r: 4 }} />
</LineChart>
) : (
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" fontSize={12} />
<YAxis fontSize={12} />
<Tooltip />
<Legend />
<Bar dataKey={dataKey} fill={fill} />
</BarChart>
)}
</ResponsiveContainer>
</div>
);
}
export default Chart;

View File

@@ -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;
}
}

View File

@@ -1,33 +1,216 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { apiClient } from '../services/api'; 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() { function Dashboard() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [summary, setSummary] = useState<any>(null); 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,
});
useEffect(() => { useEffect(() => {
loadData();
}, []);
const loadData = async () => { const loadData = async () => {
try { try {
// TODO: Fetch health summary data 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); setLoading(false);
} catch (error) { } catch (err: any) {
console.error('Failed to load summary:', error); const errorMsg = err.response?.data?.error?.message || 'Failed to load data';
setError(errorMsg);
setLoading(false); setLoading(false);
} }
}; };
loadData();
}, []);
if (loading) { if (loading) {
return <div className="page-loading">...</div>; return <div className="page-loading"> ...</div>;
} }
return ( return (
<div className="page"> <div className="page">
<h2></h2> <h2>📊 </h2>
<p className="placeholder">...</p>
{error && <div className="error-message">{error}</div>}
{/* Today's Summary */}
{today ? (
<div className="today-summary">
<h3></h3>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon">🚶</div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{today.steps || '-'}</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{today.heartRate || '-'} bpm</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">😴</div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{today.sleep?.duration || '-'} </div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">🔥</div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{today.caloriesBurned || '-'} kcal</div>
</div>
</div>
</div>
</div>
) : (
<div className="placeholder"></div>
)}
{/* 30-Day Statistics */}
<div className="statistics">
<h3>30 </h3>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon">📈</div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{stats.avgSteps.toLocaleString()}</div>
</div>
</div>
<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>
<div className="charts-grid">
<Chart title="步数" data={summary} type="bar" dataKey="steps" fill="#667eea" />
<Chart
title="心率"
data={summary.filter((d) => d.heartRate)}
type="line"
dataKey="heartRate"
stroke="#ff6b9d"
/>
<Chart
title="睡眠时长"
data={summary.filter((d) => d.sleep)}
type="line"
dataKey={(d: any) => d.sleep?.duration}
stroke="#ffd93d"
/>
<Chart
title="卡路里消耗"
data={summary}
type="bar"
dataKey="caloriesBurned"
fill="#6bcf7f"
/>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -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%;
}
}

View File

@@ -1,18 +1,161 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import './Pages.css'; import { apiClient } from '../services/api';
import './DataSync.css';
interface SyncStatus {
status: 'idle' | 'syncing' | 'error';
lastSyncTime: string | null;
recordsSynced: number;
lastError?: string;
}
function DataSync() { function DataSync() {
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>('');
const [message, setMessage] = useState<string>('');
// 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 () => { 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 ( return (
<div className="page"> <div className="page">
<h2></h2> <h2>📱 </h2>
<p className="placeholder">...</p> <p className="subtitle"> Garmin Connect </p>
<button onClick={handleSync} className="btn btn-primary">
Garmin <div className="sync-container">
{/* Status Card */}
<div 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>
</div>
{syncStatus.lastSyncTime && (
<div className="status-item">
<span className="label"></span>
<span className="value">
{new Date(syncStatus.lastSyncTime).toLocaleString('zh-CN')}
</span>
</div>
)}
<div className="status-item">
<span className="label"></span>
<span className="value">{syncStatus.recordsSynced} </span>
</div>
{syncStatus.lastError && (
<div className="status-item error">
<span className="label"></span>
<span className="value">{syncStatus.lastError}</span>
</div>
)}
</div>
) : (
<p className="placeholder">...</p>
)}
</div>
{/* 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> </button>
<p className="help-text">
7 Garmin
</p>
</div>
{/* Messages */}
{error && <div className="error-message">{error}</div>}
{message && <div className="success-message">{message}</div>}
{/* Info Box */}
<div className="info-box">
<h4>💡 </h4>
<ul>
<li> 7 </li>
<li></li>
<li></li>
<li> Garmin API </li>
</ul>
</div>
</div>
</div> </div>
); );
} }