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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
返回
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user