feat: 重构应用加载流程和动画管理

- 新增 AppLoader 组件作为全局加载器
- 引入 Pinia 状态管理应用加载状态
- 创建 usePageScrollLock 组合式函数管理滚动锁定
- 重构首页动画流程,使用 Promise 链式调用
- 移除旧的 HomeLoader 组件
- 优化头部导航栏的入场动画触发逻辑
This commit is contained in:
2026-04-27 02:19:29 +08:00
parent 6b41e053a1
commit 044042fe7e
9 changed files with 393 additions and 407 deletions
+19 -218
View File
@@ -1,19 +1,18 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type Lenis from 'lenis'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { useUserStore } from '~/stores/user'
import HomeLoader from '~/components/home/HomeLoader.vue'
import { useAppStore } from '~/stores/app'
import { usePageScrollLock } from '~/composables/usePageScrollLock'
import HomeIntro from '~/components/home/HomeIntro.vue'
import HomeHero from '~/components/home/HomeHero.vue'
gsap.registerPlugin(ScrollTrigger)
const { t } = useI18n()
const isIntroActive = ref(true)
const userStore = useUserStore()
const appStore = useAppStore()
const isIntroActive = ref(true)
const demoUserName = computed(() => userStore.displayName)
const demoUserRole = computed(() => userStore.role)
@@ -28,6 +27,9 @@ useHead({
},
})
// 使用组合式函数管理 Lenis 滚动锁定
usePageScrollLock(isIntroActive)
// 首页的 SEO 和 Meta 优化,支持国际化响应式
useSeoMeta({
title: () => t('home.title'),
@@ -38,228 +40,27 @@ useSeoMeta({
twitterDescription: () => t('home.welcome'),
})
const pageRef = ref<HTMLElement | null>(null)
const homeLoaderRef = ref<InstanceType<typeof HomeLoader> | null>(null)
const homeIntroRef = ref<InstanceType<typeof HomeIntro> | null>(null)
const homeHeroRef = ref<InstanceType<typeof HomeHero> | null>(null)
let heroAnimationContext: gsap.Context | null = null
let removeLoadListener: (() => void) | null = null
let restorePageScroll: (() => void) | null = null
let hasIntroStarted = false
let loaderTween: gsap.core.Tween | null = null
let introTimeline: gsap.core.Timeline | null = null
let introStartTimer: ReturnType<typeof window.setTimeout> | null = null
const setHomeIntroActive = (active: boolean) => {
isIntroActive.value = active
document.documentElement.classList.toggle('is-home-intro-active', active)
}
onMounted(() => {
const pageElement = pageRef.value
const loaderElement = homeLoaderRef.value?.loaderRef
const loaderBarElement = homeLoaderRef.value?.loaderBarRef
const introElement = homeIntroRef.value?.introRef
const introTitleElement = homeIntroRef.value?.introTitleRef
const heroElement = homeHeroRef.value?.heroRef
if (
!pageElement
|| !loaderElement
|| !loaderBarElement
|| !introElement
|| !introTitleElement
|| !heroElement
) {
return
// 监听全局加载状态,加载完成后依次播放开场和首屏动画
watch(() => appStore.isAppLoading, async (isLoading) => {
if (!isLoading) {
await homeIntroRef.value?.play()
await homeHeroRef.value?.play()
// 动画播放完毕后解除页面滚动锁定
isIntroActive.value = false
}
const introCharElements = gsap.utils.toArray('.hero-intro-char', introElement)
const tmElement = introElement.querySelector('.hero-intro-tm')
if (introCharElements.length === 0 || !tmElement) {
return
}
const { $lenis } = useNuxtApp() as ReturnType<typeof useNuxtApp> & { $lenis?: Lenis }
heroAnimationContext = gsap.context(() => {
const playIntro = () => {
if (hasIntroStarted) {
return
}
hasIntroStarted = true
removeLoadListener?.()
removeLoadListener = null
if (introStartTimer) {
window.clearTimeout(introStartTimer)
introStartTimer = null
}
loaderTween?.kill()
loaderTween = null
introTimeline?.kill()
introTimeline = gsap.timeline({
defaults: { ease: 'power3.out' },
onComplete: () => {
gsap.set(introElement, { display: 'none' })
gsap.set(heroElement, { clearProps: 'transform' })
restorePageScroll?.()
restorePageScroll = null
ScrollTrigger.refresh()
introTimeline = null
},
})
introTimeline
.to(loaderBarElement, {
scaleX: 1,
duration: 0.25,
ease: 'power2.out',
})
.to(loaderElement, {
yPercent: -100,
duration: 0.75,
ease: 'power4.inOut',
})
.set(loaderElement, {
display: 'none',
})
.set(introElement, {
display: 'grid',
yPercent: 0,
autoAlpha: 1,
})
.set(introTitleElement, {
y: 0,
autoAlpha: 1,
})
.set(tmElement, {
autoAlpha: 0,
})
.fromTo(introCharElements, {
yPercent: 110,
y: 0,
}, {
yPercent: 0,
y: 0,
duration: 0.75,
stagger: 0.055,
})
.to(tmElement, {
autoAlpha: 1,
duration: 0.4,
ease: 'power2.out',
})
.addLabel('introOut', '+=0.35')
.to(introTitleElement, {
autoAlpha: 0,
duration: 1.05,
ease: 'power4.inOut',
}, 'introOut')
.to(introElement, {
yPercent: -100,
duration: 1.05,
ease: 'power4.inOut',
}, 'introOut')
.to(heroElement, {
yPercent: 0,
duration: 1.05,
ease: 'power4.inOut',
onUpdate: function() {
// 当 Hero (白色背景) 的 yPercent 运动到 40% 的时候(即大部分背景已经上浮到了屏幕内,还没完全贴顶),触发导航栏出场
const progress = this.progress()
// 使用一个标记防止重复触发
if (progress > 0.6 && !this._headerTriggered) {
this._headerTriggered = true
window.dispatchEvent(new Event('hero-intro-done'))
}
},
onStart: function() {
this._headerTriggered = false
}
}, 'introOut')
}
const releasePageScroll = () => {
setHomeIntroActive(false)
$lenis?.start()
}
// 首屏入场期间只通过 html class 锁滚动,避免 class 和 inline overflow 两套状态互相覆盖。
setHomeIntroActive(true)
$lenis?.scrollTo(0, { immediate: true })
$lenis?.stop()
restorePageScroll = releasePageScroll
// Loading 层先接管首屏;真正的品牌入场等 window.load 完成后再开始。
gsap.set(loaderElement, { display: 'grid', yPercent: 0, autoAlpha: 1 })
gsap.set(loaderBarElement, { scaleX: 0.18, transformOrigin: 'left center' })
gsap.set(introElement, { display: 'grid', yPercent: 0, autoAlpha: 0 })
gsap.set(introTitleElement, { y: 0, autoAlpha: 1 })
// 强制清除 CSS 中的 translateY 像素值,避免被 GSAP 解析为固定 y 像素导致一直不可见
gsap.set(introCharElements, { y: 0, yPercent: 110 })
gsap.set(tmElement, { autoAlpha: 0 })
gsap.set(heroElement, { yPercent: 100 })
loaderTween = gsap.to(loaderBarElement, {
scaleX: 0.82,
duration: 1.2,
ease: 'power1.inOut',
repeat: -1,
yoyo: true,
})
const loaderStartedAt = window.performance.now()
const minLoaderDuration = 1200
const queueIntro = () => {
const remainingTime = Math.max(0, minLoaderDuration - (window.performance.now() - loaderStartedAt))
introStartTimer = window.setTimeout(() => {
window.requestAnimationFrame(playIntro)
}, remainingTime)
}
if (document.readyState === 'complete') {
queueIntro()
return
}
window.addEventListener('load', queueIntro, { once: true })
removeLoadListener = () => window.removeEventListener('load', queueIntro)
}, pageElement)
})
onBeforeUnmount(() => {
setHomeIntroActive(false)
hasIntroStarted = false
if (introStartTimer) {
window.clearTimeout(introStartTimer)
introStartTimer = null
}
loaderTween?.kill()
loaderTween = null
introTimeline?.kill()
introTimeline = null
removeLoadListener?.()
removeLoadListener = null
restorePageScroll?.()
restorePageScroll = null
heroAnimationContext?.revert()
heroAnimationContext = null
})
}, { immediate: true })
</script>
<template>
<main
ref="pageRef"
id="top"
:data-demo-user="demoUserName"
:data-demo-user-role="demoUserRole"
>
<HomeLoader ref="homeLoaderRef" />
<HomeIntro ref="homeIntroRef" />
<HomeHero ref="homeHeroRef" />