Files
BigStickNewOfficialWebsite/components/SiteHeader.vue
T

248 lines
7.7 KiB
Vue
Raw Normal View History

2026-07-10 18:23:41 +08:00
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { brand, navItems } from "~/data/navigation";
const props = defineProps({
announcementVisible: {
type: Boolean,
default: true,
},
});
const route = useRoute();
const mobileOpen = ref(false);
const expandedItem = ref("");
const headerRef = ref(null);
const barRef = ref(null);
let ctx;
let scrollTrigger;
const topOffset = computed(() => (props.announcementVisible ? "59px" : "0px"));
const contactHref = computed(() => `${route.path === "/" ? "" : route.path}#contact`);
const isActive = (item) => {
if (item.href === "/") {
return route.path === "/";
}
return route.path === item.href || route.path.startsWith(`${item.href}/`);
};
const closeMobile = () => {
mobileOpen.value = false;
expandedItem.value = "";
};
const toggleMobileItem = (label) => {
expandedItem.value = expandedItem.value === label ? "" : label;
};
watch(
() => props.announcementVisible,
() => {
closeMobile();
},
);
watch(
() => route.fullPath,
() => {
closeMobile();
},
);
onMounted(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return;
}
const { $gsap, $ScrollTrigger } = useNuxtApp();
if (!headerRef.value || !barRef.value || !$gsap || !$ScrollTrigger) {
return;
}
ctx = $gsap.context(() => {
scrollTrigger = $ScrollTrigger.create({
start: 40,
end: 99999,
onToggle: (self) => {
$gsap.to(barRef.value, {
height: self.isActive ? 64 : 75,
duration: 0.28,
ease: "power2.out",
});
$gsap.to(headerRef.value, {
boxShadow: self.isActive
? "0 18px 50px rgba(0, 0, 0, 0.08)"
: "0 0 0 rgba(0, 0, 0, 0)",
duration: 0.28,
ease: "power2.out",
});
},
});
}, headerRef.value);
});
onBeforeUnmount(() => {
scrollTrigger?.kill?.();
ctx?.revert();
});
</script>
<template>
<header
ref="headerRef"
class="fixed inset-x-0 z-[999] border-b border-gm-border bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/85"
:style="{ top: topOffset }"
>
<div ref="barRef" class="container-gm flex h-[75px] items-center justify-between">
<NuxtLink to="/" class="shrink-0" :aria-label="brand.name">
<span class="block text-[18px] font-semibold leading-none text-[#1f2430] md:text-[20px]">
{{ brand.shortName }}
</span>
<span class="mt-1 block text-[11px] leading-none text-[#666]">
{{ brand.productName }}
</span>
</NuxtLink>
<nav class="hidden items-center gap-5 lg:flex xl:gap-7">
<div
v-for="item in navItems"
:key="item.label"
class="group relative py-6"
>
<NuxtLink
:to="item.href"
class="inline-flex items-center gap-2 text-[14px] transition hover:text-gm-green"
:class="isActive(item) ? 'text-gm-green' : 'text-[#1f2430]'"
>
{{ item.label }}
<span v-if="item.children?.length" class="text-[10px]" aria-hidden="true"></span>
</NuxtLink>
<div
v-if="item.children?.length"
class="pointer-events-none absolute left-0 top-full w-[260px] translate-y-2 rounded-b-[6px] border border-gm-border bg-white p-2 opacity-0 shadow-lg transition-all duration-200 group-hover:pointer-events-auto group-hover:translate-y-0 group-hover:opacity-100"
>
<NuxtLink
v-for="child in item.children"
:key="child.label"
:to="child.href"
class="block rounded px-4 py-3 text-sm text-[#555] transition hover:bg-gm-light hover:text-gm-green"
>
{{ child.label }}
</NuxtLink>
</div>
</div>
</nav>
<div class="flex items-center gap-3">
<NuxtLink :to="contactHref" class="gm-btn gm-btn-primary hidden md:inline-flex">
获取方案
</NuxtLink>
<button
type="button"
class="inline-flex size-11 items-center justify-center rounded-full border border-gm-border text-gm-text transition hover:border-gm-green hover:text-gm-green lg:hidden"
aria-label="打开菜单"
@click="mobileOpen = true"
>
<span class="space-y-1.5">
<span class="block h-0.5 w-5 rounded-full bg-current" />
<span class="block h-0.5 w-5 rounded-full bg-current" />
<span class="block h-0.5 w-5 rounded-full bg-current" />
</span>
</button>
</div>
</div>
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="mobileOpen"
class="fixed inset-0 z-[1001] bg-white lg:hidden"
:style="{ top: topOffset }"
>
<div class="container-gm flex h-[75px] items-center justify-between border-b border-gm-border">
<div>
<span class="block text-lg font-semibold text-[#1f2430]">{{ brand.shortName }}</span>
<span class="text-xs text-[#666]">{{ brand.productName }}</span>
</div>
<button
type="button"
class="inline-flex size-11 items-center justify-center rounded-full border border-gm-border text-gm-text transition hover:border-gm-green hover:text-gm-green"
aria-label="关闭菜单"
@click="closeMobile"
>
<span aria-hidden="true" class="text-2xl leading-none">×</span>
</button>
</div>
<nav class="container-gm h-[calc(100vh-75px)] overflow-y-auto py-8">
<div
v-for="item in navItems"
:key="item.label"
class="border-b border-gm-border py-2"
>
<div class="flex items-center justify-between gap-4">
<NuxtLink
:to="item.href"
class="block flex-1 py-4 text-xl text-[#1f2430]"
@click="closeMobile"
>
{{ item.label }}
</NuxtLink>
<button
v-if="item.children?.length"
type="button"
class="flex size-10 items-center justify-center rounded-full border border-gm-border text-gm-text"
:aria-label="`展开${item.label}菜单`"
@click="toggleMobileItem(item.label)"
>
<span
class="transition-transform duration-200"
:class="expandedItem === item.label ? 'rotate-180' : ''"
aria-hidden="true"
>
</span>
</button>
</div>
<div
v-if="item.children?.length && expandedItem === item.label"
class="space-y-2 pb-4"
>
<NuxtLink
v-for="child in item.children"
:key="child.label"
:to="child.href"
class="block rounded bg-gm-light px-4 py-3 text-sm text-[#555]"
@click="closeMobile"
>
{{ child.label }}
</NuxtLink>
</div>
</div>
<NuxtLink
:to="contactHref"
class="mt-8 inline-flex h-12 items-center justify-center rounded-full bg-gm-green px-6 text-base font-medium text-white"
@click="closeMobile"
>
获取方案
</NuxtLink>
</nav>
</div>
</transition>
</header>
</template>