[阶段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:
ericwyuan
2026-08-23 12:43:52 +08:00
parent c83340742c
commit 8616a13525
30 changed files with 1135 additions and 1777 deletions

View File

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