refactor: 完成静态网站转换,移除所有 CMS 和动态功能

- 删除数据库相关代码 (src/db/)
- 删除 API 路由 (src/app/api/)
- 删除认证相关代码 (src/lib/auth/, src/providers/)
- 删除监控和安全中间件 (src/lib/security/, src/lib/monitoring/)
- 删除 hooks (use-news, use-products, use-services)
- 更新组件为静态数据源
- 添加 nginx 静态配置和部署脚本
- 添加 static-link 组件
This commit is contained in:
张翔
2026-04-21 07:53:56 +08:00
parent cd1d6aa28a
commit 6403489954
197 changed files with 654 additions and 24762 deletions
-237
View File
@@ -1,237 +0,0 @@
'use client';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import {
Bold,
Italic,
Strikethrough,
Code,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Undo,
Redo,
Link as LinkIcon,
Image as ImageIcon
} from 'lucide-react';
import { useCallback, useState } from 'react';
interface RichTextEditorProps {
content: string;
onChange: (content: string) => void;
}
export default function RichTextEditor({ content, onChange }: RichTextEditorProps) {
const [uploading, setUploading] = useState(false);
const editor = useEditor({
extensions: [
StarterKit,
Image.configure({
HTMLAttributes: {
class: 'max-w-full h-auto rounded-lg',
},
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-[#C41E3A] underline',
},
}),
],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
});
const handleImageUpload = useCallback(async () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
setUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
formData.append('type', 'image');
const res = await fetch('/api/admin/upload', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (res.ok && editor) {
editor.chain().focus().setImage({ src: data.file.url }).run();
}
} catch (error) {
console.error('上传图片失败:', error);
} finally {
setUploading(false);
}
};
input.click();
}, [editor]);
const addLink = useCallback(() => {
if (!editor) return;
const url = window.prompt('输入链接地址:');
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
}, [editor]);
if (!editor) {
return (
<div className="border border-gray-300 rounded-lg overflow-hidden">
<div className="bg-gray-50 border-b border-gray-300 p-2 h-12 flex items-center justify-center">
<div className="h-4 w-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
<span className="ml-2 text-sm text-gray-500">...</span>
</div>
<div className="min-h-[200px] bg-gray-50" />
</div>
);
}
return (
<div className="border border-gray-300 rounded-lg overflow-hidden">
<div className="bg-gray-50 border-b border-gray-300 p-2 flex flex-wrap gap-1">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('bold') ? 'bg-gray-200' : ''}`}
title="粗体"
>
<Bold className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('italic') ? 'bg-gray-200' : ''}`}
title="斜体"
>
<Italic className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('strike') ? 'bg-gray-200' : ''}`}
title="删除线"
>
<Strikethrough className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('code') ? 'bg-gray-200' : ''}`}
title="代码"
>
<Code className="h-4 w-4" />
</button>
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('heading', { level: 1 }) ? 'bg-gray-200' : ''}`}
title="标题 1"
>
<Heading1 className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('heading', { level: 2 }) ? 'bg-gray-200' : ''}`}
title="标题 2"
>
<Heading2 className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('heading', { level: 3 }) ? 'bg-gray-200' : ''}`}
title="标题 3"
>
<Heading3 className="h-4 w-4" />
</button>
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('bulletList') ? 'bg-gray-200' : ''}`}
title="无序列表"
>
<List className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('orderedList') ? 'bg-gray-200' : ''}`}
title="有序列表"
>
<ListOrdered className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('blockquote') ? 'bg-gray-200' : ''}`}
title="引用"
>
<Quote className="h-4 w-4" />
</button>
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
<button
onClick={addLink}
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('link') ? 'bg-gray-200' : ''}`}
title="链接"
>
<LinkIcon className="h-4 w-4" />
</button>
<button
onClick={handleImageUpload}
disabled={uploading}
className="p-2 rounded hover:bg-gray-200 disabled:opacity-50"
title="上传图片"
>
{uploading ? (
<div className="h-4 w-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
) : (
<ImageIcon className="h-4 w-4" />
)}
</button>
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
<button
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
className="p-2 rounded hover:bg-gray-200 disabled:opacity-50"
title="撤销"
>
<Undo className="h-4 w-4" />
</button>
<button
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
className="p-2 rounded hover:bg-gray-200 disabled:opacity-50"
title="重做"
>
<Redo className="h-4 w-4" />
</button>
</div>
<EditorContent
editor={editor}
className="prose prose-sm max-w-none p-4 min-h-[300px] focus:outline-none"
/>
</div>
);
}
+2 -10
View File
@@ -1,16 +1,10 @@
'use client';
import Script from 'next/script';
import { useEffect } from 'react';
import { GA_MEASUREMENT_ID } from '@/lib/analytics';
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
export function GoogleAnalytics() {
useEffect(() => {
if (GA_MEASUREMENT_ID) {
console.log('Google Analytics initialized:', GA_MEASUREMENT_ID);
}
}, []);
if (!GA_MEASUREMENT_ID) {
return null;
}
@@ -25,10 +19,8 @@ export function GoogleAnalytics() {
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
window.gtag = gtag;
gtag('js', new Date());
gtag('config', '${GA_MEASUREMENT_ID}', {
page_path: window.location.pathname,
send_page_view: false
});
`}
@@ -1,21 +0,0 @@
import '@testing-library/jest-dom';
jest.mock('./GoogleAnalytics', () => ({
GoogleAnalytics: () => null,
}));
jest.mock('./web-vitals', () => ({
WebVitals: () => null,
}));
describe('Analytics Components', () => {
it('should export GoogleAnalytics', () => {
const { GoogleAnalytics } = require('./GoogleAnalytics');
expect(GoogleAnalytics).toBeDefined();
});
it('should export WebVitals', () => {
const { WebVitals } = require('./web-vitals');
expect(WebVitals).toBeDefined();
});
});
-33
View File
@@ -1,33 +0,0 @@
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitals() {
useReportWebVitals((metric) => {
if (process.env.NODE_ENV === 'development') {
console.log('[Web Vitals]', metric);
}
if (process.env.NODE_ENV === 'production') {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
});
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/analytics', body);
} else {
fetch('/api/analytics', {
body,
method: 'POST',
keepalive: true,
}).catch(() => {});
}
}
});
return null;
}
+5 -5
View File
@@ -1,6 +1,6 @@
'use client';
import Link from 'next/link';
import { StaticLink } from '@/components/ui/static-link';
import { ChevronRight, Home } from 'lucide-react';
interface BreadcrumbItem {
@@ -15,18 +15,18 @@ interface BreadcrumbProps {
export function Breadcrumb({ items }: BreadcrumbProps) {
return (
<nav aria-label="breadcrumb" className="flex items-center space-x-2 text-sm text-[#5C5C5C] py-4">
<Link href="/" className="flex items-center hover:text-[#C41E3A] transition-colors">
<StaticLink href="/" className="flex items-center hover:text-[#C41E3A] transition-colors">
<Home className="w-4 h-4" />
</Link>
</StaticLink>
{items.map((item, index) => (
<div key={index} className="flex items-center">
<ChevronRight className="w-4 h-4 text-[#E5E5E5]" />
<Link
<StaticLink
href={item.href}
className="ml-2 hover:text-[#C41E3A] transition-colors"
>
{item.label}
</Link>
</StaticLink>
</div>
))}
</nav>
+19 -19
View File
@@ -1,4 +1,4 @@
import Link from 'next/link';
import { StaticLink } from '@/components/ui/static-link';
import Image from 'next/image';
import { Mail, MapPin } from 'lucide-react';
import { COMPANY_INFO, NAVIGATION } from '@/lib/constants';
@@ -44,12 +44,12 @@ export function Footer() {
<ul className="space-y-2.5">
{NAVIGATION.map((item) => (
<li key={item.id}>
<Link
<StaticLink
href={item.href}
className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1"
>
{item.label}
</Link>
</StaticLink>
</li>
))}
</ul>
@@ -58,24 +58,24 @@ export function Footer() {
<h3 className="font-semibold text-lg mb-4 text-[#1C1C1C]"></h3>
<ul className="space-y-2.5">
<li>
<Link href="/services/software" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1">
<StaticLink href="/services/software" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1">
</Link>
</StaticLink>
</li>
<li>
<Link href="/services/cloud" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1">
</Link>
</li>
<li>
<Link href="/services/data" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1">
<StaticLink href="/services/data" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1">
</Link>
</StaticLink>
</li>
<li>
<Link href="/services/security" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1">
</Link>
<StaticLink href="/services/consulting" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1">
</StaticLink>
</li>
<li>
<StaticLink href="/services/solutions" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1">
</StaticLink>
</li>
</ul>
</div>
@@ -116,12 +116,12 @@ export function Footer() {
© {new Date().getFullYear()} {COMPANY_INFO.name}. All rights reserved.
</p>
<div className="flex gap-6">
<Link href="/privacy" className="text-[#5C5C5C] hover:text-[#C41E3A] text-sm transition-colors duration-200">
<StaticLink href="/privacy" className="text-[#5C5C5C] hover:text-[#C41E3A] text-sm transition-colors duration-200">
</Link>
<Link href="/terms" className="text-[#5C5C5C] hover:text-[#C41E3A] text-sm transition-colors duration-200">
</StaticLink>
<StaticLink href="/terms" className="text-[#5C5C5C] hover:text-[#C41E3A] text-sm transition-colors duration-200">
</Link>
</StaticLink>
</div>
</div>
+16 -17
View File
@@ -1,9 +1,9 @@
'use client';
import { Suspense, useState, useEffect, useCallback, useRef } from 'react';
import Link from 'next/link';
import { StaticLink } from '@/components/ui/static-link';
import Image from 'next/image';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
import { usePathname, useSearchParams } from 'next/navigation';
import { Menu, X } from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
import { Button } from '@/components/ui/button';
@@ -15,13 +15,12 @@ function HeaderContent() {
const [isScrolled, setIsScrolled] = useState(false);
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
const focusTrapRef = useFocusTrap<HTMLDivElement>(isOpen);
const isScrollingRef = useRef(false);
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const getActiveSection = useCallback(() => {
if (pathname === '/contact') return 'contact';
if (pathname === '/contact') {return 'contact';}
if (pathname === '/') {
const section = searchParams.get('section');
return section || 'home';
@@ -90,7 +89,7 @@ function HeaderContent() {
e.preventDefault();
if (item.id === 'contact') {
router.push('/contact');
window.location.href = '/contact';
} else if (item.id === 'home') {
if (pathname === '/') {
isScrollingRef.current = true;
@@ -101,7 +100,7 @@ function HeaderContent() {
isScrollingRef.current = false;
}, 1000);
} else {
router.push('/');
window.location.href = '/';
}
} else {
if (pathname === '/') {
@@ -121,12 +120,12 @@ function HeaderContent() {
};
scrollToSection();
} else {
router.push(`/?section=${item.id}`);
window.location.href = `/?section=${item.id}`;
}
}
setIsOpen(false);
}, [pathname, router]);
}, [pathname]);
const isActive = useCallback((item: NavigationItem) => {
if (item.id === 'contact') {
@@ -156,7 +155,7 @@ function HeaderContent() {
>
<div className="container-wide">
<div className="flex items-center justify-between h-16">
<Link
<StaticLink
href="/"
className="flex items-center group"
>
@@ -168,11 +167,11 @@ function HeaderContent() {
className="h-8 w-auto transition-transform duration-200 group-hover:scale-105"
priority
/>
</Link>
</StaticLink>
<nav className="hidden md:flex items-center gap-1" role="navigation" aria-label="主导航" data-testid="desktop-navigation">
{navigationItems.map((item) => (
<Link
<StaticLink
key={item.id}
href={item.href}
onClick={(e) => handleNavClick(e, item)}
@@ -197,7 +196,7 @@ function HeaderContent() {
}
`}
/>
</Link>
</StaticLink>
))}
</nav>
@@ -206,7 +205,7 @@ function HeaderContent() {
size="sm"
asChild
>
<Link href="/contact" data-testid="consult-button"></Link>
<StaticLink href="/contact" data-testid="consult-button"></StaticLink>
</Button>
</div>
@@ -260,7 +259,7 @@ function HeaderContent() {
animate={{ x: 0, opacity: 1 }}
transition={{ delay: index * 0.05 }}
>
<Link
<StaticLink
href={item.href}
onClick={(e) => handleNavClick(e, item)}
className={`
@@ -274,7 +273,7 @@ function HeaderContent() {
style={{ minHeight: '48px', display: 'flex', alignItems: 'center' }}
>
{item.label}
</Link>
</StaticLink>
</motion.div>
))}
<div className="mt-6 px-4 pt-6 border-t border-[#E2E8F0]">
@@ -283,9 +282,9 @@ function HeaderContent() {
asChild
size="lg"
>
<Link href="/contact" onClick={() => setIsOpen(false)}>
<StaticLink href="/contact" onClick={() => setIsOpen(false)}>
</Link>
</StaticLink>
</Button>
</div>
</nav>
+3 -3
View File
@@ -1,6 +1,6 @@
'use client';
import Link from 'next/link';
import { StaticLink } from '@/components/ui/static-link';
import { usePathname } from 'next/navigation';
import { Home, Briefcase, Package, FileText, User } from 'lucide-react';
import { motion } from 'framer-motion';
@@ -33,7 +33,7 @@ export function MobileTabBar() {
const active = isActive(tab.href);
return (
<Link
<StaticLink
key={tab.id}
href={tab.href}
className="flex flex-col items-center justify-center flex-1 h-full relative group min-h-12"
@@ -61,7 +61,7 @@ export function MobileTabBar() {
/>
)}
</div>
</Link>
</StaticLink>
);
})}
</div>
+3 -3
View File
@@ -3,7 +3,7 @@
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef } from 'react';
import Link from 'next/link';
import { StaticLink } from '@/components/ui/static-link';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { COMPANY_INFO, STATS } from '@/lib/constants';
@@ -66,10 +66,10 @@ export function AboutSection() {
className="text-center"
>
<Button size="lg" variant="outline" className="group" asChild>
<Link href="/about">
<StaticLink href="/about">
<ArrowRight className="ml-2 w-4 h-4 transition-transform group-hover:translate-x-1" />
</Link>
</StaticLink>
</Button>
</motion.div>
</motion.div>
@@ -1,172 +0,0 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { CasesSection } from './cases-section';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
},
useInView: () => true,
}));
jest.mock('next/link', () => {
return ({ children, href }: any) => <a href={href}>{children}</a>;
});
jest.mock('@/components/ui/card', () => ({
Card: ({ children, className, ...props }: any) => (
<div className={className} {...props}>{children}</div>
),
CardContent: ({ children, className, ...props }: any) => (
<div className={className} {...props}>{children}</div>
),
}));
jest.mock('@/components/ui/button', () => ({
Button: ({ children, className, ...props }: any) => (
<button className={className} {...props}>{children}</button>
),
}));
jest.mock('@/components/ui/badge', () => ({
Badge: ({ children, className, ...props }: any) => (
<span className={className} {...props}>{children}</span>
),
}));
jest.mock('@/components/ui/touch-swipe', () => ({
TouchSwipe: ({ children, className }: any) => (
<div className={className}>{children}</div>
),
}));
jest.mock('lucide-react', () => ({
ArrowRight: () => <span data-testid="arrow-right-icon" />,
Building2: () => <span data-testid="building-icon" />,
}));
const mockCases = [
{
id: 'case-1',
title: '测试案例',
excerpt: '测试描述',
category: '制造业',
slug: 'test-case-1',
},
{
id: 'case-2',
title: '测试案例2',
excerpt: '测试描述2',
category: '零售业',
slug: 'test-case-2',
},
];
jest.mock('@/lib/api/services', () => ({
contentService: {
getCases: jest.fn(),
},
}));
import { contentService } from '@/lib/api/services';
describe('CasesSection', () => {
beforeEach(() => {
jest.clearAllMocks();
(contentService.getCases as jest.Mock).mockResolvedValue(mockCases);
});
describe('Rendering', () => {
it('should render cases section', async () => {
render(<CasesSection />);
await waitFor(() => {
const section = document.querySelector('section#cases');
expect(section).toBeInTheDocument();
});
});
it('should render section heading', async () => {
render(<CasesSection />);
await waitFor(() => {
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
});
});
it('should render section description', async () => {
render(<CasesSection />);
await waitFor(() => {
expect(screen.getByText(/我们与优秀的企业同行/)).toBeInTheDocument();
});
});
it('should render case cards', async () => {
render(<CasesSection />);
await waitFor(() => {
expect(screen.getByText('测试案例')).toBeInTheDocument();
});
});
it('should render industry badges', async () => {
render(<CasesSection />);
await waitFor(() => {
expect(screen.getByText('制造业')).toBeInTheDocument();
});
});
it('should render view more button', async () => {
render(<CasesSection />);
await waitFor(() => {
expect(screen.getByText(/查看更多案例/)).toBeInTheDocument();
});
});
});
describe('Accessibility', () => {
it('should have section id', async () => {
render(<CasesSection />);
await waitFor(() => {
const section = document.querySelector('section#cases');
expect(section).toBeInTheDocument();
});
});
it('should have region role', async () => {
render(<CasesSection />);
await waitFor(() => {
const section = document.querySelector('section[role="region"]');
expect(section).toBeInTheDocument();
});
});
it('should have aria-labelledby', async () => {
render(<CasesSection />);
await waitFor(() => {
const section = document.querySelector('section[aria-labelledby="cases-heading"]');
expect(section).toBeInTheDocument();
});
});
});
describe('Loading State', () => {
it('should show loading state initially', () => {
(contentService.getCases as jest.Mock).mockImplementation(() =>
new Promise(resolve => setTimeout(() => resolve(mockCases), 100))
);
render(<CasesSection />);
expect(screen.getByText('加载中...')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('should handle fetch errors gracefully', async () => {
(contentService.getCases as jest.Mock).mockRejectedValue(new Error('API Error'));
render(<CasesSection />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
});
});
});
+10 -48
View File
@@ -2,56 +2,18 @@
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef, useState, useEffect } from 'react';
import Link from 'next/link';
import { useRef } from 'react';
import { StaticLink } from '@/components/ui/static-link';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TouchSwipe } from '@/components/ui/touch-swipe';
import { contentService } from '@/lib/api/services';
import { CASES } from '@/lib/constants';
import { ArrowRight, Building2 } from 'lucide-react';
interface CaseItem {
id: string;
title: string;
excerpt: string;
category: string;
slug: string;
}
export function CasesSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
const [cases, setCases] = useState<CaseItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchCases = async () => {
try {
const casesData = await contentService.getCases(3);
setCases(casesData);
} catch (error) {
console.error('Failed to fetch cases:', error);
} finally {
setLoading(false);
}
};
fetchCases();
}, []);
if (loading) {
return (
<section id="cases" role="region" aria-labelledby="cases-heading" className="py-24 bg-white relative overflow-hidden">
<div className="container-wide relative z-10">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#C41E3A] mx-auto mb-4" />
<p className="text-[#5C5C5C]">...</p>
</div>
</div>
</section>
);
}
return (
<section id="cases" role="region" aria-labelledby="cases-heading" className="py-24 bg-white relative overflow-hidden" ref={ref}>
@@ -81,20 +43,20 @@ export function CasesSection() {
className="md:hidden"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{cases.map((caseItem, index) => (
{CASES.map((caseItem, index) => (
<motion.div
key={caseItem.id}
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.1 + index * 0.1 }}
>
<Link href={`/cases/${caseItem.slug}`}>
<StaticLink href={`/cases/${caseItem.id}`}>
<Card className="h-full group cursor-pointer border-[#E5E5E5] hover:border-[#C41E3A] transition-colors overflow-hidden">
<div className="relative h-40 bg-gradient-to-br from-[#F5F5F5] to-[#E5E5E5] flex items-center justify-center">
<Building2 className="w-16 h-16 text-[#C41E3A]/20 group-hover:scale-110 transition-transform duration-300" />
<div className="absolute top-4 right-4">
<Badge className="bg-white/90 text-[#1C1C1C] hover:bg-white">
{caseItem.category}
{caseItem.industry}
</Badge>
</div>
</div>
@@ -107,11 +69,11 @@ export function CasesSection() {
{caseItem.title}
</h3>
<p className="text-[#5C5C5C] text-sm line-clamp-2 mb-4">
{caseItem.excerpt}
{caseItem.description}
</p>
</CardContent>
</Card>
</Link>
</StaticLink>
</motion.div>
))}
</div>
@@ -124,10 +86,10 @@ export function CasesSection() {
className="text-center mt-12"
>
<Button variant="outline" size="lg" className="group" asChild>
<Link href="/cases">
<StaticLink href="/cases">
<ArrowRight className="ml-2 w-4 h-4 transition-transform group-hover:translate-x-1" />
</Link>
</StaticLink>
</Button>
</motion.div>
</div>
+43 -129
View File
@@ -6,11 +6,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Toast } from '@/components/ui/toast';
import { sanitizeInput } from '@/lib/sanitize';
import { generateCSRFToken, setCSRFTokenToStorage, getCSRFTokenFromStorage } from '@/lib/csrf';
import { generateCaptcha } from '@/lib/security/captcha';
import { useFormAutosave } from '@/hooks/use-form-autosave';
import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2, RefreshCw, Save } from 'lucide-react';
import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2, Save } from 'lucide-react';
import { COMPANY_INFO } from '@/lib/constants';
const contactFormSchema = z.object({
@@ -27,7 +24,6 @@ interface FormErrors {
phone?: string;
email?: string;
message?: string;
captcha?: string;
}
export function ContactSection() {
@@ -38,8 +34,6 @@ export function ContactSection() {
const [toastMessage, setToastMessage] = useState('');
const [toastType, setToastType] = useState<'success' | 'error'>('success');
const [errors, setErrors] = useState<FormErrors>({});
const [captcha, setCaptcha] = useState(generateCaptcha('simple'));
const [captchaAnswer, setCaptchaAnswer] = useState('');
const sectionRef = useRef<HTMLElement>(null);
// 使用表单自动保存功能
@@ -74,9 +68,6 @@ export function ContactSection() {
observer.observe(sectionRef.current);
}
const csrfToken = generateCSRFToken();
setCSRFTokenToStorage(csrfToken);
return () => observer.disconnect();
}, []);
@@ -95,10 +86,9 @@ export function ContactSection() {
};
const handleChange = (field: keyof ContactFormData, value: string) => {
const sanitizedValue = sanitizeInput(value);
updateData({ [field]: sanitizedValue });
updateData({ [field]: value });
if (errors[field]) {
validateField(field, sanitizedValue);
validateField(field, value);
}
};
@@ -106,32 +96,9 @@ export function ContactSection() {
validateField(field, value);
};
const handleCaptchaRefresh = () => {
setCaptcha(generateCaptcha('simple'));
setCaptchaAnswer('');
setErrors((prev) => ({ ...prev, captcha: undefined }));
};
const handleCaptchaChange = (value: string) => {
setCaptchaAnswer(value);
if (errors.captcha) {
setErrors((prev) => ({ ...prev, captcha: undefined }));
}
};
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const storedToken = getCSRFTokenFromStorage();
if (!storedToken) {
setToastMessage('安全验证失败,请刷新页面重试。');
setToastType('error');
setShowToast(true);
return;
}
const result = contactFormSchema.safeParse(formData);
if (!result.success) {
const fieldErrors: FormErrors = {};
result.error.issues.forEach((issue) => {
@@ -141,50 +108,30 @@ export function ContactSection() {
setErrors(fieldErrors);
return;
}
if (!captchaAnswer || parseInt(captchaAnswer) !== captcha.answer) {
setErrors((prev) => ({ ...prev, captcha: '验证码错误,请重新计算' }));
handleCaptchaRefresh();
return;
}
setIsSubmitting(true);
try {
const response = await fetch('/api/contact', {
const response = await fetch('https://formspree.io/f/' + process.env.NEXT_PUBLIC_FORMSPREE_ID, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...formData,
csrfToken: storedToken,
mathHash: captcha.hash,
mathTimestamp: captcha.timestamp,
mathAnswer: captcha.answer,
}),
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(formData),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '提交失败');
if (response.ok) {
setIsSubmitted(true);
clearSavedData();
setToastMessage('表单提交成功!我们会尽快与您联系。');
setToastType('success');
setShowToast(true);
} else {
setToastMessage('提交失败,请稍后重试。');
setToastType('error');
setShowToast(true);
}
const newCsrfToken = generateCSRFToken();
setCSRFTokenToStorage(newCsrfToken);
setIsSubmitting(false);
setIsSubmitted(true);
clearSavedData(); // 提交成功后清除保存的数据
setToastMessage('表单提交成功!我们会尽快与您联系。');
setToastType('success');
setShowToast(true);
} catch (error) {
setIsSubmitting(false);
setToastMessage(error instanceof Error ? error.message : '提交失败,请稍后重试。');
} catch {
setToastMessage('网络错误,请稍后重试。');
setToastType('error');
setShowToast(true);
} finally {
setIsSubmitting(false);
}
}
@@ -203,7 +150,7 @@ export function ContactSection() {
</div>
<div className="container-wide relative z-10">
<div
<div
className={`
mb-16 opacity-0 translate-y-4
${isVisible ? 'animate-fade-in-up' : ''}
@@ -222,7 +169,7 @@ export function ContactSection() {
</div>
<div className="grid lg:grid-cols-5 gap-12 lg:gap-16">
<div
<div
className={`
lg:col-span-2 space-y-8 flex flex-col
opacity-0 translate-y-4
@@ -327,7 +274,7 @@ export function ContactSection() {
</button>
</div>
)}
{isSubmitted ? (
<div className="text-center py-12 flex-1 flex items-center justify-center" data-testid="success-message">
<div className="w-16 h-16 bg-[#C41E3A] rounded-full flex items-center justify-center mx-auto mb-4 animate-stamp-in">
@@ -339,23 +286,23 @@ export function ContactSection() {
) : (
<form onSubmit={handleSubmit} className="space-y-5 flex-1 flex flex-col">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
<Input
label="姓名"
id="name"
placeholder="请输入您的姓名"
required
id="name"
placeholder="请输入您的姓名"
required
data-testid="name-input"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
onBlur={(e) => handleBlur('name', e.target.value)}
error={errors.name}
/>
<Input
<Input
label="电话"
id="phone"
type="tel"
placeholder="请输入您的电话"
required
id="phone"
type="tel"
placeholder="请输入您的电话"
required
data-testid="phone-input"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
@@ -363,65 +310,32 @@ export function ContactSection() {
error={errors.phone}
/>
</div>
<Input
<Input
label="邮箱"
id="email"
type="email"
placeholder="请输入您的邮箱"
required
id="email"
type="email"
placeholder="请输入您的邮箱"
required
data-testid="email-input"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
onBlur={(e) => handleBlur('email', e.target.value)}
error={errors.email}
/>
<Textarea
<Textarea
label="留言内容"
id="message"
placeholder="请输入您想咨询的内容"
id="message"
placeholder="请输入您想咨询的内容"
rows={5}
required
required
data-testid="message-input"
value={formData.message}
onChange={(e) => handleChange('message', e.target.value)}
onBlur={(e) => handleBlur('message', e.target.value)}
error={errors.message}
/>
<div className="space-y-2">
<label htmlFor="captcha" className="block text-sm font-medium text-[#1A1A2E]">
<span className="text-[#C41E3A]">*</span>
</label>
<div className="flex items-center gap-3">
<div className="bg-[#E2E8F0] px-4 py-2 rounded-md font-mono text-lg text-[#1A1A2E] min-w-30 text-center" data-testid="captcha-question">
{captcha.question}
</div>
<div className="flex-1">
<Input
id="captcha"
type="number"
placeholder="请输入答案"
required
data-testid="captcha-input"
value={captchaAnswer}
onChange={(e) => handleCaptchaChange(e.target.value)}
error={errors.captcha}
/>
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCaptchaRefresh}
disabled={isSubmitting}
data-testid="refresh-captcha"
className="shrink-0"
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
</div>
<Button
type="submit"
<Button
type="submit"
size="lg"
className="w-full group mt-auto min-h-13 md:min-h-0"
disabled={isSubmitting}
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import Link from 'next/link';
import { StaticLink } from '@/components/ui/static-link';
import { RippleButton, SealButton } from '@/components/ui/ripple-button';
import { MagneticButton, BlurReveal, CounterWithEffect } from '@/lib/animations';
import { COMPANY_INFO, STATS } from '@/lib/constants';
@@ -102,12 +102,12 @@ export function HeroButtons({ isVisible }: HeroContentProps) {
className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8"
>
<MagneticButton strength={0.4}>
<Link href="/contact">
<StaticLink href="/contact">
<SealButton size="lg" className="min-w-45">
<ArrowRight className="w-4 h-4 ml-2" />
</SealButton>
</Link>
</StaticLink>
</MagneticButton>
<MagneticButton strength={0.4}>
<RippleButton
@@ -157,7 +157,7 @@ export function HeroStats() {
useEffect(() => {
const statsEl = document.getElementById('stats-section');
if (!statsEl) return;
if (!statsEl) {return;}
const observer = new IntersectionObserver(
([entry]) => {
+1 -1
View File
@@ -45,7 +45,7 @@ export function HeroSection() {
id="home"
ref={sectionRef}
aria-labelledby="hero-heading"
className="relative min-h-screen flex items-center pt-16 overflow-hidden bg-linear-to-b from-[#FAFAFA] to-white"
className="relative min-h-screen flex items-center overflow-hidden bg-linear-to-b from-[#FAFAFA] to-white"
>
<InkBackground />
<DataParticleFlow
@@ -1,513 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { NewsSection } from './news-section';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
},
useInView: () => true,
}));
jest.mock('next/link', () => {
return ({ children, href }: any) => <a href={href}>{children}</a>;
});
jest.mock('@/hooks/use-news', () => ({
useNews: jest.fn(),
}));
const { useNews } = require('@/hooks/use-news');
describe('NewsSection Integration', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('Data Loading States', () => {
it('should show loading state when data is loading', () => {
useNews.mockReturnValue({
news: [],
loading: true,
error: null,
refetch: jest.fn(),
});
render(<NewsSection />);
expect(screen.getByText('加载中...')).toBeInTheDocument();
});
it('should render news when data is loaded successfully', async () => {
const mockNews = [
{
id: '1',
title: '测试新闻1',
excerpt: '这是一个测试新闻',
category: '公司新闻',
date: '2026-01-15',
},
{
id: '2',
title: '测试新闻2',
excerpt: '这是另一个测试新闻',
category: '行业资讯',
date: '2026-01-16',
},
];
useNews.mockReturnValue({
news: mockNews,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<NewsSection />);
await waitFor(() => {
expect(screen.getByText('测试新闻1')).toBeInTheDocument();
expect(screen.getByText('测试新闻2')).toBeInTheDocument();
});
});
it('should show error message when data loading fails', async () => {
useNews.mockReturnValue({
news: [],
loading: false,
error: new Error('Failed to load news'),
refetch: jest.fn(),
});
render(<NewsSection />);
await waitFor(() => {
expect(screen.getByText('加载新闻信息失败,请稍后重试')).toBeInTheDocument();
});
});
it('should show empty state when no news are available', async () => {
useNews.mockReturnValue({
news: [],
loading: false,
error: null,
refetch: jest.fn(),
});
render(<NewsSection />);
await waitFor(() => {
expect(screen.getByText('暂无新闻信息')).toBeInTheDocument();
});
});
});
describe('News Display', () => {
it('should render all news from API', async () => {
const mockNews = [
{
id: '1',
title: '新闻A',
excerpt: '摘要A',
category: '公司新闻',
date: '2026-01-15',
},
{
id: '2',
title: '新闻B',
excerpt: '摘要B',
category: '行业资讯',
date: '2026-01-16',
},
{
id: '3',
title: '新闻C',
excerpt: '摘要C',
category: '技术分享',
date: '2026-01-17',
},
];
useNews.mockReturnValue({
news: mockNews,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<NewsSection />);
await waitFor(() => {
expect(screen.getByText('新闻A')).toBeInTheDocument();
expect(screen.getByText('新闻B')).toBeInTheDocument();
expect(screen.getByText('新闻C')).toBeInTheDocument();
});
});
it('should display news categories correctly', async () => {
const mockNews = [
{
id: '1',
title: '新闻A',
excerpt: '摘要A',
category: '公司新闻',
date: '2026-01-15',
},
];
useNews.mockReturnValue({
news: mockNews,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<NewsSection />);
await waitFor(() => {
expect(screen.getByText('公司新闻')).toBeInTheDocument();
});
});
it('should display news dates correctly', async () => {
const mockNews = [
{
id: '1',
title: '新闻A',
excerpt: '摘要A',
category: '公司新闻',
date: '2026-01-15',
},
];
useNews.mockReturnValue({
news: mockNews,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<NewsSection />);
await waitFor(() => {
expect(screen.getByText('2026-01-15')).toBeInTheDocument();
});
});
it('should display news excerpts', async () => {
const mockNews = [
{
id: '1',
title: '新闻A',
excerpt: '这是一个关于公司最新发展的新闻摘要',
category: '公司新闻',
date: '2026-01-15',
},
];
useNews.mockReturnValue({
news: mockNews,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<NewsSection />);
await waitFor(() => {
expect(screen.getByText('这是一个关于公司最新发展的新闻摘要')).toBeInTheDocument();
});
});
});
describe('News Filtering', () => {
it('should filter news by categories config', async () => {
const mockNews = [
{
id: '1',
title: '新闻A',
excerpt: '摘要A',
category: '公司新闻',
date: '2026-01-15',
},
{
id: '2',
title: '新闻B',
excerpt: '摘要B',
category: '行业资讯',
date: '2026-01-16',
},
{
id: '3',
title: '新闻C',
excerpt: '摘要C',
category: '公司新闻',
date: '2026-01-17',
},
];
useNews.mockReturnValue({
news: mockNews,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<NewsSection config={{ categories: ['公司新闻'] }} />);
await waitFor(() => {
expect(screen.getByText('新闻A')).toBeInTheDocument();
expect(screen.getByText('新闻C')).toBeInTheDocument();
expect(screen.queryByText('新闻B')).not.toBeInTheDocument();
});
});
it('should show all news when no categories config is provided', async () => {
const mockNews = [
{
id: '1',
title: '新闻A',
excerpt: '摘要A',
category: '公司新闻',
date: '2026-01-15',
},
{
id: '2',
title: '新闻B',
excerpt: '摘要B',
category: '行业资讯',
date: '2026-01-16',
},
];
useNews.mockReturnValue({
news: mockNews,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<NewsSection />);
await waitFor(() => {
expect(screen.getByText('新闻A')).toBeInTheDocument();
expect(screen.getByText('新闻B')).toBeInTheDocument();
});
});
it('should limit news display count', async () => {
const mockNews = [
{
id: '1',
title: '新闻A',
excerpt: '摘要A',
category: '公司新闻',
date: '2026-01-15',
},
{
id: '2',
title: '新闻B',
excerpt: '摘要B',
category: '行业资讯',
date: '2026-01-16',
},
{
id: '3',
title: '新闻C',
excerpt: '摘要C',
category: '技术分享',
date: '2026-01-17',
},
];
useNews.mockReturnValue({
news: mockNews,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<NewsSection config={{ displayCount: 2 }} />);
await waitFor(() => {
expect(screen.getByText('新闻C')).toBeInTheDocument();
expect(screen.getByText('新闻B')).toBeInTheDocument();
expect(screen.queryByText('新闻A')).not.toBeInTheDocument();
});
});
});
describe('Sorting', () => {
it('should sort news in descending order by default', async () => {
const mockNews = [
{
id: '1',
title: '新闻A',
excerpt: '摘要A',
category: '公司新闻',
date: '2026-01-15',
},
{
id: '2',
title: '新闻B',
excerpt: '摘要B',
category: '行业资讯',
date: '2026-01-17',
},
{
id: '3',
title: '新闻C',
excerpt: '摘要C',
category: '技术分享',
date: '2026-01-16',
},
];
useNews.mockReturnValue({
news: mockNews,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<NewsSection config={{ sortOrder: 'desc' }} />);
await waitFor(() => {
const newsItems = screen.getAllByText(/新闻[ABC]/);
expect(newsItems[0]).toHaveTextContent('新闻B');
expect(newsItems[1]).toHaveTextContent('新闻C');
expect(newsItems[2]).toHaveTextContent('新闻A');
});
});
it('should sort news in ascending order when configured', async () => {
const mockNews = [
{
id: '1',
title: '新闻A',
excerpt: '摘要A',
category: '公司新闻',
date: '2026-01-15',
},
{
id: '2',
title: '新闻B',
excerpt: '摘要B',
category: '行业资讯',
date: '2026-01-17',
},
{
id: '3',
title: '新闻C',
excerpt: '摘要C',
category: '技术分享',
date: '2026-01-16',
},
];
useNews.mockReturnValue({
news: mockNews,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<NewsSection config={{ sortOrder: 'asc' }} />);
await waitFor(() => {
const newsItems = screen.getAllByText(/新闻[ABC]/);
expect(newsItems[0]).toHaveTextContent('新闻A');
expect(newsItems[1]).toHaveTextContent('新闻C');
expect(newsItems[2]).toHaveTextContent('新闻B');
});
});
});
describe('Navigation', () => {
it('should link to news detail pages', async () => {
const mockNews = [
{
id: '1',
title: '新闻A',
excerpt: '摘要A',
category: '公司新闻',
date: '2026-01-15',
},
];
useNews.mockReturnValue({
news: mockNews,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<NewsSection />);
await waitFor(() => {
const newsLink = screen.getByRole('link', { name: /阅读更多/ });
expect(newsLink).toHaveAttribute('href', '/news/1');
});
});
it('should link to all news page', async () => {
const mockNews = [
{
id: '1',
title: '新闻A',
excerpt: '摘要A',
category: '公司新闻',
date: '2026-01-15',
},
];
useNews.mockReturnValue({
news: mockNews,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<NewsSection />);
await waitFor(() => {
const allNewsLink = screen.getByRole('link', { name: /查看全部新闻/ });
expect(allNewsLink).toHaveAttribute('href', '/news');
});
});
});
describe('Accessibility', () => {
it('should maintain accessibility with dynamic data', async () => {
const mockNews = [
{
id: '1',
title: '新闻A',
excerpt: '摘要A',
category: '公司新闻',
date: '2026-01-15',
},
];
useNews.mockReturnValue({
news: mockNews,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<NewsSection />);
await waitFor(() => {
const section = screen.getByRole('region');
expect(section).toBeInTheDocument();
expect(section).toHaveAttribute('aria-labelledby', 'news-heading');
});
});
});
});
+11 -64
View File
@@ -2,72 +2,19 @@
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef, useMemo } from 'react';
import Link from 'next/link';
import { useRef } from 'react';
import { StaticLink } from '@/components/ui/static-link';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { ArrowRight, Calendar } from 'lucide-react';
import { useNews } from '@/hooks/use-news';
import { NEWS } from '@/lib/constants';
interface NewsConfig {
enabled?: boolean;
displayCount?: number;
categories?: string[];
sortOrder?: 'asc' | 'desc';
}
interface NewsSectionProps {
config?: NewsConfig;
}
export function NewsSection({ config }: NewsSectionProps) {
export function NewsSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
const { news, loading, error } = useNews();
const displayedNews = useMemo(() => {
if (!news || news.length === 0) {
return [];
}
let filtered = news;
if (config?.categories && config.categories.length > 0) {
filtered = filtered.filter(newsItem => config.categories?.includes(newsItem.category));
}
if (config?.sortOrder === 'asc') {
filtered = [...filtered].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
} else {
filtered = [...filtered].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
const count = config?.displayCount || 4;
return filtered.slice(0, count);
}, [news, config]);
if (loading) {
return (
<section id="news" role="region" aria-labelledby="news-heading" className="py-24 bg-[#F5F5F5]" ref={ref}>
<div className="container-custom">
<div className="text-center">
<p className="text-lg text-[#5C5C5C]">...</p>
</div>
</div>
</section>
);
}
if (error) {
return (
<section id="news" role="region" aria-labelledby="news-heading" className="py-24 bg-[#F5F5F5]" ref={ref}>
<div className="container-custom">
<div className="text-center">
<p className="text-lg text-red-600"></p>
</div>
</div>
</section>
);
}
const displayedNews = [...NEWS]
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 4);
return (
<section id="news" role="region" aria-labelledby="news-heading" className="py-24 bg-[#F5F5F5]" ref={ref}>
@@ -112,13 +59,13 @@ export function NewsSection({ config }: NewsSectionProps) {
<CardDescription className="text-base leading-relaxed mb-6 flex-1">
{newsItem.excerpt}
</CardDescription>
<Link
<StaticLink
href={`/news/${newsItem.id}`}
className="inline-flex items-center text-sm font-medium text-[#1C1C1C] hover:text-[#C41E3A] transition-colors group/link"
>
<ArrowRight className="ml-1 w-4 h-4 transition-transform group-hover/link:translate-x-1" />
</Link>
</StaticLink>
</CardContent>
</Card>
</motion.div>
@@ -136,13 +83,13 @@ export function NewsSection({ config }: NewsSectionProps) {
transition={{ duration: 0.6, delay: 0.5 }}
className="mt-12 text-center"
>
<Link
<StaticLink
href="/news"
className="inline-flex items-center text-sm font-medium text-[#1C1C1C] hover:text-[#C41E3A] transition-colors bg-transparent border-none cursor-pointer group"
>
<ArrowRight className="ml-1 w-4 h-4 transition-transform group-hover:translate-x-1" />
</Link>
</StaticLink>
</motion.div>
</div>
</section>
@@ -1,472 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ProductsSection } from './products-section';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
},
useInView: () => true,
}));
jest.mock('next/link', () => {
return ({ children, href }: any) => <a href={href}>{children}</a>;
});
jest.mock('@/hooks/use-products', () => ({
useProducts: jest.fn(),
}));
const { useProducts } = require('@/hooks/use-products');
describe('ProductsSection Integration', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('Data Loading States', () => {
it('should show loading state when data is loading', () => {
useProducts.mockReturnValue({
products: [],
loading: true,
error: null,
refetch: jest.fn(),
});
render(<ProductsSection />);
expect(screen.getByText('加载中...')).toBeInTheDocument();
});
it('should render products when data is loaded successfully', async () => {
const mockProducts = [
{
id: '1',
title: '测试产品1',
description: '这是一个测试产品',
category: '软件',
features: ['功能1', '功能2'],
benefits: ['价值1', '价值2'],
},
{
id: '2',
title: '测试产品2',
description: '这是另一个测试产品',
category: '硬件',
features: ['功能3', '功能4'],
benefits: ['价值3', '价值4'],
},
];
useProducts.mockReturnValue({
products: mockProducts,
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<ProductsSection />);
await waitFor(() => {
expect(screen.getByText('测试产品1')).toBeInTheDocument();
expect(screen.getByText('测试产品2')).toBeInTheDocument();
});
});
it('should show error message when data loading fails', async () => {
useProducts.mockReturnValue({
products: [],
isLoading: false,
error: new Error('Failed to load products'),
refetch: jest.fn(),
});
render(<ProductsSection />);
await waitFor(() => {
expect(screen.getByText('加载产品信息失败,请稍后重试')).toBeInTheDocument();
});
});
it('should show empty state when no products are available', async () => {
useProducts.mockReturnValue({
products: [],
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<ProductsSection />);
await waitFor(() => {
expect(screen.getByText('暂无产品信息')).toBeInTheDocument();
});
});
});
describe('Product Display', () => {
it('should render all products from API', async () => {
const mockProducts = [
{
id: '1',
title: '产品A',
description: '描述A',
category: '类别1',
features: ['功能A1', '功能A2'],
benefits: ['价值A1'],
},
{
id: '2',
title: '产品B',
description: '描述B',
category: '类别2',
features: ['功能B1'],
benefits: ['价值B1', '价值B2'],
},
{
id: '3',
title: '产品C',
description: '描述C',
category: '类别1',
features: ['功能C1', '功能C2', '功能C3'],
benefits: ['价值C1'],
},
];
useProducts.mockReturnValue({
products: mockProducts,
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<ProductsSection />);
await waitFor(() => {
expect(screen.getByText('产品A')).toBeInTheDocument();
expect(screen.getByText('产品B')).toBeInTheDocument();
expect(screen.getByText('产品C')).toBeInTheDocument();
});
});
it('should display product categories correctly', async () => {
const mockProducts = [
{
id: '1',
title: '产品A',
description: '描述A',
category: '企业软件',
features: ['功能A1'],
benefits: ['价值A1'],
},
];
useProducts.mockReturnValue({
products: mockProducts,
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<ProductsSection />);
await waitFor(() => {
expect(screen.getByText('企业软件')).toBeInTheDocument();
});
});
it('should display product features', async () => {
const mockProducts = [
{
id: '1',
title: '产品A',
description: '描述A',
category: '软件',
features: ['智能分析', '实时监控', '自动化报告'],
benefits: ['提高效率'],
},
];
useProducts.mockReturnValue({
products: mockProducts,
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<ProductsSection />);
await waitFor(() => {
expect(screen.getByText('智能分析')).toBeInTheDocument();
expect(screen.getByText('实时监控')).toBeInTheDocument();
expect(screen.getByText('自动化报告')).toBeInTheDocument();
});
});
it('should display product benefits', async () => {
const mockProducts = [
{
id: '1',
title: '产品A',
description: '描述A',
category: '软件',
features: ['功能A1'],
benefits: ['降低成本', '提高效率', '增强竞争力'],
},
];
useProducts.mockReturnValue({
products: mockProducts,
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<ProductsSection />);
await waitFor(() => {
expect(screen.getByText('降低成本')).toBeInTheDocument();
expect(screen.getByText('提高效率')).toBeInTheDocument();
expect(screen.getByText('增强竞争力')).toBeInTheDocument();
});
});
});
describe('Product Filtering', () => {
it('should filter products by featured products config', async () => {
const mockProducts = [
{
id: '1',
title: '产品A',
description: '描述A',
category: '软件',
features: ['功能A1'],
benefits: ['价值A1'],
},
{
id: '2',
title: '产品B',
description: '描述B',
category: '硬件',
features: ['功能B1'],
benefits: ['价值B1'],
},
{
id: '3',
title: '产品C',
description: '描述C',
category: '服务',
features: ['功能C1'],
benefits: ['价值C1'],
},
];
useProducts.mockReturnValue({
products: mockProducts,
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<ProductsSection config={{ featuredProducts: ['1', '3'] }} />);
await waitFor(() => {
expect(screen.getByText('产品A')).toBeInTheDocument();
expect(screen.getByText('产品C')).toBeInTheDocument();
expect(screen.queryByText('产品B')).not.toBeInTheDocument();
});
});
it('should show all products when no featured products config is provided', async () => {
const mockProducts = [
{
id: '1',
title: '产品A',
description: '描述A',
category: '软件',
features: ['功能A1'],
benefits: ['价值A1'],
},
{
id: '2',
title: '产品B',
description: '描述B',
category: '硬件',
features: ['功能B1'],
benefits: ['价值B1'],
},
];
useProducts.mockReturnValue({
products: mockProducts,
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<ProductsSection />);
await waitFor(() => {
expect(screen.getByText('产品A')).toBeInTheDocument();
expect(screen.getByText('产品B')).toBeInTheDocument();
});
});
});
describe('Pricing Display', () => {
it('should display pricing when showPricing is enabled', async () => {
const mockProducts = [
{
id: '1',
title: '产品A',
description: '描述A',
category: '软件',
features: ['功能A1'],
benefits: ['价值A1'],
pricing: {
basic: '基础版:¥999/月',
pro: '专业版:¥1999/月',
enterprise: '企业版:联系销售',
},
},
];
useProducts.mockReturnValue({
products: mockProducts,
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<ProductsSection config={{ showPricing: true }} />);
await waitFor(() => {
expect(screen.getByText('价格方案')).toBeInTheDocument();
expect(screen.getByText('基础版:¥999/月')).toBeInTheDocument();
expect(screen.getByText('专业版:¥1999/月')).toBeInTheDocument();
expect(screen.getByText('企业版:联系销售')).toBeInTheDocument();
});
});
it('should not display pricing when showPricing is disabled', async () => {
const mockProducts = [
{
id: '1',
title: '产品A',
description: '描述A',
category: '软件',
features: ['功能A1'],
benefits: ['价值A1'],
pricing: {
basic: '基础版:¥999/月',
},
},
];
useProducts.mockReturnValue({
products: mockProducts,
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<ProductsSection config={{ showPricing: false }} />);
await waitFor(() => {
expect(screen.queryByText('价格方案')).not.toBeInTheDocument();
expect(screen.queryByText('基础版:¥999/月')).not.toBeInTheDocument();
});
});
});
describe('Navigation', () => {
it('should link to product detail pages', async () => {
const mockProducts = [
{
id: '1',
title: '产品A',
description: '描述A',
category: '软件',
features: ['功能A1'],
benefits: ['价值A1'],
},
];
useProducts.mockReturnValue({
products: mockProducts,
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<ProductsSection />);
await waitFor(() => {
const productLink = screen.getByRole('link', { name: /产品A/ });
expect(productLink).toHaveAttribute('href', '/products/1');
});
});
it('should link to contact page for custom solutions', async () => {
const mockProducts = [
{
id: '1',
title: '产品A',
description: '描述A',
category: '软件',
features: ['功能A1'],
benefits: ['价值A1'],
},
];
useProducts.mockReturnValue({
products: mockProducts,
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<ProductsSection />);
await waitFor(() => {
const contactLink = screen.getByRole('link', { name: /联系我们/ });
expect(contactLink).toHaveAttribute('href', '/contact');
});
});
});
describe('Accessibility', () => {
it('should maintain accessibility with dynamic data', async () => {
const mockProducts = [
{
id: '1',
title: '产品A',
description: '描述A',
category: '软件',
features: ['功能A1'],
benefits: ['价值A1'],
},
];
useProducts.mockReturnValue({
products: mockProducts,
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<ProductsSection />);
await waitFor(() => {
const section = screen.getByRole('region');
expect(section).toBeInTheDocument();
expect(section).toHaveAttribute('aria-labelledby', 'products-heading');
});
});
});
});
+11 -56
View File
@@ -2,62 +2,17 @@
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef, useMemo } from 'react';
import Link from 'next/link';
import { useRef } from 'react';
import { StaticLink } from '@/components/ui/static-link';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowRight, Check, TrendingUp } from 'lucide-react';
import { useProducts } from '@/hooks/use-products';
import { PRODUCTS } from '@/lib/constants';
interface ProductsConfig {
enabled?: boolean;
showPricing?: boolean;
featuredProducts?: string[];
}
interface ProductsSectionProps {
config?: ProductsConfig;
}
export function ProductsSection({ config }: ProductsSectionProps) {
export function ProductsSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
const { products, loading, error } = useProducts();
const filteredProducts = useMemo(() => {
if (!products || products.length === 0) {
return [];
}
if (!config?.featuredProducts || config.featuredProducts.length === 0) {
return products;
}
return products.filter(product => config.featuredProducts?.includes(product.id));
}, [products, config]);
if (loading) {
return (
<section id="products" role="region" aria-labelledby="products-heading" className="py-24 bg-[#F5F7FA] relative overflow-hidden">
<div className="container-wide relative z-10">
<div className="text-center">
<p className="text-lg text-[#5C5C5C]">...</p>
</div>
</div>
</section>
);
}
if (error) {
return (
<section id="products" role="region" aria-labelledby="products-heading" className="py-24 bg-[#F5F7FA] relative overflow-hidden">
<div className="container-wide relative z-10">
<div className="text-center">
<p className="text-lg text-red-600"></p>
</div>
</div>
</section>
);
}
return (
<section id="products" role="region" aria-labelledby="products-heading" className="py-24 bg-[#F5F7FA] relative overflow-hidden" ref={ref}>
@@ -78,16 +33,16 @@ export function ProductsSection({ config }: ProductsSectionProps) {
</p>
</motion.div>
{filteredProducts.length > 0 ? (
{PRODUCTS.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredProducts.map((product, idx) => (
{PRODUCTS.map((product, idx) => (
<motion.div
key={product.id}
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.1 + idx * 0.1 }}
>
<Link href={`/products/${product.id}`}>
<StaticLink href={`/products/${product.id}`}>
<Card className="h-full flex flex-col group cursor-pointer border-[#E5E5E5] hover:border-[#1C1C1C] transition-colors">
<CardHeader>
<Badge variant="secondary" className="w-fit mb-3">
@@ -130,7 +85,7 @@ export function ProductsSection({ config }: ProductsSectionProps) {
</ul>
</div>
{config?.showPricing && product.pricing && (
{product.pricing && (
<div className="mb-4 p-3 bg-[#F5F7FA] rounded-lg">
<p className="text-sm font-medium text-[#1C1C1C] mb-2"></p>
<div className="space-y-1">
@@ -149,7 +104,7 @@ export function ProductsSection({ config }: ProductsSectionProps) {
</Button>
</CardContent>
</Card>
</Link>
</StaticLink>
</motion.div>
))}
</div>
@@ -181,10 +136,10 @@ export function ProductsSection({ config }: ProductsSectionProps) {
size="lg"
asChild
>
<Link href="/contact">
<StaticLink href="/contact">
<ArrowRight className="ml-2 w-4 h-4" />
</Link>
</StaticLink>
</Button>
</div>
</div>
@@ -1,347 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ServicesSection } from './services-section';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
},
useInView: () => true,
}));
jest.mock('next/link', () => {
return ({ children, href }: any) => <a href={href}>{children}</a>;
});
jest.mock('@/hooks/use-services', () => ({
useServices: jest.fn(),
}));
const { useServices } = require('@/hooks/use-services');
describe('ServicesSection Integration', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('Data Loading States', () => {
it('should show loading state when data is loading', () => {
useServices.mockReturnValue({
services: [],
loading: true,
error: null,
refetch: jest.fn(),
});
render(<ServicesSection />);
expect(screen.getByText('加载中...')).toBeInTheDocument();
});
it('should render services when data is loaded successfully', async () => {
const mockServices = [
{
id: '1',
title: '测试服务1',
description: '这是一个测试服务',
icon: 'Code',
},
{
id: '2',
title: '测试服务2',
description: '这是另一个测试服务',
icon: 'Cloud',
},
];
useServices.mockReturnValue({
services: mockServices,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<ServicesSection />);
await waitFor(() => {
expect(screen.getByText('测试服务1')).toBeInTheDocument();
expect(screen.getByText('测试服务2')).toBeInTheDocument();
});
});
it('should show error message when data loading fails', async () => {
useServices.mockReturnValue({
services: [],
loading: false,
error: new Error('Failed to load services'),
refetch: jest.fn(),
});
render(<ServicesSection />);
await waitFor(() => {
expect(screen.getByText('加载服务信息失败,请稍后重试')).toBeInTheDocument();
});
});
it('should show empty state when no services are available', async () => {
useServices.mockReturnValue({
services: [],
loading: false,
error: null,
refetch: jest.fn(),
});
render(<ServicesSection />);
await waitFor(() => {
expect(screen.getByText('暂无服务信息')).toBeInTheDocument();
});
});
});
describe('Service Display', () => {
it('should render all services from API', async () => {
const mockServices = [
{
id: '1',
title: '服务A',
description: '描述A',
icon: 'Code',
},
{
id: '2',
title: '服务B',
description: '描述B',
icon: 'Cloud',
},
{
id: '3',
title: '服务C',
description: '描述C',
icon: 'BarChart3',
},
];
useServices.mockReturnValue({
services: mockServices,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<ServicesSection />);
await waitFor(() => {
expect(screen.getByText('服务A')).toBeInTheDocument();
expect(screen.getByText('服务B')).toBeInTheDocument();
expect(screen.getByText('服务C')).toBeInTheDocument();
});
});
it('should display service descriptions', async () => {
const mockServices = [
{
id: '1',
title: '服务A',
description: '这是一个专业的软件开发服务',
icon: 'Code',
},
];
useServices.mockReturnValue({
services: mockServices,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<ServicesSection />);
await waitFor(() => {
expect(screen.getByText('这是一个专业的软件开发服务')).toBeInTheDocument();
});
});
it('should display service icons', async () => {
const mockServices = [
{
id: '1',
title: '服务A',
description: '描述A',
icon: 'Code',
},
];
useServices.mockReturnValue({
services: mockServices,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<ServicesSection />);
await waitFor(() => {
const icons = document.querySelectorAll('svg');
expect(icons.length).toBeGreaterThan(0);
});
});
});
describe('Service Filtering', () => {
it('should filter services by items config', async () => {
const mockServices = [
{
id: '1',
title: '服务A',
description: '描述A',
icon: 'Code',
},
{
id: '2',
title: '服务B',
description: '描述B',
icon: 'Cloud',
},
{
id: '3',
title: '服务C',
description: '描述C',
icon: 'BarChart3',
},
];
useServices.mockReturnValue({
services: mockServices,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<ServicesSection config={{ items: ['1', '3'] }} />);
await waitFor(() => {
expect(screen.getByText('服务A')).toBeInTheDocument();
expect(screen.getByText('服务C')).toBeInTheDocument();
expect(screen.queryByText('服务B')).not.toBeInTheDocument();
});
});
it('should show all services when no items config is provided', async () => {
const mockServices = [
{
id: '1',
title: '服务A',
description: '描述A',
icon: 'Code',
},
{
id: '2',
title: '服务B',
description: '描述B',
icon: 'Cloud',
},
];
useServices.mockReturnValue({
services: mockServices,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<ServicesSection />);
await waitFor(() => {
expect(screen.getByText('服务A')).toBeInTheDocument();
expect(screen.getByText('服务B')).toBeInTheDocument();
});
});
});
describe('Navigation', () => {
it('should link to service detail pages', async () => {
const mockServices = [
{
id: '1',
title: '服务A',
description: '描述A',
icon: 'Code',
},
];
useServices.mockReturnValue({
services: mockServices,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<ServicesSection />);
await waitFor(() => {
const serviceLink = screen.getByRole('link', { name: /服务A/ });
expect(serviceLink).toHaveAttribute('href', '/services/1');
});
});
it('should link to all services page', async () => {
const mockServices = [
{
id: '1',
title: '服务A',
description: '描述A',
icon: 'Code',
},
];
useServices.mockReturnValue({
services: mockServices,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<ServicesSection />);
await waitFor(() => {
const allServicesLink = screen.getByRole('link', { name: /查看全部服务/ });
expect(allServicesLink).toHaveAttribute('href', '/services');
});
});
});
describe('Accessibility', () => {
it('should maintain accessibility with dynamic data', async () => {
const mockServices = [
{
id: '1',
title: '服务A',
description: '描述A',
icon: 'Code',
},
];
useServices.mockReturnValue({
services: mockServices,
loading: false,
error: null,
refetch: jest.fn(),
});
render(<ServicesSection />);
await waitFor(() => {
const section = screen.getByRole('region');
expect(section).toBeInTheDocument();
expect(section).toHaveAttribute('aria-labelledby', 'services-heading');
});
});
});
});
+13 -57
View File
@@ -2,67 +2,23 @@
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef, useMemo } from 'react';
import Link from 'next/link';
import { Code, Cloud, BarChart3, Shield, ArrowRight } from 'lucide-react';
import { useRef } from 'react';
import { StaticLink } from '@/components/ui/static-link';
import { Code, BarChart3, Lightbulb, Puzzle, ArrowRight } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useServices } from '@/hooks/use-services';
import { SERVICES } from '@/lib/constants';
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
Code,
Cloud,
BarChart3,
Shield,
Lightbulb,
Puzzle,
};
interface ServicesConfig {
enabled?: boolean;
items?: string[];
}
interface ServicesSectionProps {
config?: ServicesConfig;
}
export function ServicesSection({ config }: ServicesSectionProps) {
export function ServicesSection() {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
const { services, loading, error } = useServices();
const filteredServices = useMemo(() => {
if (!services || services.length === 0) {
return [];
}
if (!config?.items || config.items.length === 0) {
return services;
}
return services.filter(service => config.items?.includes(service.id));
}, [services, config]);
if (loading) {
return (
<section id="services" aria-labelledby="services-heading" className="py-24 bg-white relative overflow-hidden" ref={ref}>
<div className="container-wide relative z-10">
<div className="text-center">
<p className="text-lg text-[#5C5C5C]">...</p>
</div>
</div>
</section>
);
}
if (error) {
return (
<section id="services" aria-labelledby="services-heading" className="py-24 bg-white relative overflow-hidden" ref={ref}>
<div className="container-wide relative z-10">
<div className="text-center">
<p className="text-lg text-red-600"></p>
</div>
</div>
</section>
);
}
return (
<section id="services" aria-labelledby="services-heading" className="py-24 bg-white relative overflow-hidden" ref={ref}>
@@ -84,9 +40,9 @@ export function ServicesSection({ config }: ServicesSectionProps) {
</p>
</motion.div>
{filteredServices.length > 0 ? (
{SERVICES.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{filteredServices.map((service, index) => {
{SERVICES.map((service, index) => {
const Icon = iconMap[service.icon];
return (
<motion.div
@@ -96,7 +52,7 @@ export function ServicesSection({ config }: ServicesSectionProps) {
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Link href={`/services/${service.id}`}>
<StaticLink href={`/services/${service.id}`}>
<Card className="p-6 h-full group cursor-pointer border-[#E5E5E5] hover:border-[#C41E3A] transition-colors">
<CardContent className="p-0">
<div className="w-12 h-12 rounded-xl bg-[#F5F5F5] flex items-center justify-center mb-4 group-hover:bg-[#C41E3A] transition-all duration-300">
@@ -110,7 +66,7 @@ export function ServicesSection({ config }: ServicesSectionProps) {
</div>
</CardContent>
</Card>
</Link>
</StaticLink>
</motion.div>
);
})}
@@ -128,10 +84,10 @@ export function ServicesSection({ config }: ServicesSectionProps) {
className="text-center mt-12"
>
<Button variant="outline" size="lg" className="group" asChild>
<Link href="/services">
<StaticLink href="/services">
<ArrowRight className="ml-2 w-4 h-4 transition-transform group-hover:translate-x-1" />
</Link>
</StaticLink>
</Button>
</motion.div>
</div>
+7 -4
View File
@@ -1,18 +1,21 @@
'use client';
import { useRouter } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
/**
* BackButton - 统一的返回按钮组件
*
* 在纯静态导出模式下使用 window.history.back() 替代 Next.js 的 router.back()
* 确保在无服务端路由的环境下正常工作。
*/
export function BackButton() {
const router = useRouter();
return (
<Button
variant="ghost"
size="sm"
className="text-[#5C5C5C] hover:text-[#C41E3A] hover:bg-transparent h-auto py-2 px-3"
onClick={() => router.back()}
onClick={() => window.history.back()}
>
<ArrowLeft className="w-4 h-4 mr-2" />
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { useCallback, type AnchorHTMLAttributes, type MouseEventHandler, type ReactNode } from 'react';
/**
* StaticLink - 纯静态站点专用链接组件
*
* 在 output: 'export' 模式下,Next.js 的客户端路由会拦截所有站内 <a> 标签的点击,
* 尝试发送 RSC 请求,导致 "Failed to fetch RSC payload" 错误。
*
* 本组件通过 e.preventDefault() 阻止 Next.js 拦截,然后根据情况导航:
* - 有外部 onClick:只阻止拦截,由外部 onClick 控制导航
* - 外部链接 / 新窗口:不拦截,保持默认行为
* - Hash 链接:平滑滚动
* - 站内链接:window.location.href 完整页面导航
*
* @see https://github.com/vercel/next.js/issues/85374
*/
interface StaticLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
children: ReactNode;
href: string;
}
function isExternalLink(href: string): boolean {
return href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:') || href.startsWith('tel:');
}
export function StaticLink({ children, href, onClick, target, rel, ...props }: StaticLinkProps) {
const handleClick: MouseEventHandler<HTMLAnchorElement> = useCallback(
(e) => {
// 外部链接或新窗口打开:不拦截,保持默认行为
if (isExternalLink(href) || target === '_blank') {
onClick?.(e);
return;
}
// 阻止 Next.js 客户端路由拦截
e.preventDefault();
// 如果有外部 onClick,由它完全控制导航行为
if (onClick) {
onClick(e);
return;
}
// Hash 链接:平滑滚动
if (href.includes('#')) {
const [path, hash] = href.split('#');
if (path && path !== window.location.pathname) {
window.location.href = href;
} else if (hash) {
const el = document.getElementById(hash);
if (el) {
el.scrollIntoView({ behavior: 'smooth' });
}
}
return;
}
// 站内页面链接:完整页面导航
window.location.href = href;
},
[href, onClick, target]
);
// 外部链接自动添加安全属性
const linkRel = isExternalLink(href)
? 'noopener noreferrer'
: rel;
return (
<a href={href} onClick={handleClick} target={target} rel={linkRel} {...props}>
{children}
</a>
);
}