From 5e6fc01f76b1fbd0be1b64951fa0f1966c37d876 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Wed, 2 Sep 2026 20:38:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(sync-history):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E7=BB=93=E6=9E=9C=E6=9F=A5=E8=AF=A2(?= =?UTF-8?q?=E6=AF=8F=E6=AC=A1=E8=87=AA=E5=8A=A8/=E6=89=8B=E5=8A=A8/?= =?UTF-8?q?=E7=AB=8B=E5=8D=B3=E5=90=8C=E6=AD=A5=E7=9A=84=E8=AE=B0=E5=BD=95?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - db.py SCHEMA 新增 sync_history 表(不可变,每次同步尝试一行) + 索引 - garmin.py: _log_sync_history() 在 sync_data 全部出口记录; trigger 区分 auto(调度器)/manual(同步页开始同步)/quick(设置页立即同步); start_sync 限流拦截分支同样留档; 写入失败只告警不影响同步 - scheduler.py 自动同步传 trigger="auto"; routes 新增 GET /garmin/sync-history 按时间倒序返回(默认 50 条,上限 200) 前端: - api.ts 增加 SyncHistoryItem 类型 + getSyncHistory() - 新页面 /sync-history/ 同步记录: 卡片列表, 状态(成功/失败/被限流)chip 配色, 时间本地化(今天/昨天/X月X日), 触发类型标签, 范围与耗时 - 同步页与设置页同步区块均加入口链接 --- backend/db.py | 25 ++++++ backend/routes/garmin.py | 13 ++- backend/services/garmin.py | 109 ++++++++++++++++++++++--- backend/services/scheduler.py | 2 +- backend/tests/test_garmin_sync.py | 77 ++++++++++++++++++ backend/tests/test_scheduler.py | 11 +-- client/src/pages/DataSync.css | 23 ++++++ client/src/pages/SettingsPage.tsx | 8 ++ client/src/pages/SyncHistory.css | 70 ++++++++++++++++ client/src/pages/SyncHistoryPage.tsx | 114 +++++++++++++++++++++++++++ client/src/pages/SyncPage.tsx | 12 ++- client/src/routes.ts | 4 +- client/src/services/api.ts | 28 +++++++ 13 files changed, 476 insertions(+), 20 deletions(-) create mode 100644 client/src/pages/SyncHistory.css create mode 100644 client/src/pages/SyncHistoryPage.tsx diff --git a/backend/db.py b/backend/db.py index 1e87262..b1c8bdd 100644 --- a/backend/db.py +++ b/backend/db.py @@ -87,6 +87,31 @@ CREATE TABLE IF NOT EXISTS sync_status ( FOREIGN KEY (user_id) REFERENCES users(id) ); +-- One immutable row per sync attempt (auto / manual / 立即同步), so the user +-- can see what ran and what happened. sync_status above only keeps the +-- *current* state; it says nothing about a run that finished hours ago. +-- Column is trigger_kind, not trigger: TRIGGER is a MariaDB reserved word and +-- a bare `trigger` column fails with 1064 there (SQLite tolerates it, so a +-- local green suite does not catch it). +CREATE TABLE IF NOT EXISTS sync_history ( + id VARCHAR(64) PRIMARY KEY, + user_id VARCHAR(64) NOT NULL, + trigger_kind VARCHAR(16) NOT NULL, + status VARCHAR(24) NOT NULL, + days INT DEFAULT 0, + records_synced INT DEFAULT 0, + activities_synced INT DEFAULT 0, + badges_synced INT DEFAULT 0, + personal_records_synced INT DEFAULT 0, + started_at DATETIME NOT NULL, + finished_at DATETIME, + duration_seconds INT DEFAULT 0, + message TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) +); +CREATE INDEX IF NOT EXISTS idx_sync_history_user_time + ON sync_history (user_id, started_at); + -- Garmin OAuth tokens, obtained once through an interactive login. -- Garmin accounts with two-factor auth cannot be logged into unattended: the -- library asks for an MFA code on stdin, which a gunicorn worker does not diff --git a/backend/routes/garmin.py b/backend/routes/garmin.py index fc5df60..e6e482f 100644 --- a/backend/routes/garmin.py +++ b/backend/routes/garmin.py @@ -170,7 +170,18 @@ def sync_latest(): except (TypeError, ValueError): window = scheduler.SYNC_DAYS - return jsonify(garmin_svc.sync_data(g.user_id, {}, days=window)) + return jsonify(garmin_svc.sync_data(g.user_id, {}, days=window, trigger="quick")) + + +@bp.route("/sync-history", methods=["GET"]) +@require_auth +def sync_history(): + """Every recorded sync attempt, newest first (auto / manual / 立即同步).""" + try: + limit = max(1, min(int(request.args.get("limit", 50)), 200)) + except (TypeError, ValueError): + limit = 50 + return jsonify({"items": garmin_svc.get_sync_history(g.user_id, limit)}) @bp.route("/auto-sync", methods=["GET"]) diff --git a/backend/services/garmin.py b/backend/services/garmin.py index dbfa94c..45edabb 100644 --- a/backend/services/garmin.py +++ b/backend/services/garmin.py @@ -24,6 +24,7 @@ import json import logging import os import threading +import uuid from db import execute, query_one, query_all from config import DB_TYPE @@ -60,6 +61,78 @@ def _set_sync_status(user_id, status, now, last_sync_time=None, **fields): execute(sql, [user_id, status, last] + list(fields.values())) +def _log_sync_history(user_id, trigger, days, started_at, result): + """Append one immutable row per sync attempt. + + `trigger` names what started it — "auto" (scheduler), "manual" (同步页 + 开始同步) or "quick" (设置页立即同步) — so the 同步记录 screen can show + the whole picture. A failed or refused sync is as much a record as a + successful one; the user is usually looking at this list because one of + those did not behave. A write failure must never break the sync it is + reporting, so it is logged and swallowed. + """ + try: + finished = datetime.datetime.utcnow().isoformat(timespec="seconds") + r = result or {} + duration = 0 + try: + s = datetime.datetime.fromisoformat(str(started_at).replace(" ", "T")) + f = datetime.datetime.fromisoformat(finished) + duration = max(0, int((f - s).total_seconds())) + except (TypeError, ValueError): + pass + execute( + "INSERT INTO sync_history (id, user_id, trigger_kind, status, days, " + "records_synced, activities_synced, badges_synced, " + "personal_records_synced, started_at, finished_at, " + "duration_seconds, message) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + str(uuid.uuid4()), user_id, trigger, + r.get("status") or "error", + days if days is not None else 0, + r.get("recordsSynced") or 0, + r.get("activitiesSynced") or 0, + r.get("badgesSynced") or 0, + r.get("personalRecordsSynced") or 0, + started_at, finished, duration, + (r.get("message") or "")[:500], + ], + ) + except Exception as e: # noqa: BLE001 - history must never break a sync + logging.getLogger(__name__).warning( + "sync history write failed for %s: %s", user_id, e + ) + + +def get_sync_history(user_id, limit=50): + """Most recent sync attempts for the user, newest first.""" + rows = query_all( + "SELECT trigger_kind, status, days, records_synced, activities_synced, " + "badges_synced, personal_records_synced, started_at, finished_at, " + "duration_seconds, message " + "FROM sync_history WHERE user_id = ? " + "ORDER BY started_at DESC LIMIT ?", + [user_id, limit], + ) + return [ + { + "triggerKind": r["trigger_kind"], + "status": r["status"], + "days": r["days"], + "recordsSynced": r["records_synced"], + "activitiesSynced": r["activities_synced"], + "badgesSynced": r["badges_synced"], + "personalRecordsSynced": r["personal_records_synced"], + "startedAt": r["started_at"], + "finishedAt": r["finished_at"], + "durationSeconds": r["duration_seconds"], + "message": r["message"], + } + for r in rows + ] + + def get_sync_status(user_id): row = query_one("SELECT * FROM sync_status WHERE user_id = ?", [user_id]) # Count distinct days actually stored in the database for this user. @@ -991,6 +1064,11 @@ def start_sync(user_id, creds, days=None): records_synced=0, progress_current=0, progress_total=days, started_at=now, last_error=msg, ) + _log_sync_history(user_id, "manual", days, now, { + "status": "rate_limited", + "recordsSynced": 0, + "message": msg, + }) return { "status": "rate_limited", "message": msg, @@ -1008,15 +1086,24 @@ def start_sync(user_id, creds, days=None): return {"status": "syncing", "days": days} -def sync_data(user_id, creds, days=None, client=None): +def sync_data(user_id, creds, days=None, client=None, trigger="manual"): """Pull the last `days` days from Garmin Connect into the local database. `client` exists so tests can inject a stub instead of reaching Garmin. + `trigger` ("auto" / "manual" / "quick") names what started this run and is + recorded on the sync_history row every exit path appends. """ days = DEFAULT_SYNC_DAYS if days is None else days if days == 0: days = MAX_HISTORY_DAYS # 全部历史 → 走到数据尽头为止 now = datetime.datetime.utcnow().isoformat(timespec="seconds") + started = now + + def finish(result): + """Record the attempt, then hand the result to the caller.""" + _log_sync_history(user_id, trigger, days, started, result) + return result + until, msg = _rate_limit_block(user_id) if until: # A blocked account must issue zero Garmin requests — that is the whole @@ -1030,12 +1117,12 @@ def sync_data(user_id, creds, days=None, client=None): last_sync_time=row["last_sync_time"] if row else None, records_synced=0, last_error=msg, ) - return { + return finish({ "status": "rate_limited", "recordsSynced": 0, "message": msg, "retryAfterSeconds": int((until - datetime.datetime.utcnow()).total_seconds()), - } + }) _set_sync_status( user_id, "syncing", now, records_synced=0, progress_current=0, progress_total=days, @@ -1047,13 +1134,13 @@ def sync_data(user_id, creds, days=None, client=None): except Exception as e: message = describe(e) _set_sync_status(user_id, "error", now, records_synced=0, last_error=message) - return { + return finish({ "status": "error", "recordsSynced": 0, "message": message, "mfaRequired": isinstance(e, MFARequired), "lastSyncTime": now, - } + }) today = datetime.date.today() @@ -1156,7 +1243,7 @@ def sync_data(user_id, creds, days=None, client=None): progress_current=days_synced, progress_total=days, stage=None, last_error=message, ) - return { + return finish({ "status": "rate_limited", "recordsSynced": days_synced, "message": f"已同步 {days_synced} 天后被 Garmin 限流:{message}", @@ -1164,7 +1251,7 @@ def sync_data(user_id, creds, days=None, client=None): int((until - datetime.datetime.utcnow()).total_seconds()) if until else None ), "lastSyncTime": now, - } + }) activities_synced = 0 _set_sync_status(user_id, "syncing", now, records_synced=days_synced, @@ -1236,8 +1323,8 @@ def sync_data(user_id, creds, days=None, client=None): if days_synced == 0 and days_attempted > 0 and len(day_errors) >= days_attempted: message = "; ".join(day_errors[:3]) _set_sync_status(user_id, "error", now, records_synced=0, last_error=message) - return {"status": "error", "recordsSynced": 0, - "message": f"同步失败:{message}", "lastSyncTime": now} + return finish({"status": "error", "recordsSynced": 0, + "message": f"同步失败:{message}", "lastSyncTime": now}) _set_sync_status( user_id, "idle", now, records_synced=days_synced, @@ -1251,7 +1338,7 @@ def sync_data(user_id, creds, days=None, client=None): ) if day_errors: message += f"({len(day_errors)} 项跳过)" - return { + return finish({ "status": "success", "recordsSynced": days_synced, "activitiesSynced": activities_synced, @@ -1259,4 +1346,4 @@ def sync_data(user_id, creds, days=None, client=None): "personalRecordsSynced": records_synced_pr, "message": message, "lastSyncTime": now, - } + }) diff --git a/backend/services/scheduler.py b/backend/services/scheduler.py index 6a991fa..b842c72 100644 --- a/backend/services/scheduler.py +++ b/backend/services/scheduler.py @@ -161,7 +161,7 @@ def sync_all_accounts(days=None, respect_schedule=False): "retryAfterSeconds": int((blocked - _now()).total_seconds()), }) continue - out = garmin_svc.sync_data(uid, {}, days=d) + out = garmin_svc.sync_data(uid, {}, days=d, trigger="auto") results.append({"user": uid, "status": out.get("status"), "records": out.get("recordsSynced")}) # New data invalidates every stored insight, so queue them all at diff --git a/backend/tests/test_garmin_sync.py b/backend/tests/test_garmin_sync.py index a5964bc..b37ff1e 100644 --- a/backend/tests/test_garmin_sync.py +++ b/backend/tests/test_garmin_sync.py @@ -845,3 +845,80 @@ class TestSyncWindow: assert out["recordsSynced"] == 2, "the days pulled before the 429 are kept" assert len([c for c in client.calls if c[0] == "summary"]) == 3 assert garmin_svc.rate_limited_until(user["id"]) is not None + + +class TestSyncHistory: + """Every sync attempt gets one immutable row, whatever its outcome — the + record is what lets the 同步记录 screen show what ran, when, and whether + it worked. sync_status only keeps the latest state.""" + + def test_success_records_one_row(self, db, user): + out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient()) + assert out["status"] == "success" + + rows = garmin_svc.get_sync_history(user["id"]) + assert len(rows) == 1 + row = rows[0] + assert row["triggerKind"] == "manual" + assert row["status"] == "success" + assert row["days"] == 1 + assert row["recordsSynced"] == 1 + assert row["startedAt"] and row["finishedAt"] + assert row["durationSeconds"] >= 0 + + def test_auto_and_quick_triggers_are_tagged(self, db, user): + garmin_svc.sync_data( + user["id"], CREDS, days=1, client=StubClient(), trigger="auto" + ) + garmin_svc.sync_data( + user["id"], CREDS, days=1, client=StubClient(), trigger="quick" + ) + rows = garmin_svc.get_sync_history(user["id"]) + assert [r["triggerKind"] for r in rows] == ["quick", "auto"], "newest first" + + def test_newest_first(self, db, user): + garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient()) + garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient()) + rows = garmin_svc.get_sync_history(user["id"]) + assert len(rows) == 2 + assert rows[0]["finishedAt"] >= rows[1]["finishedAt"] + + def test_total_failure_is_recorded(self, db, user): + out = garmin_svc.sync_data( + user["id"], CREDS, days=2, client=StubClient(fail_days=[day(0), day(1)]) + ) + assert out["status"] == "error" + assert garmin_svc.get_sync_history(user["id"])[0]["status"] == "error" + + def test_a_429_mid_run_is_recorded(self, db, user): + class Throttled(StubClient): + def get_user_summary(self, cdate): + self.calls.append(("summary", cdate)) + if cdate == day(1): + raise RuntimeError("too many 429 error responses") + return summary() + + garmin_svc.sync_data(user["id"], CREDS, days=2, client=Throttled()) + assert garmin_svc.get_sync_history(user["id"])[0]["status"] == "rate_limited" + garmin_svc._rate_limited_until.clear() + + def test_a_refused_sync_is_still_a_record(self, db, user, monkeypatch): + future = datetime.datetime.utcnow() + datetime.timedelta(hours=1) + monkeypatch.setitem(garmin_svc._rate_limited_until, user["id"], future) + + out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient()) + assert out["status"] == "rate_limited" + assert garmin_svc.get_sync_history(user["id"])[0]["status"] == "rate_limited" + + def test_history_is_per_user(self, db, user, make_user): + garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient()) + other = make_user("other@example.com") + assert garmin_svc.get_sync_history(other["id"]) == [] + + def test_history_endpoint_requires_auth(self, client): + assert client.get("/api/garmin/sync-history").status_code == 401 + + def test_history_endpoint_returns_empty_list(self, client, auth): + r = client.get("/api/garmin/sync-history", headers=auth) + assert r.status_code == 200 + assert r.get_json() == {"items": []} diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py index cc97cec..882937d 100644 --- a/backend/tests/test_scheduler.py +++ b/backend/tests/test_scheduler.py @@ -88,7 +88,7 @@ class TestSyncAllAccounts: seen = [] monkeypatch.setattr( garmin_svc, "sync_data", - lambda uid, creds, days=None, client=None: seen.append(uid) + lambda uid, creds, days=None, client=None, trigger="manual": seen.append(uid) or {"status": "success", "recordsSynced": days}, ) @@ -105,7 +105,7 @@ class TestSyncAllAccounts: garmin_svc.save_token(user["id"], "t1") garmin_svc.save_token(other["id"], "t2") - def flaky(uid, creds, days=None, client=None): + def flaky(uid, creds, days=None, client=None, trigger="manual"): if uid == user["id"]: raise RuntimeError("token expired") return {"status": "success", "recordsSynced": 2} @@ -160,7 +160,7 @@ class TestEndpoints: garmin_svc.save_token(user["id"], "blob") monkeypatch.setattr( garmin_svc, "sync_data", - lambda uid, creds, days=None, client=None: { + lambda uid, creds, days=None, client=None, trigger="manual": { "status": "success", "recordsSynced": days, "message": "ok", }, ) @@ -173,7 +173,7 @@ class TestEndpoints: garmin_svc.save_token(user["id"], "blob") monkeypatch.setattr( garmin_svc, "sync_data", - lambda uid, creds, days=None, client=None: { + lambda uid, creds, days=None, client=None, trigger="manual": { "status": "success", "recordsSynced": days, }, ) @@ -200,8 +200,9 @@ class TestSyncWindowResolution: garmin_svc.save_token(user["id"], "t1") seen = {} - def record(uid, creds, days=None, client=None): + def record(uid, creds, days=None, client=None, trigger="manual"): seen["days"] = days + seen["trigger"] = trigger return {"status": "success", "recordsSynced": 0} monkeypatch.setattr(garmin_svc, "sync_data", record) diff --git a/client/src/pages/DataSync.css b/client/src/pages/DataSync.css index 69103ac..63e9855 100644 --- a/client/src/pages/DataSync.css +++ b/client/src/pages/DataSync.css @@ -299,3 +299,26 @@ background: var(--danger, #d23b3b); color: #fff; } + +/* 同步记录 entry ----------------------------------------------------------- */ +.sync-records-link { + margin-top: 0.9rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; + padding: 0.7rem 0.95rem; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 12px; + color: inherit; + text-decoration: none; +} +.sync-records-link > span:first-child { + display: flex; + flex-direction: column; + gap: 0.12rem; +} +.sync-records-link:active { + background: var(--surface-0); +} diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index fe3b10a..ba3d192 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -280,6 +280,14 @@ function SettingsPage() { 去同步页 + + + + 同步记录 + 每次自动与手动同步的结果 + + + diff --git a/client/src/pages/SyncHistory.css b/client/src/pages/SyncHistory.css new file mode 100644 index 0000000..5b0a857 --- /dev/null +++ b/client/src/pages/SyncHistory.css @@ -0,0 +1,70 @@ +/* Sync history (同步记录) ------------------------------------------------ */ +.sh-list { display: grid; gap: 0.6rem; } + +.sh-card { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 12px; + padding: 0.75rem 0.95rem; + display: grid; + gap: 0.35rem; +} + +.sh-head { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.sh-time { + font-size: 0.78rem; + font-weight: 600; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +.sh-trigger { + font-size: 0.7rem; + color: var(--text-muted); + background: var(--surface-0); + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.08rem 0.5rem; +} + +.sh-chip { + margin-left: auto; + font-size: 0.7rem; + font-weight: 600; + border-radius: 999px; + padding: 0.1rem 0.55rem; + flex-shrink: 0; +} + +.sh-chip-success { color: #0a7d0a; background: rgba(12, 163, 12, 0.12); } +.sh-chip-error { color: var(--status-critical); background: rgba(208, 59, 59, 0.12); } +.sh-chip-rate_limited { color: #9a6b00; background: rgba(250, 178, 25, 0.18); } +.sh-chip-syncing { color: var(--accent-solid); background: var(--accent-soft); } + +.sh-body { + font-size: 0.82rem; + line-height: 1.5; + color: var(--text-secondary); + word-break: break-word; +} + +.sh-meta { + display: flex; + gap: 0.9rem; + flex-wrap: wrap; + font-size: 0.7rem; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +@media (prefers-color-scheme: dark) { + .sh-chip-success { color: #3fcf3f; background: rgba(12, 163, 12, 0.22); } + .sh-chip-error { color: #ff7a7a; background: rgba(208, 59, 59, 0.22); } + .sh-chip-rate_limited { color: #ffcf6b; background: rgba(250, 178, 25, 0.2); } +} diff --git a/client/src/pages/SyncHistoryPage.tsx b/client/src/pages/SyncHistoryPage.tsx new file mode 100644 index 0000000..293a99b --- /dev/null +++ b/client/src/pages/SyncHistoryPage.tsx @@ -0,0 +1,114 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Link } from 'framework7-react'; +import { apiClient, errorMessage, parseUtc, SyncHistoryItem } from '../services/api'; +import Screen from '../components/Screen'; +import Skeleton from '../components/Skeleton'; +import './SyncHistory.css'; + +const TRIGGER_LABEL: Record = { + auto: '自动同步', + manual: '手动同步', + quick: '立即同步', +}; + +const STATUS_LABEL: Record = { + success: '成功', + error: '失败', + rate_limited: '被限流', + syncing: '同步中', +}; + +/** 2026-09-02T19:30:00 (UTC) → 今天 03:30 / 昨天 23:10 / 9/1 20:05 / 2025/12/1 … */ +const fmtTime = (value: string | null | undefined) => { + const d = parseUtc(value); + if (!d) return '—'; + const now = new Date(); + const hm = d.toLocaleTimeString('zh-CN', { + hour: '2-digit', minute: '2-digit', hour12: false, + }); + const sameDay = d.toDateString() === now.toDateString(); + if (sameDay) return `今天 ${hm}`; + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (d.toDateString() === yesterday.toDateString()) return `昨天 ${hm}`; + const md = d.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' }); + const y = d.getFullYear() === now.getFullYear() ? '' : `${d.getFullYear()}/`; + return `${y}${md} ${hm}`; +}; + +const fmtDuration = (seconds: number) => { + if (seconds < 1) return '1 秒内'; + if (seconds < 60) return `${seconds} 秒`; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return s > 0 ? `${m} 分 ${s} 秒` : `${m} 分钟`; +}; + +/** days is what the run asked for: -1 增量 / 0 全部历史 / N 最近 N 天. */ +const rangeLabel = (days: number) => + days === -1 ? '增量' + : days === 0 ? '全部历史' + : `最近 ${days} 天`; + +function SyncHistoryPage() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const load = useCallback(async () => { + setError(''); + try { + const items = await apiClient.getSyncHistory(100); + setRows(items); + } catch (err) { + setError(errorMessage(err, '加载同步记录失败')); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + if (loading) { + return ; + } + + return ( + + {error &&
{error}
} + + {rows.length === 0 ? ( +
+

还没有同步记录。自动或手动同步后,每次的结果都会出现在这里。

+ 去同步 +
+ ) : ( +
+ {rows.map((r, i) => ( +
+
+ {fmtTime(r.startedAt)} + {TRIGGER_LABEL[r.triggerKind]} + + {STATUS_LABEL[r.status]} + +
+
{r.message || '—'}
+
+ 范围 {rangeLabel(r.days)} + {r.durationSeconds > 0 && 耗时 {fmtDuration(r.durationSeconds)}} + {r.status === 'success' && r.recordsSynced > 0 && ( + 更新 {r.recordsSynced} 天 + )} +
+
+ ))} +
+ )} +
+ ); +} + +export default SyncHistoryPage; diff --git a/client/src/pages/SyncPage.tsx b/client/src/pages/SyncPage.tsx index 28ec0d9..d8860b6 100644 --- a/client/src/pages/SyncPage.tsx +++ b/client/src/pages/SyncPage.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { f7 } from 'framework7-react'; +import { f7, Link } from 'framework7-react'; import { apiClient, AutoSyncStatus, DetailSyncStatus, errorMessage, GarminLoginStatus, parseUtc, SettingsOptions, SyncStatus, @@ -508,6 +508,16 @@ function SyncPage() { + + + 同步记录 + + 每次自动、手动与立即同步的结果,按时间倒序 + + + + +

同步会取哪些数据

    diff --git a/client/src/routes.ts b/client/src/routes.ts index 5719fe4..0aabf5d 100644 --- a/client/src/routes.ts +++ b/client/src/routes.ts @@ -15,6 +15,7 @@ import DevicesPage from './pages/DevicesPage'; import ExercisePage from './pages/ExercisePage'; import SleepPage from './pages/SleepPage'; import SyncPage from './pages/SyncPage'; +import SyncHistoryPage from './pages/SyncHistoryPage'; import SettingsPage from './pages/SettingsPage'; import AiQueuePage from './pages/AiQueuePage'; import LoginPage from './pages/LoginPage'; @@ -44,6 +45,7 @@ const SCREENS: Router.RouteParameters[] = [ { path: '/challenges/', component: ChallengesPage }, { path: '/devices/', component: DevicesPage }, { path: '/sync/', component: SyncPage }, + { path: '/sync-history/', component: SyncHistoryPage }, { path: '/settings/', component: SettingsPage }, { path: '/ai-queue/', component: AiQueuePage }, { path: '/login/', component: LoginPage }, @@ -86,7 +88,7 @@ const TAB_OWNERS: Array<[RegExp, string]> = [ [/^\/metric\//, 'trends'], [/^\/(exercise|race|challenges)\/?$/, 'exercise'], [/^\/activity\//, 'exercise'], - [/^\/(settings|sync|devices|rating-basis|ai-queue)\/?$/, 'settings'], + [/^\/(settings|sync|sync-history|devices|rating-basis|ai-queue)\/?$/, 'settings'], ]; /** The tab a cold URL should open in, or null for the root and unknown paths. */ diff --git a/client/src/services/api.ts b/client/src/services/api.ts index eff349b..9f6d9fd 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -129,6 +129,26 @@ export interface SyncResult { lastSyncTime: string; } +/** One recorded sync attempt, for the 同步记录 screen. */ +export interface SyncHistoryItem { + /** What started the run: the scheduler, the sync page, or 立即同步. */ + triggerKind: 'auto' | 'manual' | 'quick'; + status: 'success' | 'error' | 'rate_limited' | 'syncing'; + days: number; + recordsSynced: number; + activitiesSynced: number; + badgesSynced: number; + personalRecordsSynced: number; + startedAt: string; + finishedAt: string | null; + durationSeconds: number; + message: string | null; +} + +export interface SyncHistoryResponse { + items: SyncHistoryItem[]; +} + export interface Recommendation { id: string; category: string; @@ -664,6 +684,14 @@ class ApiClient { return data; } + /** Every recorded sync attempt, newest first (auto / manual / 立即同步). */ + async getSyncHistory(limit = 50) { + const { data } = await this.client.get('/garmin/sync-history', { + params: { limit }, + }); + return data.items; + } + // --- health --- private range(startDate?: string, endDate?: string) { return { params: { startDate, endDate } };