fix(web): suppress duplicate toasts when auth session expires
Desktop Client Build / Resolve Build Metadata (push) Successful in 32s
Frontend CI / Frontend (push) Successful in 4m4s
Desktop Client Build / Publish Client Artifacts to NAS (push) Has been cancelled
Desktop Client Build / Build Desktop Client (push) Has been cancelled

Mark 401/refresh-failure errors as handled so per-view catch blocks fall
through silently while a single global "登录已过期" warning is shown.
Centralize ops-web error rendering via showOpsError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 00:12:29 +08:00
parent 558646b166
commit ff2bb77cdd
22 changed files with 161 additions and 78 deletions
+12 -2
View File
@@ -193,6 +193,7 @@ function authRequiredError(): ApiClientError {
message: 'not_authenticated',
code: 40100,
status: 401,
handled: true,
})
}
@@ -200,6 +201,13 @@ function expireStoredSession(): void {
clearStoredSession({ notifyAuthExpired: true })
}
function markAuthErrorHandled(error: unknown): unknown {
if (error instanceof ApiClientError && error.status === 401) {
error.handled = true
}
return error
}
export function isAuthSessionExpiredError(error: unknown): boolean {
return error instanceof ApiClientError && error.status === 401
}
@@ -250,7 +258,7 @@ export async function refreshStoredAuthSession(
expireStoredSession()
}
throw error
throw markAuthErrorHandled(error)
})
.finally(() => {
storedRefreshPromise = null
@@ -893,7 +901,7 @@ async function subscribeSSE<TEvent extends SSEEventShape>(
if (isAuthSessionExpiredError(error)) {
expireStoredSession()
}
throw error
throw markAuthErrorHandled(error)
}
}
@@ -948,6 +956,7 @@ async function buildSSERequestError(response: Response): Promise<ApiClientError>
status: response.status,
detail: payload.detail,
requestId: payload.request_id,
handled: response.status === 401,
})
}
} catch {
@@ -966,6 +975,7 @@ async function buildSSERequestError(response: Response): Promise<ApiClientError>
message: 'unknown_error',
status: response.status,
detail: detail ?? `stream request failed with status ${response.status}`,
handled: response.status === 401,
})
}
+22
View File
@@ -1,5 +1,15 @@
import { ApiClientError } from '@geo/http-client'
const authSessionErrorMessages = new Set([
'not_authenticated',
'missing_bearer_token',
'invalid_access_token',
'invalid_refresh_token',
'refresh_session_expired',
'refresh_token_mismatch',
'token_revoked',
])
const errorMessageMap: Record<string, string> = {
invalid_credentials: '邮箱或密码错误',
login_rate_limited: '登录请求过于频繁',
@@ -79,7 +89,19 @@ const errorMessageMap: Record<string, string> = {
unknown_error: '发生未知错误',
}
export function isHandledAuthError(error: unknown): boolean {
return (
error instanceof ApiClientError &&
error.status === 401 &&
(error.handled === true || authSessionErrorMessages.has(error.message))
)
}
export function formatError(error: unknown): string {
if (isHandledAuthError(error)) {
return '登录已过期,请重新登录'
}
if (error instanceof ApiClientError) {
const message = errorMessageMap[error.message] ?? error.message.replaceAll('_', ' ')
return error.detail ? `${message}: ${error.detail}` : message