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
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:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,13 +46,14 @@ import {
|
||||
Tree,
|
||||
TreeSelect,
|
||||
Upload,
|
||||
message,
|
||||
} from 'ant-design-vue'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import App from './App.vue'
|
||||
import { i18n } from './i18n'
|
||||
import { subscribeAuthExpired } from './lib/session'
|
||||
import { subscribeAuthExpired, subscribeStoredSession } from './lib/session'
|
||||
import { router } from './router'
|
||||
import { pinia } from './stores/pinia'
|
||||
import './styles.css'
|
||||
@@ -68,6 +69,17 @@ const queryClient = new QueryClient({
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
let authExpiredNotified = false
|
||||
|
||||
message.config({
|
||||
maxCount: 1,
|
||||
})
|
||||
|
||||
subscribeStoredSession((snapshot) => {
|
||||
if (snapshot.accessToken) {
|
||||
authExpiredNotified = false
|
||||
}
|
||||
})
|
||||
|
||||
subscribeAuthExpired(() => {
|
||||
const current = router.currentRoute.value
|
||||
@@ -75,10 +87,15 @@ subscribeAuthExpired(() => {
|
||||
return
|
||||
}
|
||||
|
||||
void router.replace({
|
||||
name: 'login',
|
||||
query: current.fullPath ? { redirect: current.fullPath } : undefined,
|
||||
})
|
||||
if (!authExpiredNotified) {
|
||||
authExpiredNotified = true
|
||||
message.destroy()
|
||||
message.warning('登录已过期,请重新登录')
|
||||
void router.replace({
|
||||
name: 'login',
|
||||
query: current.fullPath ? { redirect: current.fullPath } : undefined,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const IconFont = createFromIconfontCN({
|
||||
|
||||
Reference in New Issue
Block a user