mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +08:00
feat: 独立服务状态检测页面
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
// 空白布局用于登录、初始化与 404,复用全局页面动效保持切换手感一致。
|
// 空白布局用于登录、服务状态、初始化与 404,复用全局页面动效保持切换手感一致。
|
||||||
const routeTransitionKey = computed(() => route.fullPath)
|
const routeTransitionKey = computed(() => route.fullPath)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -4594,7 +4594,6 @@ export default {
|
|||||||
title: 'Welcome to MoviePilot',
|
title: 'Welcome to MoviePilot',
|
||||||
subtitle: 'Create the single super administrator account before you start managing your media library.',
|
subtitle: 'Create the single super administrator account before you start managing your media library.',
|
||||||
checking: 'Checking instance status…',
|
checking: 'Checking instance status…',
|
||||||
statusRetrying: 'The service is starting. Retrying automatically…',
|
|
||||||
accountSection: 'Administrator account',
|
accountSection: 'Administrator account',
|
||||||
username: 'Super administrator username',
|
username: 'Super administrator username',
|
||||||
usernamePlaceholder: 'For example: admin',
|
usernamePlaceholder: 'For example: admin',
|
||||||
@@ -4617,4 +4616,11 @@ export default {
|
|||||||
saved: 'Initialization complete. Sign in with your new account.',
|
saved: 'Initialization complete. Sign in with your new account.',
|
||||||
saveFailed: 'Initialization failed. Check the service and try again.',
|
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',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4521,7 +4521,6 @@ export default {
|
|||||||
title: '欢迎使用 MoviePilot',
|
title: '欢迎使用 MoviePilot',
|
||||||
subtitle: '先创建唯一的超级管理员,再开始管理你的媒体库。整个过程只需一步。',
|
subtitle: '先创建唯一的超级管理员,再开始管理你的媒体库。整个过程只需一步。',
|
||||||
checking: '正在检查实例状态…',
|
checking: '正在检查实例状态…',
|
||||||
statusRetrying: '服务正在启动,稍后将自动重试…',
|
|
||||||
accountSection: '管理员账号',
|
accountSection: '管理员账号',
|
||||||
username: '超级管理员用户名',
|
username: '超级管理员用户名',
|
||||||
usernamePlaceholder: '例如:admin',
|
usernamePlaceholder: '例如:admin',
|
||||||
@@ -4544,4 +4543,11 @@ export default {
|
|||||||
saved: '初始化完成,请使用新账号登录',
|
saved: '初始化完成,请使用新账号登录',
|
||||||
saveFailed: '初始化失败,请检查服务状态后重试',
|
saveFailed: '初始化失败,请检查服务状态后重试',
|
||||||
},
|
},
|
||||||
|
serviceStatus: {
|
||||||
|
kicker: '服务状态',
|
||||||
|
title: 'MoviePilot 正在启动',
|
||||||
|
subtitle: '正在等待后端服务就绪,这不会影响你的账号和已有数据。',
|
||||||
|
checking: '正在检测服务连接…',
|
||||||
|
automatic: '连接成功后将自动进入登录或初始化界面',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4518,7 +4518,6 @@ export default {
|
|||||||
title: '歡迎使用 MoviePilot',
|
title: '歡迎使用 MoviePilot',
|
||||||
subtitle: '先建立唯一的超級管理員,再開始管理你的媒體庫。整個過程只需一步。',
|
subtitle: '先建立唯一的超級管理員,再開始管理你的媒體庫。整個過程只需一步。',
|
||||||
checking: '正在檢查實例狀態…',
|
checking: '正在檢查實例狀態…',
|
||||||
statusRetrying: '服務正在啟動,稍後將自動重試…',
|
|
||||||
accountSection: '管理員帳號',
|
accountSection: '管理員帳號',
|
||||||
username: '超級管理員使用者名稱',
|
username: '超級管理員使用者名稱',
|
||||||
usernamePlaceholder: '例如:admin',
|
usernamePlaceholder: '例如:admin',
|
||||||
@@ -4541,4 +4540,11 @@ export default {
|
|||||||
saved: '初始化完成,請使用新帳號登入',
|
saved: '初始化完成,請使用新帳號登入',
|
||||||
saveFailed: '初始化失敗,請檢查服務狀態後重試',
|
saveFailed: '初始化失敗,請檢查服務狀態後重試',
|
||||||
},
|
},
|
||||||
|
serviceStatus: {
|
||||||
|
kicker: '服務狀態',
|
||||||
|
title: 'MoviePilot 正在啟動',
|
||||||
|
subtitle: '正在等待後端服務就緒,這不會影響你的帳號和現有資料。',
|
||||||
|
checking: '正在偵測服務連線…',
|
||||||
|
automatic: '連線成功後將自動進入登入或初始化頁面',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import InitializePage from '@/pages/initialize.vue'
|
|||||||
import { renderWithProviders } from '@tests/support/render'
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
import { screen } from '@testing-library/vue'
|
import { screen } from '@testing-library/vue'
|
||||||
import { nextTick } from '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(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
api: { post: vi.fn() },
|
api: { post: vi.fn() },
|
||||||
@@ -37,27 +37,16 @@ describe('initialization page', () => {
|
|||||||
mocks.router.replace.mockReset()
|
mocks.router.replace.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
it('moves service availability checks to the dedicated status page', async () => {
|
||||||
vi.useRealTimers()
|
mocks.getInitializationState.mockRejectedValue(new Error('service starting'))
|
||||||
})
|
|
||||||
|
|
||||||
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 renderWithProviders(InitializePage)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(screen.getByText('服务正在启动,稍后将自动重试…')).toBeInTheDocument()
|
expect(mocks.getInitializationState).toHaveBeenCalledOnce()
|
||||||
|
expect(mocks.router.replace).toHaveBeenCalledWith('/service-status')
|
||||||
expect(screen.getByRole('textbox', { name: '超级管理员用户名' })).toBeDisabled()
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -27,13 +27,9 @@ const isPasswordVisible = ref(false)
|
|||||||
const isConfirmPasswordVisible = ref(false)
|
const isConfirmPasswordVisible = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const checking = ref(true)
|
const checking = ref(true)
|
||||||
const statusRetrying = ref(false)
|
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const apiKeyCopied = ref(false)
|
const apiKeyCopied = ref(false)
|
||||||
const formRef = ref<HTMLFormElement | null>(null)
|
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>({
|
const form = ref<InitializationPayload>({
|
||||||
username: '',
|
username: '',
|
||||||
@@ -44,9 +40,6 @@ const form = ref<InitializationPayload>({
|
|||||||
|
|
||||||
const currentTheme = computed(() => theme.global.name.value)
|
const currentTheme = computed(() => theme.global.name.value)
|
||||||
const themeClass = computed(() => 'initialize-page--' + currentTheme.value)
|
const themeClass = computed(() => 'initialize-page--' + currentTheme.value)
|
||||||
const statusMessage = computed(() =>
|
|
||||||
t(statusRetrying.value ? 'initialization.statusRetrying' : 'initialization.checking'),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 使用浏览器密码学随机源生成一次性 API Key,避免把凭据交给第三方服务。 */
|
/** 使用浏览器密码学随机源生成一次性 API Key,避免把凭据交给第三方服务。 */
|
||||||
function generateApiKey(): string {
|
function generateApiKey(): string {
|
||||||
@@ -80,23 +73,17 @@ function getErrorMessage(error: unknown): string {
|
|||||||
return getApiBusinessErrorMessage(error) || t('initialization.saveFailed')
|
return getApiBusinessErrorMessage(error) || t('initialization.saveFailed')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 持续确认实例状态;服务尚未就绪时保持表单锁定并自动重试。 */
|
/** 进入表单前再次确认实例仍未初始化;服务不可达时交给独立状态页持续检测。 */
|
||||||
async function checkInitializationStatus() {
|
async function checkInitializationStatus() {
|
||||||
try {
|
try {
|
||||||
const initialized = await getInitializationState(true)
|
const initialized = await getInitializationState(true)
|
||||||
if (disposed) return
|
|
||||||
statusRetrying.value = false
|
|
||||||
if (initialized) {
|
if (initialized) {
|
||||||
await router.replace('/login')
|
await router.replace('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
checking.value = false
|
checking.value = false
|
||||||
} catch {
|
} catch {
|
||||||
if (disposed) return
|
await router.replace('/service-status')
|
||||||
statusRetrying.value = true
|
|
||||||
statusRetryTimer = setTimeout(() => {
|
|
||||||
void checkInitializationStatus()
|
|
||||||
}, INITIALIZATION_STATUS_RETRY_MS)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,11 +115,6 @@ onMounted(() => {
|
|||||||
form.value.api_key = generateApiKey()
|
form.value.api_key = generateApiKey()
|
||||||
void checkInitializationStatus()
|
void checkInitializationStatus()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
disposed = true
|
|
||||||
if (statusRetryTimer) clearTimeout(statusRetryTimer)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -197,7 +179,7 @@ onBeforeUnmount(() => {
|
|||||||
</VAlert>
|
</VAlert>
|
||||||
|
|
||||||
<VAlert v-if="checking" type="info" variant="tonal" class="mb-5">
|
<VAlert v-if="checking" type="info" variant="tonal" class="mb-5">
|
||||||
{{ statusMessage }}
|
{{ t('initialization.checking') }}
|
||||||
</VAlert>
|
</VAlert>
|
||||||
|
|
||||||
<form ref="formRef" class="initialize-form" novalidate @submit.prevent="submit">
|
<form ref="formRef" class="initialize-form" novalidate @submit.prevent="submit">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -123,16 +123,37 @@ describe('authentication route guard', () => {
|
|||||||
expect(routerMocks.setRequestNavigatingState).toHaveBeenLastCalledWith(false)
|
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'))
|
routerMocks.getInitializationState.mockRejectedValue(new Error('service starting'))
|
||||||
|
|
||||||
await runGuard(route({ fullPath: '/login', path: '/login' }))
|
await runGuard(route({ fullPath: '/login', path: '/login' }))
|
||||||
|
|
||||||
expect(routerMocks.next).toHaveBeenCalledOnce()
|
expect(routerMocks.next).toHaveBeenCalledOnce()
|
||||||
expect(routerMocks.next).toHaveBeenCalledWith('/initialize')
|
expect(routerMocks.next).toHaveBeenCalledWith('/service-status')
|
||||||
expect(routerMocks.setRequestNavigatingState).toHaveBeenLastCalledWith(false)
|
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 () => {
|
it('keeps initialized instances out of the initialization page', async () => {
|
||||||
await runGuard(route({ fullPath: '/initialize', path: '/initialize' }))
|
await runGuard(route({ fullPath: '/initialize', path: '/initialize' }))
|
||||||
|
|
||||||
|
|||||||
+12
-5
@@ -316,6 +316,10 @@ const router = createRouter({
|
|||||||
path: 'initialize',
|
path: 'initialize',
|
||||||
component: () => import('../pages/initialize.vue'),
|
component: () => import('../pages/initialize.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'service-status',
|
||||||
|
component: () => import('../pages/service-status.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
component: () => import('../pages/[...all].vue'),
|
component: () => import('../pages/[...all].vue'),
|
||||||
@@ -325,6 +329,7 @@ const router = createRouter({
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 解析普通页面或插件页面声明的权限约束,供导航守卫统一校验。 */
|
||||||
async function getRoutePermission(to: any): Promise<PermissionProtectedItem> {
|
async function getRoutePermission(to: any): Promise<PermissionProtectedItem> {
|
||||||
if (to.meta.permission) {
|
if (to.meta.permission) {
|
||||||
return {
|
return {
|
||||||
@@ -360,10 +365,10 @@ router.beforeEach(async (to: any, from: any, next: any) => {
|
|||||||
try {
|
try {
|
||||||
initialized = await getInitializationState()
|
initialized = await getInitializationState()
|
||||||
} catch {
|
} catch {
|
||||||
// 未确认初始化状态时必须进入初始化页,由页面持续等待后端就绪,避免启动竞态误放行登录页。
|
// 接口不可达只代表服务尚未就绪,不能据此推断实例需要初始化。
|
||||||
if (to.path !== '/initialize') {
|
if (to.path !== '/service-status') {
|
||||||
setRequestNavigatingState(false)
|
setRequestNavigatingState(false)
|
||||||
next('/initialize')
|
next('/service-status')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,7 +378,7 @@ router.beforeEach(async (to: any, from: any, next: any) => {
|
|||||||
next('/initialize')
|
next('/initialize')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (initialized === true && to.path === '/initialize') {
|
if (initialized === true && (to.path === '/initialize' || to.path === '/service-status')) {
|
||||||
setRequestNavigatingState(false)
|
setRequestNavigatingState(false)
|
||||||
next('/login')
|
next('/login')
|
||||||
return
|
return
|
||||||
@@ -382,7 +387,9 @@ router.beforeEach(async (to: any, from: any, next: any) => {
|
|||||||
// 认证 Store
|
// 认证 Store
|
||||||
const authStore = useAuthStore()
|
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
|
const isAuthenticated = authStore.token !== null
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !isAuthenticated) {
|
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||||
|
|||||||
Reference in New Issue
Block a user