Files
arcticnewone/app/pages/index.vue
T
virtheart 78439ba069 feat(header): 重构导航栏动画逻辑并优化入场效果
将导航栏动画控制从首页迁移到组件内部,实现更流畅的从底部飞入效果
添加自定义事件监听和保底机制确保导航栏正常显示
优化导航链接样式和交互效果
2026-04-26 22:57:41 +08:00

614 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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'
gsap.registerPlugin(ScrollTrigger)
const { t } = useI18n()
const introTitle = 'ArcticNewOne'
const introLetters = introTitle.split('')
const heroPinDistance = 1200
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'),
})
// 背景图是装饰层,左右两列共用同一套模板,只通过 side 区分位置和速度。
const heroGalleryColumns = [
{
side: 'left',
images: [
'https://picsum.photos/id/1018/900/1200',
'https://picsum.photos/id/1015/900/1200',
'https://picsum.photos/id/1024/900/1200',
'https://picsum.photos/id/1039/900/1200',
],
},
{
side: 'right',
images: [
'https://picsum.photos/id/1043/900/1200',
'https://picsum.photos/id/1050/900/1200',
'https://picsum.photos/id/1067/900/1200',
'https://picsum.photos/id/1074/900/1200',
],
},
] as const
const pageRef = ref<HTMLElement | null>(null)
const loaderRef = ref<HTMLElement | null>(null)
const loaderBarRef = ref<HTMLElement | null>(null)
const introRef = ref<HTMLElement | null>(null)
const introTitleRef = ref<HTMLElement | null>(null)
const heroStageRef = ref<HTMLElement | null>(null)
const heroRef = ref<HTMLElement | null>(null)
const heroPanelRef = ref<HTMLElement | null>(null)
let heroAnimationContext: gsap.Context | null = null
let removeLoadListener: (() => void) | null = null
let restorePageScroll: (() => void) | null = null
let cleanupHeroScroll: (() => 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 = loaderRef.value
const loaderBarElement = loaderBarRef.value
const introElement = introRef.value
const introTitleElement = introTitleRef.value
const heroStageElement = heroStageRef.value
const heroElement = heroRef.value
const heroPanelElement = heroPanelRef.value
if (
!pageElement
|| !loaderElement
|| !loaderBarElement
|| !introElement
|| !introTitleElement
|| !heroStageElement
|| !heroElement
|| !heroPanelElement
) {
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 }
const topHeader = document.querySelector<HTMLElement>('.site-header-top')
heroAnimationContext = gsap.context(() => {
const pinEndScale = 0.65
const finalScale = 0.18
const getExitDistance = () => window.innerHeight
const setupHeroScroll = () => {
cleanupHeroScroll?.()
gsap.set(heroPanelElement, {
scale: 1,
force3D: true,
transformOrigin: 'center center',
})
let renderedScale = -1
const setHeroPanelScale = (scale: number) => {
if (Math.abs(renderedScale - scale) < 0.001) {
return
}
renderedScale = scale
gsap.set(heroPanelElement, { scale })
}
const updateHeroPanelScale = () => {
const exitDistance = getExitDistance()
const totalDistance = heroPinDistance + exitDistance
// Lenis 存在时只读 Lenis 的平滑滚动值,避免向上滚时 window.scrollY 和 Lenis 值互相打架。
const currentScroll = $lenis ? $lenis.scroll : window.scrollY
const currentDistance = gsap.utils.clamp(0, totalDistance, currentScroll - heroStageElement.offsetTop)
if (currentDistance <= heroPinDistance) {
setHeroPanelScale(gsap.utils.interpolate(1, pinEndScale, currentDistance / heroPinDistance))
return
}
setHeroPanelScale(
gsap.utils.interpolate(
pinEndScale,
finalScale,
(currentDistance - heroPinDistance) / exitDistance,
),
)
}
// CSS sticky 负责固定;ticker 每帧读取当前滚动距离,避免依赖 scroll/ScrollTrigger 事件链。
updateHeroPanelScale()
gsap.ticker.add(updateHeroPanelScale)
cleanupHeroScroll = () => {
gsap.ticker.remove(updateHeroPanelScale)
}
}
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')
// 取消原本通过 index.vue 直接控制导航栏 yPercent 的行为
// 因为我们要让 Header 自己执行从屏幕底部(y: 100vh)飞入的高级动画
// if (topHeader) {
// introTimeline.to(topHeader, {
// yPercent: 0,
// autoAlpha: 1,
// duration: 0.55,
// ease: 'power3.out',
// }, 'introOut+=0.45')
// }
// 在动画即将完成或者 Hero 入场时触发自定义事件,通知导航栏执行自身的从下往上飞入动画
// 将触发时机再延后,等白色背景开始渲染的瞬间就触发
// introTimeline.add(() => {
// window.dispatchEvent(new Event('hero-intro-done'))
// }, '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 })
setupHeroScroll()
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
cleanupHeroScroll?.()
cleanupHeroScroll = 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"
>
<div ref="loaderRef" class="page-loader" aria-hidden="true" v-once>
<div class="page-loader-content">
<p>Loading</p>
<span class="page-loader-line">
<span ref="loaderBarRef" class="page-loader-line-inner" />
</span>
</div>
</div>
<div ref="introRef" class="hero-intro" aria-hidden="true">
<h1 ref="introTitleRef" class="hero-intro-title" :aria-label="introTitle" v-once>
<span class="hero-intro-word">
<span
v-for="(letter, index) in introLetters"
:key="`${letter}-${index}`"
class="hero-intro-char-wrap"
>
<span class="hero-intro-char">{{ letter }}</span>
</span>
<span class="hero-intro-tm">TM</span>
</span>
</h1>
</div>
<section
ref="heroStageRef"
class="hero-stage"
:style="{ '--hero-pin-distance': `${heroPinDistance}px` }"
>
<section ref="heroRef" class="hero-section" v-once>
<div class="hero-backdrop" aria-hidden="true">
<div
v-for="column in heroGalleryColumns"
:key="column.side"
class="hero-gallery"
:class="`hero-gallery-${column.side}`"
>
<!-- 复制两份图片组配合 translateY(-50%) 做无缝循环 -->
<div
class="hero-gallery-track"
:class="{ 'hero-gallery-track-slow': column.side === 'right' }"
>
<div v-for="copy in 2" :key="`${column.side}-copy-${copy}`" class="hero-gallery-group">
<img
v-for="image in column.images"
:key="`${column.side}-${copy}-${image}`"
class="hero-gallery-image"
:src="image"
alt=""
decoding="async"
loading="eager"
/>
</div>
</div>
</div>
</div>
<div ref="heroPanelRef" class="hero-panel">
<p>Hero Section</p>
</div>
</section>
</section>
<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>
.page-loader {
position: fixed;
inset: 0;
z-index: 90;
display: grid;
place-items: center;
color: #fff;
background: #000;
will-change: transform;
}
.page-loader-content {
width: min(220px, calc(100vw - 48px));
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.page-loader-content p {
margin: 0 0 14px;
}
.page-loader-line {
display: block;
width: 100%;
height: 1px;
overflow: hidden;
background: rgb(255 255 255 / 20%);
}
.page-loader-line-inner {
display: block;
width: 100%;
height: 100%;
background: #fff;
transform: scaleX(0);
transform-origin: left center;
}
.hero-intro {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
overflow: hidden;
color: #fff;
background: #000;
opacity: 0;
visibility: hidden;
will-change: transform;
}
.hero-intro-title {
display: flex;
margin: 0;
font-size: clamp(36px, 6.2vw, 104px);
font-weight: 700;
line-height: 0.9;
letter-spacing: -0.07em;
white-space: nowrap;
}
.hero-intro-word {
position: relative;
display: inline-flex;
}
.hero-intro-char-wrap,
.hero-intro-char {
display: inline-block;
}
.hero-intro-char-wrap {
position: relative;
overflow: hidden;
}
.hero-intro-char {
will-change: transform;
}
.hero-intro-tm {
position: absolute;
left: calc(100% + 0.12em);
top: 0.08em;
font-size: 0.13em;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1;
opacity: 0;
visibility: hidden;
}
.hero-stage {
position: relative;
height: calc(100svh + var(--hero-pin-distance));
background: #000;
}
.hero-section {
position: sticky;
top: 0;
height: 100svh;
overflow: hidden;
background: #111;
}
.hero-backdrop {
position: absolute;
inset: 0;
overflow: hidden;
background: #000;
}
.hero-gallery {
position: absolute;
top: -40px;
bottom: -40px;
width: 49vw;
overflow: hidden;
}
.hero-gallery-left {
left: 0;
}
.hero-gallery-right {
right: 0;
}
.hero-gallery-track {
will-change: transform;
animation: hero-gallery-scroll 28s linear infinite;
}
.hero-gallery-track-slow {
animation-duration: 42s;
}
.hero-gallery-group {
display: grid;
gap: 36px;
padding-bottom: 36px;
}
.hero-gallery-image {
display: block;
width: 100%;
height: clamp(260px, 42vh, 430px);
border-radius: 0;
object-fit: cover;
}
@keyframes hero-gallery-scroll {
to {
transform: translateY(-50%);
}
}
.hero-panel {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #fff;
font-size: 48px;
will-change: transform;
}
.next-section,
.work-section {
min-height: 100svh;
display: grid;
place-items: center;
}
</style>