feat(startup): skip full animation after first load

This commit is contained in:
digua committed 2026-08-08 21:47:47 +08:00
1 parent 967bfe1084
commit 52b80960e4
4 files changed
+108 -5

No files matched your search

+18 -1
View File
@@ -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(() => {
</div>
<StartupLoading
v-else
:animated="shouldPlayFullStartup"
:waiting="startupPresentation.showWaitingIndicator"
@complete="handleStartupAnimationComplete"
/>
+37
View File
@@ -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<Storage, 'getItem' | 'setItem'> {
const values = new Map<string, string>()
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<Storage, 'getItem' | 'setItem'> = {
getItem: () => {
throw new Error('storage unavailable')
},
setItem: () => {
throw new Error('storage unavailable')
},
}
assert.equal(claimFullStartupPresentation(unavailableStorage), true)
})
+18
View File
@@ -0,0 +1,18 @@
export const STARTUP_PRESENTATION_SESSION_KEY = 'chatlab:startup-presentation-shown:v1'
type StartupPlaybackStorage = Pick<Storage, 'getItem' | 'setItem'>
/**
* 为当前窗口或标签页认领一次完整启动展示。
* 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
}
}
+35 -4
View File
@@ -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(() => {
<template>
<div
class="startup-loader flex h-full w-full items-center justify-center overflow-hidden"
:class="{ 'startup-loader--static': !animated }"
:style="animationStyle"
role="status"
aria-live="polite"
@@ -151,6 +159,29 @@ onBeforeUnmount(() => {
animation: startup-loader-waiting 0.8s linear infinite;
}
.startup-loader--static .startup-loader__glow,
.startup-loader--static .startup-loader__brand,
.startup-loader--static .startup-loader__logo-wrap::before,
.startup-loader--static .startup-loader__name {
animation: none;
}
.startup-loader--static .startup-loader__glow {
opacity: 0.2;
transform: translateX(35%);
}
.startup-loader--static .startup-loader__brand,
.startup-loader--static .startup-loader__name {
opacity: 1;
transform: none;
}
.startup-loader--static .startup-loader__logo-wrap::before {
opacity: 0.62;
transform: scale(1);
}
@keyframes startup-loader-logo {
0%,
10% {