diff --git a/src/App.vue b/src/App.vue index 3bc571c2..c2098a0d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -30,6 +30,7 @@ import { initializeAppRuntime, initializeProgressiveAppRuntime } from '@/bootstr import { markStartupPhase, markStartupPhaseAfterPaint } from '@/bootstrap/startup-performance' import { resolveStartupPresentation } from '@/bootstrap/startup-presentation' import { STARTUP_PAGE_REVEAL_READY_KEY } from '@/bootstrap/startup-page-reveal' +import { claimFullStartupPresentation } from '@/bootstrap/startup-playback' const LockScreen = IS_ELECTRON ? defineAsyncComponent(() => import('@/components/lock-screen/LockScreen.vue')) : null const DataDirCleanupNotice = defineAsyncComponent(() => import('@/components/common/DataDirCleanupNotice.vue')) @@ -60,6 +61,7 @@ const { isBootstrapMaskVisible, isApplicationInteractive, markLockScreenReady, s const isLoginPage = computed(() => PLATFORM_CAPABILITIES.requiresAuth && route.name === 'login') const pageTransitionKey = computed(() => resolvePageTransitionKey(route)) const isRuntimeReady = ref(false) +const shouldPlayFullStartup = ref(true) const isStartupAnimationComplete = ref(false) const isStartupPageEntering = ref(false) const isStartupCoverHidden = ref(false) @@ -95,6 +97,16 @@ provide(STARTUP_PAGE_REVEAL_READY_KEY, readonly(isStartupPageEntering)) let initInProgress = false let unlistenPullResult: (() => void) | null = null let cancelNonCriticalUiPrefetch: (() => void) | null = null +let startupPlaybackPrepared = false + +function prepareStartupPlayback(): void { + if (startupPlaybackPrepared) return + startupPlaybackPrepared = true + shouldPlayFullStartup.value = claimFullStartupPresentation() + isStartupAnimationComplete.value = !shouldPlayFullStartup.value +} + +if (!isLoginPage.value) prepareStartupPlayback() function scheduleNonCriticalUiPrefetch() { if (cancelNonCriticalUiPrefetch) return @@ -270,7 +282,10 @@ function handleGlobalKeydown(e: KeyboardEvent) { // After login success, route changes from login → app; trigger init watch(isLoginPage, (isLogin) => { - if (!isLogin) initializeApp() + if (!isLogin) { + prepareStartupPlayback() + initializeApp() + } }) watch( @@ -343,6 +358,7 @@ onMounted(async () => { if (isLoginPage.value) return + prepareStartupPlayback() await initializeApp() }) @@ -411,6 +427,7 @@ onUnmounted(() => { diff --git a/src/bootstrap/startup-playback.test.ts b/src/bootstrap/startup-playback.test.ts new file mode 100644 index 00000000..9ed3cace --- /dev/null +++ b/src/bootstrap/startup-playback.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { claimFullStartupPresentation, STARTUP_PRESENTATION_SESSION_KEY } from './startup-playback' + +function createMemoryStorage(): Pick { + const values = new Map() + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => void values.set(key, value), + } +} + +test('plays the full startup presentation only once per browsing session', () => { + const storage = createMemoryStorage() + + assert.equal(claimFullStartupPresentation(storage), true) + assert.equal(storage.getItem(STARTUP_PRESENTATION_SESSION_KEY), '1') + assert.equal(claimFullStartupPresentation(storage), false) +}) + +test('plays the full startup presentation again in a new browsing session', () => { + assert.equal(claimFullStartupPresentation(createMemoryStorage()), true) + assert.equal(claimFullStartupPresentation(createMemoryStorage()), true) +}) + +test('keeps the full presentation when session storage is unavailable', () => { + const unavailableStorage: Pick = { + getItem: () => { + throw new Error('storage unavailable') + }, + setItem: () => { + throw new Error('storage unavailable') + }, + } + + assert.equal(claimFullStartupPresentation(unavailableStorage), true) +}) diff --git a/src/bootstrap/startup-playback.ts b/src/bootstrap/startup-playback.ts new file mode 100644 index 00000000..31dfca79 --- /dev/null +++ b/src/bootstrap/startup-playback.ts @@ -0,0 +1,18 @@ +export const STARTUP_PRESENTATION_SESSION_KEY = 'chatlab:startup-presentation-shown:v1' + +type StartupPlaybackStorage = Pick + +/** + * 为当前窗口或标签页认领一次完整启动展示。 + * sessionStorage 在刷新后保留、关闭浏览上下文后清除,正好对应一次应用浏览会话。 + */ +export function claimFullStartupPresentation(storage: StartupPlaybackStorage = sessionStorage): boolean { + try { + if (storage.getItem(STARTUP_PRESENTATION_SESSION_KEY) !== null) return false + storage.setItem(STARTUP_PRESENTATION_SESSION_KEY, '1') + return true + } catch { + // 存储不可用时保留完整启动,避免异常环境绕过既有呈现状态机。 + return true + } +} diff --git a/src/components/UI/StartupLoading.vue b/src/components/UI/StartupLoading.vue index 2771a21b..6e12b149 100644 --- a/src/components/UI/StartupLoading.vue +++ b/src/components/UI/StartupLoading.vue @@ -3,9 +3,16 @@ import { onBeforeUnmount, onMounted } from 'vue' import logoSvg from '@/assets/images/logo.svg' import { STARTUP_ANIMATION_DURATION_MS } from '@/bootstrap/startup-presentation' -defineProps<{ - waiting?: boolean -}>() +const props = withDefaults( + defineProps<{ + waiting?: boolean + animated?: boolean + }>(), + { + waiting: false, + animated: true, + } +) const emit = defineEmits<{ complete: [] @@ -29,7 +36,7 @@ function completeAnimation(): void { } onMounted(() => { - if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { + if (!props.animated || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { queueMicrotask(completeAnimation) return } @@ -46,6 +53,7 @@ onBeforeUnmount(() => {