Files
arcticnewone/app/components/layouts/ArcticNewOneHeader.vue
T
virtheart afe0dc9492 refactor(layouts): 移除未使用的Observer插件并整理导入顺序
移除ArcticNewOneHeader中已注释的Observer插件注册代码
整理default.vue中的组件导入顺序
2026-04-27 18:13:05 +08:00

289 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'
import AppLogo from '~/components/common/AppLogo.vue'
gsap.registerPlugin(ScrollTrigger)
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>