1f591fe2b4
- 完善产品页面布局与交互 - 优化服务详情页用户体验 - 增强新闻模块内容展示 - 改进团队页面设计 - 优化全局样式和响应式布局 - 添加分页组件支持 - 提升性能与SEO优化 - 修复已知问题与改进代码质量
286 lines
9.9 KiB
TypeScript
286 lines
9.9 KiB
TypeScript
'use client';
|
|
|
|
import { Suspense, useState, useEffect, useCallback } from 'react';
|
|
import { StaticLink } from '@/components/ui/static-link';
|
|
import Image from 'next/image';
|
|
import { usePathname } from 'next/navigation';
|
|
import { Menu, X } from 'lucide-react';
|
|
import { AnimatePresence, motion } from 'framer-motion';
|
|
import { Button } from '@/components/ui/button';
|
|
import { COMPANY_INFO, NAVIGATION, type NavigationItem } from '@/lib/constants';
|
|
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
|
|
|
function HeaderContent() {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isScrolled, setIsScrolled] = useState(false);
|
|
const [headerTheme, setHeaderTheme] = useState<'light' | 'dark'>('light');
|
|
const pathname = usePathname();
|
|
const focusTrapRef = useFocusTrap<HTMLDivElement>(isOpen);
|
|
|
|
// 监听 data-header-theme 属性变化(产品落地页控制导航栏颜色)
|
|
useEffect(() => {
|
|
const handleThemeChange = () => {
|
|
const theme = document.documentElement.getAttribute('data-header-theme');
|
|
setHeaderTheme(theme === 'dark' ? 'dark' : 'light');
|
|
};
|
|
|
|
handleThemeChange();
|
|
|
|
const observer = new MutationObserver(handleThemeChange);
|
|
observer.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ['data-header-theme'],
|
|
});
|
|
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleScroll = () => {
|
|
setIsScrolled(window.scrollY > 20);
|
|
};
|
|
|
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && isOpen) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
window.addEventListener('keydown', handleGlobalKeyDown);
|
|
|
|
return () => {
|
|
window.removeEventListener('scroll', handleScroll);
|
|
window.removeEventListener('keydown', handleGlobalKeyDown);
|
|
};
|
|
}, [isOpen]);
|
|
|
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
setIsOpen(!isOpen);
|
|
}
|
|
if (e.key === 'Escape' && isOpen) {
|
|
setIsOpen(false);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const handleNavClick = useCallback((href: string) => {
|
|
// Close mobile menu, then navigate (StaticLink delegates navigation to onClick when present)
|
|
setIsOpen(false);
|
|
window.location.href = href;
|
|
}, []);
|
|
|
|
const isActive = useCallback((item: NavigationItem) => {
|
|
if (item.id === 'contact') {
|
|
return pathname === '/contact';
|
|
}
|
|
|
|
if (item.id === 'home') {
|
|
return pathname === '/';
|
|
}
|
|
|
|
return pathname === `/${item.id}`;
|
|
}, [pathname]);
|
|
|
|
const navigationItems = NAVIGATION;
|
|
|
|
return (
|
|
<>
|
|
<header
|
|
className={`
|
|
fixed top-0 left-0 right-0 z-50
|
|
transition-all duration-300 ease-out
|
|
${headerTheme === 'dark'
|
|
? isScrolled
|
|
? 'bg-[#0A0A0A]/90 backdrop-blur-xl border-b border-[#1A1A1A]'
|
|
: 'bg-transparent'
|
|
: isScrolled
|
|
? 'bg-white/95 backdrop-blur-xl border-b border-[#E2E8F0] shadow-sm'
|
|
: 'bg-transparent'
|
|
}
|
|
`}
|
|
>
|
|
<div className="container-wide">
|
|
<div className="flex items-center justify-between h-16">
|
|
<StaticLink
|
|
href="/"
|
|
className="flex items-center group"
|
|
aria-label="返回首页"
|
|
>
|
|
<Image
|
|
src={headerTheme === 'dark' ? '/logo-white.svg' : '/logo.svg'}
|
|
alt={COMPANY_INFO.shortName}
|
|
width={128}
|
|
height={32}
|
|
className="transition-transform duration-200 group-hover:scale-105"
|
|
loading="eager"
|
|
priority
|
|
/>
|
|
</StaticLink>
|
|
|
|
<nav className="hidden md:flex items-center gap-1" role="navigation" aria-label="主导航" data-testid="desktop-navigation">
|
|
{navigationItems.map((item) => (
|
|
<StaticLink
|
|
key={item.id}
|
|
href={item.href}
|
|
onClick={() => handleNavClick(item.href)}
|
|
className={`
|
|
relative px-3 py-1.5 text-sm font-medium
|
|
transition-all duration-300
|
|
${headerTheme === 'dark'
|
|
? isActive(item)
|
|
? 'text-white'
|
|
: 'text-[#B0B0B0] hover:text-white'
|
|
: isActive(item)
|
|
? 'text-[#1C1C1C]'
|
|
: 'text-[#3D3D3D] hover:text-[#1C1C1C]'
|
|
}
|
|
`}
|
|
aria-current={isActive(item) ? 'page' : undefined}
|
|
>
|
|
{item.label}
|
|
<span
|
|
className={`
|
|
absolute bottom-0 left-1/2 -translate-x-1/2 w-6 h-0.5 bg-[var(--color-brand-primary)] rounded-full
|
|
transition-all duration-200 ease-out
|
|
${isActive(item)
|
|
? 'opacity-100 scale-x-100'
|
|
: 'opacity-0 scale-x-0'
|
|
}
|
|
`}
|
|
/>
|
|
</StaticLink>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="hidden md:flex items-center gap-3">
|
|
<Button
|
|
size="sm"
|
|
variant={headerTheme === 'dark' ? 'outline' : 'default'}
|
|
className={headerTheme === 'dark' ? 'border-white/30 text-white hover:bg-white/10 hover:text-white' : ''}
|
|
asChild
|
|
>
|
|
<StaticLink href="/contact" data-testid="consult-button">立即咨询</StaticLink>
|
|
</Button>
|
|
</div>
|
|
|
|
<button
|
|
className={`
|
|
md:hidden p-3 -mr-3 rounded-lg transition-all duration-200 active:scale-95
|
|
${headerTheme === 'dark'
|
|
? 'text-white hover:bg-white/10'
|
|
: 'text-[#3D3D3D] hover:text-[#1C1C1C] hover:bg-[#F5F5F5]'
|
|
}
|
|
`}
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
onKeyDown={handleKeyDown}
|
|
aria-expanded={isOpen}
|
|
aria-controls="mobile-menu"
|
|
aria-label={isOpen ? '关闭菜单' : '打开菜单'}
|
|
data-testid="mobile-menu-button"
|
|
style={{ minWidth: '44px', minHeight: '44px' }}
|
|
>
|
|
{isOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<AnimatePresence mode="wait">
|
|
{isOpen && (
|
|
<motion.div
|
|
ref={focusTrapRef}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="fixed inset-0 z-40 md:hidden"
|
|
>
|
|
<div
|
|
className="absolute inset-0 bg-black/30 backdrop-blur-sm"
|
|
onClick={() => setIsOpen(false)}
|
|
aria-hidden="true"
|
|
/>
|
|
<motion.div
|
|
initial={{ x: '100%' }}
|
|
animate={{ x: 0 }}
|
|
exit={{ x: '100%' }}
|
|
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
|
className="absolute top-16 right-0 bottom-0 left-0 bg-white/98 backdrop-blur-xl shadow-2xl overflow-y-auto"
|
|
id="mobile-menu"
|
|
role="navigation"
|
|
aria-label="移动端导航"
|
|
data-testid="mobile-navigation"
|
|
>
|
|
<nav className="container-wide py-6">
|
|
{navigationItems.map((item, index) => (
|
|
<motion.div
|
|
key={item.id}
|
|
initial={{ x: 20, opacity: 0 }}
|
|
animate={{ x: 0, opacity: 1 }}
|
|
transition={{ delay: index * 0.05 }}
|
|
>
|
|
<StaticLink
|
|
href={item.href}
|
|
onClick={() => handleNavClick(item.href)}
|
|
className={`
|
|
block px-4 py-4 text-base font-medium rounded-lg
|
|
transition-all duration-200
|
|
${isActive(item)
|
|
? 'text-[#1C1C1C] bg-[#F5F5F5] border-l-4 border-[var(--color-brand-primary)]'
|
|
: 'text-[#3D3D3D] hover:text-[#1C1C1C] hover:bg-[#F5F5F5]'
|
|
}
|
|
`}
|
|
style={{ minHeight: '48px', display: 'flex', alignItems: 'center' }}
|
|
>
|
|
{item.label}
|
|
</StaticLink>
|
|
</motion.div>
|
|
))}
|
|
<div className="mt-6 px-4 pt-6 border-t border-[#E2E8F0]">
|
|
<Button
|
|
className="w-full"
|
|
asChild
|
|
size="lg"
|
|
>
|
|
<StaticLink href="/contact" onClick={() => setIsOpen(false)}>
|
|
联系我们
|
|
</StaticLink>
|
|
</Button>
|
|
</div>
|
|
</nav>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function HeaderFallback() {
|
|
return (
|
|
<header className="fixed top-0 left-0 right-0 z-50 bg-transparent">
|
|
<div className="container-wide">
|
|
<div className="flex items-center justify-between h-16">
|
|
<div className="h-8 w-8 bg-gray-200 animate-pulse rounded" />
|
|
<nav className="hidden md:flex items-center gap-1">
|
|
{[1, 2, 3, 4, 5].map((i) => (
|
|
<div key={i} className="h-6 w-16 bg-gray-200 animate-pulse rounded mx-1" />
|
|
))}
|
|
</nav>
|
|
<div className="h-9 w-20 bg-gray-200 animate-pulse rounded" />
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
export function Header() {
|
|
return (
|
|
<Suspense fallback={<HeaderFallback />}>
|
|
<HeaderContent />
|
|
</Suspense>
|
|
);
|
|
}
|