Files
arcticnewone/app/components/layouts/ArcticNewOneHeader.vue
T
virtheart 52112284cb fix(动画): 修复首页动画重复播放和顶部导航显示问题
修复首页动画在特定条件下可能重复播放的问题,通过添加 hasPlayedIntro 状态控制
优化顶部导航的显示逻辑,增加即时显示和隐藏功能,并清理事件监听和定时器
2026-04-27 17:01:39 +08:00

290 lines
9.1 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 { onBeforeUnmount, onMounted, ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { useAppStore } from '~/stores/app'
import AppLogo from '~/components/common/AppLogo.vue'
gsap.registerPlugin(ScrollTrigger)
// gsap.registerPlugin(Observer)
const { t } = useI18n()
const route = useRoute()
const appStore = useAppStore()
// 导航菜单数据配置
const navItems = computed<Array<{ name: string, path: string }>>(() => [
{ name: t('nav.home'), path: '/' },
{ name: t('nav.studio'), path: '/studio' },
{ name: t('nav.work'), path: '/work' },
{ name: t('nav.news'), path: '/news' },
{ name: t('nav.contact'), path: '/contact' }
])
const topHeaderRef = ref<HTMLElement | null>(null)
const bottomHeaderRef = ref<HTMLElement | null>(null)
let topHeaderTween: gsap.core.Tween | null = null
let bottomHeaderScrollTrigger: ReturnType<typeof ScrollTrigger.create> | null = null
let isBottomHeaderVisible = false
let fallbackTimer: ReturnType<typeof setTimeout> | null = null
const setBottomHeaderVisible = (visible: boolean) => {
if (!bottomHeaderRef.value || isBottomHeaderVisible === visible) {
return
}
isBottomHeaderVisible = visible
gsap.to(bottomHeaderRef.value, {
yPercent: visible ? 0 : 150,
autoAlpha: visible ? 1 : 0,
duration: 0.45,
ease: 'power2.out',
overwrite: 'auto',
force3D: true,
})
}
// 定义一个供外部或延迟调用的入场动画方法
const playHeaderIntro = () => {
if (!topHeaderRef.value) return
// 如果已经触发过,清理可能存在的保底定时器
if (fallbackTimer) {
clearTimeout(fallbackTimer)
fallbackTimer = null
}
gsap.fromTo(topHeaderRef.value,
{
y: '100vh',
opacity: 0 // 恢复初始透明度为0,让它在屏幕最底部时是隐形的
},
{
y: 0,
opacity: 1, // 伴随飞入的过程逐渐完全显示
duration: 1.4,
ease: 'power4.out',
delay: 0,
overwrite: 'auto',
clearProps: 'transform',
onComplete: () => {
// 动画完成时,强制把 style 的 opacity 移除,防止被内联样式锁死
if (topHeaderRef.value) {
topHeaderRef.value.style.opacity = ''
}
// 进场动画完毕后,再将滚动消失动画挂载到 ScrollTrigger
if (topHeaderTween) {
topHeaderTween.scrollTrigger?.kill()
ScrollTrigger.create({
animation: topHeaderTween,
start: 0,
end: 200,
scrub: true,
})
ScrollTrigger.refresh()
}
}
})
}
// 监听自定义事件,当首页的 Hero 动画完成或到达特定阶段时触发导航栏入场
const handleHeroReady = () => {
playHeaderIntro()
}
const cleanupHeroReadyHook = () => {
if (fallbackTimer) {
clearTimeout(fallbackTimer)
fallbackTimer = null
}
if (typeof window !== 'undefined') {
window.removeEventListener('hero-intro-done', handleHeroReady)
}
}
const hideTopHeader = () => {
if (!topHeaderRef.value) return
gsap.set(topHeaderRef.value, { y: '100vh', opacity: 0 })
}
const showTopHeaderInstant = () => {
if (!topHeaderRef.value) return
gsap.set(topHeaderRef.value, { y: 0, opacity: 1 })
topHeaderRef.value.style.opacity = ''
if (topHeaderTween) {
topHeaderTween.scrollTrigger?.kill()
ScrollTrigger.create({
animation: topHeaderTween,
start: 0,
end: 200,
scrub: true,
})
ScrollTrigger.refresh()
}
}
onMounted(() => {
// 顶部导航在页面刚开始滚动的前 200px 内逐渐上移并隐藏。
topHeaderTween = gsap.to(topHeaderRef.value, {
yPercent: -140,
autoAlpha: 0,
ease: 'none',
paused: true, // 初始不执行,等进场动画完毕再绑定到 ScrollTrigger
})
// 底部导航默认藏在视口下方,只有滚动到深处并向上滚时才出现。
gsap.set(bottomHeaderRef.value, {
xPercent: -50,
yPercent: 150,
autoAlpha: 0,
force3D: true
})
bottomHeaderScrollTrigger = ScrollTrigger.create({
start: 0,
end: 'max',
onUpdate: (self) => {
const scrollY = self.scroll()
// 深度超过 1000px 后向上滚,显示底部导航。
if (scrollY > 1000 && self.direction === -1) {
setBottomHeaderVisible(true)
return
}
// 回到 1000px 以内,或继续向下滚时,底部导航收回。
if (scrollY <= 1000 || self.direction === 1) {
setBottomHeaderVisible(false)
}
},
})
ScrollTrigger.refresh()
if (!appStore.isAppLoading && route.path !== '/') {
showTopHeaderInstant()
}
})
watch([() => appStore.isAppLoading, () => route.path], ([isLoading, path]) => {
if (isLoading || typeof window === 'undefined') return
cleanupHeroReadyHook()
if (path === '/') {
hideTopHeader()
window.addEventListener('hero-intro-done', handleHeroReady, { once: true })
fallbackTimer = setTimeout(() => {
window.removeEventListener('hero-intro-done', handleHeroReady)
playHeaderIntro()
}, 5000)
return
}
showTopHeaderInstant()
}, { immediate: true })
onBeforeUnmount(() => {
cleanupHeroReadyHook()
topHeaderTween?.scrollTrigger?.kill()
topHeaderTween?.kill()
bottomHeaderScrollTrigger?.kill()
topHeaderTween = null
bottomHeaderScrollTrigger = null
isBottomHeaderVisible = false
if (topHeaderRef.value && bottomHeaderRef.value) {
gsap.killTweensOf([topHeaderRef.value, bottomHeaderRef.value])
}
})
</script>
<template>
<header ref="topHeaderRef"
class="site-header-top fixed left-0 right-0 top-0 z-50 mx-auto flex h-[100px] w-page-container max-w-[calc(100%-2rem)] items-center justify-between px-4 py-5"
style="opacity: 0; transform: translateY(100vh);">
<!-- 左侧Logo 标题 -->
<AppLogo />
<!-- 中间导航链接 -->
<nav class="hidden md:flex items-center gap-10 lg:gap-16">
<NuxtLink v-for="item in navItems" :key="item.name" :to="item.path"
class="nav-link-item group flex items-center overflow-hidden font-semibold tracking-[-0.7px] text-black"
style="font-size: 1rem; justify-content: flex-start; position: relative;">
<div
class="flex items-center transition-transform duration-300 group-hover:translate-x-0 -translate-x-[18px] pr-[18px]">
<span class="mr-2 h-2.5 w-2.5 rounded-full bg-black shrink-0"></span>
<span>{{ item.name }}</span>
</div>
</NuxtLink>
</nav>
<!-- 右侧菜单图标 (使用 div 绘制) -->
<div class="menu-icon group flex h-12 w-12 cursor-pointer flex-col items-end justify-center gap-[8px]">
<div class="h-[2px] w-10 bg-black transition-all duration-300 group-hover:translate-y-[2.5px]"></div>
<div class="h-[2px] w-10 bg-black transition-all duration-300 group-hover:-translate-y-[2.5px]"></div>
</div>
</header>
<header ref="bottomHeaderRef" class="site-header-bottom z-50 bg-white/80 backdrop-blur-md">
<!-- 左侧菜单 (前两个菜单项) -->
<nav class="flex items-center">
<NuxtLink v-for="item in navItems.slice(0, 2)" :key="'bottom-' + item.name" :to="item.path"
class="group flex items-center overflow-hidden font-semibold tracking-[-0.7px] text-black"
style="font-size: 1rem; justify-content: flex-start; position: relative;">
<div
class="flex items-center transition-transform duration-300 group-hover:translate-x-0 -translate-x-[18px] pr-[18px]">
<span class="mr-2 h-2.5 w-2.5 rounded-full bg-black shrink-0"></span>
<span>{{ item.name }}</span>
</div>
</NuxtLink>
</nav>
<!-- 中间 Logo (组件内部自带跳转和动画) -->
<div class="flex items-center justify-center">
<AppLogo compact click-action="top" />
</div>
<!-- 右侧菜单 (中间两个菜单项留最后一个 contact 给右上角按钮) -->
<nav class="flex items-center justify-self-end">
<NuxtLink v-for="item in navItems.slice(2, 4)" :key="'bottom-' + item.name" :to="item.path"
class="group flex items-center overflow-hidden font-semibold tracking-[-0.7px] text-black"
style="font-size: 1rem; justify-content: flex-start; position: relative;">
<div
class="flex items-center transition-transform duration-300 group-hover:translate-x-0 -translate-x-[18px] pr-[18px]">
<span class="mr-2 h-2.5 w-2.5 rounded-full bg-black shrink-0"></span>
<span>{{ item.name }}</span>
</div>
</NuxtLink>
</nav>
</header>
</template>
<style scoped>
.site-header-bottom {
border: 1px solid #ffffff47;
border-radius: 12px;
grid-template-rows: auto;
grid-template-columns: auto minmax(50px, auto) auto;
grid-auto-columns: 1fr;
place-items: center stretch;
max-width: 570px;
width: fit-content;
padding: 15px 20px;
display: grid;
position: fixed;
left: 50%;
bottom: 15px;
overflow: hidden;
visibility: hidden;
opacity: 0;
box-shadow: 0 -1px #0000001f, 0 12px 30px #0000001a, inset 0 1px #ffffff96;
}
</style>