feat(garmin): 绑定表单也能强制重试了,之前只有同步页有
用户想现在就试绑定,账号却处在昨天真实撞上的 SSO 冷却期里。发现「强制 重试」按钮只接在同步流程上——绑定表单命中同一个 429 时,除了看提示、等 到冷却过期,没有别的路。 - error/blocked 这两个状态本来就是绑定表单和同步区共用的,但重试按钮硬编码 只会调 syncHistory(true)。加一个 retryAction 记住是哪个流程触发的失败, 按钮据此调对应的重试 - startLogin 加 force 参数透传给后端已有的 /garmin/login force 支持;密码 只在请求真正成功后才清空,所以失败重试不需要用户重新输入 - 强制重试仍然只在真正拿到 429 之后才出现,不是默认可点的选项——窗口内 重试会延长冷却,这个闸门就是为了防这个 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 && (
|
||||
<button
|
||||
className="sync-force"
|
||||
onClick={() => syncHistory(true)}
|
||||
onClick={() => (
|
||||
retryAction === 'login' ? startLogin(undefined, true) : syncHistory(true)
|
||||
)}
|
||||
disabled={loading}
|
||||
>
|
||||
我确认已恢复,强制重试
|
||||
|
||||
@@ -658,10 +658,13 @@ class ApiClient {
|
||||
* 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.
|
||||
*/
|
||||
async startGarminLogin(garminPassword: string, garminEmail?: string) {
|
||||
async startGarminLogin(
|
||||
garminPassword: string, garminEmail?: string, force?: boolean
|
||||
) {
|
||||
const { data } = await this.client.post<{ session: string }>('/garmin/login', {
|
||||
garminPassword,
|
||||
...(garminEmail ? { garminEmail } : {}),
|
||||
...(force ? { force: true } : {}),
|
||||
});
|
||||
return data.session;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user