chore: sync marketing pages, CMS extensions, tests and project docs

同步工作区剩余变更,主要包括:
- 营销页面组件与布局持续优化(about/news/services/solutions/team 等)
- 详情页四层叙事组件、布局组件、UI 组件调整
- CMS 数据模型、API 路由、权限、工作流、站内通知、媒体管理扩展
- 新增/补充单元测试与 E2E 测试(cms-workflow.spec.ts 等)
- ESLint 9 迁移、jest/tsconfig 配置更新、依赖调整
- 新增 ADR、CMS 评估文档、Release Review / Acceptance 报告
- 移除水墨装饰组件与大体积未使用字体文件
This commit is contained in:
张翔
2026-07-25 08:04:01 +08:00
parent e35090b914
commit 10404dbb36
208 changed files with 18107 additions and 8330 deletions
@@ -0,0 +1,127 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import CaseDetailPage, { generateMetadata } from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItemBySlug: jest.fn(),
getAllPublishedSlugs: jest.fn(),
}));
jest.mock('./client', () => ({
CaseDetailClient: function MockCaseDetailClient({ item }: { item: { title: string } }) {
return (
<div data-testid="case-detail">
<h1>{item.title}</h1>
</div>
);
},
}));
import { getPublishedItemBySlug, getAllPublishedSlugs } from '@/lib/cms/data-server';
const mockedGetPublishedItemBySlug = getPublishedItemBySlug as jest.MockedFunction<typeof getPublishedItemBySlug>;
const mockedGetAllPublishedSlugs = getAllPublishedSlugs as jest.MockedFunction<typeof getAllPublishedSlugs>;
const createMockCase = (
topOverrides: Record<string, unknown> = {},
dataOverrides: Record<string, unknown> = {},
) => ({
id: 'item-1',
modelId: 'model-case-study',
modelCode: 'case-study',
title: '测试案例',
slug: 'test-case',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-case',
slug: 'test-case',
title: '测试案例',
client: '测试客户',
industry: '制造业',
companySize: '1000人以上',
subtitle: '测试副标题',
challenge: '测试挑战描述',
solution: '测试方案描述',
result: '测试成果描述',
metrics: [
{ value: '40%', label: '效率提升', highlight: true },
{ value: '25%', label: '成本下降' },
],
timeline: [{ phase: '诊断', duration: '1周', description: '测试阶段' }],
services: [{ id: 'svc', title: '测试服务', description: '服务描述' }],
color: 'brand',
featured: true,
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
describe('CaseDetailPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItemBySlug.mockResolvedValue(null);
mockedGetAllPublishedSlugs.mockResolvedValue([]);
});
it('returns 404 when case item is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
await expect(
CaseDetailPage({ params: Promise.resolve({ slug: 'missing' }) }),
).rejects.toThrow();
});
it('renders case detail with data from CMS', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(
createMockCase() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>,
);
const page = await CaseDetailPage({ params: Promise.resolve({ slug: 'test-case' }) });
render(page);
expect(screen.getByTestId('case-detail')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('测试案例');
});
it('generateStaticParams returns slugs from CMS', async () => {
mockedGetAllPublishedSlugs.mockResolvedValue([
{ slug: 'manufacturing-erp-upgrade' },
{ slug: 'retail-omnichannel' },
] as unknown as Awaited<ReturnType<typeof getAllPublishedSlugs>>);
const { generateStaticParams } = await import('./page');
const params = await generateStaticParams();
expect(params).toEqual([
{ slug: 'manufacturing-erp-upgrade' },
{ slug: 'retail-omnichannel' },
]);
});
describe('generateMetadata', () => {
it('returns case title and client as metadata', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(
createMockCase() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>,
);
const metadata = await generateMetadata({ params: Promise.resolve({ slug: 'test-case' }) });
expect(metadata.title).toContain('测试案例');
expect(metadata.description).toContain('测试客户');
});
it('returns fallback metadata when case is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
const metadata = await generateMetadata({ params: Promise.resolve({ slug: 'missing' }) });
expect(metadata.title).toBe('案例未找到 - 睿新致远');
});
});
});
@@ -5,6 +5,8 @@ import { COMPANY_INFO } from '@/lib/constants/company';
import type { CaseStudyData } from '@/lib/cms/types';
import type { Metadata } from 'next';
export const revalidate = 3600;
interface CaseDetailPageProps {
params: Promise<{ slug: string }>;
}
@@ -1,320 +0,0 @@
'use client';
import { useState, useMemo } from 'react';
import {
Building2, ArrowRight, TrendingUp, Target, Lightbulb,
Filter,
} from 'lucide-react';
import { ScrollReveal } from '@/components/ui/scroll-reveal';
import { Badge } from '@/components/ui/badge';
import { MetricCard } from '@/components/ui/metric-card';
import {
CASE_INDUSTRIES,
getCasesByIndustry,
type CaseStudy,
} from '@/lib/constants/cases';
import {
GrainOverlay,
FloatingInkParticles,
SectionLabel,
} from '@/components/ui/page-decoration';
import { cn } from '@/lib/utils';
const colorMap: Record<CaseStudy['color'], string> = {
brand: 'bg-brand text-brand',
blue: 'bg-accent-blue text-accent-blue',
teal: 'bg-accent-teal text-accent-teal',
amber: 'bg-accent-amber text-accent-amber',
purple: 'bg-accent-purple text-accent-purple',
};
function CaseCard({ caseItem }: { caseItem: CaseStudy; index?: number }) {
const colorStyle = colorMap[caseItem.color];
const [bgClass, textClass] = colorStyle.split(' ');
return (
<a
href={`/cases/${caseItem.slug}`}
className="group relative block overflow-hidden border border-white/[0.08] hover:border-white/20 bg-ink transition-all duration-500 hover:-translate-y-1 flex flex-col h-full"
>
<div className={cn(
'absolute top-0 left-0 right-0 h-1 scale-x-0 group-hover:scale-x-100 transition-transform duration-700 origin-left',
bgClass
)} />
<div className="p-6 sm:p-7 lg:p-8 flex flex-col flex-1">
<div className="flex items-center gap-3 mb-5">
<Badge variant="outline" className={cn('border-transparent', bgClass, 'text-white')}>
{caseItem.industry}
</Badge>
<span className="text-[10px] tracking-[0.25em] uppercase text-white/30 font-semibold">
{caseItem.companySize}
</span>
</div>
<h3 className="text-lg sm:text-xl font-bold text-white mb-4 leading-tight group-hover:text-brand transition-colors duration-300">
{caseItem.title}
</h3>
<div className="space-y-4 mb-6 flex-1">
<div>
<div className="flex items-center gap-2 mb-2">
<Target className={cn('w-3.5 h-3.5', textClass)} />
<span className={cn('text-[11px] tracking-wide uppercase font-bold', textClass)}>挑战</span>
</div>
<p className="text-sm text-white/50 leading-relaxed line-clamp-2 pl-5.5">
{caseItem.challenge}
</p>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<Lightbulb className={cn('w-3.5 h-3.5', textClass)} />
<span className={cn('text-[11px] tracking-wide uppercase font-bold', textClass)}>方案</span>
</div>
<p className="text-sm text-white/50 leading-relaxed line-clamp-2 pl-5.5">
{caseItem.solution}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-5 border-t border-white/[0.08] mb-5">
{caseItem.metrics.slice(0, 2).map((m, i) => (
<div key={i}>
<div className="text-2xl sm:text-3xl font-bold text-white mb-1 tabular-nums">
{m.value}
</div>
<div className="text-xs text-white/40">{m.label}</div>
</div>
))}
</div>
<div className="flex items-center justify-between pt-4 mt-auto border-t border-white/[0.06]">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-white/30" />
<span className="text-xs text-white/40">{caseItem.client}</span>
</div>
<div className="flex items-center gap-1.5 text-brand text-sm font-medium">
查看详情
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform duration-300" />
</div>
</div>
</div>
</a>
);
}
const PAGE_METRICS = [
{
icon: <Building2 className="w-5 h-5" />,
label: '服务客户',
value: 500,
suffix: '+',
description: '覆盖六大行业,500+企业客户的共同选择',
trend: { value: '23% YoY', direction: 'up' as const, label: '增长' },
accentColor: 'brand' as const,
},
{
icon: <TrendingUp className="w-5 h-5" />,
label: '成功率',
value: 98,
suffix: '%',
description: '项目一次性上线成功率,持续稳定交付',
trend: { value: '97%续约率', direction: 'up' as const, label: '' },
accentColor: 'teal' as const,
},
{
icon: <Target className="w-5 h-5" />,
label: '平均效率提升',
value: 35,
suffix: '%',
description: '客户核心业务流程效率平均提升幅度',
trend: { value: '基于 200+ 案例', direction: 'neutral' as const, label: '' },
accentColor: 'blue' as const,
},
{
icon: <Lightbulb className="w-5 h-5" />,
label: '行业覆盖',
value: 6,
suffix: '大',
description: '制造、零售、医疗、教育、金融、物流六大行业',
trend: { value: '持续扩展中', direction: 'up' as const, label: '' },
accentColor: 'amber' as const,
},
];
export default function CasesContentV1() {
const [selectedIndustry, setSelectedIndustry] = useState('all');
const filteredCases = useMemo(() => {
return getCasesByIndustry(selectedIndustry);
}, [selectedIndustry]);
return (
<div className="min-h-screen bg-ink text-white overflow-x-hidden" data-theme="dark">
{/* Hero Section */}
<section className="relative min-h-[70vh] flex items-center overflow-hidden bg-ink">
<GrainOverlay />
<FloatingInkParticles />
<div className="absolute inset-0 bg-gradient-to-b from-ink-light/30 via-transparent to-ink pointer-events-none" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-28 sm:py-36 md:py-40">
<div className="max-w-4xl">
<ScrollReveal>
<SectionLabel>Case Studies</SectionLabel>
</ScrollReveal>
<ScrollReveal delay={0.1}>
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[0.95] tracking-tighter mb-8 sm:mb-10">
真实案例,
<span className="block mt-3 text-brand">可衡量的成果</span>
</h1>
</ScrollReveal>
<ScrollReveal delay={0.2}>
<p className="text-lg md:text-xl text-white/60 max-w-2xl leading-relaxed mb-10">
每一个案例,都是我们与客户共同创造的价值故事。从挑战到方案,从过程到成果——用数据说话,用结果证明。
</p>
</ScrollReveal>
<ScrollReveal delay={0.3}>
<div className="flex flex-wrap gap-3">
<Badge variant="outline" className="border-brand/30 text-brand px-4 py-1.5 text-xs">
500+ 企业客户
</Badge>
<Badge variant="outline" className="border-white/20 text-white/70 px-4 py-1.5 text-xs">
98% 项目成功率
</Badge>
<Badge variant="outline" className="border-white/20 text-white/70 px-4 py-1.5 text-xs">
6 大行业覆盖
</Badge>
<Badge variant="outline" className="border-white/20 text-white/70 px-4 py-1.5 text-xs">
97% 客户续约率
</Badge>
</div>
</ScrollReveal>
</div>
</div>
</section>
{/* Key Metrics Section */}
<section className="relative py-16 sm:py-20 md:py-24 overflow-hidden bg-ink-light">
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
{PAGE_METRICS.map((metric, i) => (
<ScrollReveal key={metric.label} delay={i * 0.08}>
<div className="h-full">
<MetricCard
icon={metric.icon}
label={metric.label}
value={metric.value}
suffix={metric.suffix}
description={metric.description}
trend={metric.trend}
accentColor={metric.accentColor}
theme="dark"
className="h-full"
/>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* Cases List Section */}
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-ink">
<GrainOverlay />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-8 mb-12 sm:mb-16 md:mb-20">
<ScrollReveal className="lg:max-w-2xl">
<SectionLabel>All Cases</SectionLabel>
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight leading-tight mt-4">
全部案例
</h2>
<p className="mt-6 text-lg text-white/60 leading-relaxed">
按行业筛选,找到与您业务最相关的成功故事
</p>
</ScrollReveal>
<ScrollReveal delay={0.1}>
<div className="flex items-center gap-2 flex-wrap">
<Filter className="w-4 h-4 text-white/40" />
{CASE_INDUSTRIES.map((industry) => (
<button
key={industry.id}
onClick={() => setSelectedIndustry(industry.id)}
className={cn(
'px-4 py-2 text-xs font-medium transition-all duration-300',
selectedIndustry === industry.id
? 'bg-brand text-white'
: 'bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white'
)}
>
{industry.label}
</button>
))}
</div>
</ScrollReveal>
</div>
{filteredCases.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
{filteredCases.map((caseItem, i) => (
<ScrollReveal key={caseItem.id} delay={i * 0.08}>
<div className="h-full">
<CaseCard caseItem={caseItem} index={i} />
</div>
</ScrollReveal>
))}
</div>
) : (
<div className="text-center py-20">
<p className="text-white/50 text-lg">暂无该行业的案例</p>
</div>
)}
</div>
</section>
{/* CTA Section */}
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-ink-light">
<GrainOverlay />
<FloatingInkParticles />
<div className="absolute inset-0 bg-gradient-to-b from-ink-light/50 to-ink pointer-events-none" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="text-center max-w-3xl mx-auto">
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight leading-tight">
您的成功故事,
<span className="text-brand">从这里开始</span>
</h2>
<p className="mt-6 text-lg text-white/60 leading-relaxed">
无论您处于数字化转型的哪个阶段,我们都有相应的经验和能力帮助您。
让我们聊聊您的挑战,看看能创造什么样的成果。
</p>
<div className="mt-12 flex flex-col sm:flex-row justify-center gap-4 sm:gap-6">
<a
href="/contact"
className="group inline-flex items-center justify-center gap-3 px-12 py-6 font-semibold text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90 min-h-[52px] touch-manipulation"
>
预约咨询
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
<a
href="/services"
className="inline-flex items-center justify-center gap-3 px-12 py-6 font-semibold text-base text-white border border-white/20 hover:border-white/40 hover:bg-white/[0.03] transition-all duration-300 min-h-[52px] touch-manipulation"
>
了解服务
</a>
</div>
</ScrollReveal>
</div>
</section>
</div>
);
}
@@ -1,316 +0,0 @@
'use client';
import { useState, useMemo } from 'react';
import { motion } from 'framer-motion';
import { Building2, ArrowUpRight, Target, Lightbulb, Filter } from 'lucide-react';
import {
CASE_INDUSTRIES,
getCasesByIndustry,
type CaseStudy,
} from '@/lib/constants/cases';
import { cn } from '@/lib/utils';
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
const PAGE_METRICS = [
{ value: '500+', label: '服务客户', sub: '覆盖六大行业' },
{ value: '98%', label: '项目成功率', sub: '持续稳定交付' },
{ value: '35%', label: '平均效率提升', sub: '核心业务流程' },
{ value: '6大', label: '行业覆盖', sub: '制造零售医疗等' },
];
function CaseCard({ caseItem, index }: { caseItem: CaseStudy; index: number }) {
return (
<motion.a
href={`/cases/${caseItem.slug}`}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.6, delay: index * 0.06, ease: EASE_OUT }}
className="group relative block overflow-hidden border border-border-primary hover:border-border-secondary bg-white hover:bg-bg-secondary transition-all duration-500 flex flex-col h-full"
>
<div className="absolute top-0 left-0 h-0 w-[2px] group-hover:h-full transition-all duration-500 origin-top bg-brand" />
<div className="p-6 sm:p-7 lg:p-8 flex flex-col flex-1 relative z-10">
<div className="flex items-center gap-3 mb-5">
<span className="inline-block px-3 py-1 text-[11px] font-bold text-brand border border-brand/20 bg-brand/[0.05]">
{caseItem.industry}
</span>
<span className="text-[10px] tracking-[0.25em] uppercase text-text-muted font-semibold">
{caseItem.companySize}
</span>
</div>
<h3 className="text-lg sm:text-xl font-bold text-ink mb-4 leading-tight group-hover:text-brand transition-colors duration-300">
{caseItem.title}
</h3>
<div className="space-y-4 mb-6 flex-1">
<div>
<div className="flex items-center gap-2 mb-2">
<Target className="w-3.5 h-3.5 text-brand" />
<span className="text-[11px] tracking-wide uppercase font-bold text-brand">挑战</span>
</div>
<p className="text-sm text-text-secondary leading-relaxed line-clamp-2 pl-5.5">
{caseItem.challenge}
</p>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<Lightbulb className="w-3.5 h-3.5 text-text-muted" />
<span className="text-[11px] tracking-wide uppercase font-bold text-text-secondary">方案</span>
</div>
<p className="text-sm text-text-secondary leading-relaxed line-clamp-2 pl-5.5">
{caseItem.solution}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-5 border-t border-border-primary mb-5">
{caseItem.metrics.slice(0, 2).map((m, i) => (
<div key={i}>
<div className={`text-2xl sm:text-3xl font-black mb-1 tabular-nums tracking-tight ${
i === 0 ? 'text-brand' : 'text-ink'
}`}>
{m.value}
</div>
<div className="text-xs text-text-muted">{m.label}</div>
</div>
))}
</div>
<div className="flex items-center justify-between pt-4 mt-auto border-t border-border-primary">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-text-muted" />
<span className="text-xs text-text-muted">{caseItem.client}</span>
</div>
<div className="flex items-center gap-1.5 text-brand text-sm font-semibold">
查看详情
<ArrowUpRight className="w-4 h-4 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform duration-300" />
</div>
</div>
</div>
</motion.a>
);
}
export default function CasesContentV2({
cases: casesProp,
industries: industriesProp,
}: {
cases?: CaseStudy[];
industries?: { id: string; label: string }[];
}) {
const [selectedIndustry, setSelectedIndustry] = useState('all');
const industries = industriesProp ?? CASE_INDUSTRIES;
const filteredCases = useMemo(() => {
if (casesProp) return casesProp;
return getCasesByIndustry(selectedIndustry);
}, [selectedIndustry, casesProp]);
return (
<div className="min-h-screen bg-white text-ink overflow-x-hidden">
{/* Hero Section */}
<section className="relative min-h-[70vh] flex items-center overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-28 sm:py-36 md:py-40">
<div className="max-w-4xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.1, ease: EASE_OUT }}
className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-8"
>
Case Studies
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black text-ink leading-[0.88] tracking-tightest mb-10 sm:mb-12 md:mb-16"
>
<div className="block">真实案例,</div>
<div className="block mt-4 sm:mt-6">
<span className="relative inline-block">
<span className="relative text-brand font-black">
可衡量的成果
<motion.span
className="absolute -bottom-2 left-0 w-full h-[3px] bg-brand"
style={{ transformOrigin: 'left' }}
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.8, delay: 1.2, ease: EASE_OUT }}
/>
</span>
</span>
</div>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12"
>
每一个案例,都是我们与客户共同创造的价值故事。
<br className="hidden sm:block" />
从挑战到方案,从过程到成果——用数据说话,用结果证明。
</motion.p>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.7, ease: EASE_OUT }}
className="flex flex-wrap gap-3"
>
<span className="px-4 py-2 text-xs font-bold text-brand border border-brand/20 bg-brand/[0.05]">
500+ 企业客户
</span>
<span className="px-4 py-2 text-xs font-medium text-text-secondary border border-border-primary">
98% 项目成功率
</span>
<span className="px-4 py-2 text-xs font-medium text-text-secondary border border-border-primary">
6 大行业覆盖
</span>
<span className="px-4 py-2 text-xs font-medium text-text-secondary border border-border-primary">
97% 客户续约率
</span>
</motion.div>
</div>
</div>
</section>
{/* Metrics Strip */}
<section className="relative py-16 sm:py-20 md:py-24 overflow-hidden bg-bg-secondary">
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-border-primary" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8 md:gap-12">
{PAGE_METRICS.map((metric, i) => (
<motion.div
key={metric.label}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.6, delay: i * 0.08, ease: EASE_OUT }}
className="text-center lg:text-left"
>
<div className={`text-3xl sm:text-4xl md:text-5xl font-black tracking-tight mb-2 ${
i === 0 ? 'text-brand' : 'text-ink'
}`}>
{metric.value}
</div>
<div className="text-sm font-medium text-text-secondary mb-1">{metric.label}</div>
<div className="text-xs text-text-muted">{metric.sub}</div>
</motion.div>
))}
</div>
</div>
</section>
{/* Cases List Section */}
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-8 mb-12 sm:mb-16 md:mb-20">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.8, ease: EASE_OUT }}
className="lg:max-w-2xl"
>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6">
All Cases
</div>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]">
全部案例
</h2>
<p className="mt-6 text-lg text-text-secondary leading-relaxed">
按行业筛选,找到与您业务最相关的成功故事
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.8, delay: 0.15, ease: EASE_OUT }}
>
<div className="flex items-center gap-2 flex-wrap">
<Filter className="w-4 h-4 text-text-muted" />
{industries.map((industry) => (
<button
key={industry.id}
onClick={() => setSelectedIndustry(industry.id)}
className={cn(
'px-4 py-2 text-xs font-medium transition-all duration-300',
selectedIndustry === industry.id
? 'bg-brand text-white'
: 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary hover:text-ink'
)}
>
{industry.label}
</button>
))}
</div>
</motion.div>
</div>
{filteredCases.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-border-primary">
{filteredCases.map((caseItem, i) => (
<CaseCard key={caseItem.id} caseItem={caseItem} index={i} />
))}
</div>
) : (
<div className="text-center py-20">
<p className="text-text-secondary text-lg">暂无该行业的案例</p>
</div>
)}
</div>
</section>
{/* CTA Section */}
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-bg-secondary">
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.8, ease: EASE_OUT }}
className="max-w-3xl"
>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8">
您的成功故事,<br className="sm:hidden" />从这里开始
</h2>
<p className="text-lg text-text-secondary leading-relaxed mb-10">
无论您处于数字化转型的哪个阶段,我们都有相应的经验和能力帮助您。
<br className="hidden sm:block" />
让我们聊聊您的挑战,看看能创造什么样的成果。
</p>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
<a
href="/contact"
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90 min-h-[52px] touch-manipulation"
>
预约咨询
<ArrowUpRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</a>
<a
href="/services"
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary hover:border-border-secondary hover:bg-white transition-all duration-300 min-h-[52px] touch-manipulation"
>
了解服务
</a>
</div>
</motion.div>
</div>
</section>
</div>
);
}
+7 -2
View File
@@ -3,6 +3,11 @@
import type { CaseStudy } from '@/lib/constants/cases';
import CasesContentV3 from './cases-content-v3';
export function CasesClient({ cases }: { cases?: CaseStudy[] }) {
return <CasesContentV3 cases={cases} />;
interface CasesClientProps {
cases?: CaseStudy[];
industries?: { id: string; label: string }[];
}
export function CasesClient({ cases, industries }: CasesClientProps) {
return <CasesContentV3 cases={cases} industries={industries} />;
}
+100
View File
@@ -0,0 +1,100 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import CasesPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
}));
jest.mock('./cases-content-v3', () => ({
__esModule: true,
default: function MockCasesContentV3({ cases, industries }: { cases: Array<{ id: string; title: string }>; industries?: Array<{ id: string; label: string }> }) {
return (
<div data-testid="cases-content">
<div data-testid="industries-count">{industries?.length ?? 0}</div>
{cases.map((item) => (
<div key={item.id} data-testid="case-item">{item.title}</div>
))}
</div>
);
},
}));
import { getPublishedItems } from '@/lib/cms/data-server';
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
const createMockCase = (topOverrides: Record<string, unknown> = {}, dataOverrides: Record<string, unknown> = {}) => ({
id: 'item-1',
modelId: 'model-case-study',
modelCode: 'case-study',
title: '测试案例',
slug: 'test-case',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-case',
slug: 'test-case',
title: '测试案例',
client: '测试客户',
industry: '制造业',
companySize: '1000人以上',
subtitle: '测试副标题',
challenge: '测试挑战',
solution: '测试方案',
result: '测试成果',
metrics: [{ value: '40%', label: '效率提升', highlight: true }],
timeline: [{ phase: '诊断', duration: '1周', description: '测试阶段' }],
services: [{ id: 'svc', title: '测试服务', description: '服务描述' }],
color: 'brand',
featured: true,
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
describe('CasesPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItems.mockResolvedValue([]);
});
it('renders empty state when no CMS case items exist', async () => {
const page = await CasesPage();
render(page);
expect(screen.getByTestId('cases-content')).toBeInTheDocument();
expect(screen.queryByTestId('case-item')).not.toBeInTheDocument();
});
it('maps CMS items to CaseStudy shape and passes industries', async () => {
mockedGetPublishedItems.mockResolvedValue([
createMockCase(),
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await CasesPage();
render(page);
expect(screen.getByText('测试案例')).toBeInTheDocument();
expect(screen.getByTestId('industries-count')).toHaveTextContent('7');
});
it('falls back to item.title when data.title is missing', async () => {
mockedGetPublishedItems.mockResolvedValue([
createMockCase(
{ title: 'Fallback Title' },
{ title: undefined },
),
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await CasesPage();
render(page);
expect(screen.getByText('Fallback Title')).toBeInTheDocument();
});
});
+5 -2
View File
@@ -1,5 +1,6 @@
import { Metadata } from 'next';
import { COMPANY_INFO } from '@/lib/constants';
import { CASE_INDUSTRIES } from '@/lib/constants/cases';
import { getPublishedItems } from '@/lib/cms/data-server';
import type { CaseStudy } from '@/lib/constants/cases';
import { CasesClient } from './client';
@@ -9,8 +10,10 @@ export const metadata: Metadata = {
description: `500+企业客户的成功案例,覆盖制造、零售、医疗、教育、金融、物流六大行业。真实数据,可衡量的成果——见证数字化转型的价值。`,
};
export const revalidate = 3600;
export default async function CasesPage() {
const items = await getPublishedItems('case-study');
const cases = items.map((item) => ({ ...item.data, title: item.data.title || item.title } as unknown as CaseStudy));
return <CasesClient cases={cases} />;
const cases = items.map((item) => ({ ...item.data, id: item.data.id || item.id, slug: item.data.slug || item.slug, title: item.data.title || item.title } as unknown as CaseStudy));
return <CasesClient cases={cases} industries={CASE_INDUSTRIES} />;
}