mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 16:16:42 +08:00
fix 登录界面提示频率
This commit is contained in:
@@ -238,6 +238,35 @@ describe('MoviePilot API client', () => {
|
|||||||
expect(reportConnectionFailure).toHaveBeenCalledWith('server-unreachable')
|
expect(reportConnectionFailure).toHaveBeenCalledWith('server-unreachable')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('401 被 onUnauthorized 接管时不逐条弹请求层 Toast', async () => {
|
||||||
|
const onUnauthorized = vi.fn(() => true)
|
||||||
|
const { api } = createApiClients({
|
||||||
|
adapter: rejectWith({ detail: 'Not authenticated' }, 401),
|
||||||
|
hooks: { onUnauthorized },
|
||||||
|
notifier,
|
||||||
|
})
|
||||||
|
|
||||||
|
const error = requireApiRequestError(await api.get('/resource').catch(reason => reason))
|
||||||
|
|
||||||
|
expect(error.status).toBe(401)
|
||||||
|
expect(onUnauthorized).toHaveBeenCalledWith(error)
|
||||||
|
expect(notifier.error).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('401 未被 onUnauthorized 接管时保留逐条错误提示', async () => {
|
||||||
|
const onUnauthorized = vi.fn(() => false)
|
||||||
|
const { api } = createApiClients({
|
||||||
|
adapter: rejectWith({ detail: 'Not authenticated' }, 401),
|
||||||
|
hooks: { onUnauthorized },
|
||||||
|
notifier,
|
||||||
|
})
|
||||||
|
|
||||||
|
const error = requireApiRequestError(await api.get('/resource').catch(reason => reason))
|
||||||
|
|
||||||
|
expect(error.status).toBe(401)
|
||||||
|
expect(notifier.error).toHaveBeenCalledWith('Not authenticated')
|
||||||
|
})
|
||||||
|
|
||||||
it('拒绝缺少标准字段的普通 JSON 响应', async () => {
|
it('拒绝缺少标准字段的普通 JSON 响应', async () => {
|
||||||
const { api } = createApiClients({ adapter: resolveWith({ value: 1 }), notifier })
|
const { api } = createApiClients({ adapter: resolveWith({ value: 1 }), notifier })
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
import { AxiosHeaders, type InternalAxiosRequestConfig } from 'axios'
|
import { AxiosError, AxiosHeaders, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
i18nT: vi.fn((key: string) => `translated:${key}`),
|
i18nT: vi.fn((key: string) => `translated:${key}`),
|
||||||
|
authState: { token: null as string | null },
|
||||||
|
logout: vi.fn(),
|
||||||
|
routerPush: vi.fn(),
|
||||||
|
toastError: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/router', () => ({
|
vi.mock('@/router', () => ({
|
||||||
default: { push: vi.fn() },
|
default: { push: mocks.routerPush },
|
||||||
}))
|
}))
|
||||||
vi.mock('@/stores', () => ({
|
vi.mock('@/stores', () => ({
|
||||||
useAuthStore: () => ({ logout: vi.fn(), token: null }),
|
useAuthStore: () => ({
|
||||||
|
get token() {
|
||||||
|
return mocks.authState.token
|
||||||
|
},
|
||||||
|
// 与真实 store 一致:登出时清空 token,后续在途请求按无 token 处理。
|
||||||
|
logout: mocks.logout.mockImplementation(() => {
|
||||||
|
mocks.authState.token = null
|
||||||
|
}),
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
vi.mock('@/utils/requestOptimizer', () => ({
|
vi.mock('@/utils/requestOptimizer', () => ({
|
||||||
initializeRequestOptimizer: vi.fn(),
|
initializeRequestOptimizer: vi.fn(),
|
||||||
@@ -25,10 +37,43 @@ vi.mock('@/plugins/i18n', () => ({
|
|||||||
getCurrentLocale: () => 'zh-CN',
|
getCurrentLocale: () => 'zh-CN',
|
||||||
}))
|
}))
|
||||||
vi.mock('vue-toastification', () => ({
|
vi.mock('vue-toastification', () => ({
|
||||||
useToast: () => ({ error: vi.fn(), success: vi.fn() }),
|
useToast: () => ({ error: mocks.toastError, success: vi.fn() }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
/** 安装始终返回指定 HTTP 失败的适配器。 */
|
||||||
|
async function installFailingAdapter(status: number, data: unknown) {
|
||||||
|
const module = await import('@/api')
|
||||||
|
module.default.defaults.adapter = async config => {
|
||||||
|
const response: AxiosResponse = {
|
||||||
|
config: config as InternalAxiosRequestConfig,
|
||||||
|
data,
|
||||||
|
headers: new AxiosHeaders(),
|
||||||
|
status,
|
||||||
|
statusText: 'Error',
|
||||||
|
}
|
||||||
|
throw new AxiosError(
|
||||||
|
'Request failed',
|
||||||
|
AxiosError.ERR_BAD_RESPONSE,
|
||||||
|
config as InternalAxiosRequestConfig,
|
||||||
|
undefined,
|
||||||
|
response,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return module
|
||||||
|
}
|
||||||
|
|
||||||
describe('API application wiring', () => {
|
describe('API application wiring', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.authState.token = null
|
||||||
|
mocks.logout.mockClear()
|
||||||
|
mocks.routerPush.mockClear()
|
||||||
|
mocks.toastError.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('向 window 暴露插件 envelope 客户端,而内部默认导出数据客户端', async () => {
|
it('向 window 暴露插件 envelope 客户端,而内部默认导出数据客户端', async () => {
|
||||||
const module = await import('@/api')
|
const module = await import('@/api')
|
||||||
|
|
||||||
@@ -52,4 +97,35 @@ describe('API application wiring', () => {
|
|||||||
expect((error as Error).message).toBe('translated:common.invalidApiResponse')
|
expect((error as Error).message).toBe('translated:common.invalidApiResponse')
|
||||||
expect(mocks.i18nT).toHaveBeenCalledWith('common.invalidApiResponse')
|
expect(mocks.i18nT).toHaveBeenCalledWith('common.invalidApiResponse')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('已登录时并发 401 只统一登出并提示一次本地化文案', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
mocks.authState.token = 'expired-token'
|
||||||
|
const module = await installFailingAdapter(401, { detail: 'Not authenticated' })
|
||||||
|
|
||||||
|
await Promise.allSettled([module.default.get('/dashboard'), module.default.get('/subscribe')])
|
||||||
|
|
||||||
|
expect(mocks.logout).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.routerPush).toHaveBeenCalledWith('/login')
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledWith('translated:common.sessionExpired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('登出后短暂窗口内的在途 401 保持静默,窗口过后恢复逐条提示', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
mocks.authState.token = 'expired-token'
|
||||||
|
const module = await installFailingAdapter(401, { detail: 'Not authenticated' })
|
||||||
|
|
||||||
|
await module.default.get('/dashboard').catch(() => {})
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
|
// 窗口内的连带 401 不再弹出英文提示。
|
||||||
|
await module.default.get('/subscribe').catch(() => {})
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
|
vi.setSystemTime(Date.now() + 6000)
|
||||||
|
await module.default.get('/resource').catch(() => {})
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mocks.toastError).toHaveBeenLastCalledWith('Not authenticated')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export interface ApiFeedbackNotifier {
|
|||||||
export interface ApiClientHooks {
|
export interface ApiClientHooks {
|
||||||
markServerOnline?(): void
|
markServerOnline?(): void
|
||||||
onForbidden?(error: ApiRequestError): void
|
onForbidden?(error: ApiRequestError): void
|
||||||
|
/** 返回 true 表示认证失效已由应用层统一接管,请求层不再弹出逐条错误提示。 */
|
||||||
|
onUnauthorized?(error: ApiRequestError): boolean | void
|
||||||
reportConnectionFailure?(reason: 'network-error' | 'timeout' | 'server-unreachable'): void
|
reportConnectionFailure?(reason: 'network-error' | 'timeout' | 'server-unreachable'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +272,12 @@ function installResponseInterceptors(
|
|||||||
}
|
}
|
||||||
if (response?.status === 403) hooks?.onForbidden?.(error)
|
if (response?.status === 403) hooks?.onForbidden?.(error)
|
||||||
|
|
||||||
|
// 认证失效(如后端重启导致 token 作废)由应用层统一登出跳转,
|
||||||
|
// 避免并发请求逐条弹出 "Not authenticated" 等英文提示刷屏。
|
||||||
|
if (response?.status === 401 && hooks?.onUnauthorized?.(error) === true) {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
// 连接类失败(无响应、超时、网关不可用)统一交给离线状态系统按阈值提示,
|
// 连接类失败(无响应、超时、网关不可用)统一交给离线状态系统按阈值提示,
|
||||||
// 不在请求层逐个弹出,避免后端重启时刷屏。
|
// 不在请求层逐个弹出,避免后端重启时刷屏。
|
||||||
if (!failureReason) notifyFailure(requestConfig?.feedback, notifier, error.message)
|
if (!failureReason) notifyFailure(requestConfig?.feedback, notifier, error.message)
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export interface ConnectionAwareRequestConfig extends AxiosRequestConfig {
|
|||||||
|
|
||||||
const globalOfflineStatus = useGlobalOfflineStatus()
|
const globalOfflineStatus = useGlobalOfflineStatus()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
// 会话失效后短暂窗口内的 401 都是同一次 token 作废的连带失败,统一静默避免刷屏。
|
||||||
|
const SESSION_EXPIRED_SUPPRESSION_MS = 5000
|
||||||
|
let sessionExpiredAt = 0
|
||||||
const fallbackMessageKeys: Record<ApiFallbackMessageKey, string> = {
|
const fallbackMessageKeys: Record<ApiFallbackMessageKey, string> = {
|
||||||
'invalid-envelope': 'common.invalidApiResponse',
|
'invalid-envelope': 'common.invalidApiResponse',
|
||||||
'network-error': 'common.networkConnectionFailed',
|
'network-error': 'common.networkConnectionFailed',
|
||||||
@@ -43,6 +46,20 @@ const { api, pluginApi } = createApiClients({
|
|||||||
authStore.logout()
|
authStore.logout()
|
||||||
void router.push('/login')
|
void router.push('/login')
|
||||||
},
|
},
|
||||||
|
onUnauthorized: () => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
if (!authStore.token) {
|
||||||
|
// 无 token 的 401(如登录页验证失败)交给调用方自行展示;
|
||||||
|
// 但刚登出后的在途请求属于同一会话失效,继续静默。
|
||||||
|
return Date.now() - sessionExpiredAt < SESSION_EXPIRED_SUPPRESSION_MS
|
||||||
|
}
|
||||||
|
// 后端重启会使旧 token 作废:只提示一次并统一登出,不逐条弹英文错误。
|
||||||
|
sessionExpiredAt = Date.now()
|
||||||
|
authStore.logout()
|
||||||
|
toast.error(i18n.global.t('common.sessionExpired'))
|
||||||
|
void router.push('/login')
|
||||||
|
return true
|
||||||
|
},
|
||||||
},
|
},
|
||||||
notifier: {
|
notifier: {
|
||||||
error: message => toast.error(message),
|
error: message => toast.error(message),
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export default {
|
|||||||
requestTimeout: 'Request timed out',
|
requestTimeout: 'Request timed out',
|
||||||
invalidApiResponse: 'The server returned an invalid response',
|
invalidApiResponse: 'The server returned an invalid response',
|
||||||
apiRequestFailed: 'Request failed',
|
apiRequestFailed: 'Request failed',
|
||||||
|
sessionExpired: 'Session expired, please sign in again',
|
||||||
troubleshooting: 'Troubleshooting',
|
troubleshooting: 'Troubleshooting',
|
||||||
checking: 'Checking',
|
checking: 'Checking',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export default {
|
|||||||
requestTimeout: '请求超时',
|
requestTimeout: '请求超时',
|
||||||
invalidApiResponse: '服务器返回了无效响应',
|
invalidApiResponse: '服务器返回了无效响应',
|
||||||
apiRequestFailed: '请求失败',
|
apiRequestFailed: '请求失败',
|
||||||
|
sessionExpired: '登录状态已失效,请重新登录',
|
||||||
troubleshooting: '疑难解答',
|
troubleshooting: '疑难解答',
|
||||||
checking: '检查中',
|
checking: '检查中',
|
||||||
retry: '重试',
|
retry: '重试',
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export default {
|
|||||||
requestTimeout: '請求超時',
|
requestTimeout: '請求超時',
|
||||||
invalidApiResponse: '服務器返回了無效響應',
|
invalidApiResponse: '服務器返回了無效響應',
|
||||||
apiRequestFailed: '請求失敗',
|
apiRequestFailed: '請求失敗',
|
||||||
|
sessionExpired: '登入狀態已失效,請重新登入',
|
||||||
troubleshooting: '疑難排解',
|
troubleshooting: '疑難排解',
|
||||||
checking: '檢查中',
|
checking: '檢查中',
|
||||||
retry: '重試',
|
retry: '重試',
|
||||||
|
|||||||
Reference in New Issue
Block a user