diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 79c9fc31..0644fb2a 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -4558,6 +4558,7 @@ export default { title: 'Welcome to MoviePilot', subtitle: 'Create the single super administrator account before you start managing your media library.', checking: 'Checking instance status…', + statusRetrying: 'The service is starting. Retrying automatically…', accountSection: 'Administrator account', username: 'Super administrator username', usernamePlaceholder: 'For example: admin', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index e2b706e8..ed2b3fba 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -4486,6 +4486,7 @@ export default { title: '欢迎使用 MoviePilot', subtitle: '先创建唯一的超级管理员,再开始管理你的媒体库。整个过程只需一步。', checking: '正在检查实例状态…', + statusRetrying: '服务正在启动,稍后将自动重试…', accountSection: '管理员账号', username: '超级管理员用户名', usernamePlaceholder: '例如:admin', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 1588b388..8838e0a6 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -4483,6 +4483,7 @@ export default { title: '歡迎使用 MoviePilot', subtitle: '先建立唯一的超級管理員,再開始管理你的媒體庫。整個過程只需一步。', checking: '正在檢查實例狀態…', + statusRetrying: '服務正在啟動,稍後將自動重試…', accountSection: '管理員帳號', username: '超級管理員使用者名稱', usernamePlaceholder: '例如:admin', diff --git a/src/pages/__tests__/initialize.spec.ts b/src/pages/__tests__/initialize.spec.ts new file mode 100644 index 00000000..ea9f5b8d --- /dev/null +++ b/src/pages/__tests__/initialize.spec.ts @@ -0,0 +1,63 @@ +import InitializePage from '@/pages/initialize.vue' +import { renderWithProviders } from '@tests/support/render' +import { screen } from '@testing-library/vue' +import { nextTick } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + api: { post: vi.fn() }, + getApiBusinessErrorMessage: vi.fn(), + getInitializationState: vi.fn(), + markInitialized: vi.fn(), + router: { replace: vi.fn() }, + toast: { success: vi.fn() }, +})) + +vi.mock('@/api', () => ({ + default: mocks.api, + getApiBusinessErrorMessage: mocks.getApiBusinessErrorMessage, +})) + +vi.mock('@/router', () => ({ + default: mocks.router, +})) + +vi.mock('@/utils/initialization', () => ({ + getInitializationState: mocks.getInitializationState, + markInitialized: mocks.markInitialized, +})) + +vi.mock('vue-toastification', () => ({ + useToast: () => mocks.toast, +})) + +describe('initialization page', () => { + beforeEach(() => { + mocks.getInitializationState.mockReset() + mocks.router.replace.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('waits for the backend and retries before enabling the form', async () => { + vi.useFakeTimers() + mocks.getInitializationState.mockRejectedValueOnce(new Error('service starting')).mockResolvedValueOnce(false) + + await renderWithProviders(InitializePage) + await nextTick() + await Promise.resolve() + await nextTick() + + expect(screen.getByText('服务正在启动,稍后将自动重试…')).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: '超级管理员用户名' })).toBeDisabled() + + await vi.advanceTimersByTimeAsync(1500) + await nextTick() + + expect(mocks.getInitializationState).toHaveBeenCalledTimes(2) + expect(screen.queryByText('服务正在启动,稍后将自动重试…')).not.toBeInTheDocument() + expect(screen.getByRole('textbox', { name: '超级管理员用户名' })).toBeEnabled() + }) +}) diff --git a/src/pages/initialize.vue b/src/pages/initialize.vue index d67c7012..85f59e92 100644 --- a/src/pages/initialize.vue +++ b/src/pages/initialize.vue @@ -27,9 +27,13 @@ const isPasswordVisible = ref(false) const isConfirmPasswordVisible = ref(false) const loading = ref(false) const checking = ref(true) +const statusRetrying = ref(false) const errorMessage = ref('') const apiKeyCopied = ref(false) const formRef = ref(null) +const INITIALIZATION_STATUS_RETRY_MS = 1500 +let statusRetryTimer: ReturnType | undefined +let disposed = false const form = ref({ username: '', @@ -40,6 +44,9 @@ const form = ref({ const currentTheme = computed(() => theme.global.name.value) const themeClass = computed(() => 'initialize-page--' + currentTheme.value) +const statusMessage = computed(() => + t(statusRetrying.value ? 'initialization.statusRetrying' : 'initialization.checking'), +) /** 使用浏览器密码学随机源生成一次性 API Key,避免把凭据交给第三方服务。 */ function generateApiKey(): string { @@ -73,6 +80,26 @@ function getErrorMessage(error: unknown): string { return getApiBusinessErrorMessage(error) || t('initialization.saveFailed') } +/** 持续确认实例状态;服务尚未就绪时保持表单锁定并自动重试。 */ +async function checkInitializationStatus() { + try { + const initialized = await getInitializationState(true) + if (disposed) return + statusRetrying.value = false + if (initialized) { + await router.replace('/login') + return + } + checking.value = false + } catch { + if (disposed) return + statusRetrying.value = true + statusRetryTimer = setTimeout(() => { + void checkInitializationStatus() + }, INITIALIZATION_STATUS_RETRY_MS) + } +} + /** 提交首次初始化;后端会再次校验“零用户”条件,防止重复认领实例。 */ async function submit() { if (loading.value) return @@ -97,15 +124,14 @@ async function submit() { } } -onMounted(async () => { +onMounted(() => { form.value.api_key = generateApiKey() - try { - if (await getInitializationState(true)) await router.replace('/login') - } catch (error) { - errorMessage.value = getErrorMessage(error) - } finally { - checking.value = false - } + void checkInitializationStatus() +}) + +onBeforeUnmount(() => { + disposed = true + if (statusRetryTimer) clearTimeout(statusRetryTimer) }) @@ -159,12 +185,19 @@ onMounted(async () => { - + {{ errorMessage }} - {{ t('initialization.checking') }} + {{ statusMessage }}
@@ -254,11 +287,21 @@ onMounted(async () => { minlength="16" >
- {{ t('initialization.apiKeyWarning') }} + {{ + t('initialization.apiKeyWarning') + }} {{ t('initialization.regenerate') }} diff --git a/src/router/__tests__/auth-guard.spec.ts b/src/router/__tests__/auth-guard.spec.ts index 728af651..dd5eb984 100644 --- a/src/router/__tests__/auth-guard.spec.ts +++ b/src/router/__tests__/auth-guard.spec.ts @@ -18,6 +18,7 @@ type Redirect = () => string const routerMocks = vi.hoisted(() => ({ afterEach: undefined as AfterEachHook | undefined, + getInitializationState: vi.fn(), guard: undefined as NavigationGuard | undefined, next: vi.fn(), routes: [] as Array<{ path: string; redirect?: Redirect }>, @@ -43,6 +44,10 @@ vi.mock('@/api/nprogress', () => ({ configureNProgress: vi.fn(), })) +vi.mock('@/utils/initialization', () => ({ + getInitializationState: routerMocks.getInitializationState, +})) + vi.mock('@/utils/requestOptimizer', () => ({ abortAllRequests: vi.fn(), initializeRequestOptimizer: vi.fn(), @@ -66,6 +71,8 @@ describe('authentication route guard', () => { beforeEach(() => { setActivePinia(createPinia()) routerMocks.next.mockReset() + routerMocks.getInitializationState.mockReset() + routerMocks.getInitializationState.mockResolvedValue(true) routerMocks.setRequestNavigatingState.mockReset() }) @@ -106,6 +113,33 @@ describe('authentication route guard', () => { expect(routerMocks.next).toHaveBeenCalledWith() }) + it('redirects to initialization when the instance has no user', async () => { + routerMocks.getInitializationState.mockResolvedValue(false) + + await runGuard(route({ fullPath: '/login', path: '/login' })) + + expect(routerMocks.next).toHaveBeenCalledOnce() + expect(routerMocks.next).toHaveBeenCalledWith('/initialize') + expect(routerMocks.setRequestNavigatingState).toHaveBeenLastCalledWith(false) + }) + + it('redirects to initialization while the status endpoint is unavailable', async () => { + routerMocks.getInitializationState.mockRejectedValue(new Error('service starting')) + + await runGuard(route({ fullPath: '/login', path: '/login' })) + + expect(routerMocks.next).toHaveBeenCalledOnce() + expect(routerMocks.next).toHaveBeenCalledWith('/initialize') + expect(routerMocks.setRequestNavigatingState).toHaveBeenLastCalledWith(false) + }) + + it('keeps initialized instances out of the initialization page', async () => { + await runGuard(route({ fullPath: '/initialize', path: '/initialize' })) + + expect(routerMocks.next).toHaveBeenCalledOnce() + expect(routerMocks.next).toHaveBeenCalledWith('/login') + }) + it('allows ordinary protected routes and enforces declared permissions', async () => { const authStore = useAuthStore() const userStore = useUserStore() diff --git a/src/router/__tests__/plugin-sidebar-permission.spec.ts b/src/router/__tests__/plugin-sidebar-permission.spec.ts index 45b6b94f..42665aef 100644 --- a/src/router/__tests__/plugin-sidebar-permission.spec.ts +++ b/src/router/__tests__/plugin-sidebar-permission.spec.ts @@ -13,6 +13,7 @@ type NavigationGuard = ( ) => Promise const routerMocks = vi.hoisted(() => ({ + getInitializationState: vi.fn(), guard: undefined as NavigationGuard | undefined, next: vi.fn(), push: vi.fn(), @@ -34,6 +35,10 @@ vi.mock('@/api/nprogress', () => ({ configureNProgress: vi.fn(), })) +vi.mock('@/utils/initialization', () => ({ + getInitializationState: routerMocks.getInitializationState, +})) + vi.mock('@/utils/requestOptimizer', () => ({ abortAllRequests: vi.fn(), initializeRequestOptimizer: vi.fn(), @@ -70,6 +75,8 @@ describe('plugin sidebar route permission', () => { beforeEach(() => { setActivePinia(createPinia()) useAuthStore().login({ remember: false, token: 'test-token' }) + routerMocks.getInitializationState.mockReset() + routerMocks.getInitializationState.mockResolvedValue(true) routerMocks.next.mockReset() }) diff --git a/src/router/index.ts b/src/router/index.ts index 9844fd46..6e788ab4 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -360,7 +360,12 @@ router.beforeEach(async (to: any, from: any, next: any) => { try { initialized = await getInitializationState() } catch { - // 老版本后端或服务暂不可用时保留原有导航,页面自身会显示请求错误。 + // 未确认初始化状态时必须进入初始化页,由页面持续等待后端就绪,避免启动竞态误放行登录页。 + if (to.path !== '/initialize') { + setRequestNavigatingState(false) + next('/initialize') + return + } } if (initialized === false && to.path !== '/initialize') {