[阶段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:
60
client/src/components/Chart.tsx
Normal file
60
client/src/components/Chart.tsx
Normal 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;
|
||||
162
client/src/pages/Dashboard.css
Normal file
162
client/src/pages/Dashboard.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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<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(() => {
|
||||
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 <div className="page-loading">加载中...</div>;
|
||||
return <div className="page-loading">⏳ 加载中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>健康仪表板</h2>
|
||||
<p className="placeholder">仪表板内容即将推出...</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
155
client/src/pages/DataSync.css
Normal file
155
client/src/pages/DataSync.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
@@ -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<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 () => {
|
||||
// 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 (
|
||||
<div className="page">
|
||||
<h2>数据同步</h2>
|
||||
<p className="placeholder">数据同步功能即将推出...</p>
|
||||
<button onClick={handleSync} className="btn btn-primary">
|
||||
同步 Garmin 数据
|
||||
</button>
|
||||
<h2>📱 数据同步</h2>
|
||||
<p className="subtitle">从 Garmin Connect 同步你的健康数据</p>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user