Files
arcticnewone/app/components/layouts/ArcticNewOneHeader.vue
T
virtheart b71ab1a342 feat(tools): 添加工具箱页面及国际化支持
添加新的工具箱页面,包含三个实用工具卡片。更新国际化文件支持多语言显示。优化页面滚动锁定功能,使其可配置自定义类名。调整导航栏菜单项显示逻辑。

工具箱页面包含入场动画效果,并添加SEO元信息。滚动锁定功能现在支持自定义类名,便于不同场景使用。导航栏现在动态显示中间菜单项,排除最后一项。
2026-04-28 14:32:57 +08:00

300 lines
9.6 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.tools'), path: '/tools' },
{ 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 justify-evenly md:justify-end gap-3 md:gap-4 w-full">
<NuxtLink v-for="item in navItems.slice(0, 2)" :key="'bottom-' + item.name" :to="item.path"
class="group flex items-center font-semibold tracking-[-0.7px] text-black text-sm md:text-base whitespace-nowrap md:overflow-hidden"
style="justify-content: flex-start; position: relative;">
<div
class="flex items-center transition-transform duration-300 md:group-hover:translate-x-0 md:-translate-x-[18px] md:pr-[18px]">
<span class="hidden md:block mr-1 md:mr-2 h-1.5 w-1.5 md:h-2.5 md: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 px-1 md:px-6">
<AppLogo compact click-action="top" />
</div>
<!-- 右侧菜单 (去除左侧两个和最后contact菜单后的所有菜单) -->
<nav class="flex items-center justify-evenly md:justify-start gap-3 md:gap-4 w-full">
<NuxtLink v-for="item in navItems.slice(2, navItems.length - 1)" :key="'bottom-' + item.name" :to="item.path"
class="group flex items-center font-semibold tracking-[-0.7px] text-black text-sm md:text-base whitespace-nowrap md:overflow-hidden"
style="justify-content: flex-start; position: relative;">
<div
class="flex items-center transition-transform duration-300 md:group-hover:translate-x-0 md:-translate-x-[18px] md:pr-[18px]">
<span class="hidden md:block mr-1 md:mr-2 h-1.5 w-1.5 md:h-2.5 md: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;
/* 移动端恢复三列,靠 flex 让菜单均匀铺开 */
grid-template-columns: 1fr auto 1fr;
grid-auto-columns: 1fr;
place-items: center;
width: calc(100% - 1rem);
max-width: 570px;
padding: 10px 10px;
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;
}
@media (min-width: 768px) {
.site-header-bottom {
grid-template-columns: auto minmax(50px, auto) auto;
gap: 0;
width: fit-content;
padding: 15px 20px;
}
}
</style>