feat(layout): 重构布局组件体系与数据层
- 重构 Header 导航、Footer 页脚、MobileMenu 移动端菜单 - 新增 MobileTabBar 移动端底部导航栏 - 更新 MegaDropdown 大型下拉导航 - 重构 SEO 结构化数据组件 - 更新数据层常量 (products, services, solutions, navigation 等) - 新增 useCountUp 数字递增 Hook - 修复 .gitignore 中 src/lib 被错误忽略的问题
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user