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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user