feat: 重构联系页面并增强安全性
refactor: 优化导航和路由逻辑 fix: 修复移动端样式问题 perf: 优化字体加载和性能 test: 添加安全性和可访问性测试 style: 调整按钮和表单样式 chore: 更新依赖版本 ci: 添加安全头配置 build: 优化构建配置 docs: 更新常量信息
This commit is contained in:
@@ -1,73 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { Suspense, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { usePathname, useSearchParams, useRouter } 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() {
|
||||
function HeaderContent() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState('home');
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
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);
|
||||
const isScrollingRef = useRef(false);
|
||||
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const getActiveSection = useCallback(() => {
|
||||
if (pathname === '/contact') return 'contact';
|
||||
if (pathname === '/') {
|
||||
const section = searchParams.get('section');
|
||||
return section || 'home';
|
||||
}
|
||||
return '';
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
const activeSection = getActiveSection();
|
||||
|
||||
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';
|
||||
setIsScrolled(window.scrollY > 20);
|
||||
|
||||
if (pathname === '/' && !isScrollingRef.current) {
|
||||
const scrollPosition = window.scrollY + 100;
|
||||
const sections = ['home', 'services', 'products', 'cases', 'about', 'news'];
|
||||
|
||||
for (const sectionId of sections) {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
const offsetTop = element.offsetTop;
|
||||
const offsetHeight = element.offsetHeight;
|
||||
|
||||
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 (scrollPosition >= offsetTop && scrollPosition < offsetTop + offsetHeight) {
|
||||
const currentSection = searchParams.get('section') || 'home';
|
||||
if (currentSection !== sectionId) {
|
||||
const url = sectionId === 'home' ? '/' : `/?section=${sectionId}`;
|
||||
window.history.replaceState(null, '', url);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSection !== activeSectionRef.current) {
|
||||
setActiveSection(currentSection);
|
||||
break;
|
||||
}
|
||||
}
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,18 +66,15 @@ export function Header() {
|
||||
|
||||
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);
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [pathname, isOpen]);
|
||||
}, [pathname, isOpen, searchParams]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -102,52 +86,56 @@ export function Header() {
|
||||
}
|
||||
}, [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);
|
||||
}
|
||||
};
|
||||
const handleNavClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>, item: NavigationItem) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (item.id === 'contact') {
|
||||
router.push('/contact');
|
||||
} else if (item.id === 'home') {
|
||||
if (pathname === '/') {
|
||||
isScrollingRef.current = true;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
window.history.pushState(null, '', '/');
|
||||
|
||||
requestAnimationFrame(checkScrollComplete);
|
||||
scrollTimeoutRef.current = setTimeout(() => {
|
||||
isScrollingRef.current = false;
|
||||
}, 1000);
|
||||
} else {
|
||||
manualNavTimeoutRef.current = setTimeout(() => {
|
||||
isManualNavigationRef.current = false;
|
||||
}, 2000);
|
||||
router.push('/');
|
||||
}
|
||||
} else {
|
||||
if (pathname === '/') {
|
||||
const element = document.getElementById(item.id);
|
||||
if (element) {
|
||||
isScrollingRef.current = true;
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
window.history.pushState(null, '', `/?section=${item.id}`);
|
||||
|
||||
scrollTimeoutRef.current = setTimeout(() => {
|
||||
isScrollingRef.current = false;
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
router.push(`/?section=${item.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
}, [pathname]);
|
||||
}, [pathname, router]);
|
||||
|
||||
const isActive = useCallback((item: NavigationItem) => {
|
||||
if (item.id === 'contact') {
|
||||
return pathname === '/contact';
|
||||
}
|
||||
|
||||
if (pathname === '/') {
|
||||
return activeSection === item.id;
|
||||
}
|
||||
|
||||
const navPath = item.href.split('#')[0];
|
||||
return pathname === navPath || pathname.startsWith(navPath + '/');
|
||||
return false;
|
||||
}, [pathname, activeSection]);
|
||||
|
||||
const navigationItems = useMemo(() => NAVIGATION, []);
|
||||
const navigationItems = NAVIGATION;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -182,7 +170,7 @@ export function Header() {
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
onClick={() => handleNavClick(item)}
|
||||
onClick={(e) => handleNavClick(e, item)}
|
||||
className={`
|
||||
relative px-3 py-1.5 text-sm font-medium
|
||||
transition-all duration-300
|
||||
@@ -267,7 +255,7 @@ export function Header() {
|
||||
>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={() => handleNavClick(item)}
|
||||
onClick={(e) => handleNavClick(e, item)}
|
||||
className={`
|
||||
block px-4 py-4 text-base font-medium rounded-lg
|
||||
transition-all duration-200
|
||||
@@ -301,3 +289,11 @@ export function Header() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<Suspense fallback={<div className="h-16" />}>
|
||||
<HeaderContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,19 +26,19 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleNavClick = (href: string) => {
|
||||
const handleNavClick = (id: string) => {
|
||||
setIsOpen(false);
|
||||
const element = document.querySelector(href);
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent, href?: string) => {
|
||||
const handleKeyDown = (event: React.KeyboardEvent, id?: string) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
if (href) {
|
||||
handleNavClick(href);
|
||||
if (id) {
|
||||
handleNavClick(id);
|
||||
} else {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onKeyDown={(e) => handleKeyDown(e)}
|
||||
className="p-2 rounded-md hover:bg-[#F5F5F5] transition-colors focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-offset-2"
|
||||
className="p-3 rounded-md hover:bg-[#F5F5F5] transition-colors focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-offset-2 min-w-[48px] min-h-[48px] flex items-center justify-center"
|
||||
aria-label={isOpen ? '关闭菜单' : '打开菜单'}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="mobile-menu-panel"
|
||||
@@ -84,9 +84,9 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
||||
{NAVIGATION.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
onClick={() => handleNavClick(item.href)}
|
||||
onKeyDown={(e) => handleKeyDown(e, item.href)}
|
||||
className="block w-full text-left px-4 py-3 text-[#171717] hover:bg-[#FEF2F4] hover:text-[#C41E3A] rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-inset"
|
||||
onClick={() => handleNavClick(item.id)}
|
||||
onKeyDown={(e) => handleKeyDown(e, item.id)}
|
||||
className="block w-full text-left px-4 py-4 text-[#171717] hover:bg-[#FEF2F4] hover:text-[#C41E3A] rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:ring-inset min-h-[48px]"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
|
||||
@@ -36,9 +36,9 @@ export function MobileTabBar() {
|
||||
<Link
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
className="flex flex-col items-center justify-center flex-1 h-full relative group"
|
||||
className="flex flex-col items-center justify-center flex-1 h-full relative group min-h-[48px]"
|
||||
>
|
||||
<div className="relative flex flex-col items-center justify-center">
|
||||
<div className="relative flex flex-col items-center justify-center py-2">
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-6 h-6 transition-colors',
|
||||
@@ -56,7 +56,7 @@ export function MobileTabBar() {
|
||||
{active && (
|
||||
<motion.div
|
||||
layoutId="activeTab"
|
||||
className="absolute -bottom-2 w-8 h-0.5 bg-[#C41E3A] rounded-full"
|
||||
className="absolute -bottom-1 w-8 h-0.5 bg-[#C41E3A] rounded-full"
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { motion } from 'framer-motion';
|
||||
import { RippleButton, SealButton } from '@/components/ui/ripple-button';
|
||||
import { GradientText, MagneticButton, BlurReveal, CounterWithEffect } from '@/lib/animations';
|
||||
import { InkBackground } from '@/components/ui/ink-decoration';
|
||||
import { DataParticleFlow } from '@/components/effects/data-particle-flow';
|
||||
import { SubtleDots } from '@/components/effects/subtle-dots';
|
||||
import { COMPANY_INFO, STATS } from '@/lib/constants';
|
||||
import { ArrowRight, Shield, Zap, Award } from 'lucide-react';
|
||||
|
||||
const InkBackground = dynamic(
|
||||
() => import('@/components/ui/ink-decoration').then(mod => ({ default: mod.InkBackground })),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const DataParticleFlow = dynamic(
|
||||
() => import('@/components/effects/data-particle-flow').then(mod => ({ default: mod.DataParticleFlow })),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const SubtleDots = dynamic(
|
||||
() => import('@/components/effects/subtle-dots').then(mod => ({ default: mod.SubtleDots })),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const features = [
|
||||
{ icon: Shield, text: '安全可靠' },
|
||||
{ icon: Zap, text: '高效便捷' },
|
||||
@@ -136,15 +150,15 @@ export function HeroSection() {
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8"
|
||||
>
|
||||
<MagneticButton strength={0.4}>
|
||||
<SealButton
|
||||
size="lg"
|
||||
onClick={() => handleScrollTo('contact')}
|
||||
onKeyDown={(e) => handleKeyDown(e, 'contact')}
|
||||
className="min-w-45"
|
||||
>
|
||||
立即咨询
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</SealButton>
|
||||
<Link href="/contact">
|
||||
<SealButton
|
||||
size="lg"
|
||||
className="min-w-45"
|
||||
>
|
||||
立即咨询
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</SealButton>
|
||||
</Link>
|
||||
</MagneticButton>
|
||||
<MagneticButton strength={0.4}>
|
||||
<RippleButton
|
||||
|
||||
@@ -115,15 +115,12 @@ export function ProductsSection() {
|
||||
</p>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
const element = document.getElementById('contact');
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
联系我们
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
<Link href="/contact">
|
||||
联系我们
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,10 +26,10 @@ const rippleButtonVariants = cva(
|
||||
'bg-[#C41E3A] text-white font-semibold hover:bg-[#A01830] shadow-[0_6px_20px_rgba(196,30,58,0.3)]',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
default: 'h-11 px-4 py-2.5',
|
||||
sm: 'h-9 rounded-md px-3 text-xs',
|
||||
lg: 'h-12 rounded-lg px-6 text-base',
|
||||
icon: 'h-10 w-10',
|
||||
icon: 'h-11 w-11',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
Reference in New Issue
Block a user