feat: 重构应用加载流程和动画管理
- 新增 AppLoader 组件作为全局加载器 - 引入 Pinia 状态管理应用加载状态 - 创建 usePageScrollLock 组合式函数管理滚动锁定 - 重构首页动画流程,使用 Promise 链式调用 - 移除旧的 HomeLoader 组件 - 优化头部导航栏的入场动画触发逻辑
This commit is contained in:
+19
-218
@@ -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" />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user