前端实现: - 创建 DataSync 页面 - 显示同步状态(就绪/正在同步/失败) - 手动触发同步按钮 - 显示同步日志和错误信息 - 显示同步帮助信息 - 创建 Dashboard 页面 - 今日概览(步数、心率、睡眠、卡路里) - 30 日统计(平均值、总计) - 交互式图表展示(步数、心率、睡眠、卡路里) - 创建 Chart 组件 - 支持折线图和柱状图 - 使用 Recharts 库 - 响应式布局 前端样式: - DataSync.css - Dashboard.css - 完整的响应式设计 验收标准已满足: - 数据同步页面功能完整 - 仪表板显示健康数据 - 图表正确展示数据趋势 - 移动端响应式 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
219 lines
6.7 KiB
TypeScript
219 lines
6.7 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
import { apiClient } from '../services/api';
|
||
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 [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(() => {
|
||
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 <div className="page-loading">⏳ 加载中...</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="page">
|
||
<h2>📊 健康仪表板</h2>
|
||
|
||
{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>
|
||
);
|
||
}
|
||
|
||
export default Dashboard;
|