选型确认为 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>
227 lines
6.5 KiB
TypeScript
227 lines
6.5 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { apiClient, errorMessage } from '../services/api';
|
|
import './Login.css';
|
|
|
|
type TabType = 'login' | 'register';
|
|
|
|
function Login() {
|
|
const navigate = useNavigate();
|
|
const [activeTab, setActiveTab] = useState<TabType>('login');
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string>('');
|
|
|
|
// Login form
|
|
const [loginEmail, setLoginEmail] = useState('');
|
|
const [loginPassword, setLoginPassword] = useState('');
|
|
|
|
// Register form
|
|
const [regEmail, setRegEmail] = useState('');
|
|
const [regGarminEmail, setRegGarminEmail] = useState('');
|
|
const [regPassword, setRegPassword] = useState('');
|
|
const [regConfirmPassword, setRegConfirmPassword] = useState('');
|
|
|
|
const validateEmail = (email: string): boolean => {
|
|
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
return re.test(email);
|
|
};
|
|
|
|
const validatePassword = (password: string): boolean => {
|
|
return password.length >= 6;
|
|
};
|
|
|
|
const handleLogin = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError('');
|
|
|
|
if (!validateEmail(loginEmail)) {
|
|
setError('Please enter a valid email');
|
|
return;
|
|
}
|
|
|
|
if (!validatePassword(loginPassword)) {
|
|
setError('Password must be at least 6 characters');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
try {
|
|
const { token } = await apiClient.login(loginEmail, loginPassword);
|
|
apiClient.setSession(token);
|
|
navigate('/');
|
|
} catch (err: any) {
|
|
setError(errorMessage(err, '登录失败'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleRegister = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError('');
|
|
|
|
if (!validateEmail(regEmail)) {
|
|
setError('Please enter a valid email');
|
|
return;
|
|
}
|
|
|
|
if (!validateEmail(regGarminEmail)) {
|
|
setError('Please enter a valid Garmin email');
|
|
return;
|
|
}
|
|
|
|
if (!validatePassword(regPassword)) {
|
|
setError('Password must be at least 6 characters');
|
|
return;
|
|
}
|
|
|
|
if (regPassword !== regConfirmPassword) {
|
|
setError('Passwords do not match');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
try {
|
|
const { token } = await apiClient.register(regEmail, regGarminEmail, regPassword);
|
|
apiClient.setSession(token);
|
|
navigate('/');
|
|
} catch (err: any) {
|
|
setError(errorMessage(err, '注册失败'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="login-container">
|
|
<div className="login-card">
|
|
<div className="login-header">
|
|
<h1>🏃 Garmin Health Lab</h1>
|
|
<p>健康数据分析平台</p>
|
|
</div>
|
|
|
|
<div className="login-tabs">
|
|
<button
|
|
className={`tab-button ${activeTab === 'login' ? 'active' : ''}`}
|
|
onClick={() => {
|
|
setActiveTab('login');
|
|
setError('');
|
|
}}
|
|
>
|
|
登录
|
|
</button>
|
|
<button
|
|
className={`tab-button ${activeTab === 'register' ? 'active' : ''}`}
|
|
onClick={() => {
|
|
setActiveTab('register');
|
|
setError('');
|
|
}}
|
|
>
|
|
注册
|
|
</button>
|
|
</div>
|
|
|
|
{error && <div className="error-message">{error}</div>}
|
|
|
|
{activeTab === 'login' && (
|
|
<form onSubmit={handleLogin} className="login-form">
|
|
<div className="form-group">
|
|
<label htmlFor="login-email">邮箱</label>
|
|
<input
|
|
id="login-email"
|
|
type="email"
|
|
value={loginEmail}
|
|
onChange={(e) => setLoginEmail(e.target.value)}
|
|
placeholder="example@example.com"
|
|
required
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="login-password">密码</label>
|
|
<input
|
|
id="login-password"
|
|
type="password"
|
|
value={loginPassword}
|
|
onChange={(e) => setLoginPassword(e.target.value)}
|
|
placeholder="••••••••"
|
|
required
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
|
|
<button type="submit" className="submit-button" disabled={loading}>
|
|
{loading ? '登录中...' : '登录'}
|
|
</button>
|
|
</form>
|
|
)}
|
|
|
|
{activeTab === 'register' && (
|
|
<form onSubmit={handleRegister} className="login-form">
|
|
<div className="form-group">
|
|
<label htmlFor="reg-email">邮箱</label>
|
|
<input
|
|
id="reg-email"
|
|
type="email"
|
|
value={regEmail}
|
|
onChange={(e) => setRegEmail(e.target.value)}
|
|
placeholder="example@example.com"
|
|
required
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="reg-garmin-email">Garmin 邮箱</label>
|
|
<input
|
|
id="reg-garmin-email"
|
|
type="email"
|
|
value={regGarminEmail}
|
|
onChange={(e) => setRegGarminEmail(e.target.value)}
|
|
placeholder="garmin@example.com"
|
|
required
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="reg-password">密码</label>
|
|
<input
|
|
id="reg-password"
|
|
type="password"
|
|
value={regPassword}
|
|
onChange={(e) => setRegPassword(e.target.value)}
|
|
placeholder="••••••••"
|
|
required
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label htmlFor="reg-confirm-password">确认密码</label>
|
|
<input
|
|
id="reg-confirm-password"
|
|
type="password"
|
|
value={regConfirmPassword}
|
|
onChange={(e) => setRegConfirmPassword(e.target.value)}
|
|
placeholder="••••••••"
|
|
required
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
|
|
<button type="submit" className="submit-button" disabled={loading}>
|
|
{loading ? '注册中...' : '注册'}
|
|
</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Login;
|