From e911a7c1f019d1d0fe0e3e469d26f9ebc89a828b Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sun, 13 Sep 2026 07:31:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(garmin):=20=E7=BB=91=E5=AE=9A=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E4=B9=9F=E8=83=BD=E5=BC=BA=E5=88=B6=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E4=BA=86=EF=BC=8C=E4=B9=8B=E5=89=8D=E5=8F=AA=E6=9C=89=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E9=A1=B5=E6=9C=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户想现在就试绑定,账号却处在昨天真实撞上的 SSO 冷却期里。发现「强制 重试」按钮只接在同步流程上——绑定表单命中同一个 429 时,除了看提示、等 到冷却过期,没有别的路。 - error/blocked 这两个状态本来就是绑定表单和同步区共用的,但重试按钮硬编码 只会调 syncHistory(true)。加一个 retryAction 记住是哪个流程触发的失败, 按钮据此调对应的重试 - startLogin 加 force 参数透传给后端已有的 /garmin/login force 支持;密码 只在请求真正成功后才清空,所以失败重试不需要用户重新输入 - 强制重试仍然只在真正拿到 429 之后才出现,不是默认可点的选项——窗口内 重试会延长冷却,这个闸门就是为了防这个 Co-Authored-By: Claude Sonnet 5 --- client/src/pages/SyncPage.tsx | 24 ++++++++++++++++++++---- client/src/services/api.ts | 5 ++++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/client/src/pages/SyncPage.tsx b/client/src/pages/SyncPage.tsx index d5f5b6b..6a256c5 100644 --- a/client/src/pages/SyncPage.tsx +++ b/client/src/pages/SyncPage.tsx @@ -54,6 +54,11 @@ function SyncPage() { const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + /* Which action 强制重试 should retry — the two flows share one error banner + but call different endpoints, and retrying the wrong one either does + nothing (sync with no token bound yet) or silently drops the email/ + password the user just typed. */ + const [retryAction, setRetryAction] = useState<'sync' | 'login' | null>(null); /* Whether the last attempt was refused by the recorded cooldown. Gates the 强制重试 button: offering it unconditionally would invite the very thing the cooldown prevents. */ @@ -99,10 +104,14 @@ function SyncPage() { }, []); // --- Garmin login ------------------------------------------------------- - const startLogin = async (e: React.FormEvent) => { - e.preventDefault(); + /* `force` is only ever true when the user clicks 强制重试 after a refusal — + never the default path, because retrying inside Garmin's real cooldown + window is what extends it (see services/garmin.py's sso_cooldown). */ + const startLogin = async (e?: React.FormEvent, force?: boolean) => { + e?.preventDefault(); setError(''); setMessage(''); + setBlocked(false); if (!email) { setError('请输入 Garmin 邮箱'); return; @@ -114,7 +123,7 @@ function SyncPage() { setLoading(true); try { - const sid = await apiClient.startGarminLogin(password, email); + const sid = await apiClient.startGarminLogin(password, email, force); // The password is only ever needed for this one request. setPassword(''); setSession(sid); @@ -123,6 +132,10 @@ function SyncPage() { beginPolling(sid); } catch (err: any) { setError(errorMessage(err, '登录失败')); + if (err?.response?.status === 429 && err?.response?.data?.status === 'rate_limited') { + setBlocked(true); + setRetryAction('login'); + } } finally { setLoading(false); } @@ -246,6 +259,7 @@ function SyncPage() { setError(''); setMessage(''); setBlocked(false); + setRetryAction('sync'); setLoading(true); try { // 0 means "everything" and -1 "since the last sync"; the backend caps @@ -327,7 +341,9 @@ function SyncPage() { {blocked && (