feat: NAS 部署 + auth-hub 认证 + 同步逻辑修复

部署:
- 后端 Python/Flask,数据层可插拔 SQLite/MariaDB
- NAS 部署路径 /volume1/web/garmin-health-lab,端口 8124
- Gunicorn 生产服务器 (2 workers / 4 threads)
- 开机自启脚本 deploy/S99garmin.sh
- frp 隧道甲骨文 8124 → NAS 8124,外网访问
- 前端构建产物纳入版本管理 (backend/static/)

认证:
- auth-hub OAuth2/OIDC 统一登录接入
- 新建 NAS 专用 client,注册内外网回调地址
- 前端 LoginPage 支持回调路由 /auth/callback
- 数据同步页增加 Garmin 邮箱输入框

同步逻辑修复:
- 自动同步调度器读取用户 history_days 设置,不再固定 2 天
- 前端 0(全部历史)不再被 || 吞掉,改为 ?? 处理
- 后端路由和 sync_data 中 0 不再被当成 falsy 回退默认值
- sync_data 和调度器中 0 → 730 天(全部历史=最大范围)
- 已同步天数显示数据库实际总天数 (totalDays)
- 历史范围新增「自上次同步」增量选项 (days=-1)
- 后端 settings.py HISTORY 添加 -1 值

项目文档:
- 创建 PROGRESS.md 跟踪项目进度
This commit is contained in:
ericwyuan
2026-09-01 07:39:09 +08:00
parent 9503fca370
commit 15fba8c25c
22 changed files with 244 additions and 26 deletions

View File

@@ -23,7 +23,8 @@ const intervalLabel = (minutes: number) =>
: `${minutes / 60} 小时`;
const historyLabel = (days: number) =>
days === 0 ? '全部历史'
days === -1 ? '自上次同步'
: days === 0 ? '全部历史'
: days >= 365 ? `${days / 365}`
: `${days}`;

View File

@@ -29,6 +29,7 @@ function SyncPage() {
const [hasToken, setHasToken] = useState<boolean | null>(null);
// Garmin login (only needed until a token is stored)
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [session, setSession] = useState<string | null>(null);
const [loginState, setLoginState] = useState<GarminLoginStatus | null>(null);
@@ -81,6 +82,10 @@ function SyncPage() {
e.preventDefault();
setError('');
setMessage('');
if (!email) {
setError('请输入 Garmin 邮箱');
return;
}
if (!password) {
setError('请输入 Garmin 密码');
return;
@@ -88,7 +93,7 @@ function SyncPage() {
setLoading(true);
try {
const sid = await apiClient.startGarminLogin(password);
const sid = await apiClient.startGarminLogin(password, email);
// The password is only ever needed for this one request.
setPassword('');
setSession(sid);
@@ -213,7 +218,7 @@ function SyncPage() {
setLoading(true);
try {
// 0 means "everything"; the backend caps it at what Garmin will serve.
await apiClient.syncGarminData(settings?.historyDays || 3650);
await apiClient.syncGarminData(settings?.historyDays ?? 3650);
await refresh();
beginSyncPolling();
} catch (err: any) {
@@ -286,6 +291,17 @@ function SyncPage() {
</p>
<div className="form-group">
<input
id="garmin-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Garmin 邮箱"
autoComplete="email"
disabled={loading}
/>
</div>
<div className="form-group">
<input
id="garmin-password"
@@ -443,7 +459,7 @@ function SyncPage() {
</div>
<div className="sync-fact">
<span className="sync-fact-label"></span>
<span className="sync-fact-value">{syncStatus?.recordsSynced ?? 0}</span>
<span className="sync-fact-value">{syncStatus?.totalDays ?? 0}</span>
</div>
</div>

View File

@@ -45,6 +45,9 @@ const SCREENS: Router.RouteParameters[] = [
{ path: '/sync/', component: SyncPage },
{ path: '/settings/', component: SettingsPage },
{ path: '/login/', component: LoginPage },
// auth-hub OAuth 回调落地页LoginPage 的 useEffect 会读取 URL 中的 code
// 并调用 /api/auth/callback 交换 token
{ path: '/auth/callback', component: LoginPage },
];
/**

View File

@@ -108,6 +108,7 @@ export interface SyncStatus {
status: 'idle' | 'syncing' | 'error';
lastSyncTime: string | null;
recordsSynced: number;
totalDays: number;
lastError: string | null;
/** Days completed / requested. A backfill runs for many minutes. */
progressCurrent: number | null;