Files
novalon-website/src/components/layout/header.tsx
T
zhangxiang f8917d4fda feat(ui): 营销站 UI/UE/UX 治理 P0→P3 收官
复评(impeccable critique)全部 Priority Issues 与 Minor Observations 落地:
- P0 数字口径:MetricsBasisNote 单一真源 + metrics-basis.test fs 防线,接入
  products/solutions/services/home/about;纯渲染端加角注,不动 DB/seed 值
- P1 转化断头路:敬请期待态 + 404 语境出口 + services 空态双出口
- P1 首页并卡:TrustSection 早鸟横幅并入 CasesSection 单张深色共创卡
- P2 CTA 治理:CONSULT_CTA_ALLOWLIST 白名单 + 逐字符扫描器,/contact CTA 全归一
- P3 polish:footer 补 服务/公司列、contact 死三元/空槽/toast、GlobalErrorTracker 幂等日志
- 文档同源:PRODUCT/CONTEXT 动效带统一 180–280ms;DESIGN.md 立 The 10px Floor;
  font-floor.test 守卫禁止再引入 <10px(保留 10/11px 有意 Bain 微标签)

Gates: type-check 0 · jest 1581/132 · lint 0e/126w · contrast 7/7 · headings 10/10
2026-09-20 11:50:58 +08:00

273 lines
10 KiB
TypeScript

'use client';
import { 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, MessageCircle } from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
import { Button } from '@/components/ui/button';
import { useSiteConfig, type NavigationItem } from '@/lib/site-config';
import { MegaDropdown } from '@/components/layout/mega-dropdown';
import { useFocusTrap } from '@/hooks/use-focus-trap';
import { cn } from '@/lib/utils';
import { ThemeToggle } from '@/components/theme/theme-toggle';
function HeaderContent() {
const [isOpen, setIsOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const pathname = usePathname();
const focusTrapRef = useFocusTrap<HTMLDivElement>(isOpen);
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) => {
// Escape 关闭菜单;Enter/Space 已由原生 button 的 onClick 处理,
// 此处不再拦截,避免键盘操作时重复触发 toggle。
if (e.key === 'Escape' && isOpen) {
e.preventDefault();
setIsOpen(false);
}
}, [isOpen]);
const config = useSiteConfig();
const handleNavClick = useCallback(() => {
// StaticLink 新契约:onClick 先于导航执行,不调 preventDefault 即由
// next/link 完成客户端跳转,此处只负责关闭抽屉。
setIsOpen(false);
}, []);
const isActive = useCallback((item: NavigationItem) => {
if (item.id === 'contact') {
return pathname === '/contact';
}
if (item.id === 'home') {
return pathname === '/';
}
if (item.id === 'products') {
return pathname === '/products' || pathname.startsWith('/products/');
}
if (item.id === 'solutions') {
return pathname === '/solutions' || pathname.startsWith('/solutions/');
}
if (item.id === 'cases') {
return pathname === '/cases' || pathname.startsWith('/cases/');
}
if (item.id === 'services') {
return pathname === '/services' || pathname.startsWith('/services/');
}
return pathname === `/${item.id}`;
}, [pathname]);
return (
<>
<motion.header
initial={false}
animate={{
height: isScrolled ? 64 : 80,
}}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
className="fixed top-0 left-0 right-0 z-50 transition-colors duration-300 bg-bg-primary border-b border-border-primary"
>
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<div className="flex items-center justify-between h-16 md:h-full">
<StaticLink
href="/"
className="relative flex items-center group/logo"
aria-label="返回首页"
>
{/* Logo 明暗双版:浅色背景用 logo.svg(黑字),深色背景用 logo-white.svg(白字)。
按 html[data-theme] 显隐(规则见 globals.css),修复黑字在深色背景被吞的 bug。 */}
<Image
src="/logo.svg"
alt={config.name}
width={160}
height={40}
className="logo-on-light relative transition-all duration-300 ease-out group-hover/logo:scale-[1.02] w-auto h-8 md:h-10"
loading="eager"
priority
suppressHydrationWarning
/>
<Image
src="/logo-white.svg"
alt={config.name}
width={160}
height={40}
className="logo-on-dark relative transition-all duration-300 ease-out group-hover/logo:scale-[1.02] w-auto h-8 md:h-10"
loading="eager"
suppressHydrationWarning
/>
</StaticLink>
<nav className="hidden md:flex items-center gap-0.5" role="navigation" aria-label="主导航" data-testid="desktop-navigation">
{config.mainNav.map((item) => (
item.hasDropdown ? (
<MegaDropdown
key={item.id}
label={item.label}
groups={config.megaDropdown[item.dropdownKey!] ?? []}
isOpen={openDropdown === item.id}
isActive={isActive(item)}
onToggle={() => {
setOpenDropdown((prev) => prev === item.id ? null : item.id);
}}
onOpen={() => setOpenDropdown(item.id)}
onClose={() => setOpenDropdown((prev) => prev === item.id ? null : prev)}
/>
) : (
<div key={item.id} className="relative -mx-2 px-2 py-2">
<StaticLink
href={item.href}
onClick={handleNavClick}
className={cn(
'relative inline-flex items-center px-3 py-2 text-sm font-medium',
'transition-all duration-300 ease-out',
isActive(item)
? 'text-text-primary'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-secondary'
)}
aria-current={isActive(item) ? 'page' : undefined}
>
{item.label}
<span
className={cn(
'absolute -bottom-0.5 left-3 right-3 h-0.5 bg-brand',
'transition-all duration-300 ease-out',
isActive(item)
? 'opacity-100 scale-x-100'
: 'opacity-0 scale-x-0'
)}
/>
</StaticLink>
</div>
)
))}
</nav>
<div className="hidden md:flex items-center gap-3">
<ThemeToggle />
<Button
size="sm"
asChild
className=""
>
<StaticLink href="/contact" data-testid="consult-button" aria-label="预约咨询">
<MessageCircle className="w-4 h-4" />
<span className="hidden lg:inline">预约咨询</span>
<span className="lg:hidden" aria-hidden="true">咨询</span>
</StaticLink>
</Button>
</div>
<button
className="md:hidden p-2 -mr-2 text-text-secondary hover:text-text-primary hover:bg-bg-secondary rounded-sm transition-all duration-300 ease-out active:scale-95"
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-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</div>
</div>
</motion.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/50 backdrop-blur-sm"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
className="absolute top-16 right-0 bottom-0 left-0 bg-bg-primary/98 backdrop-blur-xl overflow-y-auto"
id="mobile-menu"
role="navigation"
aria-label="移动端导航"
data-testid="mobile-navigation"
>
<nav className="max-w-container mx-auto px-4 sm:px-6 py-6">
{config.mainNav.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}
className={`
block px-4 py-4 text-base font-medium
transition-all duration-300 ease-out
${isActive(item)
? 'text-text-primary bg-bg-secondary'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-secondary'
}
`}
style={{ minHeight: '48px', display: 'flex', alignItems: 'center' }}
>
{item.label}
</StaticLink>
</motion.div>
))}
<div className="mt-6 px-4 pt-6 border-t border-border-primary">
<Button
className="w-full"
asChild
size="lg"
>
<StaticLink href="/contact" onClick={() => setIsOpen(false)}>
<MessageCircle className="w-4 h-4" />
预约咨询
</StaticLink>
</Button>
</div>
</nav>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}
export function Header() {
return <HeaderContent />;
}