mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(startup): skip full animation after first load
This commit is contained in:
1 parent
967bfe1084
commit
52b80960e4
4 files changed
+108
-5
No files matched your search
+18
-1
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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% {
|
||||
|
||||
Reference in new issue
Block a user