feat(header): 添加logo悬停动画效果并优化代码格式

添加logo元素的悬停动画效果,当鼠标悬停时显示品牌名称和副标题的动画过渡
优化代码格式,包括调整缩进和注释掉未使用的Observer插件
重构logo部分的DOM结构,使用绝对定位确保布局稳定性
This commit is contained in:
2026-04-27 15:41:57 +08:00
parent bb7e2c04c8
commit 5bb0c344b9
+80 -28
View File
@@ -7,13 +7,14 @@ import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { useAppStore } from '~/stores/app' import { useAppStore } from '~/stores/app'
gsap.registerPlugin(ScrollTrigger) gsap.registerPlugin(ScrollTrigger)
// gsap.registerPlugin(Observer)
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
const appStore = useAppStore() const appStore = useAppStore()
// 导航菜单数据配置 // 导航菜单数据配置
const navItems = computed<Array<{name: string, path: string}>>(() => [ const navItems = computed<Array<{ name: string, path: string }>>(() => [
{ name: t('nav.home'), path: '/' }, { name: t('nav.home'), path: '/' },
{ name: t('nav.studio'), path: '/studio' }, { name: t('nav.studio'), path: '/studio' },
{ name: t('nav.work'), path: '/work' }, { name: t('nav.work'), path: '/work' },
@@ -23,8 +24,10 @@ const navItems = computed<Array<{name: string, path: string}>>(() => [
const topHeaderRef = ref<HTMLElement | null>(null) const topHeaderRef = ref<HTMLElement | null>(null)
const bottomHeaderRef = ref<HTMLElement | null>(null) const bottomHeaderRef = ref<HTMLElement | null>(null)
const logoRef = ref<HTMLElement | null>(null)
let topHeaderTween: gsap.core.Tween | null = null let topHeaderTween: gsap.core.Tween | null = null
let bottomHeaderScrollTrigger: ReturnType<typeof ScrollTrigger.create> | null = null let bottomHeaderScrollTrigger: ReturnType<typeof ScrollTrigger.create> | null = null
let logoObserver: ReturnType<typeof ScrollTrigger.observe> | null = null
let isBottomHeaderVisible = false let isBottomHeaderVisible = false
let fallbackTimer: ReturnType<typeof setTimeout> | null = null let fallbackTimer: ReturnType<typeof setTimeout> | null = null
@@ -54,17 +57,17 @@ const playHeaderIntro = () => {
fallbackTimer = null fallbackTimer = null
} }
gsap.fromTo(topHeaderRef.value, gsap.fromTo(topHeaderRef.value,
{ {
y: '100vh', y: '100vh',
opacity: 0 // 恢复初始透明度为0,让它在屏幕最底部时是隐形的 opacity: 0 // 恢复初始透明度为0,让它在屏幕最底部时是隐形的
}, },
{ {
y: 0, y: 0,
opacity: 1, // 伴随飞入的过程逐渐完全显示 opacity: 1, // 伴随飞入的过程逐渐完全显示
duration: 1.4, duration: 1.4,
ease: 'power4.out', ease: 'power4.out',
delay: 0, delay: 0,
clearProps: 'transform', clearProps: 'transform',
onComplete: () => { onComplete: () => {
// 动画完成时,强制把 style 的 opacity 移除,防止被内联样式锁死 // 动画完成时,强制把 style 的 opacity 移除,防止被内联样式锁死
@@ -82,7 +85,7 @@ const playHeaderIntro = () => {
ScrollTrigger.refresh() ScrollTrigger.refresh()
} }
} }
}) })
} }
// 监听自定义事件,当首页的 Hero 动画完成或到达特定阶段时触发导航栏入场 // 监听自定义事件,当首页的 Hero 动画完成或到达特定阶段时触发导航栏入场
@@ -90,6 +93,45 @@ const handleHeroReady = () => {
playHeaderIntro() 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(() => { onMounted(() => {
// 顶部导航在页面刚开始滚动的前 200px 内逐渐上移并隐藏。 // 顶部导航在页面刚开始滚动的前 200px 内逐渐上移并隐藏。
topHeaderTween = gsap.to(topHeaderRef.value, { topHeaderTween = gsap.to(topHeaderRef.value, {
@@ -125,6 +167,9 @@ onMounted(() => {
}) })
ScrollTrigger.refresh() ScrollTrigger.refresh()
// Logo 文字Hover 动画
logoHover()
}) })
// 监听全局加载状态,当加载完成后再决定 Header 的进场时机 // 监听全局加载状态,当加载完成后再决定 Header 的进场时机
@@ -133,13 +178,13 @@ watch(() => appStore.isAppLoading, (isLoading) => {
if (route.path === '/') { if (route.path === '/') {
// 首页:等待自定义事件 // 首页:等待自定义事件
window.addEventListener('hero-intro-done', handleHeroReady, { once: true }) window.addEventListener('hero-intro-done', handleHeroReady, { once: true })
// 增加一个保底的 setTimeout,防止首页的动画事件因为某种原因未能触发 // 增加一个保底的 setTimeout,防止首页的动画事件因为某种原因未能触发
// 从 loading 结束开始算起,首页的 intro 和 hero 动画加起来大约 3~4 秒 // 从 loading 结束开始算起,首页的 intro 和 hero 动画加起来大约 3~4 秒
fallbackTimer = setTimeout(() => { fallbackTimer = setTimeout(() => {
window.removeEventListener('hero-intro-done', handleHeroReady) window.removeEventListener('hero-intro-done', handleHeroReady)
playHeaderIntro() playHeaderIntro()
}, 5000) }, 5000)
} else { } else {
// 非首页:加载完成后直接进场 // 非首页:加载完成后直接进场
playHeaderIntro() playHeaderIntro()
@@ -158,21 +203,28 @@ onBeforeUnmount(() => {
topHeaderTween?.scrollTrigger?.kill() topHeaderTween?.scrollTrigger?.kill()
topHeaderTween?.kill() topHeaderTween?.kill()
bottomHeaderScrollTrigger?.kill() bottomHeaderScrollTrigger?.kill()
logoObserver?.kill()
topHeaderTween = null topHeaderTween = null
bottomHeaderScrollTrigger = null bottomHeaderScrollTrigger = null
logoObserver = null
isBottomHeaderVisible = false isBottomHeaderVisible = false
if (topHeaderRef.value && bottomHeaderRef.value) { if (topHeaderRef.value && bottomHeaderRef.value) {
gsap.killTweensOf([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> </script>
<template> <template>
<header <header ref="topHeaderRef"
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" 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);" style="opacity: 0; transform: translateY(100vh);">
>
<!-- 左侧Logo 标题 --> <!-- 左侧Logo 标题 -->
<div class="flex items-center gap-4"> <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"> <div class="logo-icon flex h-10 w-10 items-center justify-center rounded-full bg-black text-white">
@@ -183,22 +235,24 @@ onBeforeUnmount(() => {
<path d="M2 12h20" /> <path d="M2 12h20" />
</svg> </svg>
</div> </div>
<div class="flex flex-col justify-center"> <!-- 改为 relative 并且把子元素设置 absolute 以保证布局不受干扰 -->
<span class="text-base font-bold leading-none tracking-wider text-black">{{ t('app.brand') }}</span> <div ref="logoRef" class="relative flex h-10 items-center overflow-hidden cursor-pointer" id="logo"
<span class="mt-1 text-xs leading-none text-gray-500">{{ t('app.name') }}</span> 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>
</div> </div>
<!-- 中间导航链接 --> <!-- 中间导航链接 -->
<nav class="hidden md:flex items-center gap-10 lg:gap-16"> <nav class="hidden md:flex items-center gap-10 lg:gap-16">
<NuxtLink <NuxtLink v-for="item in navItems" :key="item.name" :to="item.path"
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" 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;" style="font-size: 1rem; justify-content: flex-start; position: relative;">
> <div
<div class="flex items-center transition-transform duration-300 group-hover:translate-x-0 -translate-x-[18px] pr-[18px]"> 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 class="mr-2 h-2.5 w-2.5 rounded-full bg-black shrink-0"></span>
<span>{{ item.name }}</span> <span>{{ item.name }}</span>
</div> </div>
@@ -212,11 +266,9 @@ onBeforeUnmount(() => {
</div> </div>
</header> </header>
<header <header ref="bottomHeaderRef"
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" 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;" style="visibility: hidden; opacity: 0;">
>
<nav>Bottom Navigation</nav> <nav>Bottom Navigation</nav>
</header> </header>
</template> </template>