feat(layout): 重构布局组件体系与数据层

- 重构 Header 导航、Footer 页脚、MobileMenu 移动端菜单
- 新增 MobileTabBar 移动端底部导航栏
- 更新 MegaDropdown 大型下拉导航
- 重构 SEO 结构化数据组件
- 更新数据层常量 (products, services, solutions, navigation 等)
- 新增 useCountUp 数字递增 Hook
- 修复 .gitignore 中 src/lib 被错误忽略的问题
This commit is contained in:
张翔
2026-07-07 06:53:13 +08:00
parent e78df62cd1
commit 767931202d
28 changed files with 898 additions and 468 deletions
+57 -64
View File
@@ -7,9 +7,7 @@ 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 { ThemeToggle } from '@/components/ui/theme-toggle';
import { useTheme } from '@/contexts/theme-context';
import { COMPANY_INFO, NAVIGATION_V2, MEGA_DROPDOWN_DATA, type NavigationItemV2 } from '@/lib/constants';
import { useSiteConfig, type NavigationItem } from '@/lib/site-config';
import { MegaDropdown } from '@/components/layout/mega-dropdown';
import { useFocusTrap } from '@/hooks/use-focus-trap';
@@ -19,7 +17,6 @@ function HeaderContent() {
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const pathname = usePathname();
const focusTrapRef = useFocusTrap<HTMLDivElement>(isOpen);
const { resolvedTheme } = useTheme();
useEffect(() => {
const handleScroll = () => {
@@ -51,13 +48,14 @@ function HeaderContent() {
}
}, [isOpen]);
const handleNavClick = useCallback((_e: React.MouseEvent<HTMLAnchorElement>, item: NavigationItemV2) => {
const config = useSiteConfig();
const handleNavClick = useCallback((_e: React.MouseEvent<HTMLAnchorElement>, item: NavigationItem) => {
setIsOpen(false);
// StaticLink 会 e.preventDefault(),需要手动执行导航
window.location.href = item.href;
}, []);
const isActive = useCallback((item: NavigationItemV2) => {
const isActive = useCallback((item: NavigationItem) => {
if (item.id === 'contact') {
return pathname === '/contact';
}
@@ -70,49 +68,56 @@ function HeaderContent() {
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 (
<>
<header
<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-all duration-300 ease-out
${isScrolled
? 'bg-[var(--color-bg-primary)]/90 backdrop-blur-xl border-b border-[var(--color-border-primary)] shadow-sm'
: 'bg-transparent'
}
bg-white border-b border-border-primary
`}
>
<div className="container-wide">
<div className="flex items-center justify-between h-16">
<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="返回首页"
>
<div className="absolute inset-0 rounded-lg bg-[var(--color-brand-primary)] opacity-0 group-hover/logo:opacity-5 blur-xl transition-all duration-500 scale-75 group-hover/logo:scale-100" />
<Image
src="/logo.svg"
alt={COMPANY_INFO.name}
width={120}
height={30}
className={`relative transition-all duration-300 group-hover/logo:scale-105 w-auto h-8 md:h-8 ${resolvedTheme === 'dark' ? 'dark:invert dark:brightness-0 dark:hue-rotate-180' : ''}`}
alt={config.name}
width={160}
height={40}
className="relative transition-all duration-300 ease-out group-hover/logo:scale-[1.02] w-auto h-8 md:h-10"
loading="eager"
priority
suppressHydrationWarning
/>
</StaticLink>
<nav className="hidden md:flex items-center gap-1" role="navigation" aria-label="主导航" data-testid="desktop-navigation">
{NAVIGATION_V2.map((item) => (
<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={MEGA_DROPDOWN_DATA[item.dropdownKey!] ?? []}
groups={config.megaDropdown[item.dropdownKey!] ?? []}
isOpen={openDropdown === item.id}
isActive={isActive(item)}
onToggle={() => {
setOpenDropdown((prev) => prev === item.id ? null : item.id);
}}
@@ -120,16 +125,16 @@ function HeaderContent() {
onClose={() => setOpenDropdown((prev) => prev === item.id ? null : prev)}
/>
) : (
<div key={item.id} className="-mx-2 px-2 py-2">
<div key={item.id} className="px-1 py-2">
<StaticLink
href={item.href}
onClick={(e) => handleNavClick(e, item)}
className={`
relative block px-3 py-1.5 text-sm font-medium
transition-all duration-300 rounded-md
relative block px-3 py-2 text-sm font-medium
transition-all duration-300 ease-out
${isActive(item)
? 'text-[var(--color-text-primary)] bg-[var(--color-brand-primary-bg)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]'
? 'text-text-primary'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-secondary'
}
`}
aria-current={isActive(item) ? 'page' : undefined}
@@ -137,8 +142,8 @@ function HeaderContent() {
{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
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'
@@ -152,28 +157,20 @@ function HeaderContent() {
</nav>
<div className="hidden md:flex items-center gap-3">
<ThemeToggle />
<Button
size="sm"
asChild
className="hidden lg:flex"
>
<StaticLink href="/contact" data-testid="consult-button">
<MessageCircle className="w-4 h-4 mr-1.5" />
<MessageCircle className="w-4 h-4" />
<span className="hidden lg:inline"></span>
<span className="lg:hidden" aria-hidden="true"></span>
</StaticLink>
</Button>
<Button
size="sm"
asChild
className="lg:hidden"
>
<StaticLink href="/contact" data-testid="consult-button"></StaticLink>
</Button>
</div>
<button
className="md:hidden p-3 -mr-3 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-primary-lighter)] rounded-lg transition-all duration-200 active:scale-95"
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}
@@ -182,11 +179,11 @@ function HeaderContent() {
data-testid="mobile-menu-button"
style={{ minWidth: '44px', minHeight: '44px' }}
>
{isOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</div>
</div>
</header>
</motion.header>
<AnimatePresence mode="wait">
{isOpen && (
@@ -199,7 +196,7 @@ function HeaderContent() {
className="fixed inset-0 z-40 md:hidden"
>
<div
className="absolute inset-0 bg-[var(--color-primary)]/30 backdrop-blur-sm"
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
@@ -207,15 +204,15 @@ function HeaderContent() {
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-[var(--color-bg-primary)]/98 backdrop-blur-xl shadow-2xl overflow-y-auto"
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
className="absolute top-16 right-0 bottom-0 left-0 bg-white/98 backdrop-blur-xl overflow-y-auto"
id="mobile-menu"
role="navigation"
aria-label="移动端导航"
data-testid="mobile-navigation"
>
<nav className="container-wide py-6">
{NAVIGATION_V2.map((item, index) => (
<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 }}
@@ -226,11 +223,11 @@ function HeaderContent() {
href={item.href}
onClick={(e) => handleNavClick(e, item)}
className={`
block px-4 py-4 text-base font-medium rounded-lg
transition-all duration-200
block px-4 py-4 text-base font-medium
transition-all duration-300 ease-out
${isActive(item)
? 'text-[var(--color-text-primary)] bg-[var(--color-brand-primary-bg)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-primary-lighter)]'
? 'text-text-primary bg-bg-secondary'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-secondary'
}
`}
style={{ minHeight: '48px', display: 'flex', alignItems: 'center' }}
@@ -239,18 +236,14 @@ function HeaderContent() {
</StaticLink>
</motion.div>
))}
<div className="mt-6 px-4 pt-6 border-t border-[var(--color-border-primary)]">
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-[var(--color-text-muted)]"></span>
<ThemeToggle />
</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 mr-2" />
<MessageCircle className="w-4 h-4" />
</StaticLink>
</Button>
@@ -267,15 +260,15 @@ function HeaderContent() {
function HeaderFallback() {
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-transparent">
<div className="container-wide">
<div className="container-x">
<div className="flex items-center justify-between h-16">
<div className="h-8 w-8 bg-[var(--color-skeleton-bg)] animate-pulse rounded" />
<div className="h-8 w-24 bg-bg-tertiary animate-skeleton-pulse rounded-sm" />
<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-[var(--color-skeleton-bg)] animate-pulse rounded mx-1" />
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="h-6 w-16 bg-bg-tertiary animate-skeleton-pulse rounded-sm mx-1" />
))}
</nav>
<div className="h-9 w-20 bg-[var(--color-skeleton-bg)] animate-pulse rounded" />
<div className="h-9 w-20 bg-bg-tertiary animate-skeleton-pulse rounded-sm" />
</div>
</div>
</header>