Files
arcticnewone/app/pages/index.vue
T
virtheart 1bc881994f refactor(home): 将首页组件拆分为独立模块并优化代码结构
将首页的加载动画、介绍动画和英雄区域拆分为独立的组件文件
移除index.vue中冗余的样式和逻辑,通过组件引用方式重构
优化代码组织结构,提高可维护性
2026-04-26 23:07:37 +08:00

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>