From bec45414a71c8b88faab53d7d39a1016d45cb194 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Fri, 28 Aug 2026 15:13:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(garmin):=20=E5=A2=9E=E5=8A=A0=E3=80=8C?= =?UTF-8?q?=E9=80=80=E5=87=BA=20Garmin=20=E8=B4=A6=E5=8F=B7=E3=80=8D?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=88=A0=E9=99=A4=E5=B7=B2=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E4=BB=A4=E7=89=8C=E4=BB=A5=E4=BE=BF=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend: services/garmin.py 新增 delete_token(),删除 garmin_tokens 行并 forget_client 丢弃缓存会话;routes/garmin.py 新增 POST /api/garmin/disconnect(require_auth),返回 ok + 提示文案。 frontend: api.ts 增加 disconnectGarmin();SyncPage 增加「退出 Garmin 账号」按钮(带二次确认弹窗);DataSync.css 增加危险色样式。 设计:仅删除 OAuth 令牌,保留 users.garmin_email,下次重登只需密码;已同步的健康数据不受影响。 --- backend/routes/garmin.py | 16 +++++++++++++++ backend/services/garmin.py | 12 ++++++++++++ client/src/pages/DataSync.css | 18 +++++++++++++++++ client/src/pages/SyncPage.tsx | 37 +++++++++++++++++++++++++++++++++++ client/src/services/api.ts | 8 ++++++++ 5 files changed, 91 insertions(+) diff --git a/backend/routes/garmin.py b/backend/routes/garmin.py index 859aad0..762d13d 100644 --- a/backend/routes/garmin.py +++ b/backend/routes/garmin.py @@ -56,6 +56,22 @@ def auth_status(): return jsonify({"hasToken": garmin_svc.has_token(g.user_id)}) +@bp.route("/disconnect", methods=["POST"]) +@require_auth +def disconnect(): + """Drop the stored Garmin token so the next sync must re-authenticate. + + Deletes the OAuth token (the UI calls this a "退出 Garmin 账号"). garmin_email + stays on the user record, so re-login only needs the password again. Already + synced health data is untouched. + """ + garmin_svc.delete_token(g.user_id) + return jsonify({ + "ok": True, + "message": "已退出 Garmin 账号,已保存的授权令牌已删除,下次同步需重新登录获取新令牌。", + }) + + @bp.route("/login", methods=["POST"]) @require_auth def login(): diff --git a/backend/services/garmin.py b/backend/services/garmin.py index a9f3d5a..f64e1ae 100644 --- a/backend/services/garmin.py +++ b/backend/services/garmin.py @@ -257,6 +257,18 @@ def has_token(user_id): return load_token(user_id) is not None +def delete_token(user_id): + """Forget the stored Garmin OAuth token. + + The next sync or login will have to re-authenticate and mint a fresh token. + garmin_email on the user record is left in place so re-login only needs the + password. The cached client — built from the old token's session — is dropped + in the same step so a stale session can't keep being reused. + """ + execute("DELETE FROM garmin_tokens WHERE user_id = ?", [user_id]) + forget_client(user_id) + + # An authenticated client, reused across requests in this process. # # Building one costs ~11s against Garmin — loading the token, refreshing the diff --git a/client/src/pages/DataSync.css b/client/src/pages/DataSync.css index aefba40..69103ac 100644 --- a/client/src/pages/DataSync.css +++ b/client/src/pages/DataSync.css @@ -281,3 +281,21 @@ } .sync-list li { margin-bottom: 0.2rem; } + +/* Account actions — destructive ones are flagged in red so they don't read + like the routine sync buttons above them. */ +.sync-account { + margin-top: 1.4rem; + padding-top: 1.1rem; + border-top: 1px solid var(--border); +} + +.sync-disconnect { + color: var(--danger, #d23b3b); + border-color: var(--danger, #d23b3b); +} + +.sync-disconnect:hover:not(:disabled) { + background: var(--danger, #d23b3b); + color: #fff; +} diff --git a/client/src/pages/SyncPage.tsx b/client/src/pages/SyncPage.tsx index c43e170..ecbc3c0 100644 --- a/client/src/pages/SyncPage.tsx +++ b/client/src/pages/SyncPage.tsx @@ -164,6 +164,30 @@ function SyncPage() { setCodeSubmitted(false); }; + // --- account ------------------------------------------------------------ + /** Delete the stored Garmin token so the next sync forces a fresh login. */ + const disconnect = async () => { + if ( + !window.confirm( + '退出 Garmin 账号会删除已保存的授权令牌,下次同步需重新登录。确定继续?' + ) + ) { + return; + } + setError(''); + setMessage(''); + setLoading(true); + try { + const res = await apiClient.disconnectGarmin(); + setHasToken(false); + setMessage(res.message || '已退出 Garmin 账号,下次同步需重新登录获取新令牌。'); + } catch (err: any) { + setError(errorMessage(err, '退出失败')); + } finally { + setLoading(false); + } + }; + // --- sync --------------------------------------------------------------- /** The last couple of days, awaited inline — seconds, not minutes. */ @@ -382,6 +406,19 @@ function SyncPage() { + +
+ +

+ 删除已保存的授权令牌,下次同步需重新登录获取新令牌;已同步的数据不会丢失。 +

+
)} diff --git a/client/src/services/api.ts b/client/src/services/api.ts index c4837d9..2cd370b 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -467,6 +467,14 @@ class ApiClient { return data.hasToken; } + /** Drop the stored Garmin OAuth token; the next sync must re-authenticate. */ + async disconnectGarmin() { + const { data } = await this.client.post<{ ok: boolean; message?: string }>( + '/garmin/disconnect' + ); + return data; + } + /** * Start an interactive Garmin login. Returns a session id; the login runs * in the background and parks if Garmin asks for a two-factor code.