feat(header): 重构头部组件并添加底部导航栏
将头部组件中的logo动画逻辑提取为独立组件 添加底部导航栏并实现滚动显示逻辑 更新国际化文件中的app名称
This commit is contained in:
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import gsap from 'gsap'
|
||||||
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ showText?: boolean; compact?: boolean; clickAction?: 'home' | 'top' }>(), {
|
||||||
|
showText: true,
|
||||||
|
compact: false,
|
||||||
|
clickAction: 'home',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const logoRef = ref<HTMLElement | null>(null)
|
||||||
|
let logoObserver: ReturnType<typeof ScrollTrigger.observe> | null = null
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (props.clickAction === 'home') {
|
||||||
|
router.push('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.clickAction === 'top' && typeof window !== 'undefined') {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoHover = () => {
|
||||||
|
if (!props.showText) return
|
||||||
|
if (!logoRef.value) return
|
||||||
|
|
||||||
|
logoObserver?.kill()
|
||||||
|
|
||||||
|
gsap.set(logoRef.value.children[1] as HTMLElement, {
|
||||||
|
opacity: 0,
|
||||||
|
visibility: 'hidden',
|
||||||
|
y: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
logoHover()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
logoObserver?.kill()
|
||||||
|
if (logoRef.value) {
|
||||||
|
gsap.killTweensOf([logoRef.value.children[0], logoRef.value.children[1]])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-center cursor-pointer gap-2" @click="handleClick">
|
||||||
|
<div class="logo-icon flex h-[1.8rem] w-[1.8rem] items-center justify-center rounded-full bg-black overflow-hidden">
|
||||||
|
<video
|
||||||
|
src="/assets/logo.mp4"
|
||||||
|
class="h-[220%] w-[220%] object-cover pointer-events-none"
|
||||||
|
autoplay
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsinline
|
||||||
|
disablepictureinpicture
|
||||||
|
disableremoteplayback
|
||||||
|
controlslist="nodownload noplaybackrate noremoteplayback"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="props.showText"
|
||||||
|
ref="logoRef"
|
||||||
|
class="relative flex items-center overflow-hidden"
|
||||||
|
:class="props.compact ? 'h-[1.8rem]' : 'h-10'"
|
||||||
|
style="min-width: 100px;"
|
||||||
|
>
|
||||||
|
<span class="absolute left-0 text-base font-bold leading-none tracking-wider text-black">{{ t('app.brand') }}</span>
|
||||||
|
<span class="absolute left-0 text-xs leading-none text-gray-500 opacity-0 invisible">{{ t('app.name') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import gsap from 'gsap'
|
import gsap from 'gsap'
|
||||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||||
import { useAppStore } from '~/stores/app'
|
import { useAppStore } from '~/stores/app'
|
||||||
|
import AppLogo from '~/components/common/AppLogo.vue'
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger)
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
// gsap.registerPlugin(Observer)
|
// gsap.registerPlugin(Observer)
|
||||||
@@ -24,10 +25,8 @@ 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
|
||||||
|
|
||||||
@@ -39,13 +38,14 @@ const setBottomHeaderVisible = (visible: boolean) => {
|
|||||||
isBottomHeaderVisible = visible
|
isBottomHeaderVisible = visible
|
||||||
|
|
||||||
gsap.to(bottomHeaderRef.value, {
|
gsap.to(bottomHeaderRef.value, {
|
||||||
yPercent: visible ? 0 : 140,
|
yPercent: visible ? 0 : 150,
|
||||||
autoAlpha: visible ? 1 : 0,
|
autoAlpha: visible ? 1 : 0,
|
||||||
duration: 0.35,
|
duration: 0.45,
|
||||||
ease: 'power2.out',
|
ease: 'power2.out',
|
||||||
overwrite: 'auto',
|
overwrite: 'auto',
|
||||||
|
force3D: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义一个供外部或延迟调用的入场动画方法
|
// 定义一个供外部或延迟调用的入场动画方法
|
||||||
const playHeaderIntro = () => {
|
const playHeaderIntro = () => {
|
||||||
@@ -93,45 +93,6 @@ 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, {
|
||||||
@@ -143,8 +104,10 @@ onMounted(() => {
|
|||||||
|
|
||||||
// 底部导航默认藏在视口下方,只有滚动到深处并向上滚时才出现。
|
// 底部导航默认藏在视口下方,只有滚动到深处并向上滚时才出现。
|
||||||
gsap.set(bottomHeaderRef.value, {
|
gsap.set(bottomHeaderRef.value, {
|
||||||
yPercent: 140,
|
xPercent: -50,
|
||||||
|
yPercent: 150,
|
||||||
autoAlpha: 0,
|
autoAlpha: 0,
|
||||||
|
force3D: true
|
||||||
})
|
})
|
||||||
|
|
||||||
bottomHeaderScrollTrigger = ScrollTrigger.create({
|
bottomHeaderScrollTrigger = ScrollTrigger.create({
|
||||||
@@ -153,23 +116,20 @@ onMounted(() => {
|
|||||||
onUpdate: (self) => {
|
onUpdate: (self) => {
|
||||||
const scrollY = self.scroll()
|
const scrollY = self.scroll()
|
||||||
|
|
||||||
// 深度超过 2500px 后向上滚,显示未来用于深层内容的底部导航。
|
// 深度超过 1000px 后向上滚,显示底部导航。
|
||||||
if (scrollY > 2500 && self.direction === -1) {
|
if (scrollY > 1000 && self.direction === -1) {
|
||||||
setBottomHeaderVisible(true)
|
setBottomHeaderVisible(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回到 2300px 以内,或继续向下滚时,底部导航收回。
|
// 回到 1000px 以内,或继续向下滚时,底部导航收回。
|
||||||
if (scrollY <= 2300 || self.direction === 1) {
|
if (scrollY <= 1000 || self.direction === 1) {
|
||||||
setBottomHeaderVisible(false)
|
setBottomHeaderVisible(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
ScrollTrigger.refresh()
|
ScrollTrigger.refresh()
|
||||||
|
|
||||||
// Logo 文字Hover 动画
|
|
||||||
logoHover()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听全局加载状态,当加载完成后再决定 Header 的进场时机
|
// 监听全局加载状态,当加载完成后再决定 Header 的进场时机
|
||||||
@@ -203,22 +163,13 @@ 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>
|
||||||
@@ -226,25 +177,7 @@ onBeforeUnmount(() => {
|
|||||||
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">
|
<AppLogo />
|
||||||
<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">
|
<nav class="hidden md:flex items-center gap-10 lg:gap-16">
|
||||||
@@ -266,9 +199,58 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<header ref="bottomHeaderRef"
|
<header ref="bottomHeaderRef" class="site-header-bottom z-50 bg-white/80 backdrop-blur-md">
|
||||||
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 class="flex items-center">
|
||||||
<nav>Bottom Navigation</nav>
|
<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>
|
</header>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"app": {
|
"app": {
|
||||||
"name": "ArcticNewOne",
|
"name": "北极新一",
|
||||||
"brand": "ArcticNewOne",
|
"brand": "ArcticNewOne",
|
||||||
"description": "Explore infinite possibilities and modern experiences. Welcome to ArcticNewOne."
|
"description": "Explore infinite possibilities and modern experiences. Welcome to ArcticNewOne."
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user