feat: 独立服务状态检测页面

This commit is contained in:
jxxghp
2026-08-31 09:50:24 +08:00
parent 39e8d7ffba
commit e7f2932409
10 changed files with 324 additions and 48 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts" setup>
const route = useRoute()
// 空白布局用于登录、初始化与 404,复用全局页面动效保持切换手感一致。
// 空白布局用于登录、服务状态、初始化与 404,复用全局页面动效保持切换手感一致。
const routeTransitionKey = computed(() => route.fullPath)
</script>
+7 -1
View File
@@ -4594,7 +4594,6 @@ 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',
@@ -4617,4 +4616,11 @@ export default {
saved: 'Initialization complete. Sign in with your new account.',
saveFailed: 'Initialization failed. Check the service and try again.',
},
serviceStatus: {
kicker: 'Service status',
title: 'MoviePilot is starting',
subtitle: 'Waiting for the backend service. Your account and existing data are not affected.',
checking: 'Checking the service connection…',
automatic: 'You will continue to sign-in or setup automatically once connected',
},
}
+7 -1
View File
@@ -4521,7 +4521,6 @@ export default {
title: '欢迎使用 MoviePilot',
subtitle: '先创建唯一的超级管理员,再开始管理你的媒体库。整个过程只需一步。',
checking: '正在检查实例状态…',
statusRetrying: '服务正在启动,稍后将自动重试…',
accountSection: '管理员账号',
username: '超级管理员用户名',
usernamePlaceholder: '例如:admin',
@@ -4544,4 +4543,11 @@ export default {
saved: '初始化完成,请使用新账号登录',
saveFailed: '初始化失败,请检查服务状态后重试',
},
serviceStatus: {
kicker: '服务状态',
title: 'MoviePilot 正在启动',
subtitle: '正在等待后端服务就绪,这不会影响你的账号和已有数据。',
checking: '正在检测服务连接…',
automatic: '连接成功后将自动进入登录或初始化界面',
},
}
+7 -1
View File
@@ -4518,7 +4518,6 @@ export default {
title: '歡迎使用 MoviePilot',
subtitle: '先建立唯一的超級管理員,再開始管理你的媒體庫。整個過程只需一步。',
checking: '正在檢查實例狀態…',
statusRetrying: '服務正在啟動,稍後將自動重試…',
accountSection: '管理員帳號',
username: '超級管理員使用者名稱',
usernamePlaceholder: '例如:admin',
@@ -4541,4 +4540,11 @@ export default {
saved: '初始化完成,請使用新帳號登入',
saveFailed: '初始化失敗,請檢查服務狀態後重試',
},
serviceStatus: {
kicker: '服務狀態',
title: 'MoviePilot 正在啟動',
subtitle: '正在等待後端服務就緒,這不會影響你的帳號和現有資料。',
checking: '正在偵測服務連線…',
automatic: '連線成功後將自動進入登入或初始化頁面',
},
}
+5 -16
View File
@@ -2,7 +2,7 @@ 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'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
api: { post: vi.fn() },
@@ -37,27 +37,16 @@ describe('initialization page', () => {
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)
it('moves service availability checks to the dedicated status page', async () => {
mocks.getInitializationState.mockRejectedValue(new Error('service starting'))
await renderWithProviders(InitializePage)
await nextTick()
await Promise.resolve()
await nextTick()
expect(screen.getByText('服务正在启动,稍后将自动重试…')).toBeInTheDocument()
expect(mocks.getInitializationState).toHaveBeenCalledOnce()
expect(mocks.router.replace).toHaveBeenCalledWith('/service-status')
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()
})
})
@@ -0,0 +1,54 @@
import ServiceStatusPage from '@/pages/service-status.vue'
import { renderWithProviders } from '@tests/support/render'
import { screen } from '@testing-library/vue'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
getInitializationState: vi.fn(),
router: { replace: vi.fn() },
}))
vi.mock('@/router', () => ({
default: mocks.router,
}))
vi.mock('@/utils/initialization', () => ({
getInitializationState: mocks.getInitializationState,
}))
describe('service status page', () => {
beforeEach(() => {
mocks.getInitializationState.mockReset()
mocks.router.replace.mockReset()
})
it('keeps checking until the initialized service becomes available', async () => {
vi.useFakeTimers()
mocks.getInitializationState.mockRejectedValueOnce(new Error('service starting')).mockResolvedValueOnce(true)
await renderWithProviders(ServiceStatusPage)
await nextTick()
await Promise.resolve()
expect(screen.getByRole('heading', { name: 'MoviePilot 正在启动' })).toBeInTheDocument()
expect(screen.getByText('正在等待后端服务就绪,这不会影响你的账号和已有数据。')).toBeInTheDocument()
expect(mocks.router.replace).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1500)
await nextTick()
expect(mocks.getInitializationState).toHaveBeenCalledTimes(2)
expect(mocks.router.replace).toHaveBeenCalledWith('/login')
})
it('opens initialization only after the service confirms there is no user', async () => {
mocks.getInitializationState.mockResolvedValue(false)
await renderWithProviders(ServiceStatusPage)
await nextTick()
await Promise.resolve()
expect(mocks.router.replace).toHaveBeenCalledWith('/initialize')
})
})
+3 -21
View File
@@ -27,13 +27,9 @@ 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<HTMLFormElement | null>(null)
const INITIALIZATION_STATUS_RETRY_MS = 1500
let statusRetryTimer: ReturnType<typeof setTimeout> | undefined
let disposed = false
const form = ref<InitializationPayload>({
username: '',
@@ -44,9 +40,6 @@ const form = ref<InitializationPayload>({
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 {
@@ -80,23 +73,17 @@ 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)
await router.replace('/service-status')
}
}
@@ -128,11 +115,6 @@ onMounted(() => {
form.value.api_key = generateApiKey()
void checkInitializationStatus()
})
onBeforeUnmount(() => {
disposed = true
if (statusRetryTimer) clearTimeout(statusRetryTimer)
})
</script>
<template>
@@ -197,7 +179,7 @@ onBeforeUnmount(() => {
</VAlert>
<VAlert v-if="checking" type="info" variant="tonal" class="mb-5">
{{ statusMessage }}
{{ t('initialization.checking') }}
</VAlert>
<form ref="formRef" class="initialize-form" novalidate @submit.prevent="submit">
+205
View File
@@ -0,0 +1,205 @@
<script setup lang="ts">
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
import router from '@/router'
import { getInitializationState } from '@/utils/initialization'
import { useTheme } from 'vuetify'
const SERVICE_STATUS_RETRY_MS = 1500
const { t } = useI18n()
const theme = useTheme()
const themeClass = computed(() => 'service-status-page--' + theme.global.name.value)
let retryTimer: ReturnType<typeof setTimeout> | undefined
let disposed = false
/** 轮询后端初始化状态;首次连通后再根据明确结果进入登录或初始化页面。 */
async function checkServiceStatus() {
try {
const initialized = await getInitializationState(true)
if (disposed) return
await router.replace(initialized ? '/login' : '/initialize')
} catch {
if (disposed) return
retryTimer = setTimeout(() => {
void checkServiceStatus()
}, SERVICE_STATUS_RETRY_MS)
}
}
onMounted(() => {
void checkServiceStatus()
})
onBeforeUnmount(() => {
disposed = true
if (retryTimer) clearTimeout(retryTimer)
})
</script>
<template>
<main class="service-status-page" :class="themeClass">
<div class="service-status-page__ambient service-status-page__ambient--one" aria-hidden="true" />
<div class="service-status-page__ambient service-status-page__ambient--two" aria-hidden="true" />
<section class="service-status-shell" aria-labelledby="service-status-title">
<VCard class="service-status-card" elevation="0">
<div class="service-status-card__topline" aria-hidden="true" />
<div class="service-status-brand-mark">
<ThemeLogoMark class="service-status-brand-logo" decorative />
</div>
<div class="service-status-kicker">{{ t('serviceStatus.kicker') }}</div>
<h1 id="service-status-title" class="service-status-title">{{ t('serviceStatus.title') }}</h1>
<p class="service-status-subtitle">{{ t('serviceStatus.subtitle') }}</p>
<div class="service-status-progress" role="status" aria-live="polite">
<VProgressCircular indeterminate color="primary" :size="22" :width="2" />
<span>{{ t('serviceStatus.checking') }}</span>
</div>
<div class="service-status-hint">
<VIcon icon="mdi-information-outline" size="16" />
<span>{{ t('serviceStatus.automatic') }}</span>
</div>
</VCard>
<p class="service-status-copyright">MoviePilot · {{ new Date().getFullYear() }}</p>
</section>
</main>
</template>
<style lang="scss" scoped>
.service-status-page {
--service-status-surface: rgba(var(--v-theme-surface), 0.94);
--service-status-border: rgba(var(--v-theme-on-surface), 0.1);
position: relative;
display: grid;
min-block-size: 100dvh;
overflow: hidden;
background: rgb(var(--v-theme-background));
color: rgb(var(--v-theme-on-background));
isolation: isolate;
place-items: center;
}
.service-status-page__ambient {
position: absolute;
z-index: -1;
border-radius: 999px;
pointer-events: none;
}
.service-status-page__ambient--one {
inset-block-start: -18rem;
inset-inline-start: -14rem;
inline-size: 38rem;
block-size: 38rem;
background: radial-gradient(circle, rgba(var(--v-theme-primary), 0.2), transparent 68%);
}
.service-status-page__ambient--two {
inset-inline-end: -12rem;
inset-block-end: -24rem;
inline-size: 42rem;
block-size: 42rem;
background: radial-gradient(circle, rgba(var(--v-theme-info), 0.16), transparent 66%);
}
.service-status-shell {
inline-size: min(92vw, 31rem);
text-align: center;
}
.service-status-card {
position: relative;
overflow: hidden;
padding: clamp(2rem, 7vw, 3.5rem);
border: 1px solid var(--service-status-border);
border-radius: 1.5rem;
background: var(--service-status-surface);
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.12);
backdrop-filter: blur(20px);
}
.service-status-card__topline {
position: absolute;
inset-block-start: 0;
inset-inline: 15%;
block-size: 2px;
background: linear-gradient(90deg, transparent, rgb(var(--v-theme-primary)), transparent);
}
.service-status-brand-mark {
display: inline-grid;
inline-size: 5rem;
block-size: 5rem;
margin-block-end: 1.5rem;
border: 1px solid rgba(var(--v-theme-primary), 0.18);
border-radius: 1.4rem;
background: rgba(var(--v-theme-primary), 0.08);
place-items: center;
}
.service-status-brand-logo {
font-size: 1.15rem;
}
.service-status-kicker {
color: rgb(var(--v-theme-primary));
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.service-status-title {
margin-block: 0.65rem 0.8rem;
font-size: clamp(1.65rem, 5vw, 2.15rem);
font-weight: 700;
letter-spacing: -0.035em;
}
.service-status-subtitle {
max-inline-size: 24rem;
margin: 0 auto;
color: rgba(var(--v-theme-on-surface), 0.68);
font-size: 0.95rem;
line-height: 1.7;
}
.service-status-progress {
display: flex;
align-items: center;
justify-content: center;
gap: 0.7rem;
margin-block-start: 2rem;
color: rgba(var(--v-theme-on-surface), 0.86);
font-size: 0.9rem;
font-weight: 600;
}
.service-status-hint {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
margin-block-start: 1.25rem;
color: rgba(var(--v-theme-on-surface), 0.52);
font-size: 0.78rem;
}
.service-status-copyright {
margin-block-start: 1.25rem;
color: rgba(var(--v-theme-on-background), 0.4);
font-size: 0.75rem;
}
@media (max-width: 480px) {
.service-status-card {
padding-inline: 1.5rem;
}
.service-status-hint {
align-items: flex-start;
}
}
</style>
+23 -2
View File
@@ -123,16 +123,37 @@ describe('authentication route guard', () => {
expect(routerMocks.setRequestNavigatingState).toHaveBeenLastCalledWith(false)
})
it('redirects to initialization while the status endpoint is unavailable', async () => {
it('redirects to the service status page 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.next).toHaveBeenCalledWith('/service-status')
expect(routerMocks.setRequestNavigatingState).toHaveBeenLastCalledWith(false)
})
it('allows the service status page to keep checking while the endpoint remains unavailable', async () => {
routerMocks.getInitializationState.mockRejectedValue(new Error('service starting'))
useAuthStore().setOriginalPath('/resource?keyword=test')
await runGuard(route({ fullPath: '/service-status', path: '/service-status' }))
expect(routerMocks.next).toHaveBeenCalledOnce()
expect(routerMocks.next).toHaveBeenCalledWith()
expect(useAuthStore().originalPath).toBe('/resource?keyword=test')
})
it('leaves the service status page according to the confirmed initialization state', async () => {
await runGuard(route({ fullPath: '/service-status', path: '/service-status' }))
expect(routerMocks.next).toHaveBeenLastCalledWith('/login')
routerMocks.next.mockClear()
routerMocks.getInitializationState.mockResolvedValue(false)
await runGuard(route({ fullPath: '/service-status', path: '/service-status' }))
expect(routerMocks.next).toHaveBeenLastCalledWith('/initialize')
})
it('keeps initialized instances out of the initialization page', async () => {
await runGuard(route({ fullPath: '/initialize', path: '/initialize' }))
+12 -5
View File
@@ -316,6 +316,10 @@ const router = createRouter({
path: 'initialize',
component: () => import('../pages/initialize.vue'),
},
{
path: 'service-status',
component: () => import('../pages/service-status.vue'),
},
{
path: '/:pathMatch(.*)*',
component: () => import('../pages/[...all].vue'),
@@ -325,6 +329,7 @@ const router = createRouter({
],
})
/** 解析普通页面或插件页面声明的权限约束,供导航守卫统一校验。 */
async function getRoutePermission(to: any): Promise<PermissionProtectedItem> {
if (to.meta.permission) {
return {
@@ -360,10 +365,10 @@ router.beforeEach(async (to: any, from: any, next: any) => {
try {
initialized = await getInitializationState()
} catch {
// 未确认初始化状态时必须进入初始化页,由页面持续等待后端就绪,避免启动竞态误放行登录页
if (to.path !== '/initialize') {
// 接口不可达只代表服务尚未就绪,不能据此推断实例需要初始化
if (to.path !== '/service-status') {
setRequestNavigatingState(false)
next('/initialize')
next('/service-status')
return
}
}
@@ -373,7 +378,7 @@ router.beforeEach(async (to: any, from: any, next: any) => {
next('/initialize')
return
}
if (initialized === true && to.path === '/initialize') {
if (initialized === true && (to.path === '/initialize' || to.path === '/service-status')) {
setRequestNavigatingState(false)
next('/login')
return
@@ -382,7 +387,9 @@ router.beforeEach(async (to: any, from: any, next: any) => {
// 认证 Store
const authStore = useAuthStore()
// 登录页的实验参数不是登录后可恢复的业务目标。
if (to.path !== '/login' && to.path !== '/initialize') authStore.originalPath = to.fullPath
if (to.path !== '/login' && to.path !== '/initialize' && to.path !== '/service-status') {
authStore.originalPath = to.fullPath
}
const isAuthenticated = authStore.token !== null
if (to.meta.requiresAuth && !isAuthenticated) {