13c4a2ca49
- 更新next.config.ts配置以优化图片和静态资源 - 优化字体加载策略,减少首屏阻塞 - 使用Next.js Image组件替换img标签并实现懒加载 - 重构移动端菜单交互,提升触摸体验 - 新增安全测试和可访问性测试用例 - 修复导航栏滚动定位问题 - 更新部署就绪测试脚本 - 添加相关文档说明优化细节
304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
import Link from 'next/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';
|
|
|
|
export function Header() {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isScrolled, setIsScrolled] = useState(false);
|
|
const [activeSection, setActiveSection] = useState('home');
|
|
const pathname = usePathname();
|
|
const focusTrapRef = useFocusTrap<HTMLDivElement>(isOpen);
|
|
const sectionCacheRef = useRef(new Map<string, { offsetTop: number; offsetHeight: number }>());
|
|
const activeSectionRef = useRef(activeSection);
|
|
const isManualNavigationRef = useRef(false);
|
|
const manualNavTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
useEffect(() => {
|
|
activeSectionRef.current = activeSection;
|
|
}, [activeSection]);
|
|
|
|
useEffect(() => {
|
|
let ticking = false;
|
|
|
|
const updateSectionCache = () => {
|
|
const sections = ['home', 'services', 'products', 'cases', 'about', 'news', 'contact'];
|
|
sections.forEach(sectionId => {
|
|
const element = document.getElementById(sectionId);
|
|
if (element) {
|
|
sectionCacheRef.current.set(sectionId, {
|
|
offsetTop: element.offsetTop,
|
|
offsetHeight: element.offsetHeight
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
updateSectionCache();
|
|
|
|
const handleScroll = () => {
|
|
if (!ticking) {
|
|
requestAnimationFrame(() => {
|
|
setIsScrolled(window.scrollY > 20);
|
|
|
|
if (pathname === '/' && !isManualNavigationRef.current) {
|
|
const scrollPosition = window.scrollY + 100;
|
|
const sections = ['home', 'services', 'products', 'cases', 'about', 'news', 'contact'];
|
|
let currentSection = 'home';
|
|
|
|
for (const sectionId of sections) {
|
|
const cached = sectionCacheRef.current.get(sectionId);
|
|
if (cached && scrollPosition >= cached.offsetTop && scrollPosition < cached.offsetTop + cached.offsetHeight) {
|
|
currentSection = sectionId;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (currentSection !== activeSectionRef.current) {
|
|
setActiveSection(currentSection);
|
|
}
|
|
}
|
|
ticking = false;
|
|
});
|
|
ticking = true;
|
|
}
|
|
};
|
|
|
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && isOpen) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
window.addEventListener('keydown', handleGlobalKeyDown);
|
|
window.addEventListener('resize', updateSectionCache);
|
|
handleScroll();
|
|
|
|
return () => {
|
|
window.removeEventListener('scroll', handleScroll);
|
|
window.removeEventListener('keydown', handleGlobalKeyDown);
|
|
window.removeEventListener('resize', updateSectionCache);
|
|
if (manualNavTimeoutRef.current) {
|
|
clearTimeout(manualNavTimeoutRef.current);
|
|
}
|
|
};
|
|
}, [pathname, 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((item: NavigationItem) => {
|
|
if (pathname === '/' && item.href.startsWith('/#')) {
|
|
isManualNavigationRef.current = true;
|
|
|
|
if (manualNavTimeoutRef.current) {
|
|
clearTimeout(manualNavTimeoutRef.current);
|
|
}
|
|
|
|
setActiveSection(item.id);
|
|
|
|
const targetElement = document.getElementById(item.id);
|
|
if (targetElement) {
|
|
const checkScrollComplete = () => {
|
|
const targetPosition = targetElement.offsetTop;
|
|
const currentPosition = window.scrollY;
|
|
const threshold = 100;
|
|
|
|
if (Math.abs(currentPosition - targetPosition) < threshold) {
|
|
manualNavTimeoutRef.current = setTimeout(() => {
|
|
isManualNavigationRef.current = false;
|
|
}, 500);
|
|
} else {
|
|
requestAnimationFrame(checkScrollComplete);
|
|
}
|
|
};
|
|
|
|
requestAnimationFrame(checkScrollComplete);
|
|
} else {
|
|
manualNavTimeoutRef.current = setTimeout(() => {
|
|
isManualNavigationRef.current = false;
|
|
}, 2000);
|
|
}
|
|
}
|
|
setIsOpen(false);
|
|
}, [pathname]);
|
|
|
|
const isActive = useCallback((item: NavigationItem) => {
|
|
if (pathname === '/') {
|
|
return activeSection === item.id;
|
|
}
|
|
|
|
const navPath = item.href.split('#')[0];
|
|
return pathname === navPath || pathname.startsWith(navPath + '/');
|
|
}, [pathname, activeSection]);
|
|
|
|
const navigationItems = useMemo(() => NAVIGATION, []);
|
|
|
|
return (
|
|
<>
|
|
<header
|
|
className={`
|
|
fixed top-0 left-0 right-0 z-50
|
|
transition-all duration-300 ease-out
|
|
${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">
|
|
<Link
|
|
href="/"
|
|
className="flex items-center group"
|
|
>
|
|
<Image
|
|
src="/logo.svg"
|
|
alt={COMPANY_INFO.name}
|
|
width={32}
|
|
height={32}
|
|
className="h-8 w-auto transition-transform duration-200 group-hover:scale-105"
|
|
priority
|
|
/>
|
|
</Link>
|
|
|
|
<nav className="hidden md:flex items-center gap-1" role="navigation" aria-label="主导航">
|
|
{navigationItems.map((item) => (
|
|
<Link
|
|
key={item.id}
|
|
href={item.href}
|
|
onClick={() => handleNavClick(item)}
|
|
className={`
|
|
relative px-3 py-1.5 text-sm font-medium
|
|
transition-all duration-300
|
|
${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-[#C41E3A] rounded-full
|
|
transition-all duration-200 ease-out
|
|
${isActive(item)
|
|
? 'opacity-100 scale-x-100'
|
|
: 'opacity-0 scale-x-0'
|
|
}
|
|
`}
|
|
/>
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="hidden md:flex items-center gap-3">
|
|
<Button
|
|
size="sm"
|
|
asChild
|
|
>
|
|
<Link href="/contact">立即咨询</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<button
|
|
className="md:hidden p-3 -mr-3 text-[#3D3D3D] hover:text-[#1C1C1C] hover:bg-[#F5F5F5] rounded-lg transition-all duration-200 active:scale-95"
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
onKeyDown={handleKeyDown}
|
|
aria-expanded={isOpen}
|
|
aria-controls="mobile-menu"
|
|
aria-label={isOpen ? '关闭菜单' : '打开菜单'}
|
|
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="移动端导航"
|
|
>
|
|
<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 }}
|
|
>
|
|
<Link
|
|
href={item.href}
|
|
onClick={() => handleNavClick(item)}
|
|
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-[#C41E3A]'
|
|
: 'text-[#3D3D3D] hover:text-[#1C1C1C] hover:bg-[#F5F5F5]'
|
|
}
|
|
`}
|
|
style={{ minHeight: '48px', display: 'flex', alignItems: 'center' }}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
</motion.div>
|
|
))}
|
|
<div className="mt-6 px-4 pt-6 border-t border-[#E2E8F0]">
|
|
<Button
|
|
className="w-full"
|
|
asChild
|
|
size="lg"
|
|
>
|
|
<Link href="/contact" onClick={() => setIsOpen(false)}>
|
|
联系我们
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</nav>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
);
|
|
}
|