diff --git a/src/App.vue b/src/App.vue index c2098a0d..776fcac0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -158,6 +158,8 @@ async function initializeApp() { await settingsStore.initLocale() markStartupPhase('locale-settled') }, + deferAfterPresentationError: () => + PLATFORM_CAPABILITIES.requiresAuth && authStore.requiresAuth && !authStore.isAuthenticated, initializeBackground: [ { name: 'preferences', @@ -196,6 +198,8 @@ async function initializeApp() { ], listenForPullResults: () => apiServerStore.listenPullResult(), }) + // 401 会先切换到登录页;不要把这次未认证尝试标记为就绪,登录后由 route watcher 完整重试。 + if (result.deferred) return unlistenPullResult ??= result.stopListeningForPullResults presentationWarning.value = result.presentationError !== null markStartupPhase('runtime-ready') @@ -345,8 +349,7 @@ onMounted(async () => { const on401 = () => { if (redirectingTo401 || router.currentRoute.value.name === 'login') return redirectingTo401 = true - authStore.markRequiresAuth() - authStore.logout() + authStore.requireLogin() const currentPath = router.currentRoute.value.fullPath const redirect = currentPath.startsWith('/login') ? '/' : currentPath router.push({ name: 'login', query: { redirect } }).finally(() => { diff --git a/src/bootstrap/app-initialization.test.ts b/src/bootstrap/app-initialization.test.ts index 41f89b61..123dcbcb 100644 --- a/src/bootstrap/app-initialization.test.ts +++ b/src/bootstrap/app-initialization.test.ts @@ -151,6 +151,7 @@ test('reveals the shell after presentation state and runs independent startup wo ]) assert.equal(result.presentationError, null) assert.equal(result.stopListeningForPullResults, stop) + assert.equal(result.deferred, false) releaseTasks.forEach((release) => release()) assert.deepEqual(await result.background, []) @@ -176,5 +177,49 @@ test('uses the presentation fallback on timeout and reports background failures assert.match(String(result.presentationError), /timed out after 5ms/) assert.equal(applyLatePresentation, false) + assert.equal(result.deferred, false) assert.deepEqual(await result.background, [{ name: 'sessions', error: failure }]) }) + +test('defers one-shot background work until authentication can recover presentation loading', async () => { + const calls: string[] = [] + let authenticated = false + + const initialize = () => + initializeProgressiveAppRuntime({ + initializeServices: async () => void calls.push('services'), + loadPresentation: async () => { + calls.push('presentation') + if (!authenticated) throw new Error('HTTP 401') + return { locale: 'zh-CN' } + }, + applyPresentation: () => void calls.push('apply-presentation'), + applyPresentationFallback: () => void calls.push('fallback'), + deferAfterPresentationError: () => !authenticated, + initializeBackground: [ + { name: 'preferences', run: async () => void calls.push('preferences') }, + { name: 'llm', run: async () => void calls.push('llm') }, + { name: 'sessions', run: async () => void calls.push('sessions') }, + ], + }) + + const deferred = await initialize() + assert.equal(deferred.deferred, true) + assert.deepEqual(await deferred.background, []) + assert.deepEqual(calls, ['services', 'presentation']) + + authenticated = true + const recovered = await initialize() + assert.equal(recovered.deferred, false) + assert.deepEqual(await recovered.background, []) + assert.deepEqual(calls, [ + 'services', + 'presentation', + 'services', + 'presentation', + 'apply-presentation', + 'preferences', + 'llm', + 'sessions', + ]) +}) diff --git a/src/bootstrap/app-initialization.ts b/src/bootstrap/app-initialization.ts index e9afa1bc..cee4bd2f 100644 --- a/src/bootstrap/app-initialization.ts +++ b/src/bootstrap/app-initialization.ts @@ -29,6 +29,7 @@ export interface ProgressiveInitializationPorts { loadPresentation(): Promise applyPresentation(presentation: TPresentation): Promise | void applyPresentationFallback(): Promise | void + deferAfterPresentationError?: (error: unknown) => boolean initializeBackground: Array<{ name: string run(): Promise @@ -46,6 +47,7 @@ export interface ProgressiveInitializationResult { presentationError: unknown | null background: Promise stopListeningForPullResults: (() => void) | null + deferred: boolean } class PresentationTimeoutError extends Error { @@ -84,6 +86,16 @@ export async function initializeProgressiveAppRuntime( await ports.applyPresentation(presentation) } catch (error) { presentationError = error + // 认证等可恢复门禁失败时,不能启动会写入一次性状态的后台任务; + // 调用方完成外部恢复后,应重新执行整条初始化链路。 + if (ports.deferAfterPresentationError?.(error)) { + return { + presentationError, + background: Promise.resolve([]), + stopListeningForPullResults: null, + deferred: true, + } + } await ports.applyPresentationFallback() } @@ -94,7 +106,7 @@ export async function initializeProgressiveAppRuntime( ) ) - return { presentationError, background, stopListeningForPullResults } + return { presentationError, background, stopListeningForPullResults, deferred: false } } export async function initializeAppRuntime(ports: AppInitializationPorts): Promise { diff --git a/src/stores/auth.test.ts b/src/stores/auth.test.ts new file mode 100644 index 00000000..5f87839c --- /dev/null +++ b/src/stores/auth.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createPinia, setActivePinia } from 'pinia' +import { useAuthStore } from './auth' + +function createMemoryStorage(): Storage { + const values = new Map() + return { + get length() { + return values.size + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => void values.set(key, value), + } +} + +test('keeps authentication required after rejected credentials and allows a later login', () => { + Object.defineProperties(globalThis, { + localStorage: { configurable: true, value: createMemoryStorage() }, + sessionStorage: { configurable: true, value: createMemoryStorage() }, + }) + setActivePinia(createPinia()) + + const auth = useAuthStore() + auth.login('expired-token', false) + auth.requireLogin() + + assert.equal(auth.token, '') + assert.equal(auth.isAuthenticated, false) + assert.equal(auth.requiresAuth, true) + assert.equal(sessionStorage.getItem('chatlab_auth_token'), null) + + auth.login('valid-token', false) + assert.equal(auth.isAuthenticated, true) + assert.equal(auth.requiresAuth, true) +}) diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 719ced01..1235ba25 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -43,14 +43,24 @@ export const useAuthStore = defineStore('auth', () => { } } - function logout() { + function clearCredentials() { token.value = '' rememberDevice.value = false - requiresAuth.value = false sessionStorage.removeItem(TOKEN_KEY) localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(REMEMBER_KEY) } - return { token, requiresAuth, rememberDevice, isAuthenticated, login, logout, markRequiresAuth } + function logout() { + clearCredentials() + requiresAuth.value = false + } + + /** Clear rejected credentials while preserving the server's authentication requirement. */ + function requireLogin() { + clearCredentials() + requiresAuth.value = true + } + + return { token, requiresAuth, rememberDevice, isAuthenticated, login, logout, requireLogin, markRequiresAuth } })