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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,11 @@
|
||||
import type { CaseStudy } from '@/lib/constants/cases';
|
||||
import CasesContentV3 from './cases-content-v3';
|
||||
|
||||
export function CasesClient({ cases }: { cases?: CaseStudy[] }) {
|
||||
return <CasesContentV3 cases={cases} />;
|
||||
interface CasesClientProps {
|
||||
cases?: CaseStudy[];
|
||||
industries?: { id: string; label: string }[];
|
||||
}
|
||||
|
||||
export function CasesClient({ cases, industries }: CasesClientProps) {
|
||||
return <CasesContentV3 cases={cases} industries={industries} />;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import CasesPage from './page';
|
||||
|
||||
jest.mock('@/lib/cms/data-server', () => ({
|
||||
getPublishedItems: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./cases-content-v3', () => ({
|
||||
__esModule: true,
|
||||
default: function MockCasesContentV3({ cases, industries }: { cases: Array<{ id: string; title: string }>; industries?: Array<{ id: string; label: string }> }) {
|
||||
return (
|
||||
<div data-testid="cases-content">
|
||||
<div data-testid="industries-count">{industries?.length ?? 0}</div>
|
||||
{cases.map((item) => (
|
||||
<div key={item.id} data-testid="case-item">{item.title}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import { getPublishedItems } from '@/lib/cms/data-server';
|
||||
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
|
||||
|
||||
const createMockCase = (topOverrides: Record<string, unknown> = {}, dataOverrides: Record<string, unknown> = {}) => ({
|
||||
id: 'item-1',
|
||||
modelId: 'model-case-study',
|
||||
modelCode: 'case-study',
|
||||
title: '测试案例',
|
||||
slug: 'test-case',
|
||||
locale: 'zh-CN',
|
||||
status: 'published',
|
||||
data: {
|
||||
id: 'test-case',
|
||||
slug: 'test-case',
|
||||
title: '测试案例',
|
||||
client: '测试客户',
|
||||
industry: '制造业',
|
||||
companySize: '1000人以上',
|
||||
subtitle: '测试副标题',
|
||||
challenge: '测试挑战',
|
||||
solution: '测试方案',
|
||||
result: '测试成果',
|
||||
metrics: [{ value: '40%', label: '效率提升', highlight: true }],
|
||||
timeline: [{ phase: '诊断', duration: '1周', description: '测试阶段' }],
|
||||
services: [{ id: 'svc', title: '测试服务', description: '服务描述' }],
|
||||
color: 'brand',
|
||||
featured: true,
|
||||
...dataOverrides,
|
||||
},
|
||||
version: 1,
|
||||
sortOrder: 0,
|
||||
createdBy: 'system',
|
||||
updatedBy: 'system',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...topOverrides,
|
||||
});
|
||||
|
||||
describe('CasesPage', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedGetPublishedItems.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('renders empty state when no CMS case items exist', async () => {
|
||||
const page = await CasesPage();
|
||||
render(page);
|
||||
|
||||
expect(screen.getByTestId('cases-content')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('case-item')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('maps CMS items to CaseStudy shape and passes industries', async () => {
|
||||
mockedGetPublishedItems.mockResolvedValue([
|
||||
createMockCase(),
|
||||
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
|
||||
|
||||
const page = await CasesPage();
|
||||
render(page);
|
||||
|
||||
expect(screen.getByText('测试案例')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('industries-count')).toHaveTextContent('7');
|
||||
});
|
||||
|
||||
it('falls back to item.title when data.title is missing', async () => {
|
||||
mockedGetPublishedItems.mockResolvedValue([
|
||||
createMockCase(
|
||||
{ title: 'Fallback Title' },
|
||||
{ title: undefined },
|
||||
),
|
||||
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
|
||||
|
||||
const page = await CasesPage();
|
||||
render(page);
|
||||
|
||||
expect(screen.getByText('Fallback Title')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Metadata } from 'next';
|
||||
import { COMPANY_INFO } from '@/lib/constants';
|
||||
import { CASE_INDUSTRIES } from '@/lib/constants/cases';
|
||||
import { getPublishedItems } from '@/lib/cms/data-server';
|
||||
import type { CaseStudy } from '@/lib/constants/cases';
|
||||
import { CasesClient } from './client';
|
||||
@@ -9,8 +10,10 @@ export const metadata: Metadata = {
|
||||
description: `500+企业客户的成功案例,覆盖制造、零售、医疗、教育、金融、物流六大行业。真实数据,可衡量的成果——见证数字化转型的价值。`,
|
||||
};
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export default async function CasesPage() {
|
||||
const items = await getPublishedItems('case-study');
|
||||
const cases = items.map((item) => ({ ...item.data, title: item.data.title || item.title } as unknown as CaseStudy));
|
||||
return <CasesClient cases={cases} />;
|
||||
const cases = items.map((item) => ({ ...item.data, id: item.data.id || item.id, slug: item.data.slug || item.slug, title: item.data.title || item.title } as unknown as CaseStudy));
|
||||
return <CasesClient cases={cases} industries={CASE_INDUSTRIES} />;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 +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('新闻未找到');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 ?? {};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user