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
@@ -4,7 +4,7 @@ import { useRef, useState, useEffect } from 'react';
import { motion, useInView } from 'framer-motion';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { ArrowRight, Target, Heart, Lightbulb, CheckCircle2 } from 'lucide-react';
import { GrainOverlay, FloatingInkParticles, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import { GrainOverlay, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
const CORE_VALUES = [
@@ -140,8 +140,7 @@ export default function AboutContentV2() {
{/* Hero Section */}
<section className="relative min-h-[85vh] 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-44 lg:py-56">
@@ -347,8 +346,7 @@ export default function AboutContentV2() {
{/* CTA Section */}
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden">
<GrainOverlay />
<FloatingInkParticles />
<div className="absolute inset-0 bg-gradient-to-b from-ink to-ink-light/50 pointer-events-none" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
@@ -8,7 +8,7 @@ import {
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { MetricCard } from '@/components/ui/metric-card';
import { MilestoneTimeline } from '@/components/ui/milestone-timeline';
import { GrainOverlay, FloatingInkParticles, SectionLabel } from '@/components/ui/page-decoration';
import { GrainOverlay, SectionLabel } from '@/components/ui/page-decoration';
const CORE_VALUES = [
{
@@ -153,7 +153,6 @@ export default function AboutContentV3() {
{/* Hero Section */}
<section className="relative min-h-[85vh] 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" />
@@ -408,7 +407,6 @@ export default function AboutContentV3() {
{/* CTA Section */}
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden">
<GrainOverlay />
<FloatingInkParticles />
<div className="absolute inset-0 bg-gradient-to-b from-ink to-ink-light/50 pointer-events-none" />
@@ -105,16 +105,18 @@ function HeroSection({ data }: { data: AboutData }) {
>
<a
href={data.heroPrimaryCtaHref || '#'}
aria-label={data.heroPrimaryCtaLabel || '预约咨询'}
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"
>
{data.heroPrimaryCtaLabel || ''}
{data.heroPrimaryCtaLabel || '预约咨询'}
<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={data.heroSecondaryCtaHref || '#'}
aria-label={data.heroSecondaryCtaLabel || '了解更多'}
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-bg-secondary transition-all duration-300 min-h-[52px] touch-manipulation"
>
{data.heroSecondaryCtaLabel || ''}
{data.heroSecondaryCtaLabel || '了解更多'}
</a>
</motion.div>
</div>
@@ -377,16 +379,18 @@ function CTASection({ data }: { data: AboutData }) {
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
<a
href={data.ctaPrimaryHref || '#'}
aria-label={data.ctaPrimaryLabel || '预约咨询'}
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"
>
{data.ctaPrimaryLabel || ''}
{data.ctaPrimaryLabel || '预约咨询'}
<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={data.ctaSecondaryHref || '#'}
aria-label={data.ctaSecondaryLabel || '了解更多'}
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-bg-secondary transition-all duration-300 min-h-[52px] touch-manipulation"
>
{data.ctaSecondaryLabel || ''}
{data.ctaSecondaryLabel || '了解更多'}
</a>
</div>
</motion.div>
@@ -0,0 +1,397 @@
'use client';
import { motion } from 'framer-motion';
import { ScrollReveal } from '@/components/ui/scroll-reveal';
import {
ArrowRight,
Target,
Eye,
Heart,
TrendingUp,
Shield,
Users,
Building2,
Globe,
ChevronRight,
} from 'lucide-react';
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
// ============ 品牌数据 ============
const brandValues = [
{
icon: Target,
title: '结果导向',
description: '每个项目都以可衡量的业务改善为交付标准,不追求表面完工,只关注真实价值。',
},
{
icon: Eye,
title: '远见卓识',
description: '立足行业前沿,帮助企业预见技术趋势,提前布局数字化能力,而非被动应对。',
},
{
icon: Heart,
title: '长期陪伴',
description: '不做一次性项目,而是成为企业数字化转型的长期伙伴,共同穿越技术迭代周期。',
},
{
icon: TrendingUp,
title: '持续进化',
description: '方法论与工具持续迭代,保持对新技术、新模式的敏锐度,确保方案始终领先一步。',
},
];
const milestones = [
{
year: '2014',
title: '扬帆起航',
description: '核心团队组建,聚焦企业信息化咨询与系统集成服务,服务首批制造行业客户。',
highlight: '起点',
},
{
year: '2016',
title: '能力沉淀',
description: '建立自有方法论体系,完成 ERP、CRM 等核心产品线布局,服务客户突破 50 家。',
highlight: '50+',
},
{
year: '2018',
title: '行业深耕',
description: '业务覆盖制造、零售、医疗、金融四大行业,推出行业化解决方案,团队规模突破 100 人。',
highlight: '4 行业',
},
{
year: '2020',
title: '平台升级',
description: '完成核心产品云化改造,推出数据中台与 BI 平台,助力企业应对数字化转型加速需求。',
highlight: '云化',
},
{
year: '2022',
title: '生态扩展',
description: '业务拓展至教育、物流行业,服务客户突破 300 家,建立产学研合作生态。',
highlight: '300+',
},
{
year: '2024',
title: '品牌升级',
description: '正式确立「睿新致远」品牌,以数据驱动的决策能力为核心价值主张,服务客户突破 500 家。',
highlight: '500+',
},
];
const brandStats = [
{ value: '12+', label: '年行业深耕' },
{ value: '500+', label: '服务企业' },
{ value: '200+', label: '专业顾问' },
{ value: '6', label: '行业覆盖' },
];
export default function BrandContent() {
return (
<main className="min-h-screen bg-white overflow-x-hidden">
{/* ============ L1: Hero 品牌情感入口 ============ */}
<section className="relative min-h-[90vh] 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-44 lg:py-56">
<div className="max-w-4xl">
{/* 眉标 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1, ease: EASE_OUT }}
className="mb-8 sm:mb-10"
>
<span className="inline-flex items-center gap-2 px-3 py-1.5 bg-brand/[0.08] border border-brand/20 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-brand" />
<span className="text-[13px] font-semibold text-brand tracking-[0.08em] uppercase">
品牌故事
</span>
</span>
</motion.div>
{/* 主标题 */}
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, 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.92] tracking-tightest mb-8 sm:mb-10"
>
睿新致远
<br />
<span className="text-brand">以技术驱动业务增长</span>
</motion.h1>
{/* 数据条 */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.4, ease: EASE_OUT }}
className="flex flex-wrap gap-6 sm:gap-10 md:gap-14 mb-10"
>
{brandStats.map((stat, i) => (
<div key={i} className="flex items-baseline gap-2">
<span className="text-3xl sm:text-4xl md:text-5xl font-black text-brand tabular-nums leading-none">
{stat.value}
</span>
<span className="text-sm text-text-muted whitespace-nowrap">
{stat.label}
</span>
</div>
))}
</motion.div>
{/* 副标题 */}
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.5, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary/70 max-w-2xl leading-relaxed"
>
12 年深耕企业数字化,从战略规划到系统落地,陪伴 500+ 企业实现可衡量的业务改善。
我们相信,技术的力量在于让每一家企业都拥有数据驱动的决策能力。
</motion.p>
</div>
</div>
</section>
{/* ============ L2: 品牌价值理性支撑 ============ */}
{/* 品牌定位 */}
<section className="relative py-24 sm:py-32 md:py-40 overflow-hidden bg-bg-secondary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="max-w-3xl mb-16 sm:mb-20">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.15em] font-medium text-brand">
品牌定位
</span>
</div>
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
做企业数字化转型的
<span className="text-brand">长期同行者</span>
</h2>
<p className="text-text-secondary leading-relaxed mt-6 text-base sm:text-lg max-w-2xl">
我们不是传统的外包服务商,而是深度嵌入客户业务的战略伙伴。
从需求诊断到系统落地,从数据治理到决策赋能,我们陪伴企业穿越转型周期的每一个阶段。
</p>
</ScrollReveal>
{/* 品牌价值观 */}
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
{brandValues.map((item, i) => (
<ScrollReveal key={item.title} delay={i * 0.1}>
<div className="group relative bg-white border border-border-primary p-8 h-full transition-all duration-300 hover:border-brand/20 hover:shadow-md">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-brand scale-x-0 group-hover:scale-x-100 transition-transform duration-500 origin-left" />
<div className="w-12 h-12 flex items-center justify-center bg-brand/[0.06] text-brand mb-6 transition-colors duration-300 group-hover:bg-brand group-hover:text-white">
<item.icon className="w-5 h-5" />
</div>
<h3 className="text-lg font-bold text-ink mb-3">{item.title}</h3>
<p className="text-sm text-text-secondary leading-relaxed">{item.description}</p>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* 品牌使命与愿景 */}
<section className="relative py-24 sm:py-32 md:py-40 overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="grid lg:grid-cols-2 gap-16 lg:gap-20">
{/* 使命 */}
<ScrollReveal>
<div className="relative border border-border-primary p-10 sm:p-12 lg:p-14 h-full">
<div className="absolute top-0 left-0 bottom-0 w-1 bg-brand" />
<div className="flex items-center gap-4 mb-8">
<div className="w-14 h-14 flex items-center justify-center bg-brand text-white">
<Target className="w-6 h-6" />
</div>
<div>
<span className="text-[11px] tracking-[0.2em] uppercase text-text-muted font-medium">Mission</span>
<h3 className="text-2xl font-black text-ink tracking-tight">品牌使命</h3>
</div>
</div>
<p className="text-2xl sm:text-3xl font-black text-ink leading-tight tracking-tight">
让每一家企业
<br />
都拥有<span className="text-brand">数据驱动</span>的
<br />
决策能力
</p>
<p className="text-text-secondary leading-relaxed mt-8">
我们致力于消除技术与业务之间的鸿沟,让数据不再沉睡在系统中,而是成为企业决策的「导航仪」。
通过咨询、软件、数据的深度融合,帮助企业建立可持续的数字化竞争力。
</p>
</div>
</ScrollReveal>
{/* 愿景 */}
<ScrollReveal delay={0.1}>
<div className="relative border border-border-primary bg-dark-bg text-dark-text-primary p-10 sm:p-12 lg:p-14 h-full">
<div className="absolute top-0 left-0 bottom-0 w-1 bg-brand" />
<div className="flex items-center gap-4 mb-8">
<div className="w-14 h-14 flex items-center justify-center bg-brand text-white">
<Eye className="w-6 h-6" />
</div>
<div>
<span className="text-[11px] tracking-[0.2em] uppercase text-dark-text-muted font-medium">Vision</span>
<h3 className="text-2xl font-black text-dark-text-primary tracking-tight">品牌愿景</h3>
</div>
</div>
<p className="text-2xl sm:text-3xl font-black text-dark-text-primary leading-tight tracking-tight">
成为中国企业
<br />
<span className="text-brand">数字化转型</span>的
<br />
首选伙伴
</p>
<p className="text-dark-text-secondary leading-relaxed mt-8">
我们致力于成为企业数字化转型的首选品牌——当企业需要技术赋能业务增长时,第一个想到睿新致远。
这不仅意味着技术能力的领先,更意味着专业可信赖的品牌承诺。
</p>
</div>
</ScrollReveal>
</div>
</div>
</section>
{/* ============ L3: 品牌信任证明 — 发展历程 ============ */}
<section className="relative py-24 sm:py-32 md:py-40 overflow-hidden bg-bg-secondary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="max-w-3xl mb-16 sm:mb-20">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.15em] font-medium text-brand">
发展历程
</span>
</div>
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
12 年深耕,每一步都
<span className="text-brand">脚踏实地</span>
</h2>
</ScrollReveal>
{/* 时间线 */}
<div className="relative">
{/* 竖线 */}
<div className="absolute left-4 md:left-1/2 top-0 bottom-0 w-px bg-border-primary -translate-x-1/2 hidden md:block" />
<div className="space-y-12 md:space-y-16">
{milestones.map((item, i) => (
<ScrollReveal key={item.year} delay={i * 0.08}>
<div className={`relative flex flex-col md:flex-row gap-6 md:gap-12 ${i % 2 === 0 ? 'md:flex-row' : 'md:flex-row-reverse'}`}>
{/* 时间节点 */}
<div className="md:w-1/2 flex md:justify-end items-center">
<div className={`${i % 2 === 0 ? 'md:text-right md:pr-12' : 'md:text-left md:pl-12'} flex-1`}>
<div className="flex items-center gap-4 md:justify-end mb-3">
<span className="text-3xl sm:text-4xl font-black text-brand tracking-tight">
{item.year}
</span>
<span className="text-[11px] tracking-[0.15em] uppercase text-text-muted bg-bg-tertiary px-2 py-0.5">
{item.highlight}
</span>
</div>
<h3 className="text-lg font-bold text-ink mb-2">{item.title}</h3>
<p className="text-sm text-text-secondary leading-relaxed">{item.description}</p>
</div>
</div>
{/* 时间线节点 */}
<div className="hidden md:flex absolute left-1/2 top-0 -translate-x-1/2 flex-col items-center">
<div className="w-3 h-3 rounded-full bg-brand border-2 border-white shadow-sm" />
</div>
{/* 移动端节点 */}
<div className="md:hidden flex items-center gap-4">
<div className="w-3 h-3 rounded-full bg-brand shrink-0" />
<div className="w-8 h-px bg-brand/30" />
</div>
{/* 右侧占位 */}
<div className="hidden md:block md:w-1/2" />
</div>
</ScrollReveal>
))}
</div>
</div>
</div>
</section>
{/* 品牌实力 */}
<section className="relative py-24 sm:py-32 md:py-40 overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="max-w-3xl mb-16 sm:mb-20">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.15em] font-medium text-brand">
品牌实力
</span>
</div>
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
用实力赢得
<span className="text-brand">每一份信任</span>
</h2>
</ScrollReveal>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
{[
{ icon: Building2, title: '500+ 企业客户', desc: '覆盖制造、零售、医疗、金融、教育、物流六大行业' },
{ icon: Users, title: '200+ 专业顾问', desc: '核心团队来自头部咨询公司与科技企业,平均从业经验 8 年以上' },
{ icon: Shield, title: '可验证的交付质量', desc: '90%+ 方案落地率,客户满意度持续保持在 95% 以上' },
{ icon: Globe, title: '全行业覆盖', desc: '从传统制造到新兴零售,方法论适配多行业场景' },
].map((item, i) => (
<ScrollReveal key={item.title} delay={i * 0.1}>
<div className="group relative bg-bg-secondary border border-border-primary p-8 h-full transition-all duration-300 hover:border-brand/20 hover:bg-white hover:shadow-sm">
<div className="w-12 h-12 flex items-center justify-center bg-brand/[0.06] text-brand mb-6 transition-colors duration-300 group-hover:bg-brand group-hover:text-white">
<item.icon className="w-5 h-5" />
</div>
<h3 className="text-lg font-bold text-ink mb-3">{item.title}</h3>
<p className="text-sm text-text-secondary leading-relaxed">{item.desc}</p>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* ============ L4: CTA 品牌行动号召 ============ */}
<section className="relative py-24 sm:py-32 md:py-44 overflow-hidden bg-dark-bg">
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-dark-border to-transparent" />
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-1/3 right-1/4 w-[500px] h-[500px] bg-brand/[0.04] rounded-full blur-[140px]" />
</div>
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="max-w-3xl">
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-6xl xl:text-7xl font-black text-dark-text-primary tracking-tightest leading-[0.9] mb-8 sm:mb-10">
成为下一个
<br />
<span className="text-brand">数据驱动型企业</span>
</h2>
<p className="text-dark-text-secondary text-lg leading-relaxed mb-12 sm:mb-16 max-w-xl">
无论您处于数字化转型的哪个阶段,我们都愿意成为您的长期伙伴。
从一次深入的业务诊断开始,开启您的数据驱动之旅。
</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]"
>
预约咨询
<ArrowRight className="w-5 h-5 transition-transform duration-300 group-hover:translate-x-1" />
</a>
<a
href="/cases"
className="group inline-flex items-center justify-center gap-2 px-10 py-5 font-semibold text-base text-dark-text-primary border border-dark-border hover:border-dark-text-muted transition-all duration-300 min-h-[52px]"
>
查看客户案例
<ChevronRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
</div>
</ScrollReveal>
</div>
</section>
</main>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Metadata } from 'next';
import { COMPANY_INFO } from '@/lib/constants';
import BrandContent from './brand-content';
export const metadata: Metadata = {
title: `品牌故事 - ${COMPANY_INFO.displayName}`,
description: `了解${COMPANY_INFO.name}的品牌理念、使命愿景与发展历程。12年深耕企业数字化,以「睿新致远」为品牌精神,专注可衡量的业务改善。`,
};
export default function BrandPage() {
return <BrandContent />;
}
@@ -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} />;
}
@@ -24,7 +24,7 @@ import { COMPANY_INFO } from '@/lib/constants';
import { trackContactForm, trackConversion } from '@/lib/analytics';
import { BreadcrumbSchema } from '@/components/seo/structured-data';
import { LegacyTooltip as Tooltip } from '@/components/ui/tooltip';
import { GrainOverlay, FloatingInkParticles, SectionLabel } from '@/components/ui/page-decoration';
import { GrainOverlay, SectionLabel } from '@/components/ui/page-decoration';
const contactFormSchema = z.object({
name: z.string().min(2, '姓名至少需要2个字符'),
@@ -194,7 +194,6 @@ function ContactFormContent() {
{/* Hero Section */}
<section className="relative min-h-[75vh] 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" />
@@ -478,7 +477,6 @@ function ContactFormContent() {
{/* 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="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="max-w-3xl mx-auto text-center">
+1 -13
View File
@@ -16,18 +16,8 @@ import {
} from 'lucide-react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { cn } from '@/lib/utils';
import { GrainOverlay, FloatingInkParticles, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import { GrainOverlay, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
const HOME_PARTICLES = [
{ x: 12, y: 28, scale: 0.8, duration: 10, delay: 0, size: 3 },
{ x: 78, y: 55, scale: 0.6, duration: 12, delay: 1.5, size: 2 },
{ x: 42, y: 38, scale: 1.0, duration: 9, delay: 0.8, size: 4 },
{ x: 88, y: 22, scale: 0.7, duration: 11, delay: 2.2, size: 2.5 },
{ x: 22, y: 72, scale: 0.9, duration: 8.5, delay: 1.0, size: 3.5 },
{ x: 62, y: 48, scale: 0.55, duration: 13, delay: 2.8, size: 2 },
{ x: 55, y: 18, scale: 0.65, duration: 10.5, delay: 3.2, size: 2.5 },
{ x: 32, y: 62, scale: 0.75, duration: 9.5, delay: 0.5, size: 3 },
];
const EASE_SPRING = { type: 'spring', stiffness: 300, damping: 30 } as const;
const SERVICES = [
@@ -366,7 +356,6 @@ function HeroSection() {
className="relative min-h-screen flex items-center overflow-hidden bg-ink"
>
<GrainOverlay />
<FloatingInkParticles particles={HOME_PARTICLES} />
<div className="absolute inset-0 pointer-events-none">
<motion.div style={{ y: gridY, scale }} className="absolute inset-0 opacity-[0.035]">
@@ -1045,7 +1034,6 @@ function CTASection() {
className="relative py-24 sm:py-32 md:py-40 lg:py-52 overflow-hidden bg-ink-light"
>
<GrainOverlay />
<FloatingInkParticles particles={HOME_PARTICLES} />
<div className="absolute inset-0 pointer-events-none">
<div
+1 -13
View File
@@ -16,18 +16,8 @@ import {
} from 'lucide-react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { cn } from '@/lib/utils';
import { GrainOverlay, FloatingInkParticles, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import { GrainOverlay, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
const HOME_PARTICLES = [
{ x: 12, y: 28, scale: 0.8, duration: 10, delay: 0, size: 3 },
{ x: 78, y: 55, scale: 0.6, duration: 12, delay: 1.5, size: 2 },
{ x: 42, y: 38, scale: 1.0, duration: 9, delay: 0.8, size: 4 },
{ x: 88, y: 22, scale: 0.7, duration: 11, delay: 2.2, size: 2.5 },
{ x: 22, y: 72, scale: 0.9, duration: 8.5, delay: 1.0, size: 3.5 },
{ x: 62, y: 48, scale: 0.55, duration: 13, delay: 2.8, size: 2 },
{ x: 55, y: 18, scale: 0.65, duration: 10.5, delay: 3.2, size: 2.5 },
{ x: 32, y: 62, scale: 0.75, duration: 9.5, delay: 0.5, size: 3 },
];
const EASE_SPRING = { type: 'spring', stiffness: 300, damping: 30 } as const;
const SERVICES = [
@@ -366,7 +356,6 @@ function HeroSection() {
className="relative min-h-screen flex items-center overflow-hidden bg-ink"
>
<GrainOverlay />
<FloatingInkParticles particles={HOME_PARTICLES} />
<div className="absolute inset-0 pointer-events-none">
<motion.div style={{ y: gridY, scale }} className="absolute inset-0 opacity-[0.035]">
@@ -1088,7 +1077,6 @@ function CTASection() {
className="relative py-32 sm:py-40 md:py-52 lg:py-60 overflow-hidden bg-ink-light"
>
<GrainOverlay />
<FloatingInkParticles particles={HOME_PARTICLES} />
<div className="absolute inset-0 pointer-events-none">
<div
+87 -197
View File
@@ -6,7 +6,7 @@ import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { Badge } from '@/components/ui/badge';
import {
ArrowRight, ChevronDown,
ArrowRight,
Quote, CheckCircle2,
Zap, Award,
} from 'lucide-react';
@@ -55,7 +55,7 @@ function MagneticButton({ children, href, variant = 'primary', className }: { ch
);
}
function HeroSection({ heroData }: { heroData?: Record<string, any> | null }) {
function HeroSection({ heroData, stats }: { heroData?: Record<string, any> | null; stats?: Record<string, any>[] }) {
const containerRef = useRef<HTMLDivElement>(null);
const scrollProgress = useMotionValue(0);
@@ -81,13 +81,17 @@ function HeroSection({ heroData }: { heroData?: Record<string, any> | null }) {
const y = useTransform(scrollProgress, [0, 1], [0, 160]);
const opacity = useTransform(scrollProgress, [0, 0.7], [1, 0.15]);
const headingTop = heroData?.headingTop || '';
const headingBottom = heroData?.headingBottom || '';
const description = heroData?.description || '';
const ctaLabel = heroData?.ctaLabel || '';
const ctaHref = heroData?.ctaHref || '#';
const statValue = heroData?.statValue || '';
const statSubtext = heroData?.statSubtext || '';
// 兼容新旧数据字段
const eyebrow = heroData?.eyebrow || '';
const heading = heroData?.heading || heroData?.headingBottom || '';
const subheading = heroData?.subheading || heroData?.description || '';
const ctaLabel = heroData?.ctaLabel || '预约咨询';
const ctaHref = heroData?.ctaHref || '/contact';
const secondaryCtaLabel = heroData?.secondaryCtaLabel || '';
const secondaryCtaHref = heroData?.secondaryCtaHref || '#';
const hasValidStats = stats?.length && stats[0]?.value;
const displayStats = hasValidStats ? stats!.slice(0, 4) : [];
if (!heroData) {
return (
@@ -104,137 +108,93 @@ function HeroSection({ heroData }: { heroData?: Record<string, any> | null }) {
ref={containerRef}
className="relative min-h-screen flex items-center overflow-hidden bg-white"
>
{/* 右侧抽象几何装饰 — 桌面端可见,暗示咨询框架 */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 hidden lg:block pointer-events-none select-none" aria-hidden="true">
<div className="relative w-[480px] h-[480px]">
{/* 外圈:结构框架 */}
<motion.div
initial={{ opacity: 0, rotate: -8 }}
animate={{ opacity: 0.12, rotate: 0 }}
transition={{ duration: 1.5, delay: 0.3, ease: EASE_OUT }}
className="absolute top-0 right-0 w-64 h-64 border border-ink/20"
/>
<motion.div
initial={{ opacity: 0, rotate: 8 }}
animate={{ opacity: 0.08, rotate: 0 }}
transition={{ duration: 1.5, delay: 0.5, ease: EASE_OUT }}
className="absolute top-16 right-16 w-48 h-48 border border-ink/15"
/>
{/* 内圈:数据节点 */}
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 0.06, scale: 1 }}
transition={{ duration: 1.2, delay: 0.7, ease: EASE_OUT }}
className="absolute top-24 right-24 w-24 h-24 bg-brand"
/>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 0.03, scale: 1 }}
transition={{ duration: 1.2, delay: 0.9, ease: EASE_OUT }}
className="absolute bottom-32 right-32 w-16 h-16 bg-ink"
/>
{/* 连线和节点 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.06 }}
transition={{ duration: 1, delay: 1.1, ease: EASE_OUT }}
className="absolute top-32 right-12 w-20 h-px bg-ink/30"
/>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.04 }}
transition={{ duration: 1, delay: 1.3, ease: EASE_OUT }}
className="absolute top-48 right-0 w-28 h-px bg-ink/25"
/>
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 0.1, scale: 1 }}
transition={{ duration: 0.8, delay: 1.5, ease: EASE_OUT }}
className="absolute top-32 right-12 w-1.5 h-1.5 bg-brand"
/>
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 0.08, scale: 1 }}
transition={{ duration: 0.8, delay: 1.7, ease: EASE_OUT }}
className="absolute top-48 right-28 w-1.5 h-1.5 bg-ink"
/>
</div>
</div>
<div className="absolute bottom-0 left-0 w-full h-1/3 bg-gradient-to-t from-white to-transparent pointer-events-none" />
<motion.div
style={{ y, opacity }}
className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-32 sm:py-40 md:py-48 lg:py-56"
>
<div className="max-w-4xl">
<motion.h1
initial={{ opacity: 0, y: 60 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1.2, delay: 0.1, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black text-ink leading-[0.92] tracking-tightest mb-10 sm:mb-12 md:mb-16"
>
{headingTop && (
<div className="block text-text-secondary font-bold text-2xl sm:text-3xl md:text-4xl lg:text-5xl tracking-tight mb-6 sm:mb-8">
{headingTop}
</div>
)}
<div className="block">
<span className="relative inline-block">
<span className="relative text-brand font-black">
{headingBottom}
<motion.span
className="absolute -bottom-3 left-0 w-full h-[3px] bg-brand"
style={{ transformOrigin: 'left' }}
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.9, delay: 1.4, ease: EASE_OUT }}
/>
{/* 眉标 Badge */}
{eyebrow && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1, ease: EASE_OUT }}
className="mb-8 sm:mb-10"
>
<span className="inline-flex items-center gap-2 px-3 py-1.5 bg-brand/[0.08] border border-brand/20 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-brand" />
<span className="text-[13px] font-semibold text-brand tracking-[0.08em] uppercase">
{eyebrow}
</span>
</span>
</div>
</motion.div>
)}
{/* 主标题 — Bain 风格:大号加粗 + 品牌红关键词 */}
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, 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.92] tracking-tightest mb-10 sm:mb-12"
>
<span className="relative">
{heading}
</span>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.5, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary/80 max-w-2xl leading-relaxed mb-12 sm:mb-14 md:mb-16"
>
{description}
</motion.p>
{/* 数据指标条 — Bain 风格:答案优先,数据先行 */}
{displayStats.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.4, ease: EASE_OUT }}
className="flex flex-wrap gap-6 sm:gap-10 md:gap-14 mb-10 sm:mb-12"
>
{displayStats.map((stat, i) => (
<div key={i} className="flex items-baseline gap-2">
<span className="text-3xl sm:text-4xl md:text-5xl font-black text-brand tabular-nums leading-none">
{stat.value}
</span>
<span className="text-sm text-text-muted whitespace-nowrap">
{stat.label}
</span>
</div>
))}
</motion.div>
)}
{/* 副标题 */}
{subheading && (
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.5, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary/70 max-w-2xl leading-relaxed mb-12 sm:mb-14"
>
{subheading}
</motion.p>
)}
{/* CTA 按钮组 */}
<motion.div
initial={{ opacity: 0, y: 40 }}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.65, ease: EASE_OUT }}
className="flex flex-col sm:flex-row sm:items-center gap-6 sm:gap-10"
transition={{ duration: 0.7, delay: 0.6, ease: EASE_OUT }}
className="flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6"
>
<MagneticButton href={ctaHref}>{ctaLabel || '预约咨询'}</MagneticButton>
{statValue && (
<div className="flex items-center gap-5 pl-4 border-l border-border-primary">
<div className="flex items-center gap-1.5">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="w-2 h-2 rounded-full bg-brand" style={{ opacity: 1 - i * 0.22 }} />
))}
</div>
<div>
<div className="text-sm font-bold text-ink">{statValue}</div>
<div className="text-xs text-text-muted">{statSubtext}</div>
</div>
</div>
<MagneticButton href={ctaHref}>{ctaLabel}</MagneticButton>
{secondaryCtaLabel && (
<a
href={secondaryCtaHref}
className="group inline-flex items-center gap-2 text-sm font-medium text-text-secondary hover:text-ink transition-colors duration-200 py-4"
>
{secondaryCtaLabel}
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
)}
</motion.div>
</div>
</motion.div>
<motion.div
animate={{ y: [0, 12, 0] }}
transition={{ duration: 2.5, repeat: Infinity, ease: 'easeInOut' }}
className="absolute left-4 sm:left-6 lg:left-10 flex flex-col items-start gap-4 bottom-[calc(4rem+env(safe-area-inset-bottom,0px))] md:bottom-16"
>
<ChevronDown className="w-5 h-5 text-text-muted" />
</motion.div>
</section>
);
}
@@ -260,9 +220,9 @@ function ServicesSection({ services }: { services?: Record<string, any>[] }) {
<ScrollReveal>
<SectionLabel>核心服务</SectionLabel>
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-6xl font-black text-ink tracking-tight leading-[0.95] mt-6">
四大服务,
从战略到落地,
<br />
覆盖数字化全链路
可量化的一站式服务
</h2>
</ScrollReveal>
</div>
@@ -412,7 +372,7 @@ function TrustSection() {
</span>
</div>
<h2 className="font-sans text-xl sm:text-2xl md:text-3xl lg:text-4xl font-black text-ink tracking-tight leading-tight">
选择我们的理由
12年深耕,500+项目验证的交付能力
</h2>
</ScrollReveal>
@@ -622,75 +582,6 @@ function CasesSection({ cases }: { cases?: Record<string, any>[] }) {
);
}
function ApproachStrip({ stats }: { stats?: Record<string, any>[] }) {
const hasValidData = stats?.length && stats[0]?.value;
const APPROACH_STRIP = hasValidData ? stats : [];
if (APPROACH_STRIP.length === 0) {
return (
<section className="relative py-20 overflow-hidden bg-dark-bg-secondary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 text-center">
<p className="text-dark-text-muted text-lg">暂无数据</p>
</div>
</section>
);
}
return (
<section
className="relative py-20 sm:py-24 md:py-28 lg:py-32 overflow-hidden bg-dark-bg-secondary"
>
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-dark-border to-transparent" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-dark-border to-transparent" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="mb-10 sm:mb-12 md:mb-14">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.15em] font-medium text-brand">
交付方法
</span>
</div>
<h2 className="font-sans text-xl sm:text-2xl md:text-3xl lg:text-4xl font-black text-dark-text-primary tracking-tight leading-tight">
500+ 项目验证的交付方法论
</h2>
</ScrollReveal>
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-0">
{APPROACH_STRIP.map((item, i) => (
<div key={i} className="flex flex-col">
{i > 0 && (
<div className="hidden md:block absolute left-0 top-1/2 -translate-y-1/2 w-px h-16 bg-dark-border" />
)}
<div className="relative">
<motion.div
className="text-3xl sm:text-4xl lg:text-5xl font-black text-dark-text-primary tracking-tightest mb-2"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 * i }}
>
{item.value}
</motion.div>
<div className="text-xs text-dark-text-muted tracking-wide mb-4">
{item.label}
</div>
<div className="h-1 bg-dark-border/50 rounded-full overflow-hidden max-w-[160px]">
<motion.div
className="h-full bg-brand rounded-full"
initial={{ width: 0 }}
whileInView={{ width: `${(item as any).progress || 80}%` }}
viewport={{ once: true }}
transition={{ duration: 1.2, delay: 0.2 + 0.1 * i, ease: 'easeOut' }}
/>
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
}
function CTASection({ heroData }: { heroData?: Record<string, any> | null }) {
const ctaTitleLine1 = heroData?.ctaTitleLine1 || '';
const ctaTitleLine2 = heroData?.ctaTitleLine2 || '';
@@ -727,11 +618,10 @@ export default function HomeContentV13({ services, cases, stats, heroData }: {
}) {
return (
<main>
<HeroSection heroData={heroData} />
<HeroSection heroData={heroData} stats={stats} />
<ServicesSection services={services} />
<TrustSection />
<CasesSection cases={cases} />
<ApproachStrip stats={stats} />
<CTASection heroData={heroData} />
</main>
);
+417
View File
@@ -0,0 +1,417 @@
'use client';
import { useRef } from 'react';
import { motion } from 'framer-motion';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { Badge } from '@/components/ui/badge';
import { ArrowRight, Lightbulb, Database, Layers } from 'lucide-react';
import { cn } from '@/lib/utils';
import { EASE_OUT } from '@/components/ui/page-decoration';
// ===== 服务数据降级兜底 =====
const FALLBACK_SERVICES = [
{
subtitle: 'Strategy Consulting',
title: '数字化转型战略咨询',
desc: '从业务诊断到数字化路线图,帮助企业识别高价值场景,规划可落地的转型路径,确保每一分投入都能产生可衡量的业务回报。',
highlights: ['业务诊断', '数字化蓝图', '实施路线图', 'ROI 评估'],
metrics: [
{ value: '40%', label: '平均运营效率提升' },
{ value: '6个月', label: '战略到试点周期' },
],
href: '/services/strategy',
icon: <Lightbulb className="w-7 h-7" />,
},
{
subtitle: 'Data Intelligence',
title: '数据智能与 AI 应用',
desc: '构建企业级数据平台与 AI 能力,打通数据孤岛,沉淀数据资产,让决策从经验驱动转向数据驱动。',
highlights: ['数据中台', 'AI 建模', '可视化分析', '预测决策'],
metrics: [
{ value: '98%', label: '数据质量达标率' },
{ value: '10x', label: '分析效率提升' },
],
href: '/services/data',
icon: <Database className="w-7 h-7" />,
},
{
subtitle: 'Solution Delivery',
title: '行业解决方案落地',
desc: '基于深耕行业的最佳实践,提供从方案设计、系统开发到上线运维的一站式交付,确保数字化转型真正落地生根。',
highlights: ['行业套件', '定制开发', '系统集成', '持续运维'],
metrics: [
{ value: '500+', label: '项目交付经验' },
{ value: '99.9%', label: '系统可用性' },
],
href: '/services/solution',
icon: <Layers className="w-7 h-7" />,
},
];
// ===== 品牌 CTA 按钮 =====
function CTAButton({ children, href, variant = 'primary', className }: {
children: React.ReactNode;
href: string;
variant?: 'primary' | 'secondary';
className?: string;
}) {
return (
<a
href={href}
className={cn(
'group inline-flex items-center gap-3 px-10 py-4 font-semibold text-base transition-all duration-200',
variant === 'primary'
? 'bg-brand text-white hover:bg-brand-hover'
: 'text-ink border border-ink/20 hover:border-ink/40 hover:bg-ink/5',
className
)}
>
<span>{children}</span>
<ArrowRight className="w-5 h-5 transition-transform duration-300 group-hover:translate-x-2" />
</a>
);
}
// ===== Hero 区:克制白底 + 品牌红点缀 =====
function HeroSection({ heroData, stats }: {
heroData?: Record<string, any> | null;
stats?: Record<string, any>[];
}) {
const containerRef = useRef<HTMLDivElement>(null);
const heading = heroData?.heading || heroData?.headingBottom || '从数据到决策,让转型可量化';
const subheading = heroData?.subheading || heroData?.description || '';
const ctaLabel = heroData?.ctaLabel || '预约咨询';
const ctaHref = heroData?.ctaHref || '/contact';
const secondaryCtaLabel = heroData?.secondaryCtaLabel || '';
const secondaryCtaHref = heroData?.secondaryCtaHref || '#';
const hasValidStats = stats?.length && stats[0]?.value;
const displayStats = hasValidStats ? stats!.slice(0, 3) : [];
return (
<section
ref={containerRef}
className="relative min-h-[90vh] flex items-center overflow-hidden bg-white"
>
{/* 背景纹理:极淡的品牌色点阵 */}
<div className="absolute inset-0 pointer-events-none opacity-[0.03]"
style={{
backgroundImage: `radial-gradient(circle at 25% 25%, rgba(196,30,58,0.4) 1px, transparent 1px)`,
backgroundSize: '48px 48px',
}}
/>
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-24 sm:py-32 md:py-40 lg:py-48">
<div className="max-w-4xl">
{/* 主标题:大胆、直接 */}
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.1, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-ink leading-[1.05] tracking-tight mb-10"
>
{heading}
</motion.h1>
{/* 数据指标条:答案优先,数据先行 */}
{displayStats.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.3, ease: EASE_OUT }}
className="flex flex-wrap gap-8 sm:gap-12 md:gap-16 mb-10"
>
{displayStats.map((stat, i) => (
<div key={i} className="flex items-baseline gap-2">
<span className="text-3xl sm:text-4xl md:text-5xl font-black text-brand tabular-nums leading-none">
{stat.value}
</span>
<span className="text-sm text-text-secondary whitespace-nowrap">
{stat.label}
</span>
</div>
))}
</motion.div>
)}
{/* 副标题 */}
{subheading && (
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.4, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12"
>
{subheading}
</motion.p>
)}
{/* CTA 按钮组 */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.5, ease: EASE_OUT }}
className="flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6"
>
<CTAButton href={ctaHref}>{ctaLabel}</CTAButton>
{secondaryCtaLabel && (
<a
href={secondaryCtaHref}
className="group inline-flex items-center gap-2 text-sm font-medium text-text-secondary hover:text-ink transition-colors duration-200 py-4"
>
{secondaryCtaLabel}
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
)}
</motion.div>
</div>
</div>
</section>
);
}
// ===== 信任/成果区:Bain 式量化数据展示 =====
function TrustSection() {
const items = [
{ value: '12+', label: '年行业深耕', desc: '自 2014 年起专注企业数字化转型' },
{ value: '500+', label: '项目交付', desc: '覆盖金融、制造、政务等 20+ 行业' },
{ value: '98%', label: '客户续约率', desc: '长期陪跑,持续创造业务价值' },
{ value: '100%', label: '私有化部署', desc: '核心数据不出境,满足合规要求' },
];
return (
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="mb-16 sm:mb-20">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.12em] font-semibold text-brand">
值得信赖
</span>
</div>
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
以可衡量的成果
<br />
赢得长期信任
</h2>
</ScrollReveal>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{items.map((item, i) => (
<StaggerReveal key={i} staggerDelay={0.06}>
<div className="bain-card p-8 sm:p-10">
<div className="text-3xl sm:text-4xl font-black text-brand tabular-nums leading-none mb-2">
{item.value}
</div>
<div className="text-sm font-semibold text-ink mb-3">{item.label}</div>
<p className="text-sm text-text-secondary leading-relaxed">{item.desc}</p>
</div>
</StaggerReveal>
))}
</div>
</div>
</section>
);
}
// ===== 服务区:等宽三列卡片 + 兜底数据 =====
function ServicesSection({ services }: { services?: Record<string, any>[] }) {
const hasValidData = services?.length && services[0]?.title;
const SERVICE_ITEMS = hasValidData ? services! : FALLBACK_SERVICES;
return (
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-bg-secondary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="max-w-3xl mb-16 sm:mb-20">
<ScrollReveal>
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
从战略到落地,
<br />
可量化的一站式服务
</h2>
</ScrollReveal>
</div>
<div className="grid md:grid-cols-3 gap-6">
{SERVICE_ITEMS.map((service, i) => (
<StaggerReveal key={i} staggerDelay={0.05}>
<a href={service.href} className="group bain-card block p-8 sm:p-10 md:p-12 h-full flex flex-col">
<div className="flex flex-col flex-1">
<div className="flex items-start justify-between mb-10">
<div className="w-12 h-12 flex items-center justify-center bg-bg-secondary text-text-secondary" aria-hidden="true">
{service.icon}
</div>
</div>
<div className="mb-6">
<span className="block text-sm text-text-muted mb-3 font-medium">{service.subtitle}</span>
<h3 className="text-xl lg:text-2xl font-bold text-ink">{service.title}</h3>
</div>
<p className="text-text-secondary leading-relaxed mb-8 text-sm">{service.desc}</p>
<div className="flex flex-wrap gap-2 mb-8">
{service.highlights.map((h: string, j: number) => (
<span key={j} className="px-3 py-1.5 text-xs text-text-muted border border-border-primary font-medium">
{h}
</span>
))}
</div>
{(service as any).metrics && (
<div className="flex gap-6 mb-8 pb-8 border-b border-border-primary">
{(service as any).metrics.map((m: any, j: number) => (
<div key={j}>
<div className="text-2xl sm:text-3xl font-black text-brand leading-none mb-1">{m.value}</div>
<div className="text-[11px] text-text-muted font-medium">{m.label}</div>
</div>
))}
</div>
)}
<div className="mt-auto pt-2">
<div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors">
<span>了解详情</span>
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</div>
</div>
</div>
</a>
</StaggerReveal>
))}
</div>
</div>
</section>
);
}
// ===== 案例区:轻量快照卡片 =====
function CasesSection({ cases }: { cases?: Record<string, any>[] }) {
const hasValidData = cases?.length && cases[0]?.metrics;
const CASE_ITEMS = hasValidData ? cases! : [];
if (CASE_ITEMS.length === 0) {
return (
<section className="relative py-28 overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 text-center">
<p className="text-text-muted text-lg">暂无案例数据</p>
</div>
</section>
);
}
return (
<section className="relative py-20 sm:py-28 md:py-36 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 md:flex-row md:items-end md:justify-between gap-8 mb-16 sm:mb-20">
<ScrollReveal className="md:max-w-3xl">
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
每一个项目,
<br />
都以业务改善为衡量标准
</h2>
</ScrollReveal>
<ScrollReveal delay={0.1}>
<a
href="/cases"
className="group inline-flex items-center gap-3 text-ink font-semibold text-sm"
>
查看全部案例
<ArrowRight className="w-4 h-5 transition-transform duration-300 group-hover:translate-x-2 text-text-secondary group-hover:text-brand" />
</a>
</ScrollReveal>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{CASE_ITEMS.map((caseItem, i) => (
<ScrollReveal key={i} delay={i * 0.1}>
<a
href={caseItem.href || `/cases/${caseItem.slug || ''}`}
className="group bain-card block p-8 sm:p-10 h-full flex flex-col"
>
<div className="flex items-center gap-3 mb-8">
<Badge className="bg-bg-secondary text-ink border-transparent">
{caseItem.industry}
</Badge>
</div>
<h3 className="text-xl lg:text-2xl font-bold text-ink mb-8 leading-[1.2]">
{caseItem.title}
</h3>
<div className="grid grid-cols-3 gap-4 mb-8">
{caseItem.metrics.slice(0, 3).map((metric: Record<string, any>, j: number) => (
<div key={j}>
<div className={cn(
'text-xl sm:text-2xl font-black leading-none mb-2',
j === 0 ? 'text-brand' : 'text-ink'
)}>
{metric.value}
</div>
<div className="text-xs text-text-secondary font-medium leading-tight">
{metric.label}
</div>
</div>
))}
</div>
<div className="mt-auto pt-4 border-t border-border-primary">
<div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors">
<span>了解详情</span>
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</div>
</div>
</a>
</ScrollReveal>
))}
</div>
</div>
</section>
);
}
// ===== CTA 区:克制强调,品牌红仅用于按钮 =====
function CTASection({ heroData }: { heroData?: Record<string, any> | null }) {
const ctaTitleLine1 = heroData?.ctaTitleLine1 || '准备好开始了吗?';
const ctaTitleLine2 = heroData?.ctaTitleLine2 || '让数据真正转化为业务增长';
const ctaLabel = heroData?.ctaLabel || '预约咨询';
const ctaHref = heroData?.ctaHref || '/contact';
return (
<section className="relative py-24 sm:py-32 md:py-40 overflow-hidden bg-bg-secondary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="max-w-3xl">
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05] mb-10 sm:mb-14">
{ctaTitleLine1}
<br />
{ctaTitleLine2}
</h2>
<CTAButton href={ctaHref}>{ctaLabel}</CTAButton>
</ScrollReveal>
</div>
</section>
);
}
// ===== 主页面组件 =====
export default function HomeContentV14({ services, cases, stats, heroData }: {
services?: Record<string, any>[];
cases?: Record<string, any>[];
stats?: Record<string, any>[];
heroData?: Record<string, any> | null;
}) {
return (
<main>
{/* 1. Hero:克制白底 + 品牌红点缀 */}
<HeroSection heroData={heroData} stats={stats} />
{/* 2. 信任区:量化数据成果展示 */}
<TrustSection />
{/* 3. 服务区:等宽卡片 + 兜底数据 */}
<ServicesSection services={services} />
{/* 4. 案例区:轻量快照卡片 */}
<CasesSection cases={cases} />
{/* 5. CTA:克制强调,品牌红仅用于按钮 */}
<CTASection heroData={heroData} />
</main>
);
}
+3 -8
View File
@@ -3,8 +3,6 @@
import { ErrorBoundary } from '@/components/ui/error-boundary';
import { Header } from '@/components/layout/header';
import { Footer } from '@/components/layout/footer';
import { PageTransition } from '@/components/ui/page-transition';
import { Toaster } from '@/components/ui/sonner';
export default function MarketingLayout({
children,
@@ -15,14 +13,11 @@ export default function MarketingLayout({
<div className="min-h-screen flex flex-col">
<Header />
<ErrorBoundary>
<main id="main-content" tabIndex={-1} className="flex-1 pt-16" role="main" aria-label="页面主体内容">
<PageTransition>
{children}
</PageTransition>
</main>
<div className="flex-1 pt-16" role="main" aria-label="页面主体内容">
{children}
</div>
</ErrorBoundary>
<Footer />
<Toaster position="bottom-right" />
</div>
);
}
@@ -3,7 +3,7 @@
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { InkCard } from '@/components/ui/ink-wash';
import { Card } from '@/components/ui/card';
import { Calendar, ArrowLeft, Newspaper } from 'lucide-react';
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
@@ -85,14 +85,14 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
)}
{/* Excerpt */}
<InkCard padding="md" className="mb-10">
<Card className="mb-10 p-6">
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
{news.excerpt}
</p>
</InkCard>
</Card>
{/* Article Content */}
<div className="prose-ink text-[var(--color-text-primary)] text-base sm:text-lg leading-[1.8] whitespace-pre-line">
<div className="prose text-[var(--color-text-primary)] text-base sm:text-lg leading-[1.8] whitespace-pre-line">
{news.content}
</div>
@@ -103,7 +103,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
<div className="grid md:grid-cols-3 gap-6">
{relatedNews.map((related) => (
<StaticLink key={related.id} href={`/news/${related.id}`}>
<InkCard padding="none" interactive className="overflow-hidden group h-full">
<Card className="overflow-hidden group h-full transition-all duration-300 hover:-translate-y-1 hover:shadow-lg">
<div className="aspect-video overflow-hidden">
{related.image ? (
<img
@@ -124,7 +124,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
</h3>
<p className="text-sm text-[var(--color-text-muted)] line-clamp-2 leading-relaxed">{related.excerpt}</p>
</div>
</InkCard>
</Card>
</StaticLink>
))}
</div>
@@ -0,0 +1,147 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import NewsDetailPage, { generateMetadata } from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItemBySlug: jest.fn(),
getPublishedItems: jest.fn(),
getAllPublishedSlugs: jest.fn(),
}));
jest.mock('../news-detail-content-v3', () => ({
__esModule: true,
default: function MockNewsDetailContentV3({ news, relatedNews }: { news: { title: string }; relatedNews?: Array<{ title: string }> }) {
return (
<div data-testid="news-detail">
<h1>{news.title}</h1>
{relatedNews && relatedNews.length > 0 && (
<div data-testid="related-news">
{relatedNews.map((item, idx) => (
<div key={idx}>{item.title}</div>
))}
</div>
)}
</div>
);
},
}));
import { getPublishedItemBySlug, getPublishedItems, getAllPublishedSlugs } from '@/lib/cms/data-server';
const mockedGetPublishedItemBySlug = getPublishedItemBySlug as jest.MockedFunction<typeof getPublishedItemBySlug>;
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
const mockedGetAllPublishedSlugs = getAllPublishedSlugs as jest.MockedFunction<typeof getAllPublishedSlugs>;
const createMockNews = (
topOverrides: Record<string, unknown> = {},
dataOverrides: Record<string, unknown> = {},
) => ({
id: 'item-1',
modelId: 'model-news',
modelCode: 'news',
title: '测试新闻',
slug: 'test-news',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-news',
title: '测试新闻',
excerpt: '测试摘要',
date: '2026-07-17',
category: '公司新闻',
image: '/images/news/test.webp',
content: '测试正文',
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
describe('NewsDetailPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItemBySlug.mockResolvedValue(null);
mockedGetPublishedItems.mockResolvedValue([]);
mockedGetAllPublishedSlugs.mockResolvedValue([]);
});
it('returns 404 when news item is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
// next/navigation notFound() throws in test environment; verify it is called by catching
await expect(
NewsDetailPage({ params: Promise.resolve({ slug: 'missing' }) }),
).rejects.toThrow();
});
it('renders news detail with data from CMS', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(createMockNews() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>);
const page = await NewsDetailPage({ params: Promise.resolve({ slug: 'test-news' }) });
render(page);
expect(screen.getByTestId('news-detail')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('测试新闻');
});
it('filters related news by same category and excludes current slug', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(
createMockNews() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>,
);
mockedGetPublishedItems.mockResolvedValue([
createMockNews(),
createMockNews(
{ id: 'item-related', slug: 'related-1' },
{ id: 'related-1', slug: 'related-1', title: '相关新闻 1' },
),
createMockNews(
{ id: 'item-other', slug: 'other-cat' },
{ id: 'other-cat', slug: 'other-cat', title: '其他分类', category: '研发动态' },
),
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await NewsDetailPage({ params: Promise.resolve({ slug: 'test-news' }) });
render(page);
expect(screen.getByText('相关新闻 1')).toBeInTheDocument();
expect(screen.queryByText('其他分类')).not.toBeInTheDocument();
});
it('generateStaticParams returns slugs from CMS', async () => {
mockedGetAllPublishedSlugs.mockResolvedValue([
{ slug: 'company-founded' },
{ slug: 'digital-transformation-solution' },
] as unknown as Awaited<ReturnType<typeof getAllPublishedSlugs>>);
const { generateStaticParams } = await import('./page');
const params = await generateStaticParams();
expect(params).toEqual([
{ slug: 'company-founded' },
{ slug: 'digital-transformation-solution' },
]);
});
describe('generateMetadata', () => {
it('returns news title and excerpt as metadata', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(createMockNews() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>);
const metadata = await generateMetadata({ params: Promise.resolve({ slug: 'test-news' }) });
expect(metadata.title).toContain('测试新闻');
expect(metadata.description).toBe('测试摘要');
});
it('returns fallback metadata when news is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
const metadata = await generateMetadata({ params: Promise.resolve({ slug: 'missing' }) });
expect(metadata.title).toBe('新闻未找到');
});
});
});
+3
View File
@@ -5,6 +5,9 @@ import { getPublishedItemBySlug, getPublishedItems, getAllPublishedSlugs } from
import type { NewsItem } from '@/lib/constants/news';
import NewsDetailContentV3 from '../news-detail-content-v3';
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
export const revalidate = 3600;
export async function generateStaticParams() {
const slugs = await getAllPublishedSlugs('news');
return slugs.map((s) => ({ slug: s.slug }));
+2 -2
View File
@@ -53,9 +53,9 @@ function NewsCard({ newsItem, index }: { newsItem: NewsItem; index: number }) {
</div>
</div>
<h3 className="text-lg sm:text-xl font-semibold text-ink mb-3 line-clamp-2 group-hover:text-brand transition-colors duration-300 leading-snug">
<h2 className="text-lg sm:text-xl font-semibold text-ink mb-3 line-clamp-2 group-hover:text-brand transition-colors duration-300 leading-snug">
{newsItem.title}
</h3>
</h2>
<p className="text-sm text-text-secondary line-clamp-3 mb-5 leading-relaxed">
{newsItem.excerpt}
+105
View File
@@ -0,0 +1,105 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import NewsListPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
}));
jest.mock('./news-content-v3', () => ({
__esModule: true,
default: function MockNewsContentV3({ news }: { news: Array<{ id: string; title: string }> }) {
return (
<div data-testid="news-content">
{news.map((item) => (
<div key={item.id} data-testid="news-item">{item.title}</div>
))}
</div>
);
},
}));
import { getPublishedItems } from '@/lib/cms/data-server';
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
describe('NewsListPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItems.mockResolvedValue([]);
});
it('renders empty state when no CMS news items exist', async () => {
const page = await NewsListPage();
render(page);
expect(screen.getByTestId('news-content')).toBeInTheDocument();
expect(screen.queryByTestId('news-item')).not.toBeInTheDocument();
});
it('maps CMS items to NewsItem shape and passes to NewsContentV3', async () => {
mockedGetPublishedItems.mockResolvedValue([
{
id: 'item-1',
modelId: 'model-news',
modelCode: 'news',
title: 'CMS 新闻标题',
slug: 'cms-news',
locale: 'zh-CN',
status: 'published',
data: {
id: 'cms-news',
title: 'CMS 新闻标题',
excerpt: 'CMS 摘要',
date: '2026-07-17',
category: '公司新闻',
image: '/images/news/cms.webp',
content: 'CMS 正文',
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await NewsListPage();
render(page);
expect(screen.getByText('CMS 新闻标题')).toBeInTheDocument();
});
it('falls back to item.title when data.title is missing', async () => {
mockedGetPublishedItems.mockResolvedValue([
{
id: 'item-2',
modelId: 'model-news',
modelCode: 'news',
title: 'Fallback Title',
slug: 'fallback-news',
locale: 'zh-CN',
status: 'published',
data: {
id: 'fallback-news',
excerpt: '摘要',
date: '2026-07-17',
category: '研发动态',
image: '/images/news/fallback.webp',
content: '正文',
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await NewsListPage();
render(page);
expect(screen.getByText('Fallback Title')).toBeInTheDocument();
});
});
+3
View File
@@ -9,6 +9,9 @@ export const metadata: Metadata = {
description: `了解${COMPANY_INFO.displayName}的最新动态、行业洞察与技术创新。`,
};
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
export const revalidate = 3600;
export default async function NewsListPage() {
const items = await getPublishedItems('news');
const news = items.map((item) => ({ ...item.data, title: item.data.title || item.title } as unknown as NewsItem));
+114
View File
@@ -0,0 +1,114 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import HomePage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
getResolvedHomeZones: jest.fn(),
}));
jest.mock('./home-content-v14', () => ({
__esModule: true,
default: function MockHomeContentV14(props: Record<string, unknown>) {
return (
<div data-testid="home-content">
<div data-testid="services-count">{(props.services as unknown[])?.length ?? 0}</div>
<div data-testid="cases-count">{(props.cases as unknown[])?.length ?? 0}</div>
<div data-testid="stats-count">{(props.stats as unknown[])?.length ?? 0}</div>
<div data-testid="hero-data">{props.heroData ? JSON.stringify(props.heroData) : 'null'}</div>
</div>
);
},
}));
import { getPublishedItems, getResolvedHomeZones } from '@/lib/cms/data-server';
const mockedGetResolvedHomeZones = getResolvedHomeZones as jest.MockedFunction<typeof getResolvedHomeZones>;
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
const createMockItem = (modelCode: string, data: Record<string, unknown>) => ({
id: `${modelCode}-id`,
modelId: `model-${modelCode}`,
modelCode,
title: String(data.title || 'Mock'),
slug: String(data.slug || `${modelCode}-slug`),
locale: 'zh-CN',
status: 'published' as const,
data,
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
describe('HomePage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetResolvedHomeZones.mockResolvedValue({});
mockedGetPublishedItems.mockResolvedValue([]);
});
it('renders home content from resolved zones', async () => {
mockedGetResolvedHomeZones.mockResolvedValue({
hero: [createMockItem('hero-banner', { heading: 'Zone Hero', ctaLabel: '预约' })],
stats: [
createMockItem('stat-item', { value: '100+', label: '客户' }),
createMockItem('stat-item', { value: '95%', label: '满意度' }),
],
services: [createMockItem('service', { id: 'consulting', title: '咨询' })],
cases: [createMockItem('case-study', { id: 'case-1', title: '案例一' })],
});
const page = await HomePage();
render(page);
expect(screen.getByTestId('home-content')).toBeInTheDocument();
expect(screen.getByTestId('services-count')).toHaveTextContent('1');
expect(screen.getByTestId('cases-count')).toHaveTextContent('1');
expect(screen.getByTestId('stats-count')).toHaveTextContent('2');
expect(screen.getByTestId('hero-data')).toHaveTextContent('Zone Hero');
});
it('falls back to direct model queries when no zones exist', async () => {
mockedGetResolvedHomeZones.mockResolvedValue({});
mockedGetPublishedItems.mockImplementation((modelCode: string) => {
if (modelCode === 'service') {
return Promise.resolve([createMockItem('service', { id: 'consulting', title: '咨询' })]);
}
if (modelCode === 'case-study') {
return Promise.resolve([createMockItem('case-study', { id: 'case-1', title: '案例一' })]);
}
if (modelCode === 'stat-item') {
return Promise.resolve([createMockItem('stat-item', { value: '10+', label: '年' })]);
}
if (modelCode === 'hero-banner') {
return Promise.resolve([createMockItem('hero-banner', { heading: 'Direct Hero' })]);
}
return Promise.resolve([]);
});
const page = await HomePage();
render(page);
expect(screen.getByTestId('services-count')).toHaveTextContent('1');
expect(screen.getByTestId('hero-data')).toHaveTextContent('Direct Hero');
});
it('handles empty zones gracefully', async () => {
mockedGetResolvedHomeZones.mockResolvedValue({
hero: [],
stats: [],
services: [],
cases: [],
});
const page = await HomePage();
render(page);
expect(screen.getByTestId('home-content')).toBeInTheDocument();
expect(screen.getByTestId('services-count')).toHaveTextContent('0');
expect(screen.getByTestId('hero-data')).toHaveTextContent('null');
});
});
+31 -14
View File
@@ -1,18 +1,35 @@
import { getPublishedItems } from '@/lib/cms/data-server';
import HomeContentV13 from './home-content-v13';
import { getPublishedItems, getResolvedHomeZones } from '@/lib/cms/data-server';
import HomeContentV14 from './home-content-v14';
export const revalidate = 3600;
export default async function HomePage() {
const [serviceItems, caseItems, statItems, heroItems] = await Promise.all([
getPublishedItems('service'),
getPublishedItems('case-study'),
getPublishedItems('stat-item'),
getPublishedItems('hero-banner'),
]);
const zones = await getResolvedHomeZones();
const hasZones = Object.keys(zones).length > 0;
const services = serviceItems.map((item) => item.data);
const cases = caseItems.map((item) => item.data);
const stats = statItems.map((item) => item.data);
const heroData = heroItems[0]?.data ?? null;
let services: Record<string, unknown>[] = [];
let cases: Record<string, unknown>[] = [];
let stats: Record<string, unknown>[] = [];
let heroData: Record<string, unknown> | null = null;
return <HomeContentV13 services={services} cases={cases} stats={stats} heroData={heroData} />;
}
if (hasZones) {
services = zones.services?.map((item) => item.data) ?? [];
cases = zones.cases?.map((item) => item.data) ?? [];
stats = zones.stats?.map((item) => item.data) ?? [];
heroData = zones.hero?.[0]?.data ?? null;
} else {
const [serviceItems, caseItems, statItems, heroItems] = await Promise.all([
getPublishedItems('service'),
getPublishedItems('case-study'),
getPublishedItems('stat-item'),
getPublishedItems('hero-banner'),
]);
services = serviceItems.map((item) => item.data);
cases = caseItems.map((item) => item.data);
stats = statItems.map((item) => item.data);
heroData = heroItems[0]?.data ?? null;
}
return <HomeContentV14 services={services} cases={cases} stats={stats} heroData={heroData} />;
}
@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import ProductDetailPage from './page';
import ProductDetailPage, { generateMetadata, generateStaticParams } from './page';
jest.mock('next/navigation', () => ({
notFound: jest.fn(),
@@ -127,7 +127,7 @@ describe('ProductDetailPage', () => {
const page = await ProductDetailPage({ params: Promise.resolve({ id: 'test-product' }) });
render(page);
const features = screen.getByText('核心功能');
const features = screen.getByRole('heading', { name: '核心功能' });
expect(features).toBeInTheDocument();
});
@@ -135,7 +135,7 @@ describe('ProductDetailPage', () => {
const page = await ProductDetailPage({ params: Promise.resolve({ id: 'test-product' }) });
render(page);
const benefits = screen.getByText('产品价值');
const benefits = screen.getByRole('heading', { name: '产品价值' });
expect(benefits).toBeInTheDocument();
});
@@ -171,4 +171,35 @@ describe('ProductDetailPage', () => {
expect(h2s.length).toBeGreaterThan(0);
});
});
describe('generateStaticParams', () => {
it('returns product slugs from CMS', async () => {
const params = await generateStaticParams();
expect(params).toEqual([{ id: 'test-product' }]);
});
});
describe('generateMetadata', () => {
it('returns product title and description as metadata', async () => {
const metadata = await generateMetadata({ params: Promise.resolve({ id: 'test-product' }) });
expect(metadata.title).toContain('测试产品');
expect(metadata.description).toContain('这是测试产品描述');
});
it('returns fallback metadata when product is not found', async () => {
const metadata = await generateMetadata({ params: Promise.resolve({ id: 'missing' }) });
expect(metadata.title).toBe('产品未找到');
});
});
describe('NotFound', () => {
it('calls notFound when product item is not found', async () => {
await expect(
ProductDetailPage({ params: Promise.resolve({ id: 'missing' }) }),
).rejects.toThrow();
});
});
});
@@ -5,6 +5,8 @@ import { getPublishedItemBySlug, getAllPublishedSlugs } from '@/lib/cms/data-ser
import type { Product } from '@/lib/constants/products';
import ProductDetailContentV3 from '../product-detail-content-v3';
export const revalidate = 3600;
export async function generateStaticParams() {
const slugs = await getAllPublishedSlugs('product');
return slugs.map((s) => ({ id: s.slug }));
+172
View File
@@ -0,0 +1,172 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import ProductsPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
}));
jest.mock('./products-content-v3', () => ({
__esModule: true,
default: function MockProductsContentV3({ products }: { products: Array<{ id: string; title: string }> }) {
return (
<div data-testid="products-content">
{products.map((item) => (
<div key={item.id} data-testid="product-item">{item.title}</div>
))}
</div>
);
},
}));
import { getPublishedItems } from '@/lib/cms/data-server';
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
const createMockProduct = (topOverrides: Record<string, unknown> = {}, dataOverrides: Record<string, unknown> = {}) => ({
id: 'item-1',
modelId: 'model-product',
modelCode: 'product',
title: '测试产品',
slug: 'test-product',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-product',
slug: 'test-product',
title: '测试产品',
description: '测试产品描述',
image: '/placeholder.png',
category: '企业套装',
categoryId: 'enterprise',
bundle: 'enterprise',
status: '研发中',
overview: '测试产品概述',
features: ['功能1'],
benefits: ['优势1'],
process: ['步骤1'],
specs: ['规格1'],
tags: ['标签1'],
heroThemeId: 'default',
caseStudies: [],
dataProofs: [],
certifications: [],
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
const createMockStandaloneProduct = (topOverrides: Record<string, unknown> = {}, dataOverrides: Record<string, unknown> = {}) => ({
id: 'item-standalone-1',
modelId: 'model-standalone-product',
modelCode: 'standalone-product',
title: '测试独立产品',
slug: 'test-standalone',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-standalone',
slug: 'test-standalone',
title: '测试独立产品',
description: '测试独立产品描述',
image: '/placeholder.png',
category: '专业工具系列',
status: '内测中',
overview: '测试独立产品概述',
features: ['能力1'],
benefits: ['价值1'],
process: ['步骤1'],
specs: ['规格1'],
tags: ['标签1'],
heroThemeId: 'default',
caseStudies: [],
dataProofs: [],
certifications: [],
technicalParameters: [],
complianceCertifications: [],
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
describe('ProductsPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItems.mockImplementation((modelCode: string) => {
if (modelCode === 'standalone-product') {
return Promise.resolve([]);
}
return Promise.resolve([]);
});
});
it('renders empty state when no CMS product items exist', async () => {
const page = await ProductsPage();
render(page);
expect(screen.getByTestId('products-content')).toBeInTheDocument();
expect(screen.queryByTestId('product-item')).not.toBeInTheDocument();
});
it('maps CMS items to Product shape', async () => {
mockedGetPublishedItems.mockImplementation((modelCode: string) => {
if (modelCode === 'product') {
return Promise.resolve([createMockProduct()] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
}
return Promise.resolve([]);
});
const page = await ProductsPage();
render(page);
expect(screen.getByText('测试产品')).toBeInTheDocument();
});
it('falls back to item.title when data.title is missing', async () => {
mockedGetPublishedItems.mockImplementation((modelCode: string) => {
if (modelCode === 'product') {
return Promise.resolve([
createMockProduct(
{ title: 'Fallback Title' },
{ title: undefined },
),
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
}
return Promise.resolve([]);
});
const page = await ProductsPage();
render(page);
expect(screen.getByText('Fallback Title')).toBeInTheDocument();
});
it('merges standalone-product items with categoryId=specialized and bundle=standalone', async () => {
mockedGetPublishedItems.mockImplementation((modelCode: string) => {
if (modelCode === 'product') {
return Promise.resolve([createMockProduct()] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
}
if (modelCode === 'standalone-product') {
return Promise.resolve([createMockStandaloneProduct()] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
}
return Promise.resolve([]);
});
const page = await ProductsPage();
render(page);
expect(screen.getByText('测试产品')).toBeInTheDocument();
expect(screen.getByText('测试独立产品')).toBeInTheDocument();
});
});
+16 -3
View File
@@ -9,8 +9,21 @@ export const metadata: Metadata = {
description: `覆盖企业数字化全场景的产品矩阵,从数据智能到业务协同,每一款产品都经过实战验证。`,
};
export const revalidate = 3600;
export default async function ProductsPage() {
const items = await getPublishedItems('product');
const products = items.map((item) => ({ ...item.data, title: item.data.title || item.title } as unknown as Product));
return <ProductsContentV3 products={products} />;
const [items, standaloneItems] = await Promise.all([
getPublishedItems('product'),
getPublishedItems('standalone-product'),
]);
const products = 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 Product));
const standaloneProducts = standaloneItems.map((item) => ({
...item.data,
id: item.data.id || item.id,
slug: item.data.slug || item.slug,
title: item.data.title || item.title,
categoryId: 'specialized',
bundle: 'standalone',
} as unknown as Product));
return <ProductsContentV3 products={[...products, ...standaloneProducts]} />;
}
@@ -6,9 +6,9 @@ import { DetailTrustSection } from '@/components/detail/detail-trust-section';
import { DetailCTASection } from '@/components/detail/detail-cta-section';
import { DetailSwipeNav } from '@/components/ui/detail-swipe-nav';
import { motion } from 'framer-motion';
import { CheckCircle2, Shield, Lock, Cpu, HardDrive, ArrowRight } from 'lucide-react';
import { CheckCircle2, Shield, Lock, Cpu, HardDrive, ArrowRight, FileCheck, Gauge } from 'lucide-react';
import type { ContentItem } from '@/lib/cms/types';
import type { CaseStudy, DataProof, Certification } from '@/lib/constants/products';
import type { CaseStudy, DataProof, Certification, ComplianceCertification, TechnicalParameter } from '@/lib/constants/products';
interface StandaloneProductClientProps {
item: ContentItem;
@@ -39,6 +39,8 @@ export function StandaloneProductClient({ item }: StandaloneProductClientProps)
const caseStudies = (data.caseStudies ?? []) as CaseStudy[];
const dataProofs = (data.dataProofs ?? []) as DataProof[];
const certifications = (data.certifications ?? []) as Certification[];
const technicalParameters = (data.technicalParameters ?? []) as TechnicalParameter[];
const complianceCertifications = (data.complianceCertifications ?? []) as ComplianceCertification[];
const theme = getHeroTheme(heroThemeId);
const Icon = categoryIcons[category] || Shield;
@@ -163,6 +165,104 @@ export function StandaloneProductClient({ item }: StandaloneProductClientProps)
</div>
</section>
{technicalParameters.length > 0 && (
<section className="bg-bg-secondary py-16 lg:py-20">
<div className="container-wide">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="mb-10"
>
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-soft text-brand">
<Gauge className="h-5 w-5" />
</div>
<h2 className="text-2xl font-bold text-ink">技术参数</h2>
</div>
<p className="text-text-secondary max-w-2xl">
关键性能指标与技术指标,经实际场景验证。
</p>
</motion.div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{technicalParameters.map((param, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: index * 0.05 }}
className="rounded-lg border border-border-primary bg-white p-5"
>
<div className="mb-2 text-sm text-text-muted">{param.name}</div>
<div className="mb-2 text-2xl font-bold text-brand">{param.value}</div>
{param.description && (
<div className="text-sm leading-relaxed text-text-secondary">{param.description}</div>
)}
</motion.div>
))}
</div>
</div>
</section>
)}
{complianceCertifications.length > 0 && (
<section className="bg-white py-16 lg:py-20">
<div className="container-wide">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="mb-10"
>
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-soft text-brand">
<FileCheck className="h-5 w-5" />
</div>
<h2 className="text-2xl font-bold text-ink">合规认证</h2>
</div>
<p className="text-text-secondary max-w-2xl">
通过权威安全与行业合规认证,满足高安全场景要求。
</p>
</motion.div>
<div className="space-y-4">
{complianceCertifications.map((cert, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: index * 0.05 }}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-lg border border-border-primary bg-bg-secondary p-5"
>
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-white text-brand">
<FileCheck className="h-5 w-5" />
</div>
<div>
<h3 className="font-semibold text-ink">{cert.name}</h3>
<div className="text-sm text-text-secondary">颁发机构:{cert.issuer}</div>
{cert.standard && (
<div className="text-sm text-text-muted">认证标准:{cert.standard}</div>
)}
</div>
</div>
{cert.validUntil && (
<div className="text-sm text-text-muted sm:text-right">
有效期至:{cert.validUntil}
</div>
)}
</motion.div>
))}
</div>
</div>
</section>
)}
<DetailTrustSection
caseStudies={caseStudies}
dataProofs={dataProofs}
@@ -0,0 +1,151 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import StandaloneProductPage, { generateMetadata, generateStaticParams } from './page';
jest.mock('next/navigation', () => ({
notFound: jest.fn(() => {
throw new Error('notFound called');
}),
useRouter: jest.fn(() => ({
push: jest.fn(),
back: jest.fn(),
})),
}));
jest.mock('next/link', () => {
const MockLink = ({ children, href }: { children: React.ReactNode; href: string }) => {
return <a href={href}>{children}</a>;
};
MockLink.displayName = 'MockLink';
return MockLink;
});
jest.mock('@/lib/cms/data-server', () => {
const testStandaloneProduct = {
id: 'test-standalone',
title: '测试独立产品',
category: '专业工具系列',
status: '内测中',
description: '这是测试独立产品描述',
overview: '这是测试独立产品概述',
image: '/placeholder.png',
heroThemeId: 'default',
features: ['能力1', '能力2'],
benefits: ['价值1', '价值2'],
process: ['步骤1', '步骤2'],
specs: ['规格1', '规格2'],
caseStudies: [],
dataProofs: [],
certifications: [],
technicalParameters: [
{ name: '参数1', value: '100万+', description: '参数说明' },
],
complianceCertifications: [
{ name: '等保三级', issuer: '公安部', standard: 'GB/T 22239', validUntil: '2027-12' },
],
};
const testItem = {
id: 'test-standalone',
slug: 'test-standalone',
title: '测试独立产品',
data: testStandaloneProduct,
};
return {
getPublishedItems: jest.fn(() => Promise.resolve([testItem])),
getPublishedItemBySlug: jest.fn((_modelCode: string, slug: string) =>
Promise.resolve(slug === 'test-standalone' ? testItem : null),
),
getAllPublishedSlugs: jest.fn(() => Promise.resolve([{ slug: 'test-standalone' }])),
};
});
jest.mock('@/lib/constants', () => ({
COMPANY_INFO: {
displayName: '睿新致远',
name: '四川睿新致远科技有限公司',
},
PRODUCTS: [
{ id: 'erp', title: '睿新ERP管理系统' },
{ id: 'crm', title: '睿新CRM管理系统' },
],
SERVICES: [],
}));
describe('StandaloneProductPage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render standalone product detail page', async () => {
const page = await StandaloneProductPage({ params: Promise.resolve({ id: 'test-standalone' }) });
render(page);
const titles = screen.getAllByText('测试独立产品');
expect(titles.length).toBeGreaterThan(0);
});
it('should render product title as h1', async () => {
const page = await StandaloneProductPage({ params: Promise.resolve({ id: 'test-standalone' }) });
render(page);
const title = screen.getByRole('heading', { level: 1 });
expect(title).toBeInTheDocument();
expect(title).toHaveTextContent('测试独立产品');
});
it('should render product description', async () => {
const page = await StandaloneProductPage({ params: Promise.resolve({ id: 'test-standalone' }) });
render(page);
expect(screen.getByText('这是测试独立产品描述')).toBeInTheDocument();
});
it('should render technical parameters section', async () => {
const page = await StandaloneProductPage({ params: Promise.resolve({ id: 'test-standalone' }) });
render(page);
expect(screen.getByRole('heading', { name: '技术参数' })).toBeInTheDocument();
expect(screen.getByText('100万+')).toBeInTheDocument();
});
it('should render compliance certifications section', async () => {
const page = await StandaloneProductPage({ params: Promise.resolve({ id: 'test-standalone' }) });
render(page);
expect(screen.getByRole('heading', { name: '合规认证' })).toBeInTheDocument();
expect(screen.getByText('等保三级')).toBeInTheDocument();
});
});
describe('generateStaticParams', () => {
it('returns standalone product slugs from CMS', async () => {
const params = await generateStaticParams();
expect(params).toEqual([{ id: 'test-standalone' }]);
});
});
describe('generateMetadata', () => {
it('returns standalone product title and description as metadata', async () => {
const metadata = await generateMetadata({ params: Promise.resolve({ id: 'test-standalone' }) });
expect(metadata.title).toContain('测试独立产品');
expect(metadata.description).toContain('这是测试独立产品描述');
});
it('returns fallback metadata when standalone product is not found', async () => {
const metadata = await generateMetadata({ params: Promise.resolve({ id: 'missing' }) });
expect(metadata.title).toBe('产品未找到');
});
});
describe('NotFound', () => {
it('calls notFound when standalone product item is not found', async () => {
await expect(
StandaloneProductPage({ params: Promise.resolve({ id: 'missing' }) }),
).rejects.toThrow();
});
});
});
@@ -1,18 +1,19 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getPublishedItemBySlug, getPublishedItems } from '@/lib/cms/data-server';
import { COMPANY_INFO } from '@/lib/constants';
import { StandaloneProductClient } from './client';
export const revalidate = 3600;
export async function generateStaticParams() {
const items = await getPublishedItems('product');
return items
.filter((item) => item.data.categoryId === 'specialized')
.map((item) => ({ id: item.slug! }));
const items = await getPublishedItems('standalone-product');
return items.map((item) => ({ id: item.slug! }));
}
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const item = await getPublishedItemBySlug('product', id);
const item = await getPublishedItemBySlug('standalone-product', id);
if (!item) {
return {
@@ -28,17 +29,10 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
export default async function StandaloneProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const item = await getPublishedItemBySlug('product', id);
const item = await getPublishedItemBySlug('standalone-product', id);
if (!item || item.data.categoryId !== 'specialized') {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--color-bg-primary)]">
<div className="text-center">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4">产品未找到</h2>
<p className="text-[var(--color-text-muted)]">该产品不存在或已被移除</p>
</div>
</div>
);
if (!item) {
notFound();
}
return <StandaloneProductClient item={JSON.parse(JSON.stringify(item))} />;
@@ -0,0 +1,121 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import ServiceDetailPage, { generateMetadata, generateStaticParams } from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItemBySlug: jest.fn(),
getAllPublishedSlugs: jest.fn(),
}));
jest.mock('./client', () => ({
ServiceDetailClient: function MockServiceDetailClient({ service }: { service: { title: string } }) {
return (
<div data-testid="service-detail">
<h1>{service.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 createMockService = (
topOverrides: Record<string, unknown> = {},
dataOverrides: Record<string, unknown> = {},
) => ({
id: 'item-1',
modelId: 'model-service',
modelCode: 'service',
title: '测试服务',
slug: 'test-service',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-service',
slug: 'test-service',
title: '测试服务',
description: '测试服务描述',
icon: 'Lightbulb',
overview: '测试服务概述',
features: ['能力一:描述一'],
benefits: ['价值一'],
capabilities: ['能力一'],
process: ['步骤一:描述一'],
heroThemeId: 'service',
caseStudies: [],
dataProofs: [],
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
describe('ServiceDetailPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItemBySlug.mockResolvedValue(null);
mockedGetAllPublishedSlugs.mockResolvedValue([]);
});
it('returns 404 when service item is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
await expect(
ServiceDetailPage({ params: Promise.resolve({ id: 'missing' }) }),
).rejects.toThrow();
});
it('renders service detail with data from CMS', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(
createMockService() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>,
);
const page = await ServiceDetailPage({ params: Promise.resolve({ id: 'test-service' }) });
render(page);
expect(screen.getByTestId('service-detail')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('测试服务');
});
it('generateStaticParams returns slugs from CMS', async () => {
mockedGetAllPublishedSlugs.mockResolvedValue([
{ slug: 'consulting' },
{ slug: 'software' },
] as unknown as Awaited<ReturnType<typeof getAllPublishedSlugs>>);
const params = await generateStaticParams();
expect(params).toEqual([
{ id: 'consulting' },
{ id: 'software' },
]);
});
describe('generateMetadata', () => {
it('returns service title and description as metadata', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(
createMockService() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>,
);
const metadata = await generateMetadata({ params: Promise.resolve({ id: 'test-service' }) });
expect(metadata.title).toContain('测试服务');
expect(metadata.description).toContain('测试服务描述');
});
it('returns fallback metadata when service is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
const metadata = await generateMetadata({ params: Promise.resolve({ id: 'missing' }) });
expect(metadata.title).toBe('服务未找到');
});
});
});
@@ -5,6 +5,9 @@ import { getPublishedItemBySlug, getAllPublishedSlugs } from '@/lib/cms/data-ser
import type { Service } from '@/lib/constants/services';
import { ServiceDetailClient } from './client';
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
export const revalidate = 3600;
export async function generateStaticParams() {
const slugs = await getAllPublishedSlugs('service');
return slugs.map((s) => ({ id: s.slug }));
@@ -0,0 +1,96 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import ServicesPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
}));
jest.mock('./services-content-v3', () => ({
__esModule: true,
default: function MockServicesContentV3({ services }: { services: Array<{ id: string; title: string }> }) {
return (
<div data-testid="services-content">
{services.map((item) => (
<div key={item.id} data-testid="service-item">{item.title}</div>
))}
</div>
);
},
}));
import { getPublishedItems } from '@/lib/cms/data-server';
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
const createMockService = (topOverrides: Record<string, unknown> = {}, dataOverrides: Record<string, unknown> = {}) => ({
id: 'item-1',
modelId: 'model-service',
modelCode: 'service',
title: '测试服务',
slug: 'test-service',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-service',
slug: 'test-service',
title: '测试服务',
description: '测试服务描述',
icon: 'Lightbulb',
overview: '测试服务概述',
features: ['能力一:描述一'],
benefits: ['价值一'],
capabilities: ['能力一'],
process: ['步骤一:描述一'],
heroThemeId: 'service',
caseStudies: [],
dataProofs: [],
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
describe('ServicesPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItems.mockResolvedValue([]);
});
it('renders empty state when no CMS service items exist', async () => {
const page = await ServicesPage();
render(page);
expect(screen.getByTestId('services-content')).toBeInTheDocument();
expect(screen.queryByTestId('service-item')).not.toBeInTheDocument();
});
it('maps CMS items to Service shape', async () => {
mockedGetPublishedItems.mockResolvedValue([
createMockService(),
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await ServicesPage();
render(page);
expect(screen.getByText('测试服务')).toBeInTheDocument();
});
it('falls back to item.title when data.title is missing', async () => {
mockedGetPublishedItems.mockResolvedValue([
createMockService(
{ title: 'Fallback Title' },
{ title: undefined },
),
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await ServicesPage();
render(page);
expect(screen.getByText('Fallback Title')).toBeInTheDocument();
});
});
+4 -1
View File
@@ -9,8 +9,11 @@ export const metadata: Metadata = {
description: `专业技术团队,为您提供全方位的数字化解决方案。${COMPANY_INFO.displayName}涵盖软件开发、数据分析、咨询服务等核心业务。`,
};
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
export const revalidate = 3600;
export default async function ServicesPage() {
const items = await getPublishedItems('service');
const services = items.map((item) => ({ ...item.data, title: item.data.title || item.title } as unknown as Service));
const services = 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 Service));
return <ServicesContentV3 services={services} />;
}
@@ -4,7 +4,7 @@ import { motion } from 'framer-motion';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { Button } from '@/components/ui/button';
import { ArrowRight, CheckCircle2, Zap, Clock, Users, Award, TrendingUp, BarChart3, Code, Lightbulb, Puzzle, Shield } from 'lucide-react';
import { GrainOverlay, FloatingInkParticles, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import { GrainOverlay, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import type { Service, CaseStudy } from '@/lib/constants/services';
import { MethodologyFramework, TechStackShowcase, FAQSection, DataProofSection } from '@/components/content/sections';
@@ -17,15 +17,6 @@ const ICON_MAP: Record<string, React.ReactNode> = {
const FEATURE_ICONS = [Zap, Clock, Users, Award, TrendingUp];
const SERVICE_DETAIL_PARTICLES = [
{ x: 12, y: 35, scale: 0.8, duration: 10, delay: 0, size: 3 },
{ x: 85, y: 55, scale: 0.6, duration: 12, delay: 1.5, size: 2 },
{ x: 48, y: 45, scale: 1.0, duration: 9, delay: 0.8, size: 4 },
{ x: 70, y: 28, scale: 0.7, duration: 11, delay: 2.2, size: 2.5 },
{ x: 30, y: 78, scale: 0.9, duration: 8.5, delay: 1.0, size: 3.5 },
{ x: 65, y: 68, scale: 0.55, duration: 13, delay: 2.8, size: 2 },
];
interface DetailHeroProps {
service: Service;
}
@@ -34,7 +25,6 @@ function DetailHero({ service }: DetailHeroProps) {
return (
<section className="relative min-h-[80vh] flex items-center overflow-hidden bg-ink pt-24">
<GrainOverlay />
<FloatingInkParticles particles={SERVICE_DETAIL_PARTICLES} />
<div className="absolute inset-0 pointer-events-none">
<div
@@ -457,7 +447,6 @@ function CTASection({ service }: CTASectionProps) {
return (
<section className="relative py-36 lg:py-44 overflow-hidden bg-ink">
<GrainOverlay />
<FloatingInkParticles particles={SERVICE_DETAIL_PARTICLES} />
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-brand/30 to-transparent" />
@@ -2,7 +2,6 @@
import { motion } from 'framer-motion';
import { ArrowUpRight, CheckCircle2 } from 'lucide-react';
import { ScrollProgress } from '@/components/ui/scroll-progress';
import type { Service, CaseStudy } from '@/lib/constants/services';
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
@@ -359,7 +358,6 @@ function CTASection() {
export default function ServiceDetailContentV4({ service }: { service: Service }) {
return (
<main className="min-h-screen bg-white text-ink">
<ScrollProgress />
<DetailHero service={service} />
<OverviewSection service={service} />
<FeaturesSection service={service} />
@@ -6,7 +6,7 @@ import type { Service } from '@/lib/constants/services';
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
/** UI metadata keyed by service ID — presentation-only fields not stored in CMS */
/** Fallback metrics keyed by service ID — used when CMS data does not provide metrics */
const SERVICE_UI_META: Record<string, { metrics: { value: string; label: string }[] }> = {
consulting: { metrics: [{ value: '90%+', label: '方案落地率' }, { value: '50+', label: '咨询项目' }] },
software: { metrics: [{ value: '95%+', label: '准时交付率' }, { value: 'A+', label: '代码质量' }] },
@@ -22,7 +22,7 @@ function mapServicesToCards(services: Service[]) {
title: svc.title,
description: svc.description,
capabilities: svc.capabilities?.slice(0, 4) ?? [],
metrics: uiMeta?.metrics ?? [],
metrics: svc.metrics ?? uiMeta?.metrics ?? [],
};
});
}
@@ -0,0 +1,137 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import SolutionDetailPage, { generateMetadata, generateStaticParams } from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItemBySlug: jest.fn(),
getAllPublishedSlugs: jest.fn(),
getPublishedItems: jest.fn(),
}));
jest.mock('./client', () => ({
SolutionDetailClient: function MockSolutionDetailClient({ solution }: { solution: { title: string } }) {
return (
<div data-testid="solution-detail">
<h1>{solution.title}</h1>
</div>
);
},
}));
import { getPublishedItemBySlug, getAllPublishedSlugs, getPublishedItems } from '@/lib/cms/data-server';
const mockedGetPublishedItemBySlug = getPublishedItemBySlug as jest.MockedFunction<typeof getPublishedItemBySlug>;
const mockedGetAllPublishedSlugs = getAllPublishedSlugs as jest.MockedFunction<typeof getAllPublishedSlugs>;
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
const createMockSolution = (
topOverrides: Record<string, unknown> = {},
dataOverrides: Record<string, unknown> = {},
) => ({
id: 'item-1',
modelId: 'model-solution',
modelCode: 'solution',
title: '测试方案',
slug: 'test-solution',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-solution',
slug: 'test-solution',
title: '测试方案',
subtitle: '测试副标题',
industry: '制造业',
description: '测试方案描述',
icon: 'Factory',
overview: '测试方案概述',
challenges: ['痛点一', '痛点二'],
solutions: ['方案一', '方案二'],
relatedProducts: ['erp', 'bi'],
heroThemeId: 'solution',
valueProposition: {
headline: '价值主张',
points: [
{ icon: 'Factory', title: '价值一', description: '描述一' },
],
},
suiteCombination: {
primaryProducts: ['erp'],
complementaryServices: ['consulting'],
rationale: 'rationale',
},
painPoints: ['痛点一'],
outcomes: [{ value: '30%', label: '效率提升' }],
recommendedProducts: ['erp', 'bi'],
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
describe('SolutionDetailPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItemBySlug.mockResolvedValue(null);
mockedGetAllPublishedSlugs.mockResolvedValue([]);
mockedGetPublishedItems.mockResolvedValue([]);
});
it('returns 404 when solution item is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
await expect(
SolutionDetailPage({ params: Promise.resolve({ id: 'missing' }) }),
).rejects.toThrow();
});
it('renders solution detail with data from CMS', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(
createMockSolution() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>,
);
const page = await SolutionDetailPage({ params: Promise.resolve({ id: 'test-solution' }) });
render(page);
expect(screen.getByTestId('solution-detail')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('测试方案');
});
it('generateStaticParams returns slugs from CMS', async () => {
mockedGetAllPublishedSlugs.mockResolvedValue([
{ slug: 'manufacturing' },
{ slug: 'retail' },
] as unknown as Awaited<ReturnType<typeof getAllPublishedSlugs>>);
const params = await generateStaticParams();
expect(params).toEqual([
{ id: 'manufacturing' },
{ id: 'retail' },
]);
});
describe('generateMetadata', () => {
it('returns solution title and description as metadata', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(
createMockSolution() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>,
);
const metadata = await generateMetadata({ params: Promise.resolve({ id: 'test-solution' }) });
expect(metadata.title).toContain('测试方案');
expect(metadata.description).toContain('测试方案描述');
});
it('returns fallback metadata when solution is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
const metadata = await generateMetadata({ params: Promise.resolve({ id: 'missing' }) });
expect(metadata.title).toBe('解决方案未找到');
});
});
});
@@ -6,6 +6,8 @@ import type { Solution } from '@/lib/constants/solutions';
import type { Product } from '@/lib/constants/products';
import { SolutionDetailClient } from './client';
export const revalidate = 3600;
export async function generateStaticParams() {
const slugs = await getAllPublishedSlugs('solution');
return slugs.map((s) => ({ id: s.slug }));
+109
View File
@@ -0,0 +1,109 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import SolutionsPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
}));
jest.mock('./solutions-content-v3', () => ({
__esModule: true,
default: function MockSolutionsContentV3({ solutions }: { solutions: Array<{ id: string; title: string }> }) {
return (
<div data-testid="solutions-content">
{solutions.map((item) => (
<div key={item.id} data-testid="solution-item">{item.title}</div>
))}
</div>
);
},
}));
import { getPublishedItems } from '@/lib/cms/data-server';
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
const createMockSolution = (topOverrides: Record<string, unknown> = {}, dataOverrides: Record<string, unknown> = {}) => ({
id: 'item-1',
modelId: 'model-solution',
modelCode: 'solution',
title: '测试方案',
slug: 'test-solution',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-solution',
slug: 'test-solution',
title: '测试方案',
subtitle: '测试副标题',
industry: '制造业',
description: '测试方案描述',
icon: 'Factory',
overview: '测试方案概述',
challenges: ['痛点一', '痛点二'],
solutions: ['方案一', '方案二'],
relatedProducts: ['erp', 'bi'],
heroThemeId: 'solution',
valueProposition: {
headline: '价值主张',
points: [
{ icon: 'Factory', title: '价值一', description: '描述一' },
],
},
suiteCombination: {
primaryProducts: ['erp'],
complementaryServices: ['consulting'],
rationale: 'rationale',
},
painPoints: ['痛点一'],
outcomes: [{ value: '30%', label: '效率提升' }],
recommendedProducts: ['erp', 'bi'],
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
describe('SolutionsPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItems.mockResolvedValue([]);
});
it('renders empty state when no CMS solution items exist', async () => {
const page = await SolutionsPage();
render(page);
expect(screen.getByTestId('solutions-content')).toBeInTheDocument();
expect(screen.queryByTestId('solution-item')).not.toBeInTheDocument();
});
it('maps CMS items to Solution shape', async () => {
mockedGetPublishedItems.mockResolvedValue([
createMockSolution(),
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await SolutionsPage();
render(page);
expect(screen.getByText('测试方案')).toBeInTheDocument();
});
it('falls back to item.title when data.title is missing', async () => {
mockedGetPublishedItems.mockResolvedValue([
createMockSolution(
{ title: 'Fallback Title' },
{ title: undefined },
),
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await SolutionsPage();
render(page);
expect(screen.getByText('Fallback Title')).toBeInTheDocument();
});
});
+4 -2
View File
@@ -10,12 +10,14 @@ export const metadata: Metadata = {
description: `深耕行业场景的解决方案,端到端交付,推荐套装组合 + 配套服务包。基于自研产品矩阵,为制造业、贸易零售、教育培训、医疗健康、金融服务、物流运输六大行业量身定制最佳实践。`,
};
export const revalidate = 3600;
export default async function SolutionsPage() {
const [solutionItems, productItems] = await Promise.all([
getPublishedItems('solution'),
getPublishedItems('product'),
]);
const solutions = solutionItems.map((item) => ({ ...item.data, title: item.data.title || item.title } as unknown as Solution));
const products = productItems.map((item) => ({ ...item.data, title: item.data.title || item.title } as unknown as Product));
const solutions = solutionItems.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 Solution));
const products = productItems.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 Product));
return <SolutionsContentV3 solutions={solutions} products={products} />;
}
+68
View File
@@ -0,0 +1,68 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import TeamPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
}));
jest.mock('./client', () => ({
TeamClient: function MockTeamClient({ data }: { data: Record<string, unknown> }) {
return (
<div data-testid="team-client">
<div data-testid="hero-title">{String(data.heroTitle || '')}</div>
<div data-testid="has-data">{Object.keys(data).length > 0 ? 'true' : 'false'}</div>
</div>
);
},
}));
import { getPublishedItems } from '@/lib/cms/data-server';
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
describe('TeamPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItems.mockResolvedValue([]);
});
it('passes empty object when no CMS team-page item exists', async () => {
const page = await TeamPage();
render(page);
expect(screen.getByTestId('team-client')).toBeInTheDocument();
expect(screen.getByTestId('has-data')).toHaveTextContent('false');
});
it('passes first CMS team-page data to TeamClient', async () => {
mockedGetPublishedItems.mockResolvedValue([
{
id: 'item-team',
modelId: 'model-team-page',
modelCode: 'team-page',
title: '团队介绍',
slug: 'default',
locale: 'zh-CN',
status: 'published',
data: {
heroSubtitle: '核心团队',
heroTitle: 'CMS 团队标题',
heroDescription: 'CMS 团队描述',
stats: [{ value: 12, suffix: '+', label: '年行业经验' }],
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await TeamPage();
render(page);
expect(screen.getByTestId('hero-title')).toHaveTextContent('CMS 团队标题');
expect(screen.getByTestId('has-data')).toHaveTextContent('true');
});
});
+3
View File
@@ -8,6 +8,9 @@ export const metadata: Metadata = {
description: `了解${COMPANY_INFO.name}的核心团队。我们的团队成员拥有丰富的行业经验和技术专长,致力于为客户提供专业的数字化转型服务。`,
};
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
export const revalidate = 3600;
export default async function TeamPage() {
const items = await getPublishedItems('team-page');
const data = items[0]?.data ?? {};
+143 -147
View File
@@ -1,10 +1,9 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import { motion, useInView } from 'framer-motion';
import { motion } from 'framer-motion';
import { ScrollReveal } from '@/components/ui/scroll-reveal';
import { ArrowRight, Shield, Building2, Users, Code, Target, Layers, BookOpen, Sparkles, type LucideIcon } from 'lucide-react';
import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import { EASE_OUT } from '@/components/ui/page-decoration';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
/** Icon name-to-component mapping for CMS string-based icons */
@@ -33,12 +32,6 @@ interface CultureData {
iconName: string;
}
interface StatData {
value: number;
suffix: string;
label: string;
}
interface ParagraphData {
text: string;
}
@@ -53,7 +46,7 @@ interface TeamData {
heroSecondaryCtaHref?: string;
strengths?: StrengthData[];
culture?: CultureData[];
stats?: StatData[];
stats?: { value: number; suffix: string; label: string }[];
aboutTitle?: string;
aboutParagraphs?: ParagraphData[];
ctaTitle?: string;
@@ -64,58 +57,6 @@ interface TeamData {
ctaSecondaryHref?: string;
}
function useCountUp(target: number, start: boolean, duration: number = 2000) {
const [count, setCount] = useState(0);
const prefersReducedMotion = useReducedMotion();
useEffect(() => {
if (!start || prefersReducedMotion) {
setCount(target);
return;
}
const startTime = performance.now();
let animationId: number;
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
setCount(Math.floor(target * easeOutQuart));
if (progress < 1) {
animationId = requestAnimationFrame(animate);
} else {
setCount(target);
}
};
animationId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationId);
}, [target, start, duration, prefersReducedMotion]);
return count;
}
function StatItem({ value, suffix, label, start, delay }: { value: number; suffix: string; label: string; start: boolean; delay: number }) {
const count = useCountUp(value, start, 2000);
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={start ? { opacity: 1, y: 0 } : {}}
transition={{ delay, duration: 0.6, ease: EASE_OUT }}
className="text-center"
>
<div className="text-4xl sm:text-5xl font-bold text-ink tracking-tight leading-none mb-2">
{count}<span className="text-brand">{suffix}</span>
</div>
<div className="text-sm text-text-muted tracking-wide">{label}</div>
</motion.div>
);
}
function StrengthCard({ strength, index }: { strength: StrengthData; index: number }) {
const Icon = ICON_MAP[strength.iconName] ?? Shield;
const shouldReduceMotion = useReducedMotion();
@@ -183,8 +124,6 @@ function CultureCard({ culture, index }: { culture: CultureData; index: number }
export default function TeamContentV3({ data }: { data: Record<string, unknown> }) {
const teamData = data as TeamData;
const statsRef = useRef(null);
const isInView = useInView(statsRef, { once: true, margin: '-100px' });
const strengths = teamData.strengths ?? [];
const culture = teamData.culture ?? [];
@@ -202,81 +141,120 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
return (
<div className="min-h-screen bg-white text-ink overflow-x-hidden">
{/* Hero Section */}
{/* ============ L1: Hero 团队情感入口 ============ */}
<section className="relative min-h-[85vh] 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-44 lg:py-56">
<div className="max-w-4xl">
<ScrollReveal>
<SectionLabel>{teamData.heroSubtitle || ''}</SectionLabel>
</ScrollReveal>
{/* 眉标 Badge */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1, ease: EASE_OUT }}
className="mb-8 sm:mb-10"
>
<span className="inline-flex items-center gap-2 px-3 py-1.5 bg-brand/[0.08] border border-brand/20 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-brand" />
<span className="text-[13px] font-semibold text-brand tracking-[0.08em] uppercase">
{teamData.heroSubtitle || '核心团队'}
</span>
</span>
</motion.div>
<ScrollReveal delay={0.1}>
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-bold leading-[0.92] tracking-tighter mb-10 sm:mb-12 md:mb-16">
{(teamData.heroTitle || '').split('\n').map((line, i) => (
<span key={i}>
{i > 0 && <span className="block mt-4" />}
{i === 0 ? line : <span className="text-brand">{line}</span>}
</span>
{/* 主标题 — Bain 风格:大号加粗 + 品牌红关键词 */}
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, 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.92] tracking-tightest mb-10 sm:mb-12"
>
{(teamData.heroTitle || '').split('\n').map((line, i) => (
<span key={i}>
{i > 0 && <span className="block mt-4 sm:mt-6" />}
{i === 0 ? line : <span className="text-brand">{line}</span>}
</span>
))}
</motion.h1>
{/* 数据指标条 — Bain 风格:答案优先,数据先行 */}
{stats.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.4, ease: EASE_OUT }}
className="flex flex-wrap gap-6 sm:gap-10 md:gap-14 mb-10 sm:mb-12"
>
{stats.map((stat, i) => (
<div key={i} className="flex items-baseline gap-2">
<span className="text-3xl sm:text-4xl md:text-5xl font-black text-brand tabular-nums leading-none">
{stat.value}{stat.suffix}
</span>
<span className="text-sm text-text-muted whitespace-nowrap">
{stat.label}
</span>
</div>
))}
</h1>
</ScrollReveal>
</motion.div>
)}
<ScrollReveal delay={0.2}>
<p className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12">
{teamData.heroDescription || ''}
</p>
</ScrollReveal>
{/* 描述 */}
{teamData.heroDescription && (
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.5, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary/70 max-w-2xl leading-relaxed mb-12 sm:mb-14"
>
{teamData.heroDescription}
</motion.p>
)}
<ScrollReveal delay={0.3}>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 mb-16">
<a
href={teamData.heroPrimaryCtaHref || '#'}
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"
>
{teamData.heroPrimaryCtaLabel || ''}
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
{/* CTA 按钮组 */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.6, ease: EASE_OUT }}
className="flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6"
>
<a
href={teamData.heroPrimaryCtaHref || '/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]"
>
{teamData.heroPrimaryCtaLabel || '加入我们'}
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
{teamData.heroSecondaryCtaLabel && (
<a
href={teamData.heroSecondaryCtaHref || '#'}
className="inline-flex items-center justify-center gap-3 px-12 py-6 font-semibold text-base text-ink border border-border-primary hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300"
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-bg-secondary transition-all duration-300 min-h-[52px]"
>
{teamData.heroSecondaryCtaLabel || ''}
{teamData.heroSecondaryCtaLabel}
</a>
</div>
</ScrollReveal>
{stats.length > 0 && (
<div ref={statsRef} className="grid grid-cols-3 gap-6 sm:gap-8 md:gap-12 max-w-2xl">
{stats.map((stat, idx) => (
<StatItem
key={stat.label}
value={stat.value}
suffix={stat.suffix}
label={stat.label}
start={isInView}
delay={idx * 0.15}
/>
))}
</div>
)}
)}
</motion.div>
</div>
</div>
</section>
{/* Team Strengths Section */}
{/* ============ L2: 团队实力理性支撑 ============ */}
{strengths.length > 0 && (
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-bg-secondary">
<section className="relative py-24 sm:py-32 md:py-40 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">
<ScrollReveal className="text-center max-w-3xl mx-auto mb-16 sm:mb-20">
<SectionLabel className="justify-center">Team Strengths</SectionLabel>
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight leading-tight text-ink">
我们的底气,从何而来
<ScrollReveal className="max-w-3xl mb-16 sm:mb-20">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.15em] font-medium text-brand">
团队实力
</span>
</div>
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
12 年深耕,
<span className="text-brand">500+ 项目验证</span>
的交付能力
</h2>
<p className="mt-6 text-lg text-text-secondary leading-relaxed">
不是自吹自擂,而是多年实战积累的真实能力
<p className="text-text-secondary leading-relaxed mt-4 text-base sm:text-lg">
复合型专家团队,从战略咨询到技术落地,全链路覆盖
</p>
</ScrollReveal>
@@ -291,11 +269,20 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
{/* Team About Section */}
{(teamData.aboutTitle || aboutParagraphs.length > 0) && (
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-white">
<section className="relative py-24 sm:py-32 md:py-40 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">
<ScrollReveal>
<div className="max-w-4xl mx-auto p-8 sm:p-10 lg:p-16 border border-border-primary bg-bg-secondary/50">
<h2 className="text-2xl sm:text-3xl font-bold mb-8 text-center text-ink">
<div className="max-w-4xl mx-auto p-8 sm:p-10 lg:p-16 border border-border-primary bg-white">
<div className="flex items-center gap-3 mb-8 justify-center">
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.15em] font-medium text-brand">
关于我们
</span>
<div className="w-10 h-px bg-brand" />
</div>
<h2 className="text-2xl sm:text-3xl font-black text-ink tracking-tight mb-8 text-center">
{teamData.aboutTitle || ''}
</h2>
<div className="space-y-6 max-w-3xl mx-auto text-center">
@@ -311,19 +298,24 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
</section>
)}
{/* Team Culture Section */}
{/* ============ L3: 团队文化信任证明 ============ */}
{culture.length > 0 && (
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-bg-secondary">
<section className="relative py-24 sm:py-32 md:py-40 overflow-hidden bg-white">
<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">
<ScrollReveal className="text-center max-w-3xl mx-auto mb-16 sm:mb-20">
<SectionLabel className="justify-center">Team Culture</SectionLabel>
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight leading-tight text-ink">
我们怎样工作
<ScrollReveal className="max-w-3xl mb-16 sm:mb-20">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.15em] font-medium text-brand">
团队文化
</span>
</div>
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
专业文化,让每个项目都成为
<span className="text-brand">行业标杆</span>
</h2>
<p className="mt-6 text-lg text-text-secondary leading-relaxed">
<p className="text-text-secondary leading-relaxed mt-4 text-base sm:text-lg">
好的团队文化,最终体现在为客户创造的价值上
</p>
</ScrollReveal>
@@ -337,32 +329,36 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
</section>
)}
{/* CTA Section */}
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-white">
<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" />
{/* ============ L4: CTA 团队行动号召 ============ */}
<section className="relative py-24 sm:py-32 md:py-44 overflow-hidden bg-dark-bg">
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-dark-border to-transparent" />
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-1/3 right-1/4 w-[500px] h-[500px] bg-brand/[0.04] rounded-full blur-[140px]" />
</div>
<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 text-ink">
{teamData.ctaTitle || ''}
<ScrollReveal className="max-w-3xl">
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-6xl xl:text-7xl font-black text-dark-text-primary tracking-tightest leading-[0.9] mb-8 sm:mb-10">
{teamData.ctaTitle || '成为下一个'}
<br />
<span className="text-brand">数据驱动型企业</span>
</h2>
<p className="mt-6 text-lg text-text-secondary leading-relaxed">
{teamData.ctaDescription || ''}
<p className="text-dark-text-secondary text-lg leading-relaxed mb-12 sm:mb-16 max-w-xl">
{teamData.ctaDescription || '无论您处于数字化转型的哪个阶段,我们都愿意成为您的长期伙伴。'}
</p>
<div className="mt-12 flex flex-col sm:flex-row justify-center gap-4 sm:gap-6">
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
<a
href={teamData.ctaPrimaryHref || '#'}
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"
href={teamData.ctaPrimaryHref || '/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]"
>
{teamData.ctaPrimaryLabel || ''}
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
{teamData.ctaPrimaryLabel || '预约咨询'}
<ArrowRight className="w-5 h-5 transition-transform duration-300 group-hover:translate-x-1" />
</a>
<a
href={teamData.ctaSecondaryHref || '#'}
className="inline-flex items-center justify-center gap-3 px-12 py-6 font-semibold text-base text-ink border border-border-primary hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300"
href={teamData.ctaSecondaryHref || '/cases'}
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-dark-text-primary border border-dark-border hover:border-dark-text-muted transition-all duration-300 min-h-[52px]"
>
{teamData.ctaSecondaryLabel || ''}
{teamData.ctaSecondaryLabel || '查看客户案例'}
</a>
</div>
</ScrollReveal>