diff --git a/backend/services/garmin.py b/backend/services/garmin.py index 408ed8a..dbfa94c 100644 --- a/backend/services/garmin.py +++ b/backend/services/garmin.py @@ -34,7 +34,15 @@ from services import garmin_extras as extras DEFAULT_SYNC_DAYS = int(os.environ.get("GARMIN_SYNC_DAYS") or 7) -def _set_sync_status(user_id, status, now, **fields): +def _set_sync_status(user_id, status, now, last_sync_time=None, **fields): + """Write the status row; `now` becomes the new last_sync_time by default. + + Callers that merely *report* being blocked (a rate-limit refusal, a + refused start) pass the previous last_sync_time explicitly: a sync that + never happened must not move "上次同步" forward, or the UI reads "刚刚" + while the data actually stopped syncing hours ago. + """ + last = last_sync_time if last_sync_time is not None else now cols = ["user_id", "status", "last_sync_time"] + list(fields.keys()) placeholders = ", ".join(["?"] * len(cols)) if DB_TYPE == "mariadb": @@ -49,7 +57,7 @@ def _set_sync_status(user_id, status, now, **fields): f"INSERT INTO sync_status ({', '.join(cols)}) VALUES ({placeholders}) " f"ON CONFLICT(user_id) DO UPDATE SET {updates}" ) - execute(sql, [user_id, status, now] + list(fields.values())) + execute(sql, [user_id, status, last] + list(fields.values())) def get_sync_status(user_id): @@ -971,8 +979,15 @@ def start_sync(user_id, creds, days=None): now = datetime.datetime.utcnow().isoformat(timespec="seconds") until, msg = _rate_limit_block(user_id) if until: + # A refused start synced nothing — keep the previous last_sync_time so + # the UI's 上次同步 keeps telling the truth (the data is stale since + # the last *successful* sync, and that date is what the user needs). + row = query_one( + "SELECT last_sync_time FROM sync_status WHERE user_id = ?", [user_id] + ) _set_sync_status( user_id, "rate_limited", now, + last_sync_time=row["last_sync_time"] if row else None, records_synced=0, progress_current=0, progress_total=days, started_at=now, last_error=msg, ) @@ -1005,9 +1020,14 @@ def sync_data(user_id, creds, days=None, client=None): until, msg = _rate_limit_block(user_id) if until: # A blocked account must issue zero Garmin requests — that is the whole - # point. Return immediately without touching the network. + # point. Return immediately without touching the network, and keep the + # previous last_sync_time (this was not a sync). + row = query_one( + "SELECT last_sync_time FROM sync_status WHERE user_id = ?", [user_id] + ) _set_sync_status( user_id, "rate_limited", now, + last_sync_time=row["last_sync_time"] if row else None, records_synced=0, last_error=msg, ) return { diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index c8e6a25..fe3b10a 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -29,6 +29,10 @@ function SettingsPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [saved, setSaved] = useState(''); + /** A sync is running right now (blocks the button). */ + const [syncing, setSyncing] = useState(false); + /** A Garmin account is bound; sync-latest refuses to run without one. */ + const [hasToken, setHasToken] = useState(false); useEffect(() => { Promise.all([apiClient.getSettings(), apiClient.getSettingsOptions()]) @@ -36,6 +40,8 @@ function SettingsPage() { .catch((err) => setError(errorMessage(err, '加载设置失败'))) .finally(() => setLoading(false)); + apiClient.getGarminAuthStatus().then(setHasToken).catch(() => setHasToken(false)); + if (FEATURES.ai) { apiClient.getModels().then(setModels).catch(() => setModels([])); } @@ -64,6 +70,30 @@ function SettingsPage() { } }; + /** 立即同步最近几天(秒级阻塞返回,不用进同步页)。 + /garmin/sync-latest 只做最近 1-7 天的增量拉取,速度足够直接等待; + 正在限流冷却时后端零请求直接拦截并返回恢复倒计时。 */ + const syncNow = async () => { + setError(''); + setSaved(''); + setSyncing(true); + try { + const res = await apiClient.syncLatest(2); + if (res.status === 'rate_limited') { + setError(res.message || 'Garmin 正在限流,请耐心等待自动恢复。'); + } else if (res.status === 'success') { + setSaved(res.message || '已同步最近数据'); + window.setTimeout(() => setSaved(''), 3200); + } else { + setError(res.message || '同步没有完成,去同步页看具体原因。'); + } + } catch (err: any) { + setError(errorMessage(err, '同步失败')); + } finally { + setSyncing(false); + } + }; + const pick = ( title: string, values: Array, @@ -224,6 +254,24 @@ function SettingsPage() { + + 数据同步