Files
arcticnewone/app/components/layouts/ArcticNewOneHeader.vue
T
virtheart 5bb0c344b9 feat(header): 添加logo悬停动画效果并优化代码格式
添加logo元素的悬停动画效果,当鼠标悬停时显示品牌名称和副标题的动画过渡
优化代码格式,包括调整缩进和注释掉未使用的Observer插件
重构logo部分的DOM结构,使用绝对定位确保布局稳定性
2026-04-27 15:41:57 +08:00

275 lines
9.0 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'
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)
const logoRef = ref<HTMLElement | null>(null)
let topHeaderTween: gsap.core.Tween | null = null
let bottomHeaderScrollTrigger: ReturnType<typeof ScrollTrigger.create> | null = null
let logoObserver: ReturnType<typeof ScrollTrigger.observe> | 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 : 140,
autoAlpha: visible ? 1 : 0,
duration: 0.35,
ease: 'power2.out',
overwrite: 'auto',
})
}
// 定义一个供外部或延迟调用的入场动画方法
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,
clearProps: 'transform',
onComplete: () => {
// 动画完成时,强制把 style 的 opacity 移除,防止被内联样式锁死
if (topHeaderRef.value) {
topHeaderRef.value.style.opacity = ''
}
// 进场动画完毕后,再将滚动消失动画挂载到 ScrollTrigger
if (topHeaderTween) {
ScrollTrigger.create({
animation: topHeaderTween,
start: 0,
end: 200,
scrub: true,
})
ScrollTrigger.refresh()
}
}
})
}
// 监听自定义事件,当首页的 Hero 动画完成或到达特定阶段时触发导航栏入场
const handleHeroReady = () => {
playHeaderIntro()
}
// Logo 文字Hover 动画
const logoHover = () => {
if (!logoRef.value) return
logoObserver?.kill()
logoObserver = ScrollTrigger.observe({
target: logoRef.value,
onHover: (self) => {
gsap.to(self.target.children[0] as HTMLElement, {
y: -10,
duration: 0.3,
ease: 'power2.out',
})
gsap.to(self.target.children[1] as HTMLElement, {
y: 10,
duration: 0.3,
ease: 'power2.out',
opacity: 1,
visibility: 'visible',
})
},
onHoverEnd: (self) => {
gsap.to(self.target.children[0] as HTMLElement, {
y: 0,
duration: 0.3,
ease: 'power2.out',
})
gsap.to(self.target.children[1] as HTMLElement, {
y: 0,
duration: 0.3,
ease: 'power2.out',
opacity: 0,
visibility: 'hidden',
})
},
})
}
onMounted(() => {
// 顶部导航在页面刚开始滚动的前 200px 内逐渐上移并隐藏。
topHeaderTween = gsap.to(topHeaderRef.value, {
yPercent: -140,
autoAlpha: 0,
ease: 'none',
paused: true, // 初始不执行,等进场动画完毕再绑定到 ScrollTrigger
})
// 底部导航默认藏在视口下方,只有滚动到深处并向上滚时才出现。
gsap.set(bottomHeaderRef.value, {
yPercent: 140,
autoAlpha: 0,
})
bottomHeaderScrollTrigger = ScrollTrigger.create({
start: 0,
end: 'max',
onUpdate: (self) => {
const scrollY = self.scroll()
// 深度超过 2500px 后向上滚,显示未来用于深层内容的底部导航。
if (scrollY > 2500 && self.direction === -1) {
setBottomHeaderVisible(true)
return
}
// 回到 2300px 以内,或继续向下滚时,底部导航收回。
if (scrollY <= 2300 || self.direction === 1) {
setBottomHeaderVisible(false)
}
},
})
ScrollTrigger.refresh()
// Logo 文字Hover 动画
logoHover()
})
// 监听全局加载状态,当加载完成后再决定 Header 的进场时机
watch(() => appStore.isAppLoading, (isLoading) => {
if (!isLoading && typeof window !== 'undefined') {
if (route.path === '/') {
// 首页:等待自定义事件
window.addEventListener('hero-intro-done', handleHeroReady, { once: true })
// 增加一个保底的 setTimeout,防止首页的动画事件因为某种原因未能触发
// 从 loading 结束开始算起,首页的 intro 和 hero 动画加起来大约 3~4 秒
fallbackTimer = setTimeout(() => {
window.removeEventListener('hero-intro-done', handleHeroReady)
playHeaderIntro()
}, 5000)
} else {
// 非首页:加载完成后直接进场
playHeaderIntro()
}
}
}, { immediate: true })
onBeforeUnmount(() => {
if (fallbackTimer) {
clearTimeout(fallbackTimer)
fallbackTimer = null
}
if (typeof window !== 'undefined') {
window.removeEventListener('hero-intro-done', handleHeroReady)
}
topHeaderTween?.scrollTrigger?.kill()
topHeaderTween?.kill()
bottomHeaderScrollTrigger?.kill()
logoObserver?.kill()
topHeaderTween = null
bottomHeaderScrollTrigger = null
logoObserver = null
isBottomHeaderVisible = false
if (topHeaderRef.value && bottomHeaderRef.value) {
gsap.killTweensOf([topHeaderRef.value, bottomHeaderRef.value])
}
if (logoRef.value) {
const brand = logoRef.value.querySelector<HTMLElement>('#logo-brand')
const name = logoRef.value.querySelector<HTMLElement>('#logo-name')
if (brand && name) {
gsap.killTweensOf([brand, name])
}
}
})
</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 标题 -->
<div class="flex items-center gap-4">
<div class="logo-icon flex h-10 w-10 items-center justify-center rounded-full bg-black text-white">
<!-- 简单的极简风格 SVG Logo -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-6 w-6">
<circle cx="12" cy="12" r="10" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
<path d="M2 12h20" />
</svg>
</div>
<!-- 改为 relative 并且把子元素设置 absolute 以保证布局不受干扰 -->
<div ref="logoRef" class="relative flex h-10 items-center overflow-hidden cursor-pointer" id="logo"
style="min-width: 100px;">
<span class="absolute left-0 text-base font-bold leading-none tracking-wider text-black" id="logo-brand">{{
t('app.brand') }}</span>
<!-- name 默认隐藏 -->
<span class="absolute left-0 text-xs leading-none text-gray-500 invisible" id="logo-name">{{
t('app.name') }}</span>
</div>
</div>
<!-- 中间导航链接 -->
<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="fixed bottom-0 left-0 right-0 z-50 mx-auto h-[100px] w-page-container max-w-[calc(100%-2rem)] bg-blue-200 px-4 py-5"
style="visibility: hidden; opacity: 0;">
<nav>Bottom Navigation</nav>
</header>
</template>