[阶段4.2] 前端对接 Flask API + 建议/分析/设置页面,移除废弃的 Node 后端
选型确认为 Python Flask 后,删除 server/ 整套 Node 实现,
根 package.json 改为只管理 client workspace,
npm run dev 同时拉起 Flask 与 React。
fix: 前端读取响应的方式与后端不符
- Flask 返回裸数组/对象,而前端读的是 response.data.data
(Node 那套 {success,data} 包装),登录后拿到 undefined 直接崩
- api.ts 重写为返回 response.data,并补齐全部端点的 TypeScript 类型
- 401 拦截器改为清除会话并跳转 /login,而不是留在空白页
fix: 同步页缺少 Garmin 密码输入
- 后端只存密码哈希、无法还原,/garmin/sync 要求请求体带明文密码,
原页面没有该输入框,点同步必然 400
- DataSync 增加密码字段,请求结束后立即清空,并说明为何每次都要输入
新增页面:
- Recommendations: 模型下拉切换(未配置密钥的模型置灰),
展示本次由哪个模型作答、是否发生了降级、分析了多少天数据;
按优先级配色,底部附免责声明
- Analysis: 6 个指标 × 4 个时间窗口,展示均值/中位数/极值/
前后半段差值,并绘制趋势图
- Settings: 模型清单与配置状态、数据隐私说明、退出登录
Dashboard:
- 修正响应结构,数据扁平化后再传给图表
(原先给 dataKey 传了函数,与 Chart 的 string 类型不符)
- 无数据时引导用户去同步,而不是显示一堆空图表
Chart:
- 按指标而非按行判断是否为空,某天缺某项指标不再让整张图判空
- 折线图 connectNulls,避免设备漏记的日子把线断成碎片
验证: 前端 tsc --noEmit 通过;后端 161 passed / 1 skipped;
实机启动 Flask 后 register/login/models/ai-recommendations 均正常,
未配置密钥时正确降级到规则引擎。
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,160 +1,140 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { apiClient } from '../services/api';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { apiClient, errorMessage, SyncStatus } 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 [garminPassword, setGarminPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [message, setMessage] = useState<string>('');
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
// Load sync status on component mount
|
||||
useEffect(() => {
|
||||
loadSyncStatus();
|
||||
}, []);
|
||||
|
||||
const loadSyncStatus = async () => {
|
||||
const loadSyncStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiClient.getGarminSyncStatus();
|
||||
setSyncStatus(response.data.data);
|
||||
} catch (err: any) {
|
||||
setSyncStatus(await apiClient.getGarminSyncStatus());
|
||||
} catch (err) {
|
||||
// A failed status poll should not blank the page; the sync button
|
||||
// remains usable and will surface its own errors.
|
||||
console.error('Failed to load sync status:', err);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSync = async () => {
|
||||
setLoading(true);
|
||||
useEffect(() => {
|
||||
loadSyncStatus();
|
||||
}, [loadSyncStatus]);
|
||||
|
||||
const handleSync = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
const response = await apiClient.syncGarminData();
|
||||
if (!garminPassword) {
|
||||
setError('请输入 Garmin 密码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data.success) {
|
||||
setMessage(`✅ 同步完成!新增/更新 ${response.data.data.recordsSynced} 天数据`);
|
||||
// Reload sync status
|
||||
loadSyncStatus();
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await apiClient.syncGarminData(garminPassword);
|
||||
if (result.status === 'success') {
|
||||
setMessage(`同步完成,新增/更新 ${result.recordsSynced} 天数据`);
|
||||
} else {
|
||||
setError(`❌ 同步失败:${response.data.data.message}`);
|
||||
setError(result.message);
|
||||
}
|
||||
// Clear the password as soon as the request is done — it is only ever
|
||||
// held in memory for the duration of the call.
|
||||
setGarminPassword('');
|
||||
loadSyncStatus();
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.error?.message || 'Sync failed';
|
||||
setError(`❌ 同步失败:${errorMsg}`);
|
||||
setError(errorMessage(err, '同步失败'));
|
||||
} 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 statusLabel: Record<string, string> = {
|
||||
idle: '就绪',
|
||||
syncing: '正在同步…',
|
||||
error: '上次同步失败',
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
return '就绪';
|
||||
case 'syncing':
|
||||
return '正在同步...';
|
||||
case 'error':
|
||||
return '同步失败';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
const busy = loading || syncStatus?.status === 'syncing';
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>📱 数据同步</h2>
|
||||
<p className="subtitle">从 Garmin Connect 同步你的健康数据</p>
|
||||
<h2>数据同步</h2>
|
||||
<p className="subtitle">从 Garmin Connect 拉取最近 7 天的健康数据</p>
|
||||
|
||||
<div className="sync-container">
|
||||
{/* Status Card */}
|
||||
<div className="status-card">
|
||||
<section 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 className={`value status-${syncStatus.status}`}>
|
||||
{statusLabel[syncStatus.status] ?? 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>
|
||||
<span className="label">最后同步</span>
|
||||
<span className="value">
|
||||
{syncStatus.lastSyncTime
|
||||
? 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="label">错误</span>
|
||||
<span className="value">{syncStatus.lastError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="placeholder">加载中...</p>
|
||||
<p className="placeholder">加载中…</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sync Button */}
|
||||
<div className="sync-actions">
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={loading || (syncStatus?.status === 'syncing')}
|
||||
className="btn btn-primary btn-large"
|
||||
>
|
||||
{loading || syncStatus?.status === 'syncing' ? '✋ 正在同步...' : '🔄 立即同步'}
|
||||
<form className="sync-actions" onSubmit={handleSync}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="garmin-password">Garmin 密码</label>
|
||||
<input
|
||||
id="garmin-password"
|
||||
type="password"
|
||||
value={garminPassword}
|
||||
onChange={(e) => setGarminPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
disabled={busy}
|
||||
/>
|
||||
<p className="field-hint">
|
||||
密码在库中只以哈希形式保存、无法还原,因此每次同步都需要重新输入。
|
||||
它仅用于本次向 Garmin 登录,不会被再次存储。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary btn-large" disabled={busy}>
|
||||
{busy ? '正在同步…' : '立即同步'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<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>
|
||||
<section className="info-box">
|
||||
<h4>关于数据同步</h4>
|
||||
<ul>
|
||||
<li>首次同步会获取你最近 7 天的数据</li>
|
||||
<li>之后同步会增量更新最新的数据</li>
|
||||
<li>数据会保存到本地数据库</li>
|
||||
<li>同步频率受 Garmin API 限制</li>
|
||||
<li>每次同步获取最近 7 天的每日汇总与运动记录</li>
|
||||
<li>同一天重复同步会更新原有记录,不会产生重复数据</li>
|
||||
<li>数据保存在本机数据库,不经过第三方服务</li>
|
||||
<li>需要先安装 <code>garminconnect</code> 库才能执行真实同步</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user