1bc881994f
将首页的加载动画、介绍动画和英雄区域拆分为独立的组件文件 移除index.vue中冗余的样式和逻辑,通过组件引用方式重构 优化代码组织结构,提高可维护性
283 lines
8.2 KiB
Vue
283 lines
8.2 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref } 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 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 demoUserName = computed(() => userStore.displayName)
|
|
const demoUserRole = computed(() => userStore.role)
|
|
|
|
if (!userStore.isLoggedIn) {
|
|
userStore.loadDemoUser()
|
|
}
|
|
|
|
// 让首屏入场的锁滚动在 SSR/首帧就生效,避免等 onMounted 才隐藏滚动条。
|
|
useHead({
|
|
htmlAttrs: {
|
|
class: computed(() => (isIntroActive.value ? 'is-home-intro-active' : undefined)),
|
|
},
|
|
})
|
|
|
|
// 首页的 SEO 和 Meta 优化,支持国际化响应式
|
|
useSeoMeta({
|
|
title: () => t('home.title'),
|
|
description: () => t('home.welcome'),
|
|
ogTitle: () => `${t('home.title')} - ${t('app.name')}`,
|
|
ogDescription: () => t('home.welcome'),
|
|
twitterTitle: () => `${t('home.title')} - ${t('app.name')}`,
|
|
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
|
|
}
|
|
|
|
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
|
|
})
|
|
</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" />
|
|
|
|
<section class="next-section">
|
|
<h2>Next Section</h2>
|
|
</section>
|
|
<section id="work" class="work-section" data-cursor-label="Read more">
|
|
<p>{{ t('home.welcome') }}</p>
|
|
</section>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.next-section,
|
|
.work-section {
|
|
min-height: 100svh;
|
|
display: grid;
|
|
place-items: center;
|
|
}
|
|
</style>
|