feat: 实现动态详情页面和性能优化

- 添加案例、新闻、产品详情页面的E2E测试
- 优化详情页面的客户端组件和页面逻辑
- 添加高性能Docker配置和Nginx配置
- 更新API服务和常量配置
- 添加性能优化文档和任务进度更新
- 修复ESLint错误和类型问题
This commit is contained in:
张翔
2026-03-26 12:53:58 +08:00
parent 498bb3a3c8
commit 14448af731
18 changed files with 2244 additions and 913 deletions
+41 -99
View File
@@ -5,24 +5,17 @@ import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { BackButton } from '@/components/ui/back-button';
import { CheckCircle2, TrendingUp, Users, Target, Quote, Clock, MessageCircle, Award } from 'lucide-react';
import { CASES } from '@/lib/constants';
import type { StaticImageData } from 'next/image';
interface CaseResult {
label: string;
value: string;
}
import { Users, Target, Quote, Clock, MessageCircle, Award, TrendingUp } from 'lucide-react';
interface CaseItem {
id: string;
title: string;
client: string;
industry: string;
description: string;
results: readonly CaseResult[];
tags: readonly string[];
image?: string | StaticImageData;
excerpt: string;
content: string;
category: string;
slug: string;
publishedAt?: string;
createdAt: string;
}
interface CaseDetailClientProps {
@@ -50,20 +43,6 @@ export function CaseDetailClient({ caseItem }: CaseDetailClientProps) {
return () => observer.disconnect();
}, []);
const relatedCases = CASES.filter((c) => c.id !== caseItem.id).slice(0, 2);
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
'业务处理效率': TrendingUp,
'客户满意度': Users,
'运营成本': Target,
'生产效率': TrendingUp,
'设备利用率': Target,
'不良品率': CheckCircle2,
'数据整合效率': TrendingUp,
'决策响应时间': Target,
'营销转化率': Users,
};
return (
<main className="min-h-screen bg-white">
<div className="relative overflow-hidden bg-gradient-to-b from-[#FAFAFA] to-white">
@@ -71,13 +50,13 @@ export function CaseDetailClient({ caseItem }: CaseDetailClientProps) {
<BackButton />
<div className="max-w-4xl mt-8">
<Badge className="mb-4 bg-[#C41E3A]/10 text-[#C41E3A] hover:bg-[#C41E3A]/20">
{caseItem.industry}
{caseItem.category}
</Badge>
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-semibold text-[#1C1C1C] mb-2">
{caseItem.title}
</h1>
<p className="text-lg text-[#5C5C5C]">
{caseItem.client}
{caseItem.excerpt}
</p>
</div>
</div>
@@ -102,11 +81,11 @@ export function CaseDetailClient({ caseItem }: CaseDetailClientProps) {
</h2>
</div>
<p className="text-[#5C5C5C] leading-relaxed text-lg">
{caseItem.description}
{caseItem.excerpt}
</p>
<div className="mt-6 p-4 bg-white rounded-lg border border-[#E5E5E5]">
<p className="text-sm text-[#737373] italic">
"在找到睿新致远之前,我们面临着巨大的挑战..."
&ldquo;...&rdquo;
</p>
</div>
</section>
@@ -120,20 +99,8 @@ export function CaseDetailClient({ caseItem }: CaseDetailClientProps) {
</h2>
</div>
<div className="space-y-4">
{caseItem.tags.map((tag, index) => (
<div key={tag} className="flex items-start gap-3">
<div className="w-8 h-8 bg-[#C41E3A]/10 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-[#C41E3A] font-semibold">{index + 1}</span>
</div>
<div>
<h3 className="font-semibold text-[#1C1C1C] mb-1">{tag}</h3>
<p className="text-sm text-[#737373]">
{tag}
</p>
</div>
</div>
))}
<div className="prose prose-sm max-w-none">
<div dangerouslySetInnerHTML={{ __html: caseItem.content }} />
</div>
</section>
@@ -182,25 +149,31 @@ export function CaseDetailClient({ caseItem }: CaseDetailClientProps) {
</h2>
</div>
<div className="grid sm:grid-cols-3 gap-4 mb-6">
{caseItem.results.map((result) => {
const Icon = iconMap[result.label] || TrendingUp;
return (
<div
key={result.label}
className="p-6 bg-white rounded-lg border border-[#E5E5E5] hover:border-[#C41E3A] transition-colors"
>
<Icon className="w-8 h-8 text-[#C41E3A] mb-3" />
<div className="text-2xl font-semibold text-[#C41E3A] mb-1">
{result.value}
</div>
<div className="text-sm text-[#737373]">{result.label}</div>
</div>
);
})}
<div className="p-6 bg-white rounded-lg border border-[#E5E5E5] hover:border-[#C41E3A] transition-colors">
<TrendingUp className="w-8 h-8 text-[#C41E3A] mb-3" />
<div className="text-2xl font-semibold text-[#C41E3A] mb-1">
300%
</div>
<div className="text-sm text-[#737373]"></div>
</div>
<div className="p-6 bg-white rounded-lg border border-[#E5E5E5] hover:border-[#C41E3A] transition-colors">
<Users className="w-8 h-8 text-[#C41E3A] mb-3" />
<div className="text-2xl font-semibold text-[#C41E3A] mb-1">
95%
</div>
<div className="text-sm text-[#737373]"></div>
</div>
<div className="p-6 bg-white rounded-lg border border-[#E5E5E5] hover:border-[#C41E3A] transition-colors">
<Target className="w-8 h-8 text-[#C41E3A] mb-3" />
<div className="text-2xl font-semibold text-[#C41E3A] mb-1">
-40%
</div>
<div className="text-sm text-[#737373]"></div>
</div>
</div>
<div className="p-4 bg-white rounded-lg border border-[#E5E5E5]">
<p className="text-sm text-[#737373] italic">
"通过三年的合作,我们不仅实现了数字化转型,更重要的是建立了一个可持续发展的技术体系。"
&ldquo;&rdquo;
</p>
</div>
</section>
@@ -221,10 +194,10 @@ export function CaseDetailClient({ caseItem }: CaseDetailClientProps) {
</p>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-[#C41E3A] rounded-full flex items-center justify-center">
<span className="text-white font-semibold">{caseItem.client[0]}</span>
<span className="text-white font-semibold"></span>
</div>
<div>
<p className="font-semibold text-[#1C1C1C]">{caseItem.client}</p>
<p className="font-semibold text-[#1C1C1C]"></p>
<p className="text-sm text-[#737373]">CEO</p>
</div>
</div>
@@ -238,25 +211,19 @@ export function CaseDetailClient({ caseItem }: CaseDetailClientProps) {
<dl className="space-y-3">
<div>
<dt className="text-sm text-[#737373]"></dt>
<dd className="text-[#1C1C1C] font-medium">{caseItem.client}</dd>
<dd className="text-[#1C1C1C] font-medium"></dd>
</div>
<div>
<dt className="text-sm text-[#737373]"></dt>
<dd className="text-[#1C1C1C] font-medium">{caseItem.industry}</dd>
<dd className="text-[#1C1C1C] font-medium">{caseItem.category}</dd>
</div>
<div>
<dt className="text-sm text-[#737373]"></dt>
<dd className="text-[#1C1C1C] font-medium">3</dd>
</div>
<div>
<dt className="text-sm text-[#737373]"></dt>
<dd className="flex flex-wrap gap-2 mt-1">
{caseItem.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</dd>
<dt className="text-sm text-[#737373]"></dt>
<dd className="text-[#1C1C1C] font-medium">{caseItem.publishedAt || caseItem.createdAt}</dd>
</div>
</dl>
</div>
@@ -277,31 +244,6 @@ export function CaseDetailClient({ caseItem }: CaseDetailClientProps) {
</div>
</div>
</div>
{relatedCases.length > 0 && (
<section className="mt-16 pt-16 border-t border-[#E5E5E5]">
<h2 className="text-2xl font-semibold text-[#171717] mb-8"></h2>
<div className="grid md:grid-cols-2 gap-6">
{relatedCases.map((relatedCase) => (
<Link
key={relatedCase.id}
href={`/cases/${relatedCase.id}`}
className="group p-6 bg-[#FAFAFA] rounded-lg border border-[#E5E5E5] hover:border-[#C41E3A] transition-colors"
>
<Badge variant="secondary" className="mb-2">
{relatedCase.industry}
</Badge>
<h3 className="text-lg font-semibold text-[#171717] group-hover:text-[#C41E3A] transition-colors">
{relatedCase.title}
</h3>
<p className="text-sm text-[#737373] mt-2 line-clamp-2">
{relatedCase.description}
</p>
</Link>
))}
</div>
</section>
)}
</div>
</main>
);
+20 -6
View File
@@ -1,17 +1,30 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { CASES } from '@/lib/constants';
import { contentService } from '@/lib/api/services';
import { CaseDetailClient } from './client';
interface CaseItem {
id: string;
title: string;
excerpt: string;
content: string;
category: string;
slug: string;
publishedAt?: string;
createdAt: string;
}
export async function generateStaticParams() {
return CASES.map((caseItem) => ({
const cases = await contentService.getCases(100);
return cases.map((caseItem) => ({
id: caseItem.id,
}));
}
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const caseItem = CASES.find((c) => c.id === id);
const cases = await contentService.getCases(100);
const caseItem = cases.find((c) => c.id === id);
if (!caseItem) {
return {
@@ -21,17 +34,18 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
return {
title: `${caseItem.title} - 睿新致远`,
description: caseItem.description,
description: caseItem.excerpt,
};
}
export default async function CaseDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const caseItem = CASES.find((c) => c.id === id);
const cases = await contentService.getCases(100);
const caseItem = cases.find((c) => c.id === id);
if (!caseItem) {
notFound();
}
return <CaseDetailClient caseItem={caseItem as any} />;
return <CaseDetailClient caseItem={caseItem as CaseItem} />;
}
+219 -68
View File
@@ -1,31 +1,40 @@
import { describe, it, expect, jest, beforeAll } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, jest } from '@jest/globals';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import PropTypes from 'prop-types';
interface MockComponentProps {
children?: React.ReactNode;
className?: string;
[key: string]: unknown;
}
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, className, ...props }: any) => (
<div className={className} {...props}>
{children}
</div>
),
section: ({ children, className, ...props }: any) => (
<section className={className} {...props}>
{children}
</section>
),
div: function MockDiv({ children, className, ...props }: MockComponentProps) {
return <div className={className} {...props}>{children}</div>;
},
section: function MockSection({ children, className, ...props }: MockComponentProps) {
return <section className={className} {...props}>{children}</section>;
},
},
AnimatePresence: function MockAnimatePresence({ children }: { children?: React.ReactNode }) {
return <>{children}</>;
},
AnimatePresence: ({ children }: any) => <>{children}</>,
useInView: () => [null, true],
}));
jest.mock('next/link', () => {
return ({ children, href, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
);
function MockLink({ children, href, ...props }: MockComponentProps) {
return <a href={href as string} {...props}>{children}</a>;
}
MockLink.propTypes = {
children: PropTypes.node,
href: PropTypes.string,
};
return MockLink;
});
MockLink.displayName = 'MockLink';
jest.mock('lucide-react', () => ({
ArrowRight: () => <span data-testid="arrow-right-icon" />,
@@ -33,117 +42,259 @@ jest.mock('lucide-react', () => ({
Building2: () => <span data-testid="building-icon" />,
Calendar: () => <span data-testid="calendar-icon" />,
TrendingUp: () => <span data-testid="trending-up-icon" />,
ChevronLeft: () => <span data-testid="chevron-left-icon" />,
ChevronRight: () => <span data-testid="chevron-right-icon" />,
Filter: () => <span data-testid="filter-icon" />,
Search: () => <span data-testid="search-icon" />,
}));
jest.mock('@/components/ui/button', () => ({
Button: ({ children, className, ...props }: any) => (
<button className={className} {...props}>
jest.mock('@/components/ui/button', () => {
function Button({ children, className, variant, ...props }: MockComponentProps) {
return <button className={className} data-variant={variant} {...props}>
{children}
</button>
),
}));
</button>;
}
Button.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
variant: PropTypes.string,
};
return Button;
});
Button.displayName = 'Button';
jest.mock('@/components/ui/badge', () => ({
Badge: ({ children, className, ...props }: any) => (
<span className={className} {...props}>
jest.mock('@/components/ui/badge', () => {
function Badge({ children, className, variant, ...props }: MockComponentProps) {
return <span className={className} data-variant={variant} {...props}>
{children}
</span>
),
}));
</span>;
}
Badge.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
variant: PropTypes.string,
};
return Badge;
});
Badge.displayName = 'Badge';
jest.mock('@/components/ui/page-header', () => ({
PageHeader: ({ title, description }: any) => (
<header>
<h1>{title}</h1>
<p>{description}</p>
</header>
),
}));
jest.mock('@/components/ui/input', () => {
function Input({ className, ...props }: MockComponentProps) {
return <input className={className} {...props} />;
}
Input.propTypes = {
className: PropTypes.string,
};
return Input;
});
Input.displayName = 'Input';
jest.mock('@/lib/constants', () => ({
CASES: [
{
id: 'case-1',
client: '客户A',
title: '数字化转型案例',
industry: '制造业',
description: '帮助客户实现数字化转型',
},
{
id: 'case-2',
client: '客户B',
title: 'ERP系统实施案例',
industry: '零售业',
description: 'ERP系统成功实施',
},
],
jest.mock('@/components/ui/page-header', () => {
function PageHeader({ title, description }: MockComponentProps) {
return (
<header>
<h1>{title as string}</h1>
<p>{description as string}</p>
</header>
);
}
PageHeader.propTypes = {
title: PropTypes.string,
description: PropTypes.string,
};
return PageHeader;
});
PageHeader.displayName = 'PageHeader';
jest.mock('@/lib/api/services', () => ({
contentService: {
getNews: jest.fn(),
},
}));
import CasesPage from './page';
import { contentService } from '@/lib/api/services';
const mockCases: Array<{
id: string;
title: string;
excerpt: string;
content: string;
category: string;
slug: string;
date: string;
}> = [
{
id: 'case-1',
title: '数字化转型案例',
excerpt: '帮助客户实现数字化转型',
content: '详细的数字化转型案例内容',
category: '制造业',
slug: 'digital-transformation',
date: '2024-01-15',
},
{
id: 'case-2',
title: 'ERP系统实施案例',
excerpt: 'ERP系统成功实施',
content: '详细的ERP系统实施案例内容',
category: '零售业',
slug: 'erp-implementation',
date: '2024-01-10',
},
{
id: 'case-3',
title: '智能制造升级',
excerpt: '智能制造系统升级',
content: '详细的智能制造升级案例内容',
category: '制造业',
slug: 'smart-manufacturing',
date: '2024-01-05',
},
];
describe('CasesPage', () => {
beforeEach(() => {
jest.clearAllMocks();
(contentService.getNews as jest.Mock).mockResolvedValue(mockCases);
});
describe('Rendering', () => {
it('should render cases page', () => {
const { container } = render(<CasesPage />);
const pageContainer = container.querySelector('.min-h-screen');
it('should render loading state initially', () => {
render(<CasesPage />);
expect(screen.getByText('加载中...')).toBeInTheDocument();
});
it('should render cases page after loading', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const pageContainer = document.querySelector('.min-h-screen');
expect(pageContainer).toBeInTheDocument();
});
it('should render page header', () => {
it('should render page header', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const title = screen.getByText(/与谁同行/i);
expect(title).toBeInTheDocument();
});
it('should render back to home link', () => {
it('should render back to home link', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const backLink = screen.getByText(/返回首页/i);
expect(backLink).toBeInTheDocument();
});
it('should render case cards', () => {
it('should render case cards', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const caseTitles = screen.getAllByRole('heading', { level: 3 });
expect(caseTitles.length).toBeGreaterThan(0);
});
it('should render case categories', () => {
it('should render case categories', async () => {
render(<CasesPage />);
const categories = screen.getByText(/制造业/i);
expect(categories).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
expect(screen.getByText('全部')).toBeInTheDocument();
expect(screen.getByText('金融')).toBeInTheDocument();
expect(screen.getByText('制造')).toBeInTheDocument();
});
it('should render CTA section', () => {
it('should render CTA section', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const cta = screen.getByText(/准备开始您的数字化转型之旅/i);
expect(cta).toBeInTheDocument();
});
});
describe('Navigation', () => {
it('should have case detail links', () => {
it('should have case detail links', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const links = screen.getAllByRole('link');
const caseLinks = links.filter(link => link.getAttribute('href')?.startsWith('/cases/'));
expect(caseLinks.length).toBeGreaterThan(0);
});
it('should have contact links', () => {
it('should have contact links', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const contactLinks = screen.getAllByRole('link', { name: /联系我们|立即咨询/i });
expect(contactLinks.length).toBeGreaterThan(0);
});
});
describe('Accessibility', () => {
it('should have proper heading hierarchy', () => {
it('should have proper heading hierarchy', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const h1 = screen.getByRole('heading', { level: 1 });
expect(h1).toBeInTheDocument();
});
});
describe('Filtering', () => {
it('should render filter buttons', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
expect(screen.getByPlaceholderText('搜索案例...')).toBeInTheDocument();
expect(screen.getByText('行业筛选:')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('should display error message when API fails', async () => {
(contentService.getNews as jest.Mock).mockRejectedValue(new Error('API Error'));
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
expect(screen.getByText('加载案例失败')).toBeInTheDocument();
});
});
});