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

同步工作区剩余变更,主要包括:
- 营销页面组件与布局持续优化(about/news/services/solutions/team 等)
- 详情页四层叙事组件、布局组件、UI 组件调整
- CMS 数据模型、API 路由、权限、工作流、站内通知、媒体管理扩展
- 新增/补充单元测试与 E2E 测试(cms-workflow.spec.ts 等)
- ESLint 9 迁移、jest/tsconfig 配置更新、依赖调整
- 新增 ADR、CMS 评估文档、Release Review / Acceptance 报告
- 移除水墨装饰组件与大体积未使用字体文件
This commit is contained in:
张翔
2026-07-25 08:04:01 +08:00
parent e35090b914
commit 10404dbb36
208 changed files with 18107 additions and 8330 deletions
@@ -4,7 +4,7 @@ import { useRef, useState, useEffect } from 'react';
import { motion, useInView } from 'framer-motion';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { ArrowRight, Target, Heart, Lightbulb, CheckCircle2 } from 'lucide-react';
import { GrainOverlay, FloatingInkParticles, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import { GrainOverlay, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
const CORE_VALUES = [
@@ -140,8 +140,7 @@ export default function AboutContentV2() {
{/* Hero Section */}
<section className="relative min-h-[85vh] flex items-center overflow-hidden bg-ink">
<GrainOverlay />
<FloatingInkParticles />
<div className="absolute inset-0 bg-gradient-to-b from-ink-light/30 via-transparent to-ink pointer-events-none" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-28 sm:py-36 md:py-44 lg:py-56">
@@ -347,8 +346,7 @@ export default function AboutContentV2() {
{/* CTA Section */}
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden">
<GrainOverlay />
<FloatingInkParticles />
<div className="absolute inset-0 bg-gradient-to-b from-ink to-ink-light/50 pointer-events-none" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
@@ -8,7 +8,7 @@ import {
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { MetricCard } from '@/components/ui/metric-card';
import { MilestoneTimeline } from '@/components/ui/milestone-timeline';
import { GrainOverlay, FloatingInkParticles, SectionLabel } from '@/components/ui/page-decoration';
import { GrainOverlay, SectionLabel } from '@/components/ui/page-decoration';
const CORE_VALUES = [
{
@@ -153,7 +153,6 @@ export default function AboutContentV3() {
{/* Hero Section */}
<section className="relative min-h-[85vh] flex items-center overflow-hidden bg-ink">
<GrainOverlay />
<FloatingInkParticles />
<div className="absolute inset-0 bg-gradient-to-b from-ink-light/30 via-transparent to-ink pointer-events-none" />
@@ -408,7 +407,6 @@ export default function AboutContentV3() {
{/* CTA Section */}
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden">
<GrainOverlay />
<FloatingInkParticles />
<div className="absolute inset-0 bg-gradient-to-b from-ink to-ink-light/50 pointer-events-none" />
@@ -105,16 +105,18 @@ function HeroSection({ data }: { data: AboutData }) {
>
<a
href={data.heroPrimaryCtaHref || '#'}
aria-label={data.heroPrimaryCtaLabel || '预约咨询'}
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90 min-h-[52px] touch-manipulation"
>
{data.heroPrimaryCtaLabel || ''}
{data.heroPrimaryCtaLabel || '预约咨询'}
<ArrowUpRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</a>
<a
href={data.heroSecondaryCtaHref || '#'}
aria-label={data.heroSecondaryCtaLabel || '了解更多'}
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300 min-h-[52px] touch-manipulation"
>
{data.heroSecondaryCtaLabel || ''}
{data.heroSecondaryCtaLabel || '了解更多'}
</a>
</motion.div>
</div>
@@ -377,16 +379,18 @@ function CTASection({ data }: { data: AboutData }) {
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
<a
href={data.ctaPrimaryHref || '#'}
aria-label={data.ctaPrimaryLabel || '预约咨询'}
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90 min-h-[52px] touch-manipulation"
>
{data.ctaPrimaryLabel || ''}
{data.ctaPrimaryLabel || '预约咨询'}
<ArrowUpRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</a>
<a
href={data.ctaSecondaryHref || '#'}
aria-label={data.ctaSecondaryLabel || '了解更多'}
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300 min-h-[52px] touch-manipulation"
>
{data.ctaSecondaryLabel || ''}
{data.ctaSecondaryLabel || '了解更多'}
</a>
</div>
</motion.div>
@@ -0,0 +1,397 @@
'use client';
import { motion } from 'framer-motion';
import { ScrollReveal } from '@/components/ui/scroll-reveal';
import {
ArrowRight,
Target,
Eye,
Heart,
TrendingUp,
Shield,
Users,
Building2,
Globe,
ChevronRight,
} from 'lucide-react';
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
// ============ 品牌数据 ============
const brandValues = [
{
icon: Target,
title: '结果导向',
description: '每个项目都以可衡量的业务改善为交付标准,不追求表面完工,只关注真实价值。',
},
{
icon: Eye,
title: '远见卓识',
description: '立足行业前沿,帮助企业预见技术趋势,提前布局数字化能力,而非被动应对。',
},
{
icon: Heart,
title: '长期陪伴',
description: '不做一次性项目,而是成为企业数字化转型的长期伙伴,共同穿越技术迭代周期。',
},
{
icon: TrendingUp,
title: '持续进化',
description: '方法论与工具持续迭代,保持对新技术、新模式的敏锐度,确保方案始终领先一步。',
},
];
const milestones = [
{
year: '2014',
title: '扬帆起航',
description: '核心团队组建,聚焦企业信息化咨询与系统集成服务,服务首批制造行业客户。',
highlight: '起点',
},
{
year: '2016',
title: '能力沉淀',
description: '建立自有方法论体系,完成 ERP、CRM 等核心产品线布局,服务客户突破 50 家。',
highlight: '50+',
},
{
year: '2018',
title: '行业深耕',
description: '业务覆盖制造、零售、医疗、金融四大行业,推出行业化解决方案,团队规模突破 100 人。',
highlight: '4 行业',
},
{
year: '2020',
title: '平台升级',
description: '完成核心产品云化改造,推出数据中台与 BI 平台,助力企业应对数字化转型加速需求。',
highlight: '云化',
},
{
year: '2022',
title: '生态扩展',
description: '业务拓展至教育、物流行业,服务客户突破 300 家,建立产学研合作生态。',
highlight: '300+',
},
{
year: '2024',
title: '品牌升级',
description: '正式确立「睿新致远」品牌,以数据驱动的决策能力为核心价值主张,服务客户突破 500 家。',
highlight: '500+',
},
];
const brandStats = [
{ value: '12+', label: '年行业深耕' },
{ value: '500+', label: '服务企业' },
{ value: '200+', label: '专业顾问' },
{ value: '6', label: '行业覆盖' },
];
export default function BrandContent() {
return (
<main className="min-h-screen bg-white overflow-x-hidden">
{/* ============ L1: Hero 品牌情感入口 ============ */}
<section className="relative min-h-[90vh] flex items-center overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-28 sm:py-36 md:py-44 lg:py-56">
<div className="max-w-4xl">
{/* 眉标 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1, ease: EASE_OUT }}
className="mb-8 sm:mb-10"
>
<span className="inline-flex items-center gap-2 px-3 py-1.5 bg-brand/[0.08] border border-brand/20 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-brand" />
<span className="text-[13px] font-semibold text-brand tracking-[0.08em] uppercase">
</span>
</span>
</motion.div>
{/* 主标题 */}
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black text-ink leading-[0.92] tracking-tightest mb-8 sm:mb-10"
>
<br />
<span className="text-brand"></span>
</motion.h1>
{/* 数据条 */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.4, ease: EASE_OUT }}
className="flex flex-wrap gap-6 sm:gap-10 md:gap-14 mb-10"
>
{brandStats.map((stat, i) => (
<div key={i} className="flex items-baseline gap-2">
<span className="text-3xl sm:text-4xl md:text-5xl font-black text-brand tabular-nums leading-none">
{stat.value}
</span>
<span className="text-sm text-text-muted whitespace-nowrap">
{stat.label}
</span>
</div>
))}
</motion.div>
{/* 副标题 */}
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.5, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary/70 max-w-2xl leading-relaxed"
>
12 500+
</motion.p>
</div>
</div>
</section>
{/* ============ L2: 品牌价值理性支撑 ============ */}
{/* 品牌定位 */}
<section className="relative py-24 sm:py-32 md:py-40 overflow-hidden bg-bg-secondary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="max-w-3xl mb-16 sm:mb-20">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.15em] font-medium text-brand">
</span>
</div>
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
<span className="text-brand"></span>
</h2>
<p className="text-text-secondary leading-relaxed mt-6 text-base sm:text-lg max-w-2xl">
穿
</p>
</ScrollReveal>
{/* 品牌价值观 */}
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
{brandValues.map((item, i) => (
<ScrollReveal key={item.title} delay={i * 0.1}>
<div className="group relative bg-white border border-border-primary p-8 h-full transition-all duration-300 hover:border-brand/20 hover:shadow-md">
<div className="absolute top-0 left-0 right-0 h-0.5 bg-brand scale-x-0 group-hover:scale-x-100 transition-transform duration-500 origin-left" />
<div className="w-12 h-12 flex items-center justify-center bg-brand/[0.06] text-brand mb-6 transition-colors duration-300 group-hover:bg-brand group-hover:text-white">
<item.icon className="w-5 h-5" />
</div>
<h3 className="text-lg font-bold text-ink mb-3">{item.title}</h3>
<p className="text-sm text-text-secondary leading-relaxed">{item.description}</p>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* 品牌使命与愿景 */}
<section className="relative py-24 sm:py-32 md:py-40 overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="grid lg:grid-cols-2 gap-16 lg:gap-20">
{/* 使命 */}
<ScrollReveal>
<div className="relative border border-border-primary p-10 sm:p-12 lg:p-14 h-full">
<div className="absolute top-0 left-0 bottom-0 w-1 bg-brand" />
<div className="flex items-center gap-4 mb-8">
<div className="w-14 h-14 flex items-center justify-center bg-brand text-white">
<Target className="w-6 h-6" />
</div>
<div>
<span className="text-[11px] tracking-[0.2em] uppercase text-text-muted font-medium">Mission</span>
<h3 className="text-2xl font-black text-ink tracking-tight">使</h3>
</div>
</div>
<p className="text-2xl sm:text-3xl font-black text-ink leading-tight tracking-tight">
<br />
<span className="text-brand"></span>
<br />
</p>
<p className="text-text-secondary leading-relaxed mt-8">
鸿
</p>
</div>
</ScrollReveal>
{/* 愿景 */}
<ScrollReveal delay={0.1}>
<div className="relative border border-border-primary bg-dark-bg text-dark-text-primary p-10 sm:p-12 lg:p-14 h-full">
<div className="absolute top-0 left-0 bottom-0 w-1 bg-brand" />
<div className="flex items-center gap-4 mb-8">
<div className="w-14 h-14 flex items-center justify-center bg-brand text-white">
<Eye className="w-6 h-6" />
</div>
<div>
<span className="text-[11px] tracking-[0.2em] uppercase text-dark-text-muted font-medium">Vision</span>
<h3 className="text-2xl font-black text-dark-text-primary tracking-tight"></h3>
</div>
</div>
<p className="text-2xl sm:text-3xl font-black text-dark-text-primary leading-tight tracking-tight">
<br />
<span className="text-brand"></span>
<br />
</p>
<p className="text-dark-text-secondary leading-relaxed mt-8">
</p>
</div>
</ScrollReveal>
</div>
</div>
</section>
{/* ============ L3: 品牌信任证明 — 发展历程 ============ */}
<section className="relative py-24 sm:py-32 md:py-40 overflow-hidden bg-bg-secondary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="max-w-3xl mb-16 sm:mb-20">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.15em] font-medium text-brand">
</span>
</div>
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
12
<span className="text-brand"></span>
</h2>
</ScrollReveal>
{/* 时间线 */}
<div className="relative">
{/* 竖线 */}
<div className="absolute left-4 md:left-1/2 top-0 bottom-0 w-px bg-border-primary -translate-x-1/2 hidden md:block" />
<div className="space-y-12 md:space-y-16">
{milestones.map((item, i) => (
<ScrollReveal key={item.year} delay={i * 0.08}>
<div className={`relative flex flex-col md:flex-row gap-6 md:gap-12 ${i % 2 === 0 ? 'md:flex-row' : 'md:flex-row-reverse'}`}>
{/* 时间节点 */}
<div className="md:w-1/2 flex md:justify-end items-center">
<div className={`${i % 2 === 0 ? 'md:text-right md:pr-12' : 'md:text-left md:pl-12'} flex-1`}>
<div className="flex items-center gap-4 md:justify-end mb-3">
<span className="text-3xl sm:text-4xl font-black text-brand tracking-tight">
{item.year}
</span>
<span className="text-[11px] tracking-[0.15em] uppercase text-text-muted bg-bg-tertiary px-2 py-0.5">
{item.highlight}
</span>
</div>
<h3 className="text-lg font-bold text-ink mb-2">{item.title}</h3>
<p className="text-sm text-text-secondary leading-relaxed">{item.description}</p>
</div>
</div>
{/* 时间线节点 */}
<div className="hidden md:flex absolute left-1/2 top-0 -translate-x-1/2 flex-col items-center">
<div className="w-3 h-3 rounded-full bg-brand border-2 border-white shadow-sm" />
</div>
{/* 移动端节点 */}
<div className="md:hidden flex items-center gap-4">
<div className="w-3 h-3 rounded-full bg-brand shrink-0" />
<div className="w-8 h-px bg-brand/30" />
</div>
{/* 右侧占位 */}
<div className="hidden md:block md:w-1/2" />
</div>
</ScrollReveal>
))}
</div>
</div>
</div>
</section>
{/* 品牌实力 */}
<section className="relative py-24 sm:py-32 md:py-40 overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="max-w-3xl mb-16 sm:mb-20">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.15em] font-medium text-brand">
</span>
</div>
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
<span className="text-brand"></span>
</h2>
</ScrollReveal>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
{[
{ icon: Building2, title: '500+ 企业客户', desc: '覆盖制造、零售、医疗、金融、教育、物流六大行业' },
{ icon: Users, title: '200+ 专业顾问', desc: '核心团队来自头部咨询公司与科技企业,平均从业经验 8 年以上' },
{ icon: Shield, title: '可验证的交付质量', desc: '90%+ 方案落地率,客户满意度持续保持在 95% 以上' },
{ icon: Globe, title: '全行业覆盖', desc: '从传统制造到新兴零售,方法论适配多行业场景' },
].map((item, i) => (
<ScrollReveal key={item.title} delay={i * 0.1}>
<div className="group relative bg-bg-secondary border border-border-primary p-8 h-full transition-all duration-300 hover:border-brand/20 hover:bg-white hover:shadow-sm">
<div className="w-12 h-12 flex items-center justify-center bg-brand/[0.06] text-brand mb-6 transition-colors duration-300 group-hover:bg-brand group-hover:text-white">
<item.icon className="w-5 h-5" />
</div>
<h3 className="text-lg font-bold text-ink mb-3">{item.title}</h3>
<p className="text-sm text-text-secondary leading-relaxed">{item.desc}</p>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* ============ L4: CTA 品牌行动号召 ============ */}
<section className="relative py-24 sm:py-32 md:py-44 overflow-hidden bg-dark-bg">
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-dark-border to-transparent" />
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-1/3 right-1/4 w-[500px] h-[500px] bg-brand/[0.04] rounded-full blur-[140px]" />
</div>
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="max-w-3xl">
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-6xl xl:text-7xl font-black text-dark-text-primary tracking-tightest leading-[0.9] mb-8 sm:mb-10">
<br />
<span className="text-brand"></span>
</h2>
<p className="text-dark-text-secondary text-lg leading-relaxed mb-12 sm:mb-16 max-w-xl">
</p>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
<a
href="/contact"
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90 min-h-[52px]"
>
<ArrowRight className="w-5 h-5 transition-transform duration-300 group-hover:translate-x-1" />
</a>
<a
href="/cases"
className="group inline-flex items-center justify-center gap-2 px-10 py-5 font-semibold text-base text-dark-text-primary border border-dark-border hover:border-dark-text-muted transition-all duration-300 min-h-[52px]"
>
<ChevronRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
</div>
</ScrollReveal>
</div>
</section>
</main>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Metadata } from 'next';
import { COMPANY_INFO } from '@/lib/constants';
import BrandContent from './brand-content';
export const metadata: Metadata = {
title: `品牌故事 - ${COMPANY_INFO.displayName}`,
description: `了解${COMPANY_INFO.name}的品牌理念、使命愿景与发展历程。12年深耕企业数字化,以「睿新致远」为品牌精神,专注可衡量的业务改善。`,
};
export default function BrandPage() {
return <BrandContent />;
}
@@ -0,0 +1,127 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import CaseDetailPage, { generateMetadata } from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItemBySlug: jest.fn(),
getAllPublishedSlugs: jest.fn(),
}));
jest.mock('./client', () => ({
CaseDetailClient: function MockCaseDetailClient({ item }: { item: { title: string } }) {
return (
<div data-testid="case-detail">
<h1>{item.title}</h1>
</div>
);
},
}));
import { getPublishedItemBySlug, getAllPublishedSlugs } from '@/lib/cms/data-server';
const mockedGetPublishedItemBySlug = getPublishedItemBySlug as jest.MockedFunction<typeof getPublishedItemBySlug>;
const mockedGetAllPublishedSlugs = getAllPublishedSlugs as jest.MockedFunction<typeof getAllPublishedSlugs>;
const createMockCase = (
topOverrides: Record<string, unknown> = {},
dataOverrides: Record<string, unknown> = {},
) => ({
id: 'item-1',
modelId: 'model-case-study',
modelCode: 'case-study',
title: '测试案例',
slug: 'test-case',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-case',
slug: 'test-case',
title: '测试案例',
client: '测试客户',
industry: '制造业',
companySize: '1000人以上',
subtitle: '测试副标题',
challenge: '测试挑战描述',
solution: '测试方案描述',
result: '测试成果描述',
metrics: [
{ value: '40%', label: '效率提升', highlight: true },
{ value: '25%', label: '成本下降' },
],
timeline: [{ phase: '诊断', duration: '1周', description: '测试阶段' }],
services: [{ id: 'svc', title: '测试服务', description: '服务描述' }],
color: 'brand',
featured: true,
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
describe('CaseDetailPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItemBySlug.mockResolvedValue(null);
mockedGetAllPublishedSlugs.mockResolvedValue([]);
});
it('returns 404 when case item is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
await expect(
CaseDetailPage({ params: Promise.resolve({ slug: 'missing' }) }),
).rejects.toThrow();
});
it('renders case detail with data from CMS', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(
createMockCase() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>,
);
const page = await CaseDetailPage({ params: Promise.resolve({ slug: 'test-case' }) });
render(page);
expect(screen.getByTestId('case-detail')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('测试案例');
});
it('generateStaticParams returns slugs from CMS', async () => {
mockedGetAllPublishedSlugs.mockResolvedValue([
{ slug: 'manufacturing-erp-upgrade' },
{ slug: 'retail-omnichannel' },
] as unknown as Awaited<ReturnType<typeof getAllPublishedSlugs>>);
const { generateStaticParams } = await import('./page');
const params = await generateStaticParams();
expect(params).toEqual([
{ slug: 'manufacturing-erp-upgrade' },
{ slug: 'retail-omnichannel' },
]);
});
describe('generateMetadata', () => {
it('returns case title and client as metadata', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(
createMockCase() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>,
);
const metadata = await generateMetadata({ params: Promise.resolve({ slug: 'test-case' }) });
expect(metadata.title).toContain('测试案例');
expect(metadata.description).toContain('测试客户');
});
it('returns fallback metadata when case is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
const metadata = await generateMetadata({ params: Promise.resolve({ slug: 'missing' }) });
expect(metadata.title).toBe('案例未找到 - 睿新致远');
});
});
});
@@ -5,6 +5,8 @@ import { COMPANY_INFO } from '@/lib/constants/company';
import type { CaseStudyData } from '@/lib/cms/types';
import type { Metadata } from 'next';
export const revalidate = 3600;
interface CaseDetailPageProps {
params: Promise<{ slug: string }>;
}
@@ -1,320 +0,0 @@
'use client';
import { useState, useMemo } from 'react';
import {
Building2, ArrowRight, TrendingUp, Target, Lightbulb,
Filter,
} from 'lucide-react';
import { ScrollReveal } from '@/components/ui/scroll-reveal';
import { Badge } from '@/components/ui/badge';
import { MetricCard } from '@/components/ui/metric-card';
import {
CASE_INDUSTRIES,
getCasesByIndustry,
type CaseStudy,
} from '@/lib/constants/cases';
import {
GrainOverlay,
FloatingInkParticles,
SectionLabel,
} from '@/components/ui/page-decoration';
import { cn } from '@/lib/utils';
const colorMap: Record<CaseStudy['color'], string> = {
brand: 'bg-brand text-brand',
blue: 'bg-accent-blue text-accent-blue',
teal: 'bg-accent-teal text-accent-teal',
amber: 'bg-accent-amber text-accent-amber',
purple: 'bg-accent-purple text-accent-purple',
};
function CaseCard({ caseItem }: { caseItem: CaseStudy; index?: number }) {
const colorStyle = colorMap[caseItem.color];
const [bgClass, textClass] = colorStyle.split(' ');
return (
<a
href={`/cases/${caseItem.slug}`}
className="group relative block overflow-hidden border border-white/[0.08] hover:border-white/20 bg-ink transition-all duration-500 hover:-translate-y-1 flex flex-col h-full"
>
<div className={cn(
'absolute top-0 left-0 right-0 h-1 scale-x-0 group-hover:scale-x-100 transition-transform duration-700 origin-left',
bgClass
)} />
<div className="p-6 sm:p-7 lg:p-8 flex flex-col flex-1">
<div className="flex items-center gap-3 mb-5">
<Badge variant="outline" className={cn('border-transparent', bgClass, 'text-white')}>
{caseItem.industry}
</Badge>
<span className="text-[10px] tracking-[0.25em] uppercase text-white/30 font-semibold">
{caseItem.companySize}
</span>
</div>
<h3 className="text-lg sm:text-xl font-bold text-white mb-4 leading-tight group-hover:text-brand transition-colors duration-300">
{caseItem.title}
</h3>
<div className="space-y-4 mb-6 flex-1">
<div>
<div className="flex items-center gap-2 mb-2">
<Target className={cn('w-3.5 h-3.5', textClass)} />
<span className={cn('text-[11px] tracking-wide uppercase font-bold', textClass)}></span>
</div>
<p className="text-sm text-white/50 leading-relaxed line-clamp-2 pl-5.5">
{caseItem.challenge}
</p>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<Lightbulb className={cn('w-3.5 h-3.5', textClass)} />
<span className={cn('text-[11px] tracking-wide uppercase font-bold', textClass)}></span>
</div>
<p className="text-sm text-white/50 leading-relaxed line-clamp-2 pl-5.5">
{caseItem.solution}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-5 border-t border-white/[0.08] mb-5">
{caseItem.metrics.slice(0, 2).map((m, i) => (
<div key={i}>
<div className="text-2xl sm:text-3xl font-bold text-white mb-1 tabular-nums">
{m.value}
</div>
<div className="text-xs text-white/40">{m.label}</div>
</div>
))}
</div>
<div className="flex items-center justify-between pt-4 mt-auto border-t border-white/[0.06]">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-white/30" />
<span className="text-xs text-white/40">{caseItem.client}</span>
</div>
<div className="flex items-center gap-1.5 text-brand text-sm font-medium">
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform duration-300" />
</div>
</div>
</div>
</a>
);
}
const PAGE_METRICS = [
{
icon: <Building2 className="w-5 h-5" />,
label: '服务客户',
value: 500,
suffix: '+',
description: '覆盖六大行业,500+企业客户的共同选择',
trend: { value: '23% YoY', direction: 'up' as const, label: '增长' },
accentColor: 'brand' as const,
},
{
icon: <TrendingUp className="w-5 h-5" />,
label: '成功率',
value: 98,
suffix: '%',
description: '项目一次性上线成功率,持续稳定交付',
trend: { value: '97%续约率', direction: 'up' as const, label: '' },
accentColor: 'teal' as const,
},
{
icon: <Target className="w-5 h-5" />,
label: '平均效率提升',
value: 35,
suffix: '%',
description: '客户核心业务流程效率平均提升幅度',
trend: { value: '基于 200+ 案例', direction: 'neutral' as const, label: '' },
accentColor: 'blue' as const,
},
{
icon: <Lightbulb className="w-5 h-5" />,
label: '行业覆盖',
value: 6,
suffix: '大',
description: '制造、零售、医疗、教育、金融、物流六大行业',
trend: { value: '持续扩展中', direction: 'up' as const, label: '' },
accentColor: 'amber' as const,
},
];
export default function CasesContentV1() {
const [selectedIndustry, setSelectedIndustry] = useState('all');
const filteredCases = useMemo(() => {
return getCasesByIndustry(selectedIndustry);
}, [selectedIndustry]);
return (
<div className="min-h-screen bg-ink text-white overflow-x-hidden" data-theme="dark">
{/* Hero Section */}
<section className="relative min-h-[70vh] flex items-center overflow-hidden bg-ink">
<GrainOverlay />
<FloatingInkParticles />
<div className="absolute inset-0 bg-gradient-to-b from-ink-light/30 via-transparent to-ink pointer-events-none" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-28 sm:py-36 md:py-40">
<div className="max-w-4xl">
<ScrollReveal>
<SectionLabel>Case Studies</SectionLabel>
</ScrollReveal>
<ScrollReveal delay={0.1}>
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[0.95] tracking-tighter mb-8 sm:mb-10">
<span className="block mt-3 text-brand"></span>
</h1>
</ScrollReveal>
<ScrollReveal delay={0.2}>
<p className="text-lg md:text-xl text-white/60 max-w-2xl leading-relaxed mb-10">
</p>
</ScrollReveal>
<ScrollReveal delay={0.3}>
<div className="flex flex-wrap gap-3">
<Badge variant="outline" className="border-brand/30 text-brand px-4 py-1.5 text-xs">
500+
</Badge>
<Badge variant="outline" className="border-white/20 text-white/70 px-4 py-1.5 text-xs">
98%
</Badge>
<Badge variant="outline" className="border-white/20 text-white/70 px-4 py-1.5 text-xs">
6
</Badge>
<Badge variant="outline" className="border-white/20 text-white/70 px-4 py-1.5 text-xs">
97%
</Badge>
</div>
</ScrollReveal>
</div>
</div>
</section>
{/* Key Metrics Section */}
<section className="relative py-16 sm:py-20 md:py-24 overflow-hidden bg-ink-light">
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
{PAGE_METRICS.map((metric, i) => (
<ScrollReveal key={metric.label} delay={i * 0.08}>
<div className="h-full">
<MetricCard
icon={metric.icon}
label={metric.label}
value={metric.value}
suffix={metric.suffix}
description={metric.description}
trend={metric.trend}
accentColor={metric.accentColor}
theme="dark"
className="h-full"
/>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* Cases List Section */}
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-ink">
<GrainOverlay />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-8 mb-12 sm:mb-16 md:mb-20">
<ScrollReveal className="lg:max-w-2xl">
<SectionLabel>All Cases</SectionLabel>
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight leading-tight mt-4">
</h2>
<p className="mt-6 text-lg text-white/60 leading-relaxed">
</p>
</ScrollReveal>
<ScrollReveal delay={0.1}>
<div className="flex items-center gap-2 flex-wrap">
<Filter className="w-4 h-4 text-white/40" />
{CASE_INDUSTRIES.map((industry) => (
<button
key={industry.id}
onClick={() => setSelectedIndustry(industry.id)}
className={cn(
'px-4 py-2 text-xs font-medium transition-all duration-300',
selectedIndustry === industry.id
? 'bg-brand text-white'
: 'bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white'
)}
>
{industry.label}
</button>
))}
</div>
</ScrollReveal>
</div>
{filteredCases.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
{filteredCases.map((caseItem, i) => (
<ScrollReveal key={caseItem.id} delay={i * 0.08}>
<div className="h-full">
<CaseCard caseItem={caseItem} index={i} />
</div>
</ScrollReveal>
))}
</div>
) : (
<div className="text-center py-20">
<p className="text-white/50 text-lg"></p>
</div>
)}
</div>
</section>
{/* CTA Section */}
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-ink-light">
<GrainOverlay />
<FloatingInkParticles />
<div className="absolute inset-0 bg-gradient-to-b from-ink-light/50 to-ink pointer-events-none" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="text-center max-w-3xl mx-auto">
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight leading-tight">
<span className="text-brand"></span>
</h2>
<p className="mt-6 text-lg text-white/60 leading-relaxed">
</p>
<div className="mt-12 flex flex-col sm:flex-row justify-center gap-4 sm:gap-6">
<a
href="/contact"
className="group inline-flex items-center justify-center gap-3 px-12 py-6 font-semibold text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90 min-h-[52px] touch-manipulation"
>
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
<a
href="/services"
className="inline-flex items-center justify-center gap-3 px-12 py-6 font-semibold text-base text-white border border-white/20 hover:border-white/40 hover:bg-white/[0.03] transition-all duration-300 min-h-[52px] touch-manipulation"
>
</a>
</div>
</ScrollReveal>
</div>
</section>
</div>
);
}
@@ -1,316 +0,0 @@
'use client';
import { useState, useMemo } from 'react';
import { motion } from 'framer-motion';
import { Building2, ArrowUpRight, Target, Lightbulb, Filter } from 'lucide-react';
import {
CASE_INDUSTRIES,
getCasesByIndustry,
type CaseStudy,
} from '@/lib/constants/cases';
import { cn } from '@/lib/utils';
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
const PAGE_METRICS = [
{ value: '500+', label: '服务客户', sub: '覆盖六大行业' },
{ value: '98%', label: '项目成功率', sub: '持续稳定交付' },
{ value: '35%', label: '平均效率提升', sub: '核心业务流程' },
{ value: '6大', label: '行业覆盖', sub: '制造零售医疗等' },
];
function CaseCard({ caseItem, index }: { caseItem: CaseStudy; index: number }) {
return (
<motion.a
href={`/cases/${caseItem.slug}`}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.6, delay: index * 0.06, ease: EASE_OUT }}
className="group relative block overflow-hidden border border-border-primary hover:border-border-secondary bg-white hover:bg-bg-secondary transition-all duration-500 flex flex-col h-full"
>
<div className="absolute top-0 left-0 h-0 w-[2px] group-hover:h-full transition-all duration-500 origin-top bg-brand" />
<div className="p-6 sm:p-7 lg:p-8 flex flex-col flex-1 relative z-10">
<div className="flex items-center gap-3 mb-5">
<span className="inline-block px-3 py-1 text-[11px] font-bold text-brand border border-brand/20 bg-brand/[0.05]">
{caseItem.industry}
</span>
<span className="text-[10px] tracking-[0.25em] uppercase text-text-muted font-semibold">
{caseItem.companySize}
</span>
</div>
<h3 className="text-lg sm:text-xl font-bold text-ink mb-4 leading-tight group-hover:text-brand transition-colors duration-300">
{caseItem.title}
</h3>
<div className="space-y-4 mb-6 flex-1">
<div>
<div className="flex items-center gap-2 mb-2">
<Target className="w-3.5 h-3.5 text-brand" />
<span className="text-[11px] tracking-wide uppercase font-bold text-brand"></span>
</div>
<p className="text-sm text-text-secondary leading-relaxed line-clamp-2 pl-5.5">
{caseItem.challenge}
</p>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<Lightbulb className="w-3.5 h-3.5 text-text-muted" />
<span className="text-[11px] tracking-wide uppercase font-bold text-text-secondary"></span>
</div>
<p className="text-sm text-text-secondary leading-relaxed line-clamp-2 pl-5.5">
{caseItem.solution}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-5 border-t border-border-primary mb-5">
{caseItem.metrics.slice(0, 2).map((m, i) => (
<div key={i}>
<div className={`text-2xl sm:text-3xl font-black mb-1 tabular-nums tracking-tight ${
i === 0 ? 'text-brand' : 'text-ink'
}`}>
{m.value}
</div>
<div className="text-xs text-text-muted">{m.label}</div>
</div>
))}
</div>
<div className="flex items-center justify-between pt-4 mt-auto border-t border-border-primary">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-text-muted" />
<span className="text-xs text-text-muted">{caseItem.client}</span>
</div>
<div className="flex items-center gap-1.5 text-brand text-sm font-semibold">
<ArrowUpRight className="w-4 h-4 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform duration-300" />
</div>
</div>
</div>
</motion.a>
);
}
export default function CasesContentV2({
cases: casesProp,
industries: industriesProp,
}: {
cases?: CaseStudy[];
industries?: { id: string; label: string }[];
}) {
const [selectedIndustry, setSelectedIndustry] = useState('all');
const industries = industriesProp ?? CASE_INDUSTRIES;
const filteredCases = useMemo(() => {
if (casesProp) return casesProp;
return getCasesByIndustry(selectedIndustry);
}, [selectedIndustry, casesProp]);
return (
<div className="min-h-screen bg-white text-ink overflow-x-hidden">
{/* Hero Section */}
<section className="relative min-h-[70vh] flex items-center overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-28 sm:py-36 md:py-40">
<div className="max-w-4xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.1, ease: EASE_OUT }}
className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-8"
>
Case Studies
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black text-ink leading-[0.88] tracking-tightest mb-10 sm:mb-12 md:mb-16"
>
<div className="block"></div>
<div className="block mt-4 sm:mt-6">
<span className="relative inline-block">
<span className="relative text-brand font-black">
<motion.span
className="absolute -bottom-2 left-0 w-full h-[3px] bg-brand"
style={{ transformOrigin: 'left' }}
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.8, delay: 1.2, ease: EASE_OUT }}
/>
</span>
</span>
</div>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12"
>
<br className="hidden sm:block" />
</motion.p>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.7, ease: EASE_OUT }}
className="flex flex-wrap gap-3"
>
<span className="px-4 py-2 text-xs font-bold text-brand border border-brand/20 bg-brand/[0.05]">
500+
</span>
<span className="px-4 py-2 text-xs font-medium text-text-secondary border border-border-primary">
98%
</span>
<span className="px-4 py-2 text-xs font-medium text-text-secondary border border-border-primary">
6
</span>
<span className="px-4 py-2 text-xs font-medium text-text-secondary border border-border-primary">
97%
</span>
</motion.div>
</div>
</div>
</section>
{/* Metrics Strip */}
<section className="relative py-16 sm:py-20 md:py-24 overflow-hidden bg-bg-secondary">
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-border-primary" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8 md:gap-12">
{PAGE_METRICS.map((metric, i) => (
<motion.div
key={metric.label}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.6, delay: i * 0.08, ease: EASE_OUT }}
className="text-center lg:text-left"
>
<div className={`text-3xl sm:text-4xl md:text-5xl font-black tracking-tight mb-2 ${
i === 0 ? 'text-brand' : 'text-ink'
}`}>
{metric.value}
</div>
<div className="text-sm font-medium text-text-secondary mb-1">{metric.label}</div>
<div className="text-xs text-text-muted">{metric.sub}</div>
</motion.div>
))}
</div>
</div>
</section>
{/* Cases List Section */}
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-8 mb-12 sm:mb-16 md:mb-20">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.8, ease: EASE_OUT }}
className="lg:max-w-2xl"
>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6">
All Cases
</div>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]">
</h2>
<p className="mt-6 text-lg text-text-secondary leading-relaxed">
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.8, delay: 0.15, ease: EASE_OUT }}
>
<div className="flex items-center gap-2 flex-wrap">
<Filter className="w-4 h-4 text-text-muted" />
{industries.map((industry) => (
<button
key={industry.id}
onClick={() => setSelectedIndustry(industry.id)}
className={cn(
'px-4 py-2 text-xs font-medium transition-all duration-300',
selectedIndustry === industry.id
? 'bg-brand text-white'
: 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary hover:text-ink'
)}
>
{industry.label}
</button>
))}
</div>
</motion.div>
</div>
{filteredCases.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-border-primary">
{filteredCases.map((caseItem, i) => (
<CaseCard key={caseItem.id} caseItem={caseItem} index={i} />
))}
</div>
) : (
<div className="text-center py-20">
<p className="text-text-secondary text-lg"></p>
</div>
)}
</div>
</section>
{/* CTA Section */}
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-bg-secondary">
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.8, ease: EASE_OUT }}
className="max-w-3xl"
>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8">
<br className="sm:hidden" />
</h2>
<p className="text-lg text-text-secondary leading-relaxed mb-10">
<br className="hidden sm:block" />
</p>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
<a
href="/contact"
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90 min-h-[52px] touch-manipulation"
>
<ArrowUpRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</a>
<a
href="/services"
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary hover:border-border-secondary hover:bg-white transition-all duration-300 min-h-[52px] touch-manipulation"
>
</a>
</div>
</motion.div>
</div>
</section>
</div>
);
}
+7 -2
View File
@@ -3,6 +3,11 @@
import type { CaseStudy } from '@/lib/constants/cases';
import CasesContentV3 from './cases-content-v3';
export function CasesClient({ cases }: { cases?: CaseStudy[] }) {
return <CasesContentV3 cases={cases} />;
interface CasesClientProps {
cases?: CaseStudy[];
industries?: { id: string; label: string }[];
}
export function CasesClient({ cases, industries }: CasesClientProps) {
return <CasesContentV3 cases={cases} industries={industries} />;
}
+100
View File
@@ -0,0 +1,100 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import CasesPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
}));
jest.mock('./cases-content-v3', () => ({
__esModule: true,
default: function MockCasesContentV3({ cases, industries }: { cases: Array<{ id: string; title: string }>; industries?: Array<{ id: string; label: string }> }) {
return (
<div data-testid="cases-content">
<div data-testid="industries-count">{industries?.length ?? 0}</div>
{cases.map((item) => (
<div key={item.id} data-testid="case-item">{item.title}</div>
))}
</div>
);
},
}));
import { getPublishedItems } from '@/lib/cms/data-server';
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
const createMockCase = (topOverrides: Record<string, unknown> = {}, dataOverrides: Record<string, unknown> = {}) => ({
id: 'item-1',
modelId: 'model-case-study',
modelCode: 'case-study',
title: '测试案例',
slug: 'test-case',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-case',
slug: 'test-case',
title: '测试案例',
client: '测试客户',
industry: '制造业',
companySize: '1000人以上',
subtitle: '测试副标题',
challenge: '测试挑战',
solution: '测试方案',
result: '测试成果',
metrics: [{ value: '40%', label: '效率提升', highlight: true }],
timeline: [{ phase: '诊断', duration: '1周', description: '测试阶段' }],
services: [{ id: 'svc', title: '测试服务', description: '服务描述' }],
color: 'brand',
featured: true,
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
describe('CasesPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItems.mockResolvedValue([]);
});
it('renders empty state when no CMS case items exist', async () => {
const page = await CasesPage();
render(page);
expect(screen.getByTestId('cases-content')).toBeInTheDocument();
expect(screen.queryByTestId('case-item')).not.toBeInTheDocument();
});
it('maps CMS items to CaseStudy shape and passes industries', async () => {
mockedGetPublishedItems.mockResolvedValue([
createMockCase(),
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await CasesPage();
render(page);
expect(screen.getByText('测试案例')).toBeInTheDocument();
expect(screen.getByTestId('industries-count')).toHaveTextContent('7');
});
it('falls back to item.title when data.title is missing', async () => {
mockedGetPublishedItems.mockResolvedValue([
createMockCase(
{ title: 'Fallback Title' },
{ title: undefined },
),
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await CasesPage();
render(page);
expect(screen.getByText('Fallback Title')).toBeInTheDocument();
});
});
+5 -2
View File
@@ -1,5 +1,6 @@
import { Metadata } from 'next';
import { COMPANY_INFO } from '@/lib/constants';
import { CASE_INDUSTRIES } from '@/lib/constants/cases';
import { getPublishedItems } from '@/lib/cms/data-server';
import type { CaseStudy } from '@/lib/constants/cases';
import { CasesClient } from './client';
@@ -9,8 +10,10 @@ export const metadata: Metadata = {
description: `500+企业客户的成功案例,覆盖制造、零售、医疗、教育、金融、物流六大行业。真实数据,可衡量的成果——见证数字化转型的价值。`,
};
export const revalidate = 3600;
export default async function CasesPage() {
const items = await getPublishedItems('case-study');
const cases = items.map((item) => ({ ...item.data, title: item.data.title || item.title } as unknown as CaseStudy));
return <CasesClient cases={cases} />;
const cases = items.map((item) => ({ ...item.data, id: item.data.id || item.id, slug: item.data.slug || item.slug, title: item.data.title || item.title } as unknown as CaseStudy));
return <CasesClient cases={cases} industries={CASE_INDUSTRIES} />;
}
@@ -24,7 +24,7 @@ import { COMPANY_INFO } from '@/lib/constants';
import { trackContactForm, trackConversion } from '@/lib/analytics';
import { BreadcrumbSchema } from '@/components/seo/structured-data';
import { LegacyTooltip as Tooltip } from '@/components/ui/tooltip';
import { GrainOverlay, FloatingInkParticles, SectionLabel } from '@/components/ui/page-decoration';
import { GrainOverlay, SectionLabel } from '@/components/ui/page-decoration';
const contactFormSchema = z.object({
name: z.string().min(2, '姓名至少需要2个字符'),
@@ -194,7 +194,6 @@ function ContactFormContent() {
{/* Hero Section */}
<section className="relative min-h-[75vh] flex items-center overflow-hidden bg-ink">
<GrainOverlay />
<FloatingInkParticles />
<div className="absolute inset-0 bg-gradient-to-b from-ink-light/30 via-transparent to-ink pointer-events-none" />
@@ -478,7 +477,6 @@ function ContactFormContent() {
{/* CTA Section */}
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-ink-light">
<GrainOverlay />
<FloatingInkParticles />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="max-w-3xl mx-auto text-center">
+1 -13
View File
@@ -16,18 +16,8 @@ import {
} from 'lucide-react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { cn } from '@/lib/utils';
import { GrainOverlay, FloatingInkParticles, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import { GrainOverlay, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
const HOME_PARTICLES = [
{ x: 12, y: 28, scale: 0.8, duration: 10, delay: 0, size: 3 },
{ x: 78, y: 55, scale: 0.6, duration: 12, delay: 1.5, size: 2 },
{ x: 42, y: 38, scale: 1.0, duration: 9, delay: 0.8, size: 4 },
{ x: 88, y: 22, scale: 0.7, duration: 11, delay: 2.2, size: 2.5 },
{ x: 22, y: 72, scale: 0.9, duration: 8.5, delay: 1.0, size: 3.5 },
{ x: 62, y: 48, scale: 0.55, duration: 13, delay: 2.8, size: 2 },
{ x: 55, y: 18, scale: 0.65, duration: 10.5, delay: 3.2, size: 2.5 },
{ x: 32, y: 62, scale: 0.75, duration: 9.5, delay: 0.5, size: 3 },
];
const EASE_SPRING = { type: 'spring', stiffness: 300, damping: 30 } as const;
const SERVICES = [
@@ -366,7 +356,6 @@ function HeroSection() {
className="relative min-h-screen flex items-center overflow-hidden bg-ink"
>
<GrainOverlay />
<FloatingInkParticles particles={HOME_PARTICLES} />
<div className="absolute inset-0 pointer-events-none">
<motion.div style={{ y: gridY, scale }} className="absolute inset-0 opacity-[0.035]">
@@ -1045,7 +1034,6 @@ function CTASection() {
className="relative py-24 sm:py-32 md:py-40 lg:py-52 overflow-hidden bg-ink-light"
>
<GrainOverlay />
<FloatingInkParticles particles={HOME_PARTICLES} />
<div className="absolute inset-0 pointer-events-none">
<div
+1 -13
View File
@@ -16,18 +16,8 @@ import {
} from 'lucide-react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { cn } from '@/lib/utils';
import { GrainOverlay, FloatingInkParticles, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import { GrainOverlay, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
const HOME_PARTICLES = [
{ x: 12, y: 28, scale: 0.8, duration: 10, delay: 0, size: 3 },
{ x: 78, y: 55, scale: 0.6, duration: 12, delay: 1.5, size: 2 },
{ x: 42, y: 38, scale: 1.0, duration: 9, delay: 0.8, size: 4 },
{ x: 88, y: 22, scale: 0.7, duration: 11, delay: 2.2, size: 2.5 },
{ x: 22, y: 72, scale: 0.9, duration: 8.5, delay: 1.0, size: 3.5 },
{ x: 62, y: 48, scale: 0.55, duration: 13, delay: 2.8, size: 2 },
{ x: 55, y: 18, scale: 0.65, duration: 10.5, delay: 3.2, size: 2.5 },
{ x: 32, y: 62, scale: 0.75, duration: 9.5, delay: 0.5, size: 3 },
];
const EASE_SPRING = { type: 'spring', stiffness: 300, damping: 30 } as const;
const SERVICES = [
@@ -366,7 +356,6 @@ function HeroSection() {
className="relative min-h-screen flex items-center overflow-hidden bg-ink"
>
<GrainOverlay />
<FloatingInkParticles particles={HOME_PARTICLES} />
<div className="absolute inset-0 pointer-events-none">
<motion.div style={{ y: gridY, scale }} className="absolute inset-0 opacity-[0.035]">
@@ -1088,7 +1077,6 @@ function CTASection() {
className="relative py-32 sm:py-40 md:py-52 lg:py-60 overflow-hidden bg-ink-light"
>
<GrainOverlay />
<FloatingInkParticles particles={HOME_PARTICLES} />
<div className="absolute inset-0 pointer-events-none">
<div
+87 -197
View File
@@ -6,7 +6,7 @@ import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { Badge } from '@/components/ui/badge';
import {
ArrowRight, ChevronDown,
ArrowRight,
Quote, CheckCircle2,
Zap, Award,
} from 'lucide-react';
@@ -55,7 +55,7 @@ function MagneticButton({ children, href, variant = 'primary', className }: { ch
);
}
function HeroSection({ heroData }: { heroData?: Record<string, any> | null }) {
function HeroSection({ heroData, stats }: { heroData?: Record<string, any> | null; stats?: Record<string, any>[] }) {
const containerRef = useRef<HTMLDivElement>(null);
const scrollProgress = useMotionValue(0);
@@ -81,13 +81,17 @@ function HeroSection({ heroData }: { heroData?: Record<string, any> | null }) {
const y = useTransform(scrollProgress, [0, 1], [0, 160]);
const opacity = useTransform(scrollProgress, [0, 0.7], [1, 0.15]);
const headingTop = heroData?.headingTop || '';
const headingBottom = heroData?.headingBottom || '';
const description = heroData?.description || '';
const ctaLabel = heroData?.ctaLabel || '';
const ctaHref = heroData?.ctaHref || '#';
const statValue = heroData?.statValue || '';
const statSubtext = heroData?.statSubtext || '';
// 兼容新旧数据字段
const eyebrow = heroData?.eyebrow || '';
const heading = heroData?.heading || heroData?.headingBottom || '';
const subheading = heroData?.subheading || heroData?.description || '';
const ctaLabel = heroData?.ctaLabel || '预约咨询';
const ctaHref = heroData?.ctaHref || '/contact';
const secondaryCtaLabel = heroData?.secondaryCtaLabel || '';
const secondaryCtaHref = heroData?.secondaryCtaHref || '#';
const hasValidStats = stats?.length && stats[0]?.value;
const displayStats = hasValidStats ? stats!.slice(0, 4) : [];
if (!heroData) {
return (
@@ -104,137 +108,93 @@ function HeroSection({ heroData }: { heroData?: Record<string, any> | null }) {
ref={containerRef}
className="relative min-h-screen flex items-center overflow-hidden bg-white"
>
{/* 右侧抽象几何装饰 — 桌面端可见,暗示咨询框架 */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 hidden lg:block pointer-events-none select-none" aria-hidden="true">
<div className="relative w-[480px] h-[480px]">
{/* 外圈:结构框架 */}
<motion.div
initial={{ opacity: 0, rotate: -8 }}
animate={{ opacity: 0.12, rotate: 0 }}
transition={{ duration: 1.5, delay: 0.3, ease: EASE_OUT }}
className="absolute top-0 right-0 w-64 h-64 border border-ink/20"
/>
<motion.div
initial={{ opacity: 0, rotate: 8 }}
animate={{ opacity: 0.08, rotate: 0 }}
transition={{ duration: 1.5, delay: 0.5, ease: EASE_OUT }}
className="absolute top-16 right-16 w-48 h-48 border border-ink/15"
/>
{/* 内圈:数据节点 */}
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 0.06, scale: 1 }}
transition={{ duration: 1.2, delay: 0.7, ease: EASE_OUT }}
className="absolute top-24 right-24 w-24 h-24 bg-brand"
/>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 0.03, scale: 1 }}
transition={{ duration: 1.2, delay: 0.9, ease: EASE_OUT }}
className="absolute bottom-32 right-32 w-16 h-16 bg-ink"
/>
{/* 连线和节点 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.06 }}
transition={{ duration: 1, delay: 1.1, ease: EASE_OUT }}
className="absolute top-32 right-12 w-20 h-px bg-ink/30"
/>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.04 }}
transition={{ duration: 1, delay: 1.3, ease: EASE_OUT }}
className="absolute top-48 right-0 w-28 h-px bg-ink/25"
/>
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 0.1, scale: 1 }}
transition={{ duration: 0.8, delay: 1.5, ease: EASE_OUT }}
className="absolute top-32 right-12 w-1.5 h-1.5 bg-brand"
/>
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 0.08, scale: 1 }}
transition={{ duration: 0.8, delay: 1.7, ease: EASE_OUT }}
className="absolute top-48 right-28 w-1.5 h-1.5 bg-ink"
/>
</div>
</div>
<div className="absolute bottom-0 left-0 w-full h-1/3 bg-gradient-to-t from-white to-transparent pointer-events-none" />
<motion.div
style={{ y, opacity }}
className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-32 sm:py-40 md:py-48 lg:py-56"
>
<div className="max-w-4xl">
<motion.h1
initial={{ opacity: 0, y: 60 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1.2, delay: 0.1, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black text-ink leading-[0.92] tracking-tightest mb-10 sm:mb-12 md:mb-16"
>
{headingTop && (
<div className="block text-text-secondary font-bold text-2xl sm:text-3xl md:text-4xl lg:text-5xl tracking-tight mb-6 sm:mb-8">
{headingTop}
</div>
)}
<div className="block">
<span className="relative inline-block">
<span className="relative text-brand font-black">
{headingBottom}
<motion.span
className="absolute -bottom-3 left-0 w-full h-[3px] bg-brand"
style={{ transformOrigin: 'left' }}
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.9, delay: 1.4, ease: EASE_OUT }}
/>
{/* 眉标 Badge */}
{eyebrow && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1, ease: EASE_OUT }}
className="mb-8 sm:mb-10"
>
<span className="inline-flex items-center gap-2 px-3 py-1.5 bg-brand/[0.08] border border-brand/20 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-brand" />
<span className="text-[13px] font-semibold text-brand tracking-[0.08em] uppercase">
{eyebrow}
</span>
</span>
</div>
</motion.div>
)}
{/* 主标题 — Bain 风格:大号加粗 + 品牌红关键词 */}
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black text-ink leading-[0.92] tracking-tightest mb-10 sm:mb-12"
>
<span className="relative">
{heading}
</span>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.5, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary/80 max-w-2xl leading-relaxed mb-12 sm:mb-14 md:mb-16"
>
{description}
</motion.p>
{/* 数据指标条 — Bain 风格:答案优先,数据先行 */}
{displayStats.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.4, ease: EASE_OUT }}
className="flex flex-wrap gap-6 sm:gap-10 md:gap-14 mb-10 sm:mb-12"
>
{displayStats.map((stat, i) => (
<div key={i} className="flex items-baseline gap-2">
<span className="text-3xl sm:text-4xl md:text-5xl font-black text-brand tabular-nums leading-none">
{stat.value}
</span>
<span className="text-sm text-text-muted whitespace-nowrap">
{stat.label}
</span>
</div>
))}
</motion.div>
)}
{/* 副标题 */}
{subheading && (
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.5, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary/70 max-w-2xl leading-relaxed mb-12 sm:mb-14"
>
{subheading}
</motion.p>
)}
{/* CTA 按钮组 */}
<motion.div
initial={{ opacity: 0, y: 40 }}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.65, ease: EASE_OUT }}
className="flex flex-col sm:flex-row sm:items-center gap-6 sm:gap-10"
transition={{ duration: 0.7, delay: 0.6, ease: EASE_OUT }}
className="flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6"
>
<MagneticButton href={ctaHref}>{ctaLabel || '预约咨询'}</MagneticButton>
{statValue && (
<div className="flex items-center gap-5 pl-4 border-l border-border-primary">
<div className="flex items-center gap-1.5">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="w-2 h-2 rounded-full bg-brand" style={{ opacity: 1 - i * 0.22 }} />
))}
</div>
<div>
<div className="text-sm font-bold text-ink">{statValue}</div>
<div className="text-xs text-text-muted">{statSubtext}</div>
</div>
</div>
<MagneticButton href={ctaHref}>{ctaLabel}</MagneticButton>
{secondaryCtaLabel && (
<a
href={secondaryCtaHref}
className="group inline-flex items-center gap-2 text-sm font-medium text-text-secondary hover:text-ink transition-colors duration-200 py-4"
>
{secondaryCtaLabel}
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
)}
</motion.div>
</div>
</motion.div>
<motion.div
animate={{ y: [0, 12, 0] }}
transition={{ duration: 2.5, repeat: Infinity, ease: 'easeInOut' }}
className="absolute left-4 sm:left-6 lg:left-10 flex flex-col items-start gap-4 bottom-[calc(4rem+env(safe-area-inset-bottom,0px))] md:bottom-16"
>
<ChevronDown className="w-5 h-5 text-text-muted" />
</motion.div>
</section>
);
}
@@ -260,9 +220,9 @@ function ServicesSection({ services }: { services?: Record<string, any>[] }) {
<ScrollReveal>
<SectionLabel></SectionLabel>
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-6xl font-black text-ink tracking-tight leading-[0.95] mt-6">
<br />
</h2>
</ScrollReveal>
</div>
@@ -412,7 +372,7 @@ function TrustSection() {
</span>
</div>
<h2 className="font-sans text-xl sm:text-2xl md:text-3xl lg:text-4xl font-black text-ink tracking-tight leading-tight">
12500+
</h2>
</ScrollReveal>
@@ -622,75 +582,6 @@ function CasesSection({ cases }: { cases?: Record<string, any>[] }) {
);
}
function ApproachStrip({ stats }: { stats?: Record<string, any>[] }) {
const hasValidData = stats?.length && stats[0]?.value;
const APPROACH_STRIP = hasValidData ? stats : [];
if (APPROACH_STRIP.length === 0) {
return (
<section className="relative py-20 overflow-hidden bg-dark-bg-secondary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 text-center">
<p className="text-dark-text-muted text-lg"></p>
</div>
</section>
);
}
return (
<section
className="relative py-20 sm:py-24 md:py-28 lg:py-32 overflow-hidden bg-dark-bg-secondary"
>
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-dark-border to-transparent" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-dark-border to-transparent" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="mb-10 sm:mb-12 md:mb-14">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.15em] font-medium text-brand">
</span>
</div>
<h2 className="font-sans text-xl sm:text-2xl md:text-3xl lg:text-4xl font-black text-dark-text-primary tracking-tight leading-tight">
500+
</h2>
</ScrollReveal>
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-0">
{APPROACH_STRIP.map((item, i) => (
<div key={i} className="flex flex-col">
{i > 0 && (
<div className="hidden md:block absolute left-0 top-1/2 -translate-y-1/2 w-px h-16 bg-dark-border" />
)}
<div className="relative">
<motion.div
className="text-3xl sm:text-4xl lg:text-5xl font-black text-dark-text-primary tracking-tightest mb-2"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 * i }}
>
{item.value}
</motion.div>
<div className="text-xs text-dark-text-muted tracking-wide mb-4">
{item.label}
</div>
<div className="h-1 bg-dark-border/50 rounded-full overflow-hidden max-w-[160px]">
<motion.div
className="h-full bg-brand rounded-full"
initial={{ width: 0 }}
whileInView={{ width: `${(item as any).progress || 80}%` }}
viewport={{ once: true }}
transition={{ duration: 1.2, delay: 0.2 + 0.1 * i, ease: 'easeOut' }}
/>
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
}
function CTASection({ heroData }: { heroData?: Record<string, any> | null }) {
const ctaTitleLine1 = heroData?.ctaTitleLine1 || '';
const ctaTitleLine2 = heroData?.ctaTitleLine2 || '';
@@ -727,11 +618,10 @@ export default function HomeContentV13({ services, cases, stats, heroData }: {
}) {
return (
<main>
<HeroSection heroData={heroData} />
<HeroSection heroData={heroData} stats={stats} />
<ServicesSection services={services} />
<TrustSection />
<CasesSection cases={cases} />
<ApproachStrip stats={stats} />
<CTASection heroData={heroData} />
</main>
);
+417
View File
@@ -0,0 +1,417 @@
'use client';
import { useRef } from 'react';
import { motion } from 'framer-motion';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { Badge } from '@/components/ui/badge';
import { ArrowRight, Lightbulb, Database, Layers } from 'lucide-react';
import { cn } from '@/lib/utils';
import { EASE_OUT } from '@/components/ui/page-decoration';
// ===== 服务数据降级兜底 =====
const FALLBACK_SERVICES = [
{
subtitle: 'Strategy Consulting',
title: '数字化转型战略咨询',
desc: '从业务诊断到数字化路线图,帮助企业识别高价值场景,规划可落地的转型路径,确保每一分投入都能产生可衡量的业务回报。',
highlights: ['业务诊断', '数字化蓝图', '实施路线图', 'ROI 评估'],
metrics: [
{ value: '40%', label: '平均运营效率提升' },
{ value: '6个月', label: '战略到试点周期' },
],
href: '/services/strategy',
icon: <Lightbulb className="w-7 h-7" />,
},
{
subtitle: 'Data Intelligence',
title: '数据智能与 AI 应用',
desc: '构建企业级数据平台与 AI 能力,打通数据孤岛,沉淀数据资产,让决策从经验驱动转向数据驱动。',
highlights: ['数据中台', 'AI 建模', '可视化分析', '预测决策'],
metrics: [
{ value: '98%', label: '数据质量达标率' },
{ value: '10x', label: '分析效率提升' },
],
href: '/services/data',
icon: <Database className="w-7 h-7" />,
},
{
subtitle: 'Solution Delivery',
title: '行业解决方案落地',
desc: '基于深耕行业的最佳实践,提供从方案设计、系统开发到上线运维的一站式交付,确保数字化转型真正落地生根。',
highlights: ['行业套件', '定制开发', '系统集成', '持续运维'],
metrics: [
{ value: '500+', label: '项目交付经验' },
{ value: '99.9%', label: '系统可用性' },
],
href: '/services/solution',
icon: <Layers className="w-7 h-7" />,
},
];
// ===== 品牌 CTA 按钮 =====
function CTAButton({ children, href, variant = 'primary', className }: {
children: React.ReactNode;
href: string;
variant?: 'primary' | 'secondary';
className?: string;
}) {
return (
<a
href={href}
className={cn(
'group inline-flex items-center gap-3 px-10 py-4 font-semibold text-base transition-all duration-200',
variant === 'primary'
? 'bg-brand text-white hover:bg-brand-hover'
: 'text-ink border border-ink/20 hover:border-ink/40 hover:bg-ink/5',
className
)}
>
<span>{children}</span>
<ArrowRight className="w-5 h-5 transition-transform duration-300 group-hover:translate-x-2" />
</a>
);
}
// ===== Hero 区:克制白底 + 品牌红点缀 =====
function HeroSection({ heroData, stats }: {
heroData?: Record<string, any> | null;
stats?: Record<string, any>[];
}) {
const containerRef = useRef<HTMLDivElement>(null);
const heading = heroData?.heading || heroData?.headingBottom || '从数据到决策,让转型可量化';
const subheading = heroData?.subheading || heroData?.description || '';
const ctaLabel = heroData?.ctaLabel || '预约咨询';
const ctaHref = heroData?.ctaHref || '/contact';
const secondaryCtaLabel = heroData?.secondaryCtaLabel || '';
const secondaryCtaHref = heroData?.secondaryCtaHref || '#';
const hasValidStats = stats?.length && stats[0]?.value;
const displayStats = hasValidStats ? stats!.slice(0, 3) : [];
return (
<section
ref={containerRef}
className="relative min-h-[90vh] flex items-center overflow-hidden bg-white"
>
{/* 背景纹理:极淡的品牌色点阵 */}
<div className="absolute inset-0 pointer-events-none opacity-[0.03]"
style={{
backgroundImage: `radial-gradient(circle at 25% 25%, rgba(196,30,58,0.4) 1px, transparent 1px)`,
backgroundSize: '48px 48px',
}}
/>
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-24 sm:py-32 md:py-40 lg:py-48">
<div className="max-w-4xl">
{/* 主标题:大胆、直接 */}
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.1, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-ink leading-[1.05] tracking-tight mb-10"
>
{heading}
</motion.h1>
{/* 数据指标条:答案优先,数据先行 */}
{displayStats.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.3, ease: EASE_OUT }}
className="flex flex-wrap gap-8 sm:gap-12 md:gap-16 mb-10"
>
{displayStats.map((stat, i) => (
<div key={i} className="flex items-baseline gap-2">
<span className="text-3xl sm:text-4xl md:text-5xl font-black text-brand tabular-nums leading-none">
{stat.value}
</span>
<span className="text-sm text-text-secondary whitespace-nowrap">
{stat.label}
</span>
</div>
))}
</motion.div>
)}
{/* 副标题 */}
{subheading && (
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.4, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12"
>
{subheading}
</motion.p>
)}
{/* CTA 按钮组 */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.5, ease: EASE_OUT }}
className="flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6"
>
<CTAButton href={ctaHref}>{ctaLabel}</CTAButton>
{secondaryCtaLabel && (
<a
href={secondaryCtaHref}
className="group inline-flex items-center gap-2 text-sm font-medium text-text-secondary hover:text-ink transition-colors duration-200 py-4"
>
{secondaryCtaLabel}
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
)}
</motion.div>
</div>
</div>
</section>
);
}
// ===== 信任/成果区:Bain 式量化数据展示 =====
function TrustSection() {
const items = [
{ value: '12+', label: '年行业深耕', desc: '自 2014 年起专注企业数字化转型' },
{ value: '500+', label: '项目交付', desc: '覆盖金融、制造、政务等 20+ 行业' },
{ value: '98%', label: '客户续约率', desc: '长期陪跑,持续创造业务价值' },
{ value: '100%', label: '私有化部署', desc: '核心数据不出境,满足合规要求' },
];
return (
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="mb-16 sm:mb-20">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.12em] font-semibold text-brand">
</span>
</div>
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
<br />
</h2>
</ScrollReveal>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{items.map((item, i) => (
<StaggerReveal key={i} staggerDelay={0.06}>
<div className="bain-card p-8 sm:p-10">
<div className="text-3xl sm:text-4xl font-black text-brand tabular-nums leading-none mb-2">
{item.value}
</div>
<div className="text-sm font-semibold text-ink mb-3">{item.label}</div>
<p className="text-sm text-text-secondary leading-relaxed">{item.desc}</p>
</div>
</StaggerReveal>
))}
</div>
</div>
</section>
);
}
// ===== 服务区:等宽三列卡片 + 兜底数据 =====
function ServicesSection({ services }: { services?: Record<string, any>[] }) {
const hasValidData = services?.length && services[0]?.title;
const SERVICE_ITEMS = hasValidData ? services! : FALLBACK_SERVICES;
return (
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-bg-secondary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="max-w-3xl mb-16 sm:mb-20">
<ScrollReveal>
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
<br />
</h2>
</ScrollReveal>
</div>
<div className="grid md:grid-cols-3 gap-6">
{SERVICE_ITEMS.map((service, i) => (
<StaggerReveal key={i} staggerDelay={0.05}>
<a href={service.href} className="group bain-card block p-8 sm:p-10 md:p-12 h-full flex flex-col">
<div className="flex flex-col flex-1">
<div className="flex items-start justify-between mb-10">
<div className="w-12 h-12 flex items-center justify-center bg-bg-secondary text-text-secondary" aria-hidden="true">
{service.icon}
</div>
</div>
<div className="mb-6">
<span className="block text-sm text-text-muted mb-3 font-medium">{service.subtitle}</span>
<h3 className="text-xl lg:text-2xl font-bold text-ink">{service.title}</h3>
</div>
<p className="text-text-secondary leading-relaxed mb-8 text-sm">{service.desc}</p>
<div className="flex flex-wrap gap-2 mb-8">
{service.highlights.map((h: string, j: number) => (
<span key={j} className="px-3 py-1.5 text-xs text-text-muted border border-border-primary font-medium">
{h}
</span>
))}
</div>
{(service as any).metrics && (
<div className="flex gap-6 mb-8 pb-8 border-b border-border-primary">
{(service as any).metrics.map((m: any, j: number) => (
<div key={j}>
<div className="text-2xl sm:text-3xl font-black text-brand leading-none mb-1">{m.value}</div>
<div className="text-[11px] text-text-muted font-medium">{m.label}</div>
</div>
))}
</div>
)}
<div className="mt-auto pt-2">
<div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors">
<span></span>
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</div>
</div>
</div>
</a>
</StaggerReveal>
))}
</div>
</div>
</section>
);
}
// ===== 案例区:轻量快照卡片 =====
function CasesSection({ cases }: { cases?: Record<string, any>[] }) {
const hasValidData = cases?.length && cases[0]?.metrics;
const CASE_ITEMS = hasValidData ? cases! : [];
if (CASE_ITEMS.length === 0) {
return (
<section className="relative py-28 overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 text-center">
<p className="text-text-muted text-lg"></p>
</div>
</section>
);
}
return (
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-8 mb-16 sm:mb-20">
<ScrollReveal className="md:max-w-3xl">
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
<br />
</h2>
</ScrollReveal>
<ScrollReveal delay={0.1}>
<a
href="/cases"
className="group inline-flex items-center gap-3 text-ink font-semibold text-sm"
>
<ArrowRight className="w-4 h-5 transition-transform duration-300 group-hover:translate-x-2 text-text-secondary group-hover:text-brand" />
</a>
</ScrollReveal>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{CASE_ITEMS.map((caseItem, i) => (
<ScrollReveal key={i} delay={i * 0.1}>
<a
href={caseItem.href || `/cases/${caseItem.slug || ''}`}
className="group bain-card block p-8 sm:p-10 h-full flex flex-col"
>
<div className="flex items-center gap-3 mb-8">
<Badge className="bg-bg-secondary text-ink border-transparent">
{caseItem.industry}
</Badge>
</div>
<h3 className="text-xl lg:text-2xl font-bold text-ink mb-8 leading-[1.2]">
{caseItem.title}
</h3>
<div className="grid grid-cols-3 gap-4 mb-8">
{caseItem.metrics.slice(0, 3).map((metric: Record<string, any>, j: number) => (
<div key={j}>
<div className={cn(
'text-xl sm:text-2xl font-black leading-none mb-2',
j === 0 ? 'text-brand' : 'text-ink'
)}>
{metric.value}
</div>
<div className="text-xs text-text-secondary font-medium leading-tight">
{metric.label}
</div>
</div>
))}
</div>
<div className="mt-auto pt-4 border-t border-border-primary">
<div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors">
<span></span>
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</div>
</div>
</a>
</ScrollReveal>
))}
</div>
</div>
</section>
);
}
// ===== CTA 区:克制强调,品牌红仅用于按钮 =====
function CTASection({ heroData }: { heroData?: Record<string, any> | null }) {
const ctaTitleLine1 = heroData?.ctaTitleLine1 || '准备好开始了吗?';
const ctaTitleLine2 = heroData?.ctaTitleLine2 || '让数据真正转化为业务增长';
const ctaLabel = heroData?.ctaLabel || '预约咨询';
const ctaHref = heroData?.ctaHref || '/contact';
return (
<section className="relative py-24 sm:py-32 md:py-40 overflow-hidden bg-bg-secondary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="max-w-3xl">
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05] mb-10 sm:mb-14">
{ctaTitleLine1}
<br />
{ctaTitleLine2}
</h2>
<CTAButton href={ctaHref}>{ctaLabel}</CTAButton>
</ScrollReveal>
</div>
</section>
);
}
// ===== 主页面组件 =====
export default function HomeContentV14({ services, cases, stats, heroData }: {
services?: Record<string, any>[];
cases?: Record<string, any>[];
stats?: Record<string, any>[];
heroData?: Record<string, any> | null;
}) {
return (
<main>
{/* 1. Hero:克制白底 + 品牌红点缀 */}
<HeroSection heroData={heroData} stats={stats} />
{/* 2. 信任区:量化数据成果展示 */}
<TrustSection />
{/* 3. 服务区:等宽卡片 + 兜底数据 */}
<ServicesSection services={services} />
{/* 4. 案例区:轻量快照卡片 */}
<CasesSection cases={cases} />
{/* 5. CTA:克制强调,品牌红仅用于按钮 */}
<CTASection heroData={heroData} />
</main>
);
}
+3 -8
View File
@@ -3,8 +3,6 @@
import { ErrorBoundary } from '@/components/ui/error-boundary';
import { Header } from '@/components/layout/header';
import { Footer } from '@/components/layout/footer';
import { PageTransition } from '@/components/ui/page-transition';
import { Toaster } from '@/components/ui/sonner';
export default function MarketingLayout({
children,
@@ -15,14 +13,11 @@ export default function MarketingLayout({
<div className="min-h-screen flex flex-col">
<Header />
<ErrorBoundary>
<main id="main-content" tabIndex={-1} className="flex-1 pt-16" role="main" aria-label="页面主体内容">
<PageTransition>
{children}
</PageTransition>
</main>
<div className="flex-1 pt-16" role="main" aria-label="页面主体内容">
{children}
</div>
</ErrorBoundary>
<Footer />
<Toaster position="bottom-right" />
</div>
);
}
@@ -3,7 +3,7 @@
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { InkCard } from '@/components/ui/ink-wash';
import { Card } from '@/components/ui/card';
import { Calendar, ArrowLeft, Newspaper } from 'lucide-react';
import { motion } from 'framer-motion';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
@@ -85,14 +85,14 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
)}
{/* Excerpt */}
<InkCard padding="md" className="mb-10">
<Card className="mb-10 p-6">
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
{news.excerpt}
</p>
</InkCard>
</Card>
{/* Article Content */}
<div className="prose-ink text-[var(--color-text-primary)] text-base sm:text-lg leading-[1.8] whitespace-pre-line">
<div className="prose text-[var(--color-text-primary)] text-base sm:text-lg leading-[1.8] whitespace-pre-line">
{news.content}
</div>
@@ -103,7 +103,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
<div className="grid md:grid-cols-3 gap-6">
{relatedNews.map((related) => (
<StaticLink key={related.id} href={`/news/${related.id}`}>
<InkCard padding="none" interactive className="overflow-hidden group h-full">
<Card className="overflow-hidden group h-full transition-all duration-300 hover:-translate-y-1 hover:shadow-lg">
<div className="aspect-video overflow-hidden">
{related.image ? (
<img
@@ -124,7 +124,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
</h3>
<p className="text-sm text-[var(--color-text-muted)] line-clamp-2 leading-relaxed">{related.excerpt}</p>
</div>
</InkCard>
</Card>
</StaticLink>
))}
</div>
@@ -0,0 +1,147 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import NewsDetailPage, { generateMetadata } from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItemBySlug: jest.fn(),
getPublishedItems: jest.fn(),
getAllPublishedSlugs: jest.fn(),
}));
jest.mock('../news-detail-content-v3', () => ({
__esModule: true,
default: function MockNewsDetailContentV3({ news, relatedNews }: { news: { title: string }; relatedNews?: Array<{ title: string }> }) {
return (
<div data-testid="news-detail">
<h1>{news.title}</h1>
{relatedNews && relatedNews.length > 0 && (
<div data-testid="related-news">
{relatedNews.map((item, idx) => (
<div key={idx}>{item.title}</div>
))}
</div>
)}
</div>
);
},
}));
import { getPublishedItemBySlug, getPublishedItems, getAllPublishedSlugs } from '@/lib/cms/data-server';
const mockedGetPublishedItemBySlug = getPublishedItemBySlug as jest.MockedFunction<typeof getPublishedItemBySlug>;
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
const mockedGetAllPublishedSlugs = getAllPublishedSlugs as jest.MockedFunction<typeof getAllPublishedSlugs>;
const createMockNews = (
topOverrides: Record<string, unknown> = {},
dataOverrides: Record<string, unknown> = {},
) => ({
id: 'item-1',
modelId: 'model-news',
modelCode: 'news',
title: '测试新闻',
slug: 'test-news',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-news',
title: '测试新闻',
excerpt: '测试摘要',
date: '2026-07-17',
category: '公司新闻',
image: '/images/news/test.webp',
content: '测试正文',
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
describe('NewsDetailPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItemBySlug.mockResolvedValue(null);
mockedGetPublishedItems.mockResolvedValue([]);
mockedGetAllPublishedSlugs.mockResolvedValue([]);
});
it('returns 404 when news item is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
// next/navigation notFound() throws in test environment; verify it is called by catching
await expect(
NewsDetailPage({ params: Promise.resolve({ slug: 'missing' }) }),
).rejects.toThrow();
});
it('renders news detail with data from CMS', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(createMockNews() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>);
const page = await NewsDetailPage({ params: Promise.resolve({ slug: 'test-news' }) });
render(page);
expect(screen.getByTestId('news-detail')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('测试新闻');
});
it('filters related news by same category and excludes current slug', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(
createMockNews() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>,
);
mockedGetPublishedItems.mockResolvedValue([
createMockNews(),
createMockNews(
{ id: 'item-related', slug: 'related-1' },
{ id: 'related-1', slug: 'related-1', title: '相关新闻 1' },
),
createMockNews(
{ id: 'item-other', slug: 'other-cat' },
{ id: 'other-cat', slug: 'other-cat', title: '其他分类', category: '研发动态' },
),
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await NewsDetailPage({ params: Promise.resolve({ slug: 'test-news' }) });
render(page);
expect(screen.getByText('相关新闻 1')).toBeInTheDocument();
expect(screen.queryByText('其他分类')).not.toBeInTheDocument();
});
it('generateStaticParams returns slugs from CMS', async () => {
mockedGetAllPublishedSlugs.mockResolvedValue([
{ slug: 'company-founded' },
{ slug: 'digital-transformation-solution' },
] as unknown as Awaited<ReturnType<typeof getAllPublishedSlugs>>);
const { generateStaticParams } = await import('./page');
const params = await generateStaticParams();
expect(params).toEqual([
{ slug: 'company-founded' },
{ slug: 'digital-transformation-solution' },
]);
});
describe('generateMetadata', () => {
it('returns news title and excerpt as metadata', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(createMockNews() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>);
const metadata = await generateMetadata({ params: Promise.resolve({ slug: 'test-news' }) });
expect(metadata.title).toContain('测试新闻');
expect(metadata.description).toBe('测试摘要');
});
it('returns fallback metadata when news is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
const metadata = await generateMetadata({ params: Promise.resolve({ slug: 'missing' }) });
expect(metadata.title).toBe('新闻未找到');
});
});
});
+3
View File
@@ -5,6 +5,9 @@ import { getPublishedItemBySlug, getPublishedItems, getAllPublishedSlugs } from
import type { NewsItem } from '@/lib/constants/news';
import NewsDetailContentV3 from '../news-detail-content-v3';
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
export const revalidate = 3600;
export async function generateStaticParams() {
const slugs = await getAllPublishedSlugs('news');
return slugs.map((s) => ({ slug: s.slug }));
+2 -2
View File
@@ -53,9 +53,9 @@ function NewsCard({ newsItem, index }: { newsItem: NewsItem; index: number }) {
</div>
</div>
<h3 className="text-lg sm:text-xl font-semibold text-ink mb-3 line-clamp-2 group-hover:text-brand transition-colors duration-300 leading-snug">
<h2 className="text-lg sm:text-xl font-semibold text-ink mb-3 line-clamp-2 group-hover:text-brand transition-colors duration-300 leading-snug">
{newsItem.title}
</h3>
</h2>
<p className="text-sm text-text-secondary line-clamp-3 mb-5 leading-relaxed">
{newsItem.excerpt}
+105
View File
@@ -0,0 +1,105 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import NewsListPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
}));
jest.mock('./news-content-v3', () => ({
__esModule: true,
default: function MockNewsContentV3({ news }: { news: Array<{ id: string; title: string }> }) {
return (
<div data-testid="news-content">
{news.map((item) => (
<div key={item.id} data-testid="news-item">{item.title}</div>
))}
</div>
);
},
}));
import { getPublishedItems } from '@/lib/cms/data-server';
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
describe('NewsListPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItems.mockResolvedValue([]);
});
it('renders empty state when no CMS news items exist', async () => {
const page = await NewsListPage();
render(page);
expect(screen.getByTestId('news-content')).toBeInTheDocument();
expect(screen.queryByTestId('news-item')).not.toBeInTheDocument();
});
it('maps CMS items to NewsItem shape and passes to NewsContentV3', async () => {
mockedGetPublishedItems.mockResolvedValue([
{
id: 'item-1',
modelId: 'model-news',
modelCode: 'news',
title: 'CMS 新闻标题',
slug: 'cms-news',
locale: 'zh-CN',
status: 'published',
data: {
id: 'cms-news',
title: 'CMS 新闻标题',
excerpt: 'CMS 摘要',
date: '2026-07-17',
category: '公司新闻',
image: '/images/news/cms.webp',
content: 'CMS 正文',
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await NewsListPage();
render(page);
expect(screen.getByText('CMS 新闻标题')).toBeInTheDocument();
});
it('falls back to item.title when data.title is missing', async () => {
mockedGetPublishedItems.mockResolvedValue([
{
id: 'item-2',
modelId: 'model-news',
modelCode: 'news',
title: 'Fallback Title',
slug: 'fallback-news',
locale: 'zh-CN',
status: 'published',
data: {
id: 'fallback-news',
excerpt: '摘要',
date: '2026-07-17',
category: '研发动态',
image: '/images/news/fallback.webp',
content: '正文',
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await NewsListPage();
render(page);
expect(screen.getByText('Fallback Title')).toBeInTheDocument();
});
});
+3
View File
@@ -9,6 +9,9 @@ export const metadata: Metadata = {
description: `了解${COMPANY_INFO.displayName}的最新动态、行业洞察与技术创新。`,
};
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
export const revalidate = 3600;
export default async function NewsListPage() {
const items = await getPublishedItems('news');
const news = items.map((item) => ({ ...item.data, title: item.data.title || item.title } as unknown as NewsItem));
+114
View File
@@ -0,0 +1,114 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import HomePage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
getResolvedHomeZones: jest.fn(),
}));
jest.mock('./home-content-v14', () => ({
__esModule: true,
default: function MockHomeContentV14(props: Record<string, unknown>) {
return (
<div data-testid="home-content">
<div data-testid="services-count">{(props.services as unknown[])?.length ?? 0}</div>
<div data-testid="cases-count">{(props.cases as unknown[])?.length ?? 0}</div>
<div data-testid="stats-count">{(props.stats as unknown[])?.length ?? 0}</div>
<div data-testid="hero-data">{props.heroData ? JSON.stringify(props.heroData) : 'null'}</div>
</div>
);
},
}));
import { getPublishedItems, getResolvedHomeZones } from '@/lib/cms/data-server';
const mockedGetResolvedHomeZones = getResolvedHomeZones as jest.MockedFunction<typeof getResolvedHomeZones>;
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
const createMockItem = (modelCode: string, data: Record<string, unknown>) => ({
id: `${modelCode}-id`,
modelId: `model-${modelCode}`,
modelCode,
title: String(data.title || 'Mock'),
slug: String(data.slug || `${modelCode}-slug`),
locale: 'zh-CN',
status: 'published' as const,
data,
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
describe('HomePage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetResolvedHomeZones.mockResolvedValue({});
mockedGetPublishedItems.mockResolvedValue([]);
});
it('renders home content from resolved zones', async () => {
mockedGetResolvedHomeZones.mockResolvedValue({
hero: [createMockItem('hero-banner', { heading: 'Zone Hero', ctaLabel: '预约' })],
stats: [
createMockItem('stat-item', { value: '100+', label: '客户' }),
createMockItem('stat-item', { value: '95%', label: '满意度' }),
],
services: [createMockItem('service', { id: 'consulting', title: '咨询' })],
cases: [createMockItem('case-study', { id: 'case-1', title: '案例一' })],
});
const page = await HomePage();
render(page);
expect(screen.getByTestId('home-content')).toBeInTheDocument();
expect(screen.getByTestId('services-count')).toHaveTextContent('1');
expect(screen.getByTestId('cases-count')).toHaveTextContent('1');
expect(screen.getByTestId('stats-count')).toHaveTextContent('2');
expect(screen.getByTestId('hero-data')).toHaveTextContent('Zone Hero');
});
it('falls back to direct model queries when no zones exist', async () => {
mockedGetResolvedHomeZones.mockResolvedValue({});
mockedGetPublishedItems.mockImplementation((modelCode: string) => {
if (modelCode === 'service') {
return Promise.resolve([createMockItem('service', { id: 'consulting', title: '咨询' })]);
}
if (modelCode === 'case-study') {
return Promise.resolve([createMockItem('case-study', { id: 'case-1', title: '案例一' })]);
}
if (modelCode === 'stat-item') {
return Promise.resolve([createMockItem('stat-item', { value: '10+', label: '年' })]);
}
if (modelCode === 'hero-banner') {
return Promise.resolve([createMockItem('hero-banner', { heading: 'Direct Hero' })]);
}
return Promise.resolve([]);
});
const page = await HomePage();
render(page);
expect(screen.getByTestId('services-count')).toHaveTextContent('1');
expect(screen.getByTestId('hero-data')).toHaveTextContent('Direct Hero');
});
it('handles empty zones gracefully', async () => {
mockedGetResolvedHomeZones.mockResolvedValue({
hero: [],
stats: [],
services: [],
cases: [],
});
const page = await HomePage();
render(page);
expect(screen.getByTestId('home-content')).toBeInTheDocument();
expect(screen.getByTestId('services-count')).toHaveTextContent('0');
expect(screen.getByTestId('hero-data')).toHaveTextContent('null');
});
});
+31 -14
View File
@@ -1,18 +1,35 @@
import { getPublishedItems } from '@/lib/cms/data-server';
import HomeContentV13 from './home-content-v13';
import { getPublishedItems, getResolvedHomeZones } from '@/lib/cms/data-server';
import HomeContentV14 from './home-content-v14';
export const revalidate = 3600;
export default async function HomePage() {
const [serviceItems, caseItems, statItems, heroItems] = await Promise.all([
getPublishedItems('service'),
getPublishedItems('case-study'),
getPublishedItems('stat-item'),
getPublishedItems('hero-banner'),
]);
const zones = await getResolvedHomeZones();
const hasZones = Object.keys(zones).length > 0;
const services = serviceItems.map((item) => item.data);
const cases = caseItems.map((item) => item.data);
const stats = statItems.map((item) => item.data);
const heroData = heroItems[0]?.data ?? null;
let services: Record<string, unknown>[] = [];
let cases: Record<string, unknown>[] = [];
let stats: Record<string, unknown>[] = [];
let heroData: Record<string, unknown> | null = null;
return <HomeContentV13 services={services} cases={cases} stats={stats} heroData={heroData} />;
}
if (hasZones) {
services = zones.services?.map((item) => item.data) ?? [];
cases = zones.cases?.map((item) => item.data) ?? [];
stats = zones.stats?.map((item) => item.data) ?? [];
heroData = zones.hero?.[0]?.data ?? null;
} else {
const [serviceItems, caseItems, statItems, heroItems] = await Promise.all([
getPublishedItems('service'),
getPublishedItems('case-study'),
getPublishedItems('stat-item'),
getPublishedItems('hero-banner'),
]);
services = serviceItems.map((item) => item.data);
cases = caseItems.map((item) => item.data);
stats = statItems.map((item) => item.data);
heroData = heroItems[0]?.data ?? null;
}
return <HomeContentV14 services={services} cases={cases} stats={stats} heroData={heroData} />;
}
@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import ProductDetailPage from './page';
import ProductDetailPage, { generateMetadata, generateStaticParams } from './page';
jest.mock('next/navigation', () => ({
notFound: jest.fn(),
@@ -127,7 +127,7 @@ describe('ProductDetailPage', () => {
const page = await ProductDetailPage({ params: Promise.resolve({ id: 'test-product' }) });
render(page);
const features = screen.getByText('核心功能');
const features = screen.getByRole('heading', { name: '核心功能' });
expect(features).toBeInTheDocument();
});
@@ -135,7 +135,7 @@ describe('ProductDetailPage', () => {
const page = await ProductDetailPage({ params: Promise.resolve({ id: 'test-product' }) });
render(page);
const benefits = screen.getByText('产品价值');
const benefits = screen.getByRole('heading', { name: '产品价值' });
expect(benefits).toBeInTheDocument();
});
@@ -171,4 +171,35 @@ describe('ProductDetailPage', () => {
expect(h2s.length).toBeGreaterThan(0);
});
});
describe('generateStaticParams', () => {
it('returns product slugs from CMS', async () => {
const params = await generateStaticParams();
expect(params).toEqual([{ id: 'test-product' }]);
});
});
describe('generateMetadata', () => {
it('returns product title and description as metadata', async () => {
const metadata = await generateMetadata({ params: Promise.resolve({ id: 'test-product' }) });
expect(metadata.title).toContain('测试产品');
expect(metadata.description).toContain('这是测试产品描述');
});
it('returns fallback metadata when product is not found', async () => {
const metadata = await generateMetadata({ params: Promise.resolve({ id: 'missing' }) });
expect(metadata.title).toBe('产品未找到');
});
});
describe('NotFound', () => {
it('calls notFound when product item is not found', async () => {
await expect(
ProductDetailPage({ params: Promise.resolve({ id: 'missing' }) }),
).rejects.toThrow();
});
});
});
@@ -5,6 +5,8 @@ import { getPublishedItemBySlug, getAllPublishedSlugs } from '@/lib/cms/data-ser
import type { Product } from '@/lib/constants/products';
import ProductDetailContentV3 from '../product-detail-content-v3';
export const revalidate = 3600;
export async function generateStaticParams() {
const slugs = await getAllPublishedSlugs('product');
return slugs.map((s) => ({ id: s.slug }));
+172
View File
@@ -0,0 +1,172 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import ProductsPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
}));
jest.mock('./products-content-v3', () => ({
__esModule: true,
default: function MockProductsContentV3({ products }: { products: Array<{ id: string; title: string }> }) {
return (
<div data-testid="products-content">
{products.map((item) => (
<div key={item.id} data-testid="product-item">{item.title}</div>
))}
</div>
);
},
}));
import { getPublishedItems } from '@/lib/cms/data-server';
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
const createMockProduct = (topOverrides: Record<string, unknown> = {}, dataOverrides: Record<string, unknown> = {}) => ({
id: 'item-1',
modelId: 'model-product',
modelCode: 'product',
title: '测试产品',
slug: 'test-product',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-product',
slug: 'test-product',
title: '测试产品',
description: '测试产品描述',
image: '/placeholder.png',
category: '企业套装',
categoryId: 'enterprise',
bundle: 'enterprise',
status: '研发中',
overview: '测试产品概述',
features: ['功能1'],
benefits: ['优势1'],
process: ['步骤1'],
specs: ['规格1'],
tags: ['标签1'],
heroThemeId: 'default',
caseStudies: [],
dataProofs: [],
certifications: [],
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
const createMockStandaloneProduct = (topOverrides: Record<string, unknown> = {}, dataOverrides: Record<string, unknown> = {}) => ({
id: 'item-standalone-1',
modelId: 'model-standalone-product',
modelCode: 'standalone-product',
title: '测试独立产品',
slug: 'test-standalone',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-standalone',
slug: 'test-standalone',
title: '测试独立产品',
description: '测试独立产品描述',
image: '/placeholder.png',
category: '专业工具系列',
status: '内测中',
overview: '测试独立产品概述',
features: ['能力1'],
benefits: ['价值1'],
process: ['步骤1'],
specs: ['规格1'],
tags: ['标签1'],
heroThemeId: 'default',
caseStudies: [],
dataProofs: [],
certifications: [],
technicalParameters: [],
complianceCertifications: [],
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
describe('ProductsPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItems.mockImplementation((modelCode: string) => {
if (modelCode === 'standalone-product') {
return Promise.resolve([]);
}
return Promise.resolve([]);
});
});
it('renders empty state when no CMS product items exist', async () => {
const page = await ProductsPage();
render(page);
expect(screen.getByTestId('products-content')).toBeInTheDocument();
expect(screen.queryByTestId('product-item')).not.toBeInTheDocument();
});
it('maps CMS items to Product shape', async () => {
mockedGetPublishedItems.mockImplementation((modelCode: string) => {
if (modelCode === 'product') {
return Promise.resolve([createMockProduct()] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
}
return Promise.resolve([]);
});
const page = await ProductsPage();
render(page);
expect(screen.getByText('测试产品')).toBeInTheDocument();
});
it('falls back to item.title when data.title is missing', async () => {
mockedGetPublishedItems.mockImplementation((modelCode: string) => {
if (modelCode === 'product') {
return Promise.resolve([
createMockProduct(
{ title: 'Fallback Title' },
{ title: undefined },
),
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
}
return Promise.resolve([]);
});
const page = await ProductsPage();
render(page);
expect(screen.getByText('Fallback Title')).toBeInTheDocument();
});
it('merges standalone-product items with categoryId=specialized and bundle=standalone', async () => {
mockedGetPublishedItems.mockImplementation((modelCode: string) => {
if (modelCode === 'product') {
return Promise.resolve([createMockProduct()] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
}
if (modelCode === 'standalone-product') {
return Promise.resolve([createMockStandaloneProduct()] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
}
return Promise.resolve([]);
});
const page = await ProductsPage();
render(page);
expect(screen.getByText('测试产品')).toBeInTheDocument();
expect(screen.getByText('测试独立产品')).toBeInTheDocument();
});
});
+16 -3
View File
@@ -9,8 +9,21 @@ export const metadata: Metadata = {
description: `覆盖企业数字化全场景的产品矩阵,从数据智能到业务协同,每一款产品都经过实战验证。`,
};
export const revalidate = 3600;
export default async function ProductsPage() {
const items = await getPublishedItems('product');
const products = items.map((item) => ({ ...item.data, title: item.data.title || item.title } as unknown as Product));
return <ProductsContentV3 products={products} />;
const [items, standaloneItems] = await Promise.all([
getPublishedItems('product'),
getPublishedItems('standalone-product'),
]);
const products = items.map((item) => ({ ...item.data, id: item.data.id || item.id, slug: item.data.slug || item.slug, title: item.data.title || item.title } as unknown as Product));
const standaloneProducts = standaloneItems.map((item) => ({
...item.data,
id: item.data.id || item.id,
slug: item.data.slug || item.slug,
title: item.data.title || item.title,
categoryId: 'specialized',
bundle: 'standalone',
} as unknown as Product));
return <ProductsContentV3 products={[...products, ...standaloneProducts]} />;
}
@@ -6,9 +6,9 @@ import { DetailTrustSection } from '@/components/detail/detail-trust-section';
import { DetailCTASection } from '@/components/detail/detail-cta-section';
import { DetailSwipeNav } from '@/components/ui/detail-swipe-nav';
import { motion } from 'framer-motion';
import { CheckCircle2, Shield, Lock, Cpu, HardDrive, ArrowRight } from 'lucide-react';
import { CheckCircle2, Shield, Lock, Cpu, HardDrive, ArrowRight, FileCheck, Gauge } from 'lucide-react';
import type { ContentItem } from '@/lib/cms/types';
import type { CaseStudy, DataProof, Certification } from '@/lib/constants/products';
import type { CaseStudy, DataProof, Certification, ComplianceCertification, TechnicalParameter } from '@/lib/constants/products';
interface StandaloneProductClientProps {
item: ContentItem;
@@ -39,6 +39,8 @@ export function StandaloneProductClient({ item }: StandaloneProductClientProps)
const caseStudies = (data.caseStudies ?? []) as CaseStudy[];
const dataProofs = (data.dataProofs ?? []) as DataProof[];
const certifications = (data.certifications ?? []) as Certification[];
const technicalParameters = (data.technicalParameters ?? []) as TechnicalParameter[];
const complianceCertifications = (data.complianceCertifications ?? []) as ComplianceCertification[];
const theme = getHeroTheme(heroThemeId);
const Icon = categoryIcons[category] || Shield;
@@ -163,6 +165,104 @@ export function StandaloneProductClient({ item }: StandaloneProductClientProps)
</div>
</section>
{technicalParameters.length > 0 && (
<section className="bg-bg-secondary py-16 lg:py-20">
<div className="container-wide">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="mb-10"
>
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-soft text-brand">
<Gauge className="h-5 w-5" />
</div>
<h2 className="text-2xl font-bold text-ink"></h2>
</div>
<p className="text-text-secondary max-w-2xl">
</p>
</motion.div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{technicalParameters.map((param, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: index * 0.05 }}
className="rounded-lg border border-border-primary bg-white p-5"
>
<div className="mb-2 text-sm text-text-muted">{param.name}</div>
<div className="mb-2 text-2xl font-bold text-brand">{param.value}</div>
{param.description && (
<div className="text-sm leading-relaxed text-text-secondary">{param.description}</div>
)}
</motion.div>
))}
</div>
</div>
</section>
)}
{complianceCertifications.length > 0 && (
<section className="bg-white py-16 lg:py-20">
<div className="container-wide">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="mb-10"
>
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-soft text-brand">
<FileCheck className="h-5 w-5" />
</div>
<h2 className="text-2xl font-bold text-ink"></h2>
</div>
<p className="text-text-secondary max-w-2xl">
</p>
</motion.div>
<div className="space-y-4">
{complianceCertifications.map((cert, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: index * 0.05 }}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-lg border border-border-primary bg-bg-secondary p-5"
>
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-white text-brand">
<FileCheck className="h-5 w-5" />
</div>
<div>
<h3 className="font-semibold text-ink">{cert.name}</h3>
<div className="text-sm text-text-secondary">{cert.issuer}</div>
{cert.standard && (
<div className="text-sm text-text-muted">{cert.standard}</div>
)}
</div>
</div>
{cert.validUntil && (
<div className="text-sm text-text-muted sm:text-right">
{cert.validUntil}
</div>
)}
</motion.div>
))}
</div>
</div>
</section>
)}
<DetailTrustSection
caseStudies={caseStudies}
dataProofs={dataProofs}
@@ -0,0 +1,151 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import StandaloneProductPage, { generateMetadata, generateStaticParams } from './page';
jest.mock('next/navigation', () => ({
notFound: jest.fn(() => {
throw new Error('notFound called');
}),
useRouter: jest.fn(() => ({
push: jest.fn(),
back: jest.fn(),
})),
}));
jest.mock('next/link', () => {
const MockLink = ({ children, href }: { children: React.ReactNode; href: string }) => {
return <a href={href}>{children}</a>;
};
MockLink.displayName = 'MockLink';
return MockLink;
});
jest.mock('@/lib/cms/data-server', () => {
const testStandaloneProduct = {
id: 'test-standalone',
title: '测试独立产品',
category: '专业工具系列',
status: '内测中',
description: '这是测试独立产品描述',
overview: '这是测试独立产品概述',
image: '/placeholder.png',
heroThemeId: 'default',
features: ['能力1', '能力2'],
benefits: ['价值1', '价值2'],
process: ['步骤1', '步骤2'],
specs: ['规格1', '规格2'],
caseStudies: [],
dataProofs: [],
certifications: [],
technicalParameters: [
{ name: '参数1', value: '100万+', description: '参数说明' },
],
complianceCertifications: [
{ name: '等保三级', issuer: '公安部', standard: 'GB/T 22239', validUntil: '2027-12' },
],
};
const testItem = {
id: 'test-standalone',
slug: 'test-standalone',
title: '测试独立产品',
data: testStandaloneProduct,
};
return {
getPublishedItems: jest.fn(() => Promise.resolve([testItem])),
getPublishedItemBySlug: jest.fn((_modelCode: string, slug: string) =>
Promise.resolve(slug === 'test-standalone' ? testItem : null),
),
getAllPublishedSlugs: jest.fn(() => Promise.resolve([{ slug: 'test-standalone' }])),
};
});
jest.mock('@/lib/constants', () => ({
COMPANY_INFO: {
displayName: '睿新致远',
name: '四川睿新致远科技有限公司',
},
PRODUCTS: [
{ id: 'erp', title: '睿新ERP管理系统' },
{ id: 'crm', title: '睿新CRM管理系统' },
],
SERVICES: [],
}));
describe('StandaloneProductPage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render standalone product detail page', async () => {
const page = await StandaloneProductPage({ params: Promise.resolve({ id: 'test-standalone' }) });
render(page);
const titles = screen.getAllByText('测试独立产品');
expect(titles.length).toBeGreaterThan(0);
});
it('should render product title as h1', async () => {
const page = await StandaloneProductPage({ params: Promise.resolve({ id: 'test-standalone' }) });
render(page);
const title = screen.getByRole('heading', { level: 1 });
expect(title).toBeInTheDocument();
expect(title).toHaveTextContent('测试独立产品');
});
it('should render product description', async () => {
const page = await StandaloneProductPage({ params: Promise.resolve({ id: 'test-standalone' }) });
render(page);
expect(screen.getByText('这是测试独立产品描述')).toBeInTheDocument();
});
it('should render technical parameters section', async () => {
const page = await StandaloneProductPage({ params: Promise.resolve({ id: 'test-standalone' }) });
render(page);
expect(screen.getByRole('heading', { name: '技术参数' })).toBeInTheDocument();
expect(screen.getByText('100万+')).toBeInTheDocument();
});
it('should render compliance certifications section', async () => {
const page = await StandaloneProductPage({ params: Promise.resolve({ id: 'test-standalone' }) });
render(page);
expect(screen.getByRole('heading', { name: '合规认证' })).toBeInTheDocument();
expect(screen.getByText('等保三级')).toBeInTheDocument();
});
});
describe('generateStaticParams', () => {
it('returns standalone product slugs from CMS', async () => {
const params = await generateStaticParams();
expect(params).toEqual([{ id: 'test-standalone' }]);
});
});
describe('generateMetadata', () => {
it('returns standalone product title and description as metadata', async () => {
const metadata = await generateMetadata({ params: Promise.resolve({ id: 'test-standalone' }) });
expect(metadata.title).toContain('测试独立产品');
expect(metadata.description).toContain('这是测试独立产品描述');
});
it('returns fallback metadata when standalone product is not found', async () => {
const metadata = await generateMetadata({ params: Promise.resolve({ id: 'missing' }) });
expect(metadata.title).toBe('产品未找到');
});
});
describe('NotFound', () => {
it('calls notFound when standalone product item is not found', async () => {
await expect(
StandaloneProductPage({ params: Promise.resolve({ id: 'missing' }) }),
).rejects.toThrow();
});
});
});
@@ -1,18 +1,19 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getPublishedItemBySlug, getPublishedItems } from '@/lib/cms/data-server';
import { COMPANY_INFO } from '@/lib/constants';
import { StandaloneProductClient } from './client';
export const revalidate = 3600;
export async function generateStaticParams() {
const items = await getPublishedItems('product');
return items
.filter((item) => item.data.categoryId === 'specialized')
.map((item) => ({ id: item.slug! }));
const items = await getPublishedItems('standalone-product');
return items.map((item) => ({ id: item.slug! }));
}
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const item = await getPublishedItemBySlug('product', id);
const item = await getPublishedItemBySlug('standalone-product', id);
if (!item) {
return {
@@ -28,17 +29,10 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
export default async function StandaloneProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const item = await getPublishedItemBySlug('product', id);
const item = await getPublishedItemBySlug('standalone-product', id);
if (!item || item.data.categoryId !== 'specialized') {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--color-bg-primary)]">
<div className="text-center">
<h2 className="text-2xl font-bold text-[var(--color-text-primary)] mb-4"></h2>
<p className="text-[var(--color-text-muted)]"></p>
</div>
</div>
);
if (!item) {
notFound();
}
return <StandaloneProductClient item={JSON.parse(JSON.stringify(item))} />;
@@ -0,0 +1,121 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import ServiceDetailPage, { generateMetadata, generateStaticParams } from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItemBySlug: jest.fn(),
getAllPublishedSlugs: jest.fn(),
}));
jest.mock('./client', () => ({
ServiceDetailClient: function MockServiceDetailClient({ service }: { service: { title: string } }) {
return (
<div data-testid="service-detail">
<h1>{service.title}</h1>
</div>
);
},
}));
import { getPublishedItemBySlug, getAllPublishedSlugs } from '@/lib/cms/data-server';
const mockedGetPublishedItemBySlug = getPublishedItemBySlug as jest.MockedFunction<typeof getPublishedItemBySlug>;
const mockedGetAllPublishedSlugs = getAllPublishedSlugs as jest.MockedFunction<typeof getAllPublishedSlugs>;
const createMockService = (
topOverrides: Record<string, unknown> = {},
dataOverrides: Record<string, unknown> = {},
) => ({
id: 'item-1',
modelId: 'model-service',
modelCode: 'service',
title: '测试服务',
slug: 'test-service',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-service',
slug: 'test-service',
title: '测试服务',
description: '测试服务描述',
icon: 'Lightbulb',
overview: '测试服务概述',
features: ['能力一:描述一'],
benefits: ['价值一'],
capabilities: ['能力一'],
process: ['步骤一:描述一'],
heroThemeId: 'service',
caseStudies: [],
dataProofs: [],
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
describe('ServiceDetailPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItemBySlug.mockResolvedValue(null);
mockedGetAllPublishedSlugs.mockResolvedValue([]);
});
it('returns 404 when service item is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
await expect(
ServiceDetailPage({ params: Promise.resolve({ id: 'missing' }) }),
).rejects.toThrow();
});
it('renders service detail with data from CMS', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(
createMockService() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>,
);
const page = await ServiceDetailPage({ params: Promise.resolve({ id: 'test-service' }) });
render(page);
expect(screen.getByTestId('service-detail')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('测试服务');
});
it('generateStaticParams returns slugs from CMS', async () => {
mockedGetAllPublishedSlugs.mockResolvedValue([
{ slug: 'consulting' },
{ slug: 'software' },
] as unknown as Awaited<ReturnType<typeof getAllPublishedSlugs>>);
const params = await generateStaticParams();
expect(params).toEqual([
{ id: 'consulting' },
{ id: 'software' },
]);
});
describe('generateMetadata', () => {
it('returns service title and description as metadata', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(
createMockService() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>,
);
const metadata = await generateMetadata({ params: Promise.resolve({ id: 'test-service' }) });
expect(metadata.title).toContain('测试服务');
expect(metadata.description).toContain('测试服务描述');
});
it('returns fallback metadata when service is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
const metadata = await generateMetadata({ params: Promise.resolve({ id: 'missing' }) });
expect(metadata.title).toBe('服务未找到');
});
});
});
@@ -5,6 +5,9 @@ import { getPublishedItemBySlug, getAllPublishedSlugs } from '@/lib/cms/data-ser
import type { Service } from '@/lib/constants/services';
import { ServiceDetailClient } from './client';
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
export const revalidate = 3600;
export async function generateStaticParams() {
const slugs = await getAllPublishedSlugs('service');
return slugs.map((s) => ({ id: s.slug }));
@@ -0,0 +1,96 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import ServicesPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
}));
jest.mock('./services-content-v3', () => ({
__esModule: true,
default: function MockServicesContentV3({ services }: { services: Array<{ id: string; title: string }> }) {
return (
<div data-testid="services-content">
{services.map((item) => (
<div key={item.id} data-testid="service-item">{item.title}</div>
))}
</div>
);
},
}));
import { getPublishedItems } from '@/lib/cms/data-server';
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
const createMockService = (topOverrides: Record<string, unknown> = {}, dataOverrides: Record<string, unknown> = {}) => ({
id: 'item-1',
modelId: 'model-service',
modelCode: 'service',
title: '测试服务',
slug: 'test-service',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-service',
slug: 'test-service',
title: '测试服务',
description: '测试服务描述',
icon: 'Lightbulb',
overview: '测试服务概述',
features: ['能力一:描述一'],
benefits: ['价值一'],
capabilities: ['能力一'],
process: ['步骤一:描述一'],
heroThemeId: 'service',
caseStudies: [],
dataProofs: [],
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
describe('ServicesPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItems.mockResolvedValue([]);
});
it('renders empty state when no CMS service items exist', async () => {
const page = await ServicesPage();
render(page);
expect(screen.getByTestId('services-content')).toBeInTheDocument();
expect(screen.queryByTestId('service-item')).not.toBeInTheDocument();
});
it('maps CMS items to Service shape', async () => {
mockedGetPublishedItems.mockResolvedValue([
createMockService(),
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await ServicesPage();
render(page);
expect(screen.getByText('测试服务')).toBeInTheDocument();
});
it('falls back to item.title when data.title is missing', async () => {
mockedGetPublishedItems.mockResolvedValue([
createMockService(
{ title: 'Fallback Title' },
{ title: undefined },
),
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await ServicesPage();
render(page);
expect(screen.getByText('Fallback Title')).toBeInTheDocument();
});
});
+4 -1
View File
@@ -9,8 +9,11 @@ export const metadata: Metadata = {
description: `专业技术团队,为您提供全方位的数字化解决方案。${COMPANY_INFO.displayName}涵盖软件开发、数据分析、咨询服务等核心业务。`,
};
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
export const revalidate = 3600;
export default async function ServicesPage() {
const items = await getPublishedItems('service');
const services = items.map((item) => ({ ...item.data, title: item.data.title || item.title } as unknown as Service));
const services = items.map((item) => ({ ...item.data, id: item.data.id || item.id, slug: item.data.slug || item.slug, title: item.data.title || item.title } as unknown as Service));
return <ServicesContentV3 services={services} />;
}
@@ -4,7 +4,7 @@ import { motion } from 'framer-motion';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { Button } from '@/components/ui/button';
import { ArrowRight, CheckCircle2, Zap, Clock, Users, Award, TrendingUp, BarChart3, Code, Lightbulb, Puzzle, Shield } from 'lucide-react';
import { GrainOverlay, FloatingInkParticles, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import { GrainOverlay, SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import type { Service, CaseStudy } from '@/lib/constants/services';
import { MethodologyFramework, TechStackShowcase, FAQSection, DataProofSection } from '@/components/content/sections';
@@ -17,15 +17,6 @@ const ICON_MAP: Record<string, React.ReactNode> = {
const FEATURE_ICONS = [Zap, Clock, Users, Award, TrendingUp];
const SERVICE_DETAIL_PARTICLES = [
{ x: 12, y: 35, scale: 0.8, duration: 10, delay: 0, size: 3 },
{ x: 85, y: 55, scale: 0.6, duration: 12, delay: 1.5, size: 2 },
{ x: 48, y: 45, scale: 1.0, duration: 9, delay: 0.8, size: 4 },
{ x: 70, y: 28, scale: 0.7, duration: 11, delay: 2.2, size: 2.5 },
{ x: 30, y: 78, scale: 0.9, duration: 8.5, delay: 1.0, size: 3.5 },
{ x: 65, y: 68, scale: 0.55, duration: 13, delay: 2.8, size: 2 },
];
interface DetailHeroProps {
service: Service;
}
@@ -34,7 +25,6 @@ function DetailHero({ service }: DetailHeroProps) {
return (
<section className="relative min-h-[80vh] flex items-center overflow-hidden bg-ink pt-24">
<GrainOverlay />
<FloatingInkParticles particles={SERVICE_DETAIL_PARTICLES} />
<div className="absolute inset-0 pointer-events-none">
<div
@@ -457,7 +447,6 @@ function CTASection({ service }: CTASectionProps) {
return (
<section className="relative py-36 lg:py-44 overflow-hidden bg-ink">
<GrainOverlay />
<FloatingInkParticles particles={SERVICE_DETAIL_PARTICLES} />
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-brand/30 to-transparent" />
@@ -2,7 +2,6 @@
import { motion } from 'framer-motion';
import { ArrowUpRight, CheckCircle2 } from 'lucide-react';
import { ScrollProgress } from '@/components/ui/scroll-progress';
import type { Service, CaseStudy } from '@/lib/constants/services';
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
@@ -359,7 +358,6 @@ function CTASection() {
export default function ServiceDetailContentV4({ service }: { service: Service }) {
return (
<main className="min-h-screen bg-white text-ink">
<ScrollProgress />
<DetailHero service={service} />
<OverviewSection service={service} />
<FeaturesSection service={service} />
@@ -6,7 +6,7 @@ import type { Service } from '@/lib/constants/services';
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
/** UI metadata keyed by service ID — presentation-only fields not stored in CMS */
/** Fallback metrics keyed by service ID — used when CMS data does not provide metrics */
const SERVICE_UI_META: Record<string, { metrics: { value: string; label: string }[] }> = {
consulting: { metrics: [{ value: '90%+', label: '方案落地率' }, { value: '50+', label: '咨询项目' }] },
software: { metrics: [{ value: '95%+', label: '准时交付率' }, { value: 'A+', label: '代码质量' }] },
@@ -22,7 +22,7 @@ function mapServicesToCards(services: Service[]) {
title: svc.title,
description: svc.description,
capabilities: svc.capabilities?.slice(0, 4) ?? [],
metrics: uiMeta?.metrics ?? [],
metrics: svc.metrics ?? uiMeta?.metrics ?? [],
};
});
}
@@ -0,0 +1,137 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import SolutionDetailPage, { generateMetadata, generateStaticParams } from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItemBySlug: jest.fn(),
getAllPublishedSlugs: jest.fn(),
getPublishedItems: jest.fn(),
}));
jest.mock('./client', () => ({
SolutionDetailClient: function MockSolutionDetailClient({ solution }: { solution: { title: string } }) {
return (
<div data-testid="solution-detail">
<h1>{solution.title}</h1>
</div>
);
},
}));
import { getPublishedItemBySlug, getAllPublishedSlugs, getPublishedItems } from '@/lib/cms/data-server';
const mockedGetPublishedItemBySlug = getPublishedItemBySlug as jest.MockedFunction<typeof getPublishedItemBySlug>;
const mockedGetAllPublishedSlugs = getAllPublishedSlugs as jest.MockedFunction<typeof getAllPublishedSlugs>;
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
const createMockSolution = (
topOverrides: Record<string, unknown> = {},
dataOverrides: Record<string, unknown> = {},
) => ({
id: 'item-1',
modelId: 'model-solution',
modelCode: 'solution',
title: '测试方案',
slug: 'test-solution',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-solution',
slug: 'test-solution',
title: '测试方案',
subtitle: '测试副标题',
industry: '制造业',
description: '测试方案描述',
icon: 'Factory',
overview: '测试方案概述',
challenges: ['痛点一', '痛点二'],
solutions: ['方案一', '方案二'],
relatedProducts: ['erp', 'bi'],
heroThemeId: 'solution',
valueProposition: {
headline: '价值主张',
points: [
{ icon: 'Factory', title: '价值一', description: '描述一' },
],
},
suiteCombination: {
primaryProducts: ['erp'],
complementaryServices: ['consulting'],
rationale: 'rationale',
},
painPoints: ['痛点一'],
outcomes: [{ value: '30%', label: '效率提升' }],
recommendedProducts: ['erp', 'bi'],
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
describe('SolutionDetailPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItemBySlug.mockResolvedValue(null);
mockedGetAllPublishedSlugs.mockResolvedValue([]);
mockedGetPublishedItems.mockResolvedValue([]);
});
it('returns 404 when solution item is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
await expect(
SolutionDetailPage({ params: Promise.resolve({ id: 'missing' }) }),
).rejects.toThrow();
});
it('renders solution detail with data from CMS', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(
createMockSolution() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>,
);
const page = await SolutionDetailPage({ params: Promise.resolve({ id: 'test-solution' }) });
render(page);
expect(screen.getByTestId('solution-detail')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('测试方案');
});
it('generateStaticParams returns slugs from CMS', async () => {
mockedGetAllPublishedSlugs.mockResolvedValue([
{ slug: 'manufacturing' },
{ slug: 'retail' },
] as unknown as Awaited<ReturnType<typeof getAllPublishedSlugs>>);
const params = await generateStaticParams();
expect(params).toEqual([
{ id: 'manufacturing' },
{ id: 'retail' },
]);
});
describe('generateMetadata', () => {
it('returns solution title and description as metadata', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(
createMockSolution() as unknown as Awaited<ReturnType<typeof getPublishedItemBySlug>>,
);
const metadata = await generateMetadata({ params: Promise.resolve({ id: 'test-solution' }) });
expect(metadata.title).toContain('测试方案');
expect(metadata.description).toContain('测试方案描述');
});
it('returns fallback metadata when solution is not found', async () => {
mockedGetPublishedItemBySlug.mockResolvedValue(null);
const metadata = await generateMetadata({ params: Promise.resolve({ id: 'missing' }) });
expect(metadata.title).toBe('解决方案未找到');
});
});
});
@@ -6,6 +6,8 @@ import type { Solution } from '@/lib/constants/solutions';
import type { Product } from '@/lib/constants/products';
import { SolutionDetailClient } from './client';
export const revalidate = 3600;
export async function generateStaticParams() {
const slugs = await getAllPublishedSlugs('solution');
return slugs.map((s) => ({ id: s.slug }));
+109
View File
@@ -0,0 +1,109 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import SolutionsPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
}));
jest.mock('./solutions-content-v3', () => ({
__esModule: true,
default: function MockSolutionsContentV3({ solutions }: { solutions: Array<{ id: string; title: string }> }) {
return (
<div data-testid="solutions-content">
{solutions.map((item) => (
<div key={item.id} data-testid="solution-item">{item.title}</div>
))}
</div>
);
},
}));
import { getPublishedItems } from '@/lib/cms/data-server';
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
const createMockSolution = (topOverrides: Record<string, unknown> = {}, dataOverrides: Record<string, unknown> = {}) => ({
id: 'item-1',
modelId: 'model-solution',
modelCode: 'solution',
title: '测试方案',
slug: 'test-solution',
locale: 'zh-CN',
status: 'published',
data: {
id: 'test-solution',
slug: 'test-solution',
title: '测试方案',
subtitle: '测试副标题',
industry: '制造业',
description: '测试方案描述',
icon: 'Factory',
overview: '测试方案概述',
challenges: ['痛点一', '痛点二'],
solutions: ['方案一', '方案二'],
relatedProducts: ['erp', 'bi'],
heroThemeId: 'solution',
valueProposition: {
headline: '价值主张',
points: [
{ icon: 'Factory', title: '价值一', description: '描述一' },
],
},
suiteCombination: {
primaryProducts: ['erp'],
complementaryServices: ['consulting'],
rationale: 'rationale',
},
painPoints: ['痛点一'],
outcomes: [{ value: '30%', label: '效率提升' }],
recommendedProducts: ['erp', 'bi'],
...dataOverrides,
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...topOverrides,
});
describe('SolutionsPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItems.mockResolvedValue([]);
});
it('renders empty state when no CMS solution items exist', async () => {
const page = await SolutionsPage();
render(page);
expect(screen.getByTestId('solutions-content')).toBeInTheDocument();
expect(screen.queryByTestId('solution-item')).not.toBeInTheDocument();
});
it('maps CMS items to Solution shape', async () => {
mockedGetPublishedItems.mockResolvedValue([
createMockSolution(),
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await SolutionsPage();
render(page);
expect(screen.getByText('测试方案')).toBeInTheDocument();
});
it('falls back to item.title when data.title is missing', async () => {
mockedGetPublishedItems.mockResolvedValue([
createMockSolution(
{ title: 'Fallback Title' },
{ title: undefined },
),
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await SolutionsPage();
render(page);
expect(screen.getByText('Fallback Title')).toBeInTheDocument();
});
});
+4 -2
View File
@@ -10,12 +10,14 @@ export const metadata: Metadata = {
description: `深耕行业场景的解决方案,端到端交付,推荐套装组合 + 配套服务包。基于自研产品矩阵,为制造业、贸易零售、教育培训、医疗健康、金融服务、物流运输六大行业量身定制最佳实践。`,
};
export const revalidate = 3600;
export default async function SolutionsPage() {
const [solutionItems, productItems] = await Promise.all([
getPublishedItems('solution'),
getPublishedItems('product'),
]);
const solutions = solutionItems.map((item) => ({ ...item.data, title: item.data.title || item.title } as unknown as Solution));
const products = productItems.map((item) => ({ ...item.data, title: item.data.title || item.title } as unknown as Product));
const solutions = solutionItems.map((item) => ({ ...item.data, id: item.data.id || item.id, slug: item.data.slug || item.slug, title: item.data.title || item.title } as unknown as Solution));
const products = productItems.map((item) => ({ ...item.data, id: item.data.id || item.id, slug: item.data.slug || item.slug, title: item.data.title || item.title } as unknown as Product));
return <SolutionsContentV3 solutions={solutions} products={products} />;
}
+68
View File
@@ -0,0 +1,68 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import TeamPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
}));
jest.mock('./client', () => ({
TeamClient: function MockTeamClient({ data }: { data: Record<string, unknown> }) {
return (
<div data-testid="team-client">
<div data-testid="hero-title">{String(data.heroTitle || '')}</div>
<div data-testid="has-data">{Object.keys(data).length > 0 ? 'true' : 'false'}</div>
</div>
);
},
}));
import { getPublishedItems } from '@/lib/cms/data-server';
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
describe('TeamPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItems.mockResolvedValue([]);
});
it('passes empty object when no CMS team-page item exists', async () => {
const page = await TeamPage();
render(page);
expect(screen.getByTestId('team-client')).toBeInTheDocument();
expect(screen.getByTestId('has-data')).toHaveTextContent('false');
});
it('passes first CMS team-page data to TeamClient', async () => {
mockedGetPublishedItems.mockResolvedValue([
{
id: 'item-team',
modelId: 'model-team-page',
modelCode: 'team-page',
title: '团队介绍',
slug: 'default',
locale: 'zh-CN',
status: 'published',
data: {
heroSubtitle: '核心团队',
heroTitle: 'CMS 团队标题',
heroDescription: 'CMS 团队描述',
stats: [{ value: 12, suffix: '+', label: '年行业经验' }],
},
version: 1,
sortOrder: 0,
createdBy: 'system',
updatedBy: 'system',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
] as unknown as Awaited<ReturnType<typeof getPublishedItems>>);
const page = await TeamPage();
render(page);
expect(screen.getByTestId('hero-title')).toHaveTextContent('CMS 团队标题');
expect(screen.getByTestId('has-data')).toHaveTextContent('true');
});
});
+3
View File
@@ -8,6 +8,9 @@ export const metadata: Metadata = {
description: `了解${COMPANY_INFO.name}的核心团队。我们的团队成员拥有丰富的行业经验和技术专长,致力于为客户提供专业的数字化转型服务。`,
};
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
export const revalidate = 3600;
export default async function TeamPage() {
const items = await getPublishedItems('team-page');
const data = items[0]?.data ?? {};
+143 -147
View File
@@ -1,10 +1,9 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import { motion, useInView } from 'framer-motion';
import { motion } from 'framer-motion';
import { ScrollReveal } from '@/components/ui/scroll-reveal';
import { ArrowRight, Shield, Building2, Users, Code, Target, Layers, BookOpen, Sparkles, type LucideIcon } from 'lucide-react';
import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import { EASE_OUT } from '@/components/ui/page-decoration';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
/** Icon name-to-component mapping for CMS string-based icons */
@@ -33,12 +32,6 @@ interface CultureData {
iconName: string;
}
interface StatData {
value: number;
suffix: string;
label: string;
}
interface ParagraphData {
text: string;
}
@@ -53,7 +46,7 @@ interface TeamData {
heroSecondaryCtaHref?: string;
strengths?: StrengthData[];
culture?: CultureData[];
stats?: StatData[];
stats?: { value: number; suffix: string; label: string }[];
aboutTitle?: string;
aboutParagraphs?: ParagraphData[];
ctaTitle?: string;
@@ -64,58 +57,6 @@ interface TeamData {
ctaSecondaryHref?: string;
}
function useCountUp(target: number, start: boolean, duration: number = 2000) {
const [count, setCount] = useState(0);
const prefersReducedMotion = useReducedMotion();
useEffect(() => {
if (!start || prefersReducedMotion) {
setCount(target);
return;
}
const startTime = performance.now();
let animationId: number;
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
setCount(Math.floor(target * easeOutQuart));
if (progress < 1) {
animationId = requestAnimationFrame(animate);
} else {
setCount(target);
}
};
animationId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationId);
}, [target, start, duration, prefersReducedMotion]);
return count;
}
function StatItem({ value, suffix, label, start, delay }: { value: number; suffix: string; label: string; start: boolean; delay: number }) {
const count = useCountUp(value, start, 2000);
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={start ? { opacity: 1, y: 0 } : {}}
transition={{ delay, duration: 0.6, ease: EASE_OUT }}
className="text-center"
>
<div className="text-4xl sm:text-5xl font-bold text-ink tracking-tight leading-none mb-2">
{count}<span className="text-brand">{suffix}</span>
</div>
<div className="text-sm text-text-muted tracking-wide">{label}</div>
</motion.div>
);
}
function StrengthCard({ strength, index }: { strength: StrengthData; index: number }) {
const Icon = ICON_MAP[strength.iconName] ?? Shield;
const shouldReduceMotion = useReducedMotion();
@@ -183,8 +124,6 @@ function CultureCard({ culture, index }: { culture: CultureData; index: number }
export default function TeamContentV3({ data }: { data: Record<string, unknown> }) {
const teamData = data as TeamData;
const statsRef = useRef(null);
const isInView = useInView(statsRef, { once: true, margin: '-100px' });
const strengths = teamData.strengths ?? [];
const culture = teamData.culture ?? [];
@@ -202,81 +141,120 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
return (
<div className="min-h-screen bg-white text-ink overflow-x-hidden">
{/* Hero Section */}
{/* ============ L1: Hero 团队情感入口 ============ */}
<section className="relative min-h-[85vh] flex items-center overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-28 sm:py-36 md:py-44 lg:py-56">
<div className="max-w-4xl">
<ScrollReveal>
<SectionLabel>{teamData.heroSubtitle || ''}</SectionLabel>
</ScrollReveal>
{/* 眉标 Badge */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1, ease: EASE_OUT }}
className="mb-8 sm:mb-10"
>
<span className="inline-flex items-center gap-2 px-3 py-1.5 bg-brand/[0.08] border border-brand/20 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-brand" />
<span className="text-[13px] font-semibold text-brand tracking-[0.08em] uppercase">
{teamData.heroSubtitle || '核心团队'}
</span>
</span>
</motion.div>
<ScrollReveal delay={0.1}>
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-bold leading-[0.92] tracking-tighter mb-10 sm:mb-12 md:mb-16">
{(teamData.heroTitle || '').split('\n').map((line, i) => (
<span key={i}>
{i > 0 && <span className="block mt-4" />}
{i === 0 ? line : <span className="text-brand">{line}</span>}
</span>
{/* 主标题 — Bain 风格:大号加粗 + 品牌红关键词 */}
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black text-ink leading-[0.92] tracking-tightest mb-10 sm:mb-12"
>
{(teamData.heroTitle || '').split('\n').map((line, i) => (
<span key={i}>
{i > 0 && <span className="block mt-4 sm:mt-6" />}
{i === 0 ? line : <span className="text-brand">{line}</span>}
</span>
))}
</motion.h1>
{/* 数据指标条 — Bain 风格:答案优先,数据先行 */}
{stats.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.4, ease: EASE_OUT }}
className="flex flex-wrap gap-6 sm:gap-10 md:gap-14 mb-10 sm:mb-12"
>
{stats.map((stat, i) => (
<div key={i} className="flex items-baseline gap-2">
<span className="text-3xl sm:text-4xl md:text-5xl font-black text-brand tabular-nums leading-none">
{stat.value}{stat.suffix}
</span>
<span className="text-sm text-text-muted whitespace-nowrap">
{stat.label}
</span>
</div>
))}
</h1>
</ScrollReveal>
</motion.div>
)}
<ScrollReveal delay={0.2}>
<p className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12">
{teamData.heroDescription || ''}
</p>
</ScrollReveal>
{/* 描述 */}
{teamData.heroDescription && (
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.5, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary/70 max-w-2xl leading-relaxed mb-12 sm:mb-14"
>
{teamData.heroDescription}
</motion.p>
)}
<ScrollReveal delay={0.3}>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 mb-16">
<a
href={teamData.heroPrimaryCtaHref || '#'}
className="group inline-flex items-center justify-center gap-3 px-12 py-6 font-semibold text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90"
>
{teamData.heroPrimaryCtaLabel || ''}
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
{/* CTA 按钮组 */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.6, ease: EASE_OUT }}
className="flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6"
>
<a
href={teamData.heroPrimaryCtaHref || '/contact'}
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90 min-h-[52px]"
>
{teamData.heroPrimaryCtaLabel || '加入我们'}
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
{teamData.heroSecondaryCtaLabel && (
<a
href={teamData.heroSecondaryCtaHref || '#'}
className="inline-flex items-center justify-center gap-3 px-12 py-6 font-semibold text-base text-ink border border-border-primary hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300"
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300 min-h-[52px]"
>
{teamData.heroSecondaryCtaLabel || ''}
{teamData.heroSecondaryCtaLabel}
</a>
</div>
</ScrollReveal>
{stats.length > 0 && (
<div ref={statsRef} className="grid grid-cols-3 gap-6 sm:gap-8 md:gap-12 max-w-2xl">
{stats.map((stat, idx) => (
<StatItem
key={stat.label}
value={stat.value}
suffix={stat.suffix}
label={stat.label}
start={isInView}
delay={idx * 0.15}
/>
))}
</div>
)}
)}
</motion.div>
</div>
</div>
</section>
{/* Team Strengths Section */}
{/* ============ L2: 团队实力理性支撑 ============ */}
{strengths.length > 0 && (
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-bg-secondary">
<section className="relative py-24 sm:py-32 md:py-40 overflow-hidden bg-bg-secondary">
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-border-primary" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="text-center max-w-3xl mx-auto mb-16 sm:mb-20">
<SectionLabel className="justify-center">Team Strengths</SectionLabel>
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight leading-tight text-ink">
<ScrollReveal className="max-w-3xl mb-16 sm:mb-20">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.15em] font-medium text-brand">
</span>
</div>
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
12
<span className="text-brand">500+ </span>
</h2>
<p className="mt-6 text-lg text-text-secondary leading-relaxed">
<p className="text-text-secondary leading-relaxed mt-4 text-base sm:text-lg">
</p>
</ScrollReveal>
@@ -291,11 +269,20 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
{/* Team About Section */}
{(teamData.aboutTitle || aboutParagraphs.length > 0) && (
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-white">
<section className="relative py-24 sm:py-32 md:py-40 overflow-hidden bg-bg-secondary">
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal>
<div className="max-w-4xl mx-auto p-8 sm:p-10 lg:p-16 border border-border-primary bg-bg-secondary/50">
<h2 className="text-2xl sm:text-3xl font-bold mb-8 text-center text-ink">
<div className="max-w-4xl mx-auto p-8 sm:p-10 lg:p-16 border border-border-primary bg-white">
<div className="flex items-center gap-3 mb-8 justify-center">
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.15em] font-medium text-brand">
</span>
<div className="w-10 h-px bg-brand" />
</div>
<h2 className="text-2xl sm:text-3xl font-black text-ink tracking-tight mb-8 text-center">
{teamData.aboutTitle || ''}
</h2>
<div className="space-y-6 max-w-3xl mx-auto text-center">
@@ -311,19 +298,24 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
</section>
)}
{/* Team Culture Section */}
{/* ============ L3: 团队文化信任证明 ============ */}
{culture.length > 0 && (
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-bg-secondary">
<section className="relative py-24 sm:py-32 md:py-40 overflow-hidden bg-white">
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-border-primary" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="text-center max-w-3xl mx-auto mb-16 sm:mb-20">
<SectionLabel className="justify-center">Team Culture</SectionLabel>
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight leading-tight text-ink">
<ScrollReveal className="max-w-3xl mb-16 sm:mb-20">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.15em] font-medium text-brand">
</span>
</div>
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
<span className="text-brand"></span>
</h2>
<p className="mt-6 text-lg text-text-secondary leading-relaxed">
<p className="text-text-secondary leading-relaxed mt-4 text-base sm:text-lg">
</p>
</ScrollReveal>
@@ -337,32 +329,36 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
</section>
)}
{/* CTA Section */}
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-white">
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-border-primary" />
{/* ============ L4: CTA 团队行动号召 ============ */}
<section className="relative py-24 sm:py-32 md:py-44 overflow-hidden bg-dark-bg">
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-dark-border to-transparent" />
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-1/3 right-1/4 w-[500px] h-[500px] bg-brand/[0.04] rounded-full blur-[140px]" />
</div>
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="text-center max-w-3xl mx-auto">
<h2 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight leading-tight text-ink">
{teamData.ctaTitle || ''}
<ScrollReveal className="max-w-3xl">
<h2 className="font-sans text-2xl sm:text-3xl md:text-4xl lg:text-6xl xl:text-7xl font-black text-dark-text-primary tracking-tightest leading-[0.9] mb-8 sm:mb-10">
{teamData.ctaTitle || '成为下一个'}
<br />
<span className="text-brand"></span>
</h2>
<p className="mt-6 text-lg text-text-secondary leading-relaxed">
{teamData.ctaDescription || ''}
<p className="text-dark-text-secondary text-lg leading-relaxed mb-12 sm:mb-16 max-w-xl">
{teamData.ctaDescription || '无论您处于数字化转型的哪个阶段,我们都愿意成为您的长期伙伴。'}
</p>
<div className="mt-12 flex flex-col sm:flex-row justify-center gap-4 sm:gap-6">
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
<a
href={teamData.ctaPrimaryHref || '#'}
className="group inline-flex items-center justify-center gap-3 px-12 py-6 font-semibold text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90"
href={teamData.ctaPrimaryHref || '/contact'}
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90 min-h-[52px]"
>
{teamData.ctaPrimaryLabel || ''}
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
{teamData.ctaPrimaryLabel || '预约咨询'}
<ArrowRight className="w-5 h-5 transition-transform duration-300 group-hover:translate-x-1" />
</a>
<a
href={teamData.ctaSecondaryHref || '#'}
className="inline-flex items-center justify-center gap-3 px-12 py-6 font-semibold text-base text-ink border border-border-primary hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300"
href={teamData.ctaSecondaryHref || '/cases'}
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-dark-text-primary border border-dark-border hover:border-dark-text-muted transition-all duration-300 min-h-[52px]"
>
{teamData.ctaSecondaryLabel || ''}
{teamData.ctaSecondaryLabel || '查看客户案例'}
</a>
</div>
</ScrollReveal>
+9 -2
View File
@@ -2,6 +2,7 @@
import { AuthProvider } from '@/components/admin/auth-context';
import { AdminLayout } from '@/components/admin/admin-layout';
import { Toaster } from '@/components/ui/sonner';
import { usePathname } from 'next/navigation';
export default function AdminRootLayout({ children }: { children: React.ReactNode }) {
@@ -11,9 +12,15 @@ export default function AdminRootLayout({ children }: { children: React.ReactNod
return (
<AuthProvider>
{isLoginPage ? (
children
<>
{children}
<Toaster position="top-center" closeButton />
</>
) : (
<AdminLayout>{children}</AdminLayout>
<AdminLayout>
{children}
<Toaster position="top-center" closeButton />
</AdminLayout>
)}
</AuthProvider>
);
+241
View File
@@ -0,0 +1,241 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { NextRequest } from 'next/server';
// ─── Mock 权限中间件 ──────────────────────────────────────────────────────
const mockRequirePermission = jest.fn() as jest.Mock<any>;
jest.mock('@/lib/permissions', () => ({
requirePermission: mockRequirePermission,
}));
// ─── Mock 媒体服务 ────────────────────────────────────────────────────────
const mockUploadMedia = jest.fn() as jest.Mock<any>;
const mockDeleteMedia = jest.fn() as jest.Mock<any>;
const mockGetMediaById = jest.fn() as jest.Mock<any>;
const mockListMedia = jest.fn() as jest.Mock<any>;
jest.mock('@/lib/media/media-service', () => ({
uploadMedia: mockUploadMedia,
deleteMedia: mockDeleteMedia,
getMediaById: mockGetMediaById,
listMedia: mockListMedia,
}));
import { GET, POST, DELETE } from './route';
function createMockRequest(options: {
url?: string;
formData?: () => Promise<FormData>;
}): NextRequest {
return {
url: options.url || 'http://localhost/api/admin/media',
formData: options.formData || (async () => new FormData()),
} as unknown as NextRequest;
}
/**
* jsdom File arrayBuffer() File
* arrayBuffer()
*/
function createTestFile(
content: string,
name: string,
type: string,
declaredSize?: number
): File {
const file = new File([content], name, { type });
const encoder = new TextEncoder();
Object.defineProperty(file, 'arrayBuffer', {
value: async () => encoder.encode(content).buffer,
configurable: true,
});
if (declaredSize !== undefined) {
Object.defineProperty(file, 'size', { value: declaredSize });
}
return file;
}
function mockAuthorized() {
mockRequirePermission.mockResolvedValue({ user: { username: 'editor' } });
}
function mockUnauthorized() {
mockRequirePermission.mockResolvedValue({
response: new Response(JSON.stringify({ error: '未授权' }), { status: 401 }),
});
}
function mockForbidden() {
mockRequirePermission.mockResolvedValue({
response: new Response(JSON.stringify({ error: '无权限' }), { status: 403 }),
});
}
describe('/api/admin/media', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET', () => {
it('returns 401 when not authenticated', async () => {
mockUnauthorized();
const request = createMockRequest({});
const response = await GET(request);
expect(response.status).toBe(401);
});
it('returns 403 when authenticated but unauthorized', async () => {
mockForbidden();
const request = createMockRequest({});
const response = await GET(request);
expect(response.status).toBe(403);
});
it('returns media list when authorized', async () => {
mockAuthorized();
mockListMedia.mockResolvedValue({
items: [{ id: 'a1' }],
total: 1,
page: 1,
pageSize: 20,
totalPages: 1,
});
const request = createMockRequest({ url: 'http://localhost/api/admin/media?page=1&pageSize=20' });
const response = await GET(request);
const body = await response.json();
expect(response.status).toBe(200);
expect(body.items).toHaveLength(1);
expect(mockRequirePermission).toHaveBeenCalledWith(request, 'media', 'read');
});
it('returns single media by id', async () => {
mockAuthorized();
mockGetMediaById.mockResolvedValue({ id: 'a1', name: 'test.png' });
const request = createMockRequest({ url: 'http://localhost/api/admin/media?id=a1' });
const response = await GET(request);
const body = await response.json();
expect(response.status).toBe(200);
expect(body.id).toBe('a1');
expect(mockGetMediaById).toHaveBeenCalledWith('a1');
});
it('returns 404 when media by id not found', async () => {
mockAuthorized();
mockGetMediaById.mockResolvedValue(null);
const request = createMockRequest({ url: 'http://localhost/api/admin/media?id=missing' });
const response = await GET(request);
expect(response.status).toBe(404);
});
});
describe('POST', () => {
it('returns 400 when no file provided', async () => {
mockAuthorized();
const request = createMockRequest({
formData: async () => new FormData(),
});
const response = await POST(request);
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toContain('请选择文件');
});
it('uploads single file and returns created asset', async () => {
mockAuthorized();
mockUploadMedia.mockResolvedValue({ id: 'asset-1' });
const formData = new FormData();
const file = createTestFile('content', 'test.png', 'image/png');
formData.append('file', file);
const request = createMockRequest({ formData: async () => formData });
const response = await POST(request);
const body = await response.json();
expect(response.status).toBe(201);
expect(body.id).toBe('asset-1');
expect(mockUploadMedia).toHaveBeenCalledWith(
expect.objectContaining({
name: 'test.png',
mimeType: 'image/png',
size: 7,
}),
'editor'
);
});
it('uploads multiple files and returns array', async () => {
mockAuthorized();
mockUploadMedia
.mockResolvedValueOnce({ id: 'asset-1' })
.mockResolvedValueOnce({ id: 'asset-2' });
const formData = new FormData();
formData.append('files', createTestFile('a', 'a.png', 'image/png'));
formData.append('files', createTestFile('b', 'b.png', 'image/png'));
const request = createMockRequest({ formData: async () => formData });
const response = await POST(request);
const body = await response.json();
expect(response.status).toBe(201);
expect(Array.isArray(body)).toBe(true);
expect(body).toHaveLength(2);
});
it('rejects file larger than 10MB', async () => {
mockAuthorized();
const formData = new FormData();
const largeFile = createTestFile('x', 'large.png', 'image/png', 10 * 1024 * 1024 + 1);
formData.append('file', largeFile);
const request = createMockRequest({ formData: async () => formData });
const response = await POST(request);
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toContain('10MB');
expect(mockUploadMedia).not.toHaveBeenCalled();
});
});
describe('DELETE', () => {
it('returns 400 when id missing', async () => {
mockAuthorized();
const request = createMockRequest({ url: 'http://localhost/api/admin/media' });
const response = await DELETE(request);
expect(response.status).toBe(400);
});
it('deletes media by id', async () => {
mockAuthorized();
mockDeleteMedia.mockResolvedValue(undefined);
const request = createMockRequest({ url: 'http://localhost/api/admin/media?id=a1' });
const response = await DELETE(request);
const body = await response.json();
expect(response.status).toBe(200);
expect(body.message).toBe('删除成功');
expect(mockDeleteMedia).toHaveBeenCalledWith('a1');
});
it('returns 404 when media not found', async () => {
mockAuthorized();
mockDeleteMedia.mockRejectedValue(new Error('文件不存在'));
const request = createMockRequest({ url: 'http://localhost/api/admin/media?id=missing' });
const response = await DELETE(request);
expect(response.status).toBe(404);
});
});
});
+68 -106
View File
@@ -1,156 +1,118 @@
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db';
import { authenticateRequest } from '@/lib/auth';
import { requirePermission } from '@/lib/permissions';
import {
uploadMedia,
deleteMedia,
getMediaById,
listMedia,
} from '@/lib/media/media-service';
import {
success,
unauthorized,
notFound,
validationError,
internalError,
} from '@/lib/api-response';
// GET /api/admin/media - 获取媒体列表
const MODEL_CODE = 'media';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
// GET /api/admin/media - 获取媒体列表或按 ID 查询
export async function GET(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
const permission = await requirePermission(request, MODEL_CODE, 'read');
if ('response' in permission) return permission.response;
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const pageSize = parseInt(searchParams.get('pageSize') || '20');
const mimeType = searchParams.get('mimeType');
const id = searchParams.get('id');
const where: Record<string, unknown> = {};
if (mimeType) {
where.mimeType = { startsWith: mimeType };
if (id) {
const asset = await getMediaById(id);
if (!asset) return notFound('文件不存在');
return success(asset);
}
const [items, total] = await Promise.all([
prisma.mediaAsset.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.mediaAsset.count({ where }),
]);
const page = parseInt(searchParams.get('page') || '1');
const pageSize = parseInt(searchParams.get('pageSize') || '20');
const mimeType = searchParams.get('mimeType') || undefined;
return success({
items,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
});
const result = await listMedia({ page, pageSize, mimeType });
return success(result);
} catch (error) {
console.error('Get media error:', error);
return internalError();
}
}
// POST /api/admin/media - 上传媒体文件
// POST /api/admin/media - 上传媒体文件(支持单文件/多文件)
export async function POST(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
const permission = await requirePermission(request, MODEL_CODE, 'create');
if ('response' in permission) return permission.response;
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
const files: File[] = [];
if (!file) {
// 收集所有文件字段:支持 file(单文件)和 files(多文件)
const singleFile = formData.get('file');
if (singleFile instanceof File) {
files.push(singleFile);
}
const multiFiles = formData.getAll('files');
for (const f of multiFiles) {
if (f instanceof File) files.push(f);
}
if (files.length === 0) {
return validationError('请选择文件');
}
// 文件大小限制 10MB
if (file.size > 10 * 1024 * 1024) {
return validationError('文件大小不能超过 10MB');
const results = [];
for (const file of files) {
if (file.size > MAX_FILE_SIZE) {
return validationError(`文件 "${file.name}" 大小不能超过 10MB`);
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const asset = await uploadMedia(
{
name: file.name,
buffer,
mimeType: file.type || 'application/octet-stream',
size: file.size,
},
permission.user.username
);
results.push(asset);
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// 生成唯一文件名
const ext = file.name.split('.').pop() || '';
const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const uploadDir = 'public/uploads';
const filePath = `${uploadDir}/${fileName}`;
// 写入文件
const fs = await import('fs/promises');
const path = await import('path');
const dirPath = path.join(process.cwd(), uploadDir);
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(path.join(process.cwd(), filePath), buffer);
const asset = await prisma.mediaAsset.create({
data: {
name: file.name,
path: filePath,
url: `/uploads/${fileName}`,
mimeType: file.type || 'application/octet-stream',
size: file.size,
width: 0,
height: 0,
alt: file.name,
storageType: 'local',
createdBy: payload.username,
},
});
await prisma.auditLog.create({
data: {
module: 'media',
targetId: asset.id,
action: 'create',
operator: payload.username,
afterData: JSON.stringify(asset),
},
});
return success(asset, 201);
return success(files.length === 1 ? results[0] : results, 201);
} catch (error) {
console.error('Upload media error:', error);
return internalError();
}
}
// DELETE /api/admin/media/[id] - 删除媒体文件
// DELETE /api/admin/media?id=xxx - 删除媒体文件
export async function DELETE(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
const permission = await requirePermission(request, MODEL_CODE, 'delete');
if ('response' in permission) return permission.response;
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) return validationError('缺少 ID');
const existing = await prisma.mediaAsset.findUnique({ where: { id } });
if (!existing) return notFound('文件不存在');
// 删除物理文件
try {
const fs = await import('fs/promises');
const path = await import('path');
await fs.unlink(path.join(process.cwd(), existing.path));
} catch {
// 文件可能已被删除,忽略
}
await prisma.mediaAsset.delete({ where: { id } });
await prisma.auditLog.create({
data: {
module: 'media',
targetId: id,
action: 'delete',
operator: payload.username,
beforeData: JSON.stringify(existing),
},
});
await deleteMedia(id);
return success({ message: '删除成功' });
} catch (error) {
console.error('Delete media error:', error);
if (error instanceof Error && error.message === '文件不存在') {
return notFound('文件不存在');
}
return internalError();
}
}
}
+130
View File
@@ -0,0 +1,130 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { NextRequest } from 'next/server';
// ─── Mock @/lib/db ────────────────────────────────────────────────────────
const mockContentModelFindMany = jest.fn<(args: unknown) => Promise<unknown[]>>();
jest.mock('@/lib/db', () => ({
prisma: {
contentModel: {
findMany: mockContentModelFindMany,
},
},
}));
// ─── Mock permissions ─────────────────────────────────────────────────────
const mockRequirePermission = jest.fn<
(request: NextRequest, modelCode: string, action: string) => Promise<unknown>
>();
jest.mock('@/lib/permissions', () => ({
requirePermission: mockRequirePermission,
}));
jest.unmock('./route');
import { GET } from './route';
function createMockRequest(): NextRequest {
return {
url: 'http://localhost/api/admin/models',
} as unknown as NextRequest;
}
function mockAuthorized() {
mockRequirePermission.mockResolvedValue({
user: { userId: 'user-1', username: 'admin', role: 'content_admin' },
});
}
function mockUnauthorized() {
mockRequirePermission.mockResolvedValue({
response: new Response(JSON.stringify({ error: '未授权' }), { status: 401 }),
});
}
function mockForbidden() {
mockRequirePermission.mockResolvedValue({
response: new Response(JSON.stringify({ error: '无权限' }), { status: 403 }),
});
}
const mockModels = [
{
id: 'model-1',
code: 'news',
name: '新闻资讯',
description: '新闻内容模型',
fields: JSON.stringify([
{ code: 'title', name: '标题', type: 'text', required: true },
{ code: 'body', name: '正文', type: 'richtext', required: false },
]),
sortOrder: 1,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
},
{
id: 'model-2',
code: 'product',
name: '产品管理',
description: '产品内容模型',
fields: JSON.stringify([{ code: 'name', name: '产品名称', type: 'text', required: true }]),
sortOrder: 2,
createdAt: new Date('2026-01-02'),
updatedAt: new Date('2026-01-02'),
},
];
beforeEach(() => {
jest.clearAllMocks();
mockContentModelFindMany.mockResolvedValue(mockModels);
});
describe('GET /api/admin/models', () => {
it('returns 401 when not authenticated', async () => {
mockUnauthorized();
const response = await GET(createMockRequest());
expect(response.status).toBe(401);
});
it('returns 403 when authenticated but unauthorized', async () => {
mockForbidden();
const response = await GET(createMockRequest());
expect(response.status).toBe(403);
});
it('returns parsed model list ordered by sortOrder', async () => {
mockAuthorized();
const response = await GET(createMockRequest());
const body = await response.json();
expect(response.status).toBe(200);
expect(body).toHaveLength(2);
expect(body[0]).toMatchObject({
code: 'news',
name: '新闻资讯',
fields: [
{ code: 'title', name: '标题', type: 'text', required: true },
{ code: 'body', name: '正文', type: 'richtext', required: false },
],
});
expect(body[1]).toMatchObject({
code: 'product',
name: '产品管理',
});
expect(mockContentModelFindMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: { sortOrder: 'asc' },
})
);
});
it('returns 500 on database error', async () => {
mockAuthorized();
mockContentModelFindMany.mockRejectedValue(new Error('db error'));
const response = await GET(createMockRequest());
expect(response.status).toBe(500);
});
});
+4 -4
View File
@@ -1,12 +1,12 @@
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db';
import { authenticateRequest } from '@/lib/auth';
import { success, unauthorized, internalError } from '@/lib/api-response';
import { requirePermission } from '@/lib/permissions';
import { success, internalError } from '@/lib/api-response';
// GET /api/admin/models - 获取所有内容模型
export async function GET(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
const permission = await requirePermission(request, 'content-model', 'read');
if ('response' in permission) return permission.response;
try {
const models = await prisma.contentModel.findMany({
@@ -0,0 +1,70 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { NextRequest } from 'next/server';
// ─── Mock auth ────────────────────────────────────────────────────────────
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => { userId: string; username: string; role: string } | null>();
jest.mock('@/lib/auth', () => ({
authenticateRequest: mockAuthenticateRequest,
}));
// ─── Mock notification service ────────────────────────────────────────────
const mockMarkNotificationAsRead = jest.fn<(id: string, userId: string) => Promise<unknown>>();
jest.mock('@/lib/cms/notifications', () => ({
markNotificationAsRead: mockMarkNotificationAsRead,
}));
jest.unmock('./route');
import { PATCH } from './route';
function createMockRequest(): NextRequest {
return {} as unknown as NextRequest;
}
beforeEach(() => {
jest.clearAllMocks();
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'editor' });
mockMarkNotificationAsRead.mockResolvedValue({ id: 'notif-1', read: true });
});
describe('PATCH /api/admin/notifications/[id]/read', () => {
it('marks notification as read for authenticated owner', async () => {
const request = createMockRequest();
const response = await PATCH(request, { params: Promise.resolve({ id: 'notif-1' }) });
const body = await response.json();
expect(response.status).toBe(200);
expect(body.read).toBe(true);
expect(mockMarkNotificationAsRead).toHaveBeenCalledWith('notif-1', 'user-1');
});
it('returns 401 when not authenticated', async () => {
mockAuthenticateRequest.mockReturnValue(null);
const request = createMockRequest();
const response = await PATCH(request, { params: Promise.resolve({ id: 'notif-1' }) });
const body = await response.json();
expect(response.status).toBe(401);
expect(body.code).toBe('UNAUTHORIZED');
});
it('returns 404 when notification not found or not owned', async () => {
mockMarkNotificationAsRead.mockRejectedValue(new Error('Record to update not found'));
const request = createMockRequest();
const response = await PATCH(request, { params: Promise.resolve({ id: 'missing' }) });
const body = await response.json();
expect(response.status).toBe(404);
expect(body.error).toBe('通知不存在');
});
it('returns 500 on unexpected error', async () => {
mockMarkNotificationAsRead.mockRejectedValue(new Error('db error'));
const request = createMockRequest();
const response = await PATCH(request, { params: Promise.resolve({ id: 'notif-1' }) });
expect(response.status).toBe(500);
});
});
@@ -0,0 +1,25 @@
import { NextRequest } from 'next/server';
import { authenticateRequest } from '@/lib/auth';
import { markNotificationAsRead } from '@/lib/cms/notifications';
import { success, unauthorized, notFound, internalError } from '@/lib/api-response';
// PATCH /api/admin/notifications/[id]/read - 将单条通知标记为已读
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = authenticateRequest(request);
if (!user) return unauthorized();
try {
const { id } = await params;
const notification = await markNotificationAsRead(id, user.userId);
return success(notification);
} catch (error) {
if (error instanceof Error && error.message.toLowerCase().includes('not found')) {
return notFound('通知不存在');
}
console.error('Mark notification read error:', error);
return internalError();
}
}
@@ -0,0 +1,60 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { NextRequest } from 'next/server';
// ─── Mock auth ────────────────────────────────────────────────────────────
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => { userId: string; username: string; role: string } | null>();
jest.mock('@/lib/auth', () => ({
authenticateRequest: mockAuthenticateRequest,
}));
// ─── Mock notification service ────────────────────────────────────────────
const mockMarkAllNotificationsAsRead = jest.fn<(userId: string) => Promise<number>>();
jest.mock('@/lib/cms/notifications', () => ({
markAllNotificationsAsRead: mockMarkAllNotificationsAsRead,
}));
jest.unmock('./route');
import { PATCH } from './route';
function createMockRequest(): NextRequest {
return {} as unknown as NextRequest;
}
beforeEach(() => {
jest.clearAllMocks();
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'editor' });
mockMarkAllNotificationsAsRead.mockResolvedValue(5);
});
describe('PATCH /api/admin/notifications/read-all', () => {
it('marks all notifications as read for authenticated user', async () => {
const request = createMockRequest();
const response = await PATCH(request);
const body = await response.json();
expect(response.status).toBe(200);
expect(body.count).toBe(5);
expect(mockMarkAllNotificationsAsRead).toHaveBeenCalledWith('user-1');
});
it('returns 401 when not authenticated', async () => {
mockAuthenticateRequest.mockReturnValue(null);
const request = createMockRequest();
const response = await PATCH(request);
const body = await response.json();
expect(response.status).toBe(401);
expect(body.code).toBe('UNAUTHORIZED');
});
it('returns 500 on unexpected error', async () => {
mockMarkAllNotificationsAsRead.mockRejectedValue(new Error('db error'));
const request = createMockRequest();
const response = await PATCH(request);
expect(response.status).toBe(500);
});
});
@@ -0,0 +1,18 @@
import { NextRequest } from 'next/server';
import { authenticateRequest } from '@/lib/auth';
import { markAllNotificationsAsRead } from '@/lib/cms/notifications';
import { success, unauthorized, internalError } from '@/lib/api-response';
// PATCH /api/admin/notifications/read-all - 将当前用户全部未读通知标记为已读
export async function PATCH(request: NextRequest) {
const user = authenticateRequest(request);
if (!user) return unauthorized();
try {
const count = await markAllNotificationsAsRead(user.userId);
return success({ count });
} catch (error) {
console.error('Mark all notifications read error:', error);
return internalError();
}
}
@@ -0,0 +1,93 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { NextRequest } from 'next/server';
// ─── Mock auth ────────────────────────────────────────────────────────────
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => { userId: string; username: string; role: string } | null>();
jest.mock('@/lib/auth', () => ({
authenticateRequest: mockAuthenticateRequest,
}));
// ─── Mock notification service ────────────────────────────────────────────
const mockGetUserNotifications = jest.fn<(userId: string, options: unknown) => Promise<unknown>>();
jest.mock('@/lib/cms/notifications', () => ({
getUserNotifications: mockGetUserNotifications,
}));
jest.unmock('./route');
import { GET } from './route';
function createMockRequest(url: string): NextRequest {
return { url } as unknown as NextRequest;
}
beforeEach(() => {
jest.clearAllMocks();
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'editor' });
mockGetUserNotifications.mockResolvedValue({
items: [{ id: 'notif-1', read: false }],
total: 1,
page: 1,
pageSize: 20,
totalPages: 1,
});
});
describe('GET /api/admin/notifications', () => {
it('returns notifications for authenticated user', async () => {
const request = createMockRequest('http://localhost/api/admin/notifications');
const response = await GET(request);
const body = await response.json();
expect(response.status).toBe(200);
expect(body.items).toHaveLength(1);
expect(mockGetUserNotifications).toHaveBeenCalledWith('user-1', { page: 1, pageSize: 20, unreadOnly: false });
});
it('returns 401 when not authenticated', async () => {
mockAuthenticateRequest.mockReturnValue(null);
const request = createMockRequest('http://localhost/api/admin/notifications');
const response = await GET(request);
const body = await response.json();
expect(response.status).toBe(401);
expect(body.code).toBe('UNAUTHORIZED');
});
it('parses pagination and unreadOnly query params', async () => {
const request = createMockRequest(
'http://localhost/api/admin/notifications?page=2&pageSize=10&unreadOnly=true'
);
await GET(request);
expect(mockGetUserNotifications).toHaveBeenCalledWith('user-1', { page: 2, pageSize: 10, unreadOnly: true });
});
it('returns 400 for invalid page parameter', async () => {
const request = createMockRequest('http://localhost/api/admin/notifications?page=0');
const response = await GET(request);
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toBe('page 参数非法');
});
it('returns 400 for invalid pageSize parameter', async () => {
const request = createMockRequest('http://localhost/api/admin/notifications?pageSize=101');
const response = await GET(request);
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toBe('pageSize 参数非法');
});
it('returns 500 on service error', async () => {
mockGetUserNotifications.mockRejectedValue(new Error('db error'));
const request = createMockRequest('http://localhost/api/admin/notifications');
const response = await GET(request);
expect(response.status).toBe(500);
});
});
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest } from 'next/server';
import { authenticateRequest } from '@/lib/auth';
import { getUserNotifications } from '@/lib/cms/notifications';
import { success, unauthorized, validationError, internalError } from '@/lib/api-response';
// GET /api/admin/notifications - 获取当前用户通知列表
export async function GET(request: NextRequest) {
const user = authenticateRequest(request);
if (!user) return unauthorized();
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1', 10);
const pageSize = parseInt(searchParams.get('pageSize') || '20', 10);
const unreadOnly = searchParams.get('unreadOnly') === 'true';
if (Number.isNaN(page) || page < 1) {
return validationError('page 参数非法');
}
if (Number.isNaN(pageSize) || pageSize < 1 || pageSize > 100) {
return validationError('pageSize 参数非法');
}
const result = await getUserNotifications(user.userId, { page, pageSize, unreadOnly });
return success(result);
} catch (error) {
console.error('Get notifications error:', error);
return internalError();
}
}
@@ -0,0 +1,60 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { NextRequest } from 'next/server';
// ─── Mock auth ────────────────────────────────────────────────────────────
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => { userId: string; username: string; role: string } | null>();
jest.mock('@/lib/auth', () => ({
authenticateRequest: mockAuthenticateRequest,
}));
// ─── Mock notification service ────────────────────────────────────────────
const mockGetUnreadCount = jest.fn<(userId: string) => Promise<number>>();
jest.mock('@/lib/cms/notifications', () => ({
getUnreadCount: mockGetUnreadCount,
}));
jest.unmock('./route');
import { GET } from './route';
function createMockRequest(): NextRequest {
return {} as unknown as NextRequest;
}
beforeEach(() => {
jest.clearAllMocks();
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'editor' });
mockGetUnreadCount.mockResolvedValue(3);
});
describe('GET /api/admin/notifications/unread-count', () => {
it('returns unread count for authenticated user', async () => {
const request = createMockRequest();
const response = await GET(request);
const body = await response.json();
expect(response.status).toBe(200);
expect(body.count).toBe(3);
expect(mockGetUnreadCount).toHaveBeenCalledWith('user-1');
});
it('returns 401 when not authenticated', async () => {
mockAuthenticateRequest.mockReturnValue(null);
const request = createMockRequest();
const response = await GET(request);
const body = await response.json();
expect(response.status).toBe(401);
expect(body.code).toBe('UNAUTHORIZED');
});
it('returns 500 on unexpected error', async () => {
mockGetUnreadCount.mockRejectedValue(new Error('db error'));
const request = createMockRequest();
const response = await GET(request);
expect(response.status).toBe(500);
});
});
@@ -0,0 +1,18 @@
import { NextRequest } from 'next/server';
import { authenticateRequest } from '@/lib/auth';
import { getUnreadCount } from '@/lib/cms/notifications';
import { success, unauthorized, internalError } from '@/lib/api-response';
// GET /api/admin/notifications/unread-count - 获取当前用户未读通知数量
export async function GET(request: NextRequest) {
const user = authenticateRequest(request);
if (!user) return unauthorized();
try {
const count = await getUnreadCount(user.userId);
return success({ count });
} catch (error) {
console.error('Get unread count error:', error);
return internalError();
}
}
+206
View File
@@ -0,0 +1,206 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { NextRequest } from 'next/server';
// ─── Mock @/lib/db ────────────────────────────────────────────────────────
const mockRoleFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
const mockPermissionFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
const mockPermissionDeleteMany = jest.fn<(args?: unknown) => Promise<unknown>>();
const mockPermissionCreate = jest.fn<(args?: unknown) => Promise<unknown>>();
const mockContentModelFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
const mockUserRoleFindMany = jest.fn<(args?: unknown) => Promise<unknown[]>>();
const mockRoleFindUnique = jest.fn<(args?: unknown) => Promise<unknown | null>>();
jest.mock('@/lib/db', () => ({
prisma: {
role: {
findMany: mockRoleFindMany,
findUnique: mockRoleFindUnique,
},
permission: {
findMany: mockPermissionFindMany,
deleteMany: mockPermissionDeleteMany,
create: mockPermissionCreate,
},
contentModel: {
findMany: mockContentModelFindMany,
},
userRole: {
findMany: mockUserRoleFindMany,
},
},
}));
// ─── Mock @/lib/auth ──────────────────────────────────────────────────────
const mockAuthenticateRequest = jest.fn<(request: NextRequest) => unknown>();
jest.mock('@/lib/auth', () => ({
authenticateRequest: mockAuthenticateRequest,
}));
jest.unmock('./route');
import { GET, PUT } from './route';
function createMockRequest(body?: Record<string, unknown>): NextRequest {
return {
json: async () => body ?? {},
} as unknown as NextRequest;
}
const mockRoles = [
{ code: 'super_admin', name: '超级管理员', createdAt: new Date('2026-01-01') },
{ code: 'content_admin', name: '内容管理员', createdAt: new Date('2026-01-02') },
{ code: 'content_editor', name: '内容编辑', createdAt: new Date('2026-01-03') },
];
const mockModels = [
{ code: 'news', name: '新闻资讯', sortOrder: 1 },
{ code: 'product', name: '产品管理', sortOrder: 2 },
];
const mockPermissions = [
{ roleCode: 'content_editor', modelCode: 'news', action: 'read' },
{ roleCode: 'content_editor', modelCode: 'news', action: 'update' },
];
function mockAuthenticated(roleCodes: string[]) {
mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'admin' });
mockUserRoleFindMany.mockResolvedValue(roleCodes.map((code) => ({ roleCode: code })));
}
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /api/admin/roles', () => {
it('returns 401 when not authenticated', async () => {
mockAuthenticateRequest.mockReturnValue(null);
const response = await GET(createMockRequest());
const body = await response.json();
expect(response.status).toBe(401);
expect(body.code).toBe('UNAUTHORIZED');
});
it('returns 403 for non-super_admin users', async () => {
mockAuthenticated(['content_admin']);
const response = await GET(createMockRequest());
const body = await response.json();
expect(response.status).toBe(403);
expect(body.error).toContain('超级管理员');
});
it('returns roles, permissions and models for super_admin', async () => {
mockAuthenticated(['super_admin']);
mockRoleFindMany.mockResolvedValue(mockRoles);
mockPermissionFindMany.mockResolvedValue(mockPermissions);
mockContentModelFindMany.mockResolvedValue(mockModels);
const response = await GET(createMockRequest());
const body = await response.json();
expect(response.status).toBe(200);
expect(body.roles).toHaveLength(3);
expect(body.roles[0]).toEqual({ code: 'super_admin', name: '超级管理员', builtin: true });
expect(body.permissions).toEqual(mockPermissions);
expect(body.models).toEqual([
{ code: 'news', name: '新闻资讯' },
{ code: 'product', name: '产品管理' },
]);
});
});
describe('PUT /api/admin/roles', () => {
it('returns 401 when not authenticated', async () => {
mockAuthenticateRequest.mockReturnValue(null);
const response = await PUT(createMockRequest({ roleCode: 'content_editor', permissions: [] }));
const body = await response.json();
expect(response.status).toBe(401);
expect(body.code).toBe('UNAUTHORIZED');
});
it('returns 403 for non-super_admin users', async () => {
mockAuthenticated(['content_admin']);
const response = await PUT(createMockRequest({ roleCode: 'content_editor', permissions: [] }));
const body = await response.json();
expect(response.status).toBe(403);
expect(body.error).toContain('超级管理员');
});
it('returns 400 when roleCode is missing', async () => {
mockAuthenticated(['super_admin']);
const response = await PUT(createMockRequest({ permissions: [] }));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toBe('roleCode 必填');
});
it('returns 403 when modifying super_admin', async () => {
mockAuthenticated(['super_admin']);
const response = await PUT(createMockRequest({ roleCode: 'super_admin', permissions: [] }));
const body = await response.json();
expect(response.status).toBe(403);
expect(body.error).toContain('不可修改');
expect(mockPermissionDeleteMany).not.toHaveBeenCalled();
});
it('returns 400 when role does not exist', async () => {
mockAuthenticated(['super_admin']);
mockRoleFindUnique.mockResolvedValue(null);
const response = await PUT(createMockRequest({ roleCode: 'ghost', permissions: [] }));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toBe('角色不存在');
});
it('replaces permissions and filters invalid actions', async () => {
mockAuthenticated(['super_admin']);
mockRoleFindUnique.mockResolvedValue({ code: 'content_editor', name: '内容编辑' });
mockPermissionDeleteMany.mockResolvedValue({ count: 2 });
mockPermissionCreate.mockResolvedValue({});
const response = await PUT(
createMockRequest({
roleCode: 'content_editor',
permissions: [
{ modelCode: 'news', action: 'read' },
{ modelCode: 'news', action: 'update' },
{ modelCode: 'news', action: 'hack' },
{ modelCode: '', action: 'read' },
],
})
);
const body = await response.json();
expect(response.status).toBe(200);
expect(mockPermissionDeleteMany).toHaveBeenCalledWith({ where: { roleCode: 'content_editor' } });
expect(mockPermissionCreate).toHaveBeenCalledTimes(2);
expect(mockPermissionCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: { roleCode: 'content_editor', modelCode: 'news', action: 'read' },
})
);
expect(mockPermissionCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: { roleCode: 'content_editor', modelCode: 'news', action: 'update' },
})
);
expect(body.roleCode).toBe('content_editor');
expect(body.permissions).toContain('news:read');
expect(body.permissions).toContain('news:update');
expect(body.permissions).not.toContain('news:hack');
});
});
+103
View File
@@ -0,0 +1,103 @@
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db';
import { authenticateRequest } from '@/lib/auth';
import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response';
// 内置角色,不允许删除
const BUILTIN_ROLES = new Set(['super_admin', 'content_admin', 'content_editor', 'reviewer', 'readonly']);
// GET /api/admin/roles - 获取角色列表及其权限
export async function GET(request: NextRequest) {
const user = authenticateRequest(request);
if (!user) return unauthorized();
// 仅管理员可查看角色权限
const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } });
const roleCodes = userRoles.map((ur) => (ur as { roleCode: string }).roleCode);
if (!roleCodes.includes('super_admin')) {
return forbidden('仅超级管理员可管理角色权限');
}
try {
const roles = await prisma.role.findMany({
orderBy: { createdAt: 'asc' },
});
const permissions = await prisma.permission.findMany();
const contentModels = await prisma.contentModel.findMany({
orderBy: { sortOrder: 'asc' },
select: { code: true, name: true },
});
return success({
roles: roles.map((r) => ({
code: r.code,
name: r.name,
builtin: BUILTIN_ROLES.has(r.code),
})),
permissions: permissions.map((p) => ({
roleCode: (p as { roleCode: string }).roleCode,
modelCode: (p as { modelCode: string }).modelCode,
action: (p as { action: string }).action,
})),
models: contentModels.map((m) => ({ code: m.code, name: m.name })),
});
} catch (error) {
console.error('Get roles error:', error);
return internalError();
}
}
// PUT /api/admin/roles/:roleCode - 更新角色权限
export async function PUT(request: NextRequest) {
const user = authenticateRequest(request);
if (!user) return unauthorized();
const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } });
const roleCodes = userRoles.map((ur) => (ur as { roleCode: string }).roleCode);
if (!roleCodes.includes('super_admin')) {
return forbidden('仅超级管理员可管理角色权限');
}
try {
const { roleCode, permissions } = (await request.json()) as {
roleCode: string;
permissions: Array<{ modelCode: string; action: string }>;
};
if (!roleCode || typeof roleCode !== 'string') {
return validationError('roleCode 必填');
}
if (roleCode === 'super_admin') {
return forbidden('super_admin 权限不可修改');
}
const role = await prisma.role.findUnique({ where: { code: roleCode } });
if (!role) return validationError('角色不存在');
// 先删除该角色所有权限,再重新写入
await prisma.permission.deleteMany({ where: { roleCode } });
const validActions = new Set(['create', 'read', 'update', 'delete', 'publish']);
const uniqueKeys = new Set<string>();
for (const perm of permissions || []) {
if (!perm.modelCode || !validActions.has(perm.action)) continue;
const key = `${perm.modelCode}:${perm.action}`;
if (uniqueKeys.has(key)) continue;
uniqueKeys.add(key);
await prisma.permission.create({
data: {
roleCode,
modelCode: perm.modelCode,
action: perm.action,
},
});
}
return success({ roleCode, permissions: Array.from(uniqueKeys) });
} catch (error) {
console.error('Update roles error:', error);
return internalError();
}
}
+294
View File
@@ -0,0 +1,294 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { NextRequest } from 'next/server';
// ─── Mock @/lib/db ────────────────────────────────────────────────────────
const mockContentZoneFindMany = jest.fn<(args: unknown) => Promise<unknown[]>>();
const mockContentZoneFindUnique = jest.fn<(args: unknown) => Promise<unknown | null>>();
const mockContentZoneCreate = jest.fn<(args: unknown) => Promise<unknown>>();
const mockContentZoneUpdate = jest.fn<(args: unknown) => Promise<unknown>>();
const mockContentZoneDelete = jest.fn<(args: unknown) => Promise<unknown>>();
const mockAuditLogCreate = jest.fn<(args: unknown) => Promise<unknown>>();
jest.mock('@/lib/db', () => ({
prisma: {
contentZone: {
findMany: mockContentZoneFindMany,
findUnique: mockContentZoneFindUnique,
create: mockContentZoneCreate,
update: mockContentZoneUpdate,
delete: mockContentZoneDelete,
},
auditLog: {
create: mockAuditLogCreate,
},
},
}));
// ─── Mock permissions ─────────────────────────────────────────────────────
const mockRequirePermission = jest.fn<
(request: NextRequest, modelCode: string, action: string) => Promise<unknown>
>();
jest.mock('@/lib/permissions', () => ({
requirePermission: mockRequirePermission,
}));
jest.unmock('./route');
import { GET, POST, PUT, DELETE } from './route';
function createMockRequest(options: {
url?: string;
json?: () => Promise<Record<string, unknown>>;
}): NextRequest {
return {
url: options.url || 'http://localhost/api/admin/zones',
json: options.json || (async () => ({})),
} as unknown as NextRequest;
}
function mockAuthorized() {
mockRequirePermission.mockResolvedValue({
user: { userId: 'user-1', username: 'admin', role: 'content_admin' },
});
}
function mockUnauthorized() {
mockRequirePermission.mockResolvedValue({
response: new Response(JSON.stringify({ error: '未授权' }), { status: 401 }),
});
}
function mockForbidden() {
mockRequirePermission.mockResolvedValue({
response: new Response(JSON.stringify({ error: '无权限' }), { status: 403 }),
});
}
const mockZone = {
id: 'zone-1',
code: 'home-hero',
name: '首页 Hero',
description: '',
pageCode: 'home',
zoneKey: 'hero',
allowedModels: JSON.stringify(['hero-banner']),
items: JSON.stringify([{ itemId: 'item-1', sortOrder: 1 }]),
settings: JSON.stringify({ layout: 'carousel' }),
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
beforeEach(() => {
jest.clearAllMocks();
mockContentZoneFindMany.mockResolvedValue([mockZone]);
mockContentZoneFindUnique.mockResolvedValue(mockZone);
mockContentZoneCreate.mockResolvedValue(mockZone);
mockContentZoneUpdate.mockResolvedValue(mockZone);
mockContentZoneDelete.mockResolvedValue(undefined);
mockAuditLogCreate.mockResolvedValue({ id: 'log-1' });
});
describe('/api/admin/zones', () => {
describe('GET', () => {
it('returns 401 when not authenticated', async () => {
mockUnauthorized();
const response = await GET(createMockRequest({}));
expect(response.status).toBe(401);
});
it('returns 403 when authenticated but unauthorized', async () => {
mockForbidden();
const response = await GET(createMockRequest({}));
expect(response.status).toBe(403);
});
it('returns parsed zone list', async () => {
mockAuthorized();
const response = await GET(createMockRequest({}));
const body = await response.json();
expect(response.status).toBe(200);
expect(body).toHaveLength(1);
expect(body[0]).toMatchObject({
id: 'zone-1',
code: 'home-hero',
allowedModels: ['hero-banner'],
items: [{ itemId: 'item-1', sortOrder: 1 }],
settings: { layout: 'carousel' },
});
});
it('filters by pageCode', async () => {
mockAuthorized();
await GET(createMockRequest({ url: 'http://localhost/api/admin/zones?pageCode=home' }));
expect(mockContentZoneFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { pageCode: 'home' },
})
);
});
});
describe('POST', () => {
it('returns 400 when code missing', async () => {
mockAuthorized();
const response = await POST(createMockRequest({ json: async () => ({ name: '无编码' }) }));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toContain('code');
});
it('creates new zone and audit log', async () => {
mockAuthorized();
const response = await POST(
createMockRequest({
json: async () => ({
code: 'home-stats',
name: '首页数据',
pageCode: 'home',
zoneKey: 'stats',
allowedModels: ['stat-item'],
items: [],
settings: {},
}),
})
);
const body = await response.json();
expect(response.status).toBe(200);
expect(mockContentZoneCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
code: 'home-stats',
allowedModels: JSON.stringify(['stat-item']),
}),
})
);
expect(mockAuditLogCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
module: 'zone',
action: 'create',
operator: 'admin',
}),
})
);
expect(body.code).toBe('home-hero');
});
it('updates existing zone and audit log', async () => {
mockAuthorized();
const response = await POST(
createMockRequest({
json: async () => ({
id: 'zone-1',
code: 'home-hero',
name: '首页 Hero 更新',
items: [{ itemId: 'item-2', sortOrder: 2 }],
}),
})
);
const body = await response.json();
expect(response.status).toBe(200);
expect(mockContentZoneUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'zone-1' },
})
);
expect(mockAuditLogCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
module: 'zone',
action: 'update',
operator: 'admin',
}),
})
);
expect(body.name).toBe('首页 Hero');
});
it('returns 404 when updating non-existent zone', async () => {
mockAuthorized();
mockContentZoneFindUnique.mockResolvedValue(null);
const response = await POST(
createMockRequest({
json: async () => ({ id: 'missing', code: 'home-hero' }),
})
);
const body = await response.json();
expect(response.status).toBe(404);
expect(body.error).toBe('区域不存在');
});
});
describe('PUT', () => {
it('delegates to POST', async () => {
mockAuthorized();
const response = await PUT(
createMockRequest({
json: async () => ({
id: 'zone-1',
code: 'home-hero',
name: 'PUT 更新',
}),
})
);
expect(response.status).toBe(200);
expect(mockContentZoneUpdate).toHaveBeenCalled();
});
});
describe('DELETE', () => {
it('returns 400 when id missing', async () => {
mockAuthorized();
const response = await DELETE(createMockRequest({}));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toBe('缺少 ID');
});
it('returns 404 when zone not found', async () => {
mockAuthorized();
mockContentZoneFindUnique.mockResolvedValue(null);
const response = await DELETE(
createMockRequest({ url: 'http://localhost/api/admin/zones?id=missing' })
);
const body = await response.json();
expect(response.status).toBe(404);
expect(body.error).toBe('区域不存在');
});
it('deletes zone and audit log', async () => {
mockAuthorized();
const response = await DELETE(
createMockRequest({ url: 'http://localhost/api/admin/zones?id=zone-1' })
);
const body = await response.json();
expect(response.status).toBe(200);
expect(body.message).toBe('删除成功');
expect(mockContentZoneDelete).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'zone-1' } })
);
expect(mockAuditLogCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
module: 'zone',
action: 'delete',
operator: 'admin',
}),
})
);
});
});
});
+11 -12
View File
@@ -1,9 +1,8 @@
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db';
import { authenticateRequest } from '@/lib/auth';
import { requirePermission } from '@/lib/permissions';
import {
success,
unauthorized,
notFound,
validationError,
internalError,
@@ -11,8 +10,8 @@ import {
// GET /api/admin/zones - 获取所有内容区域
export async function GET(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
const permission = await requirePermission(request, 'content-zone', 'read');
if ('response' in permission) return permission.response;
try {
const { searchParams } = new URL(request.url);
@@ -35,8 +34,8 @@ export async function GET(request: NextRequest) {
// POST /api/admin/zones - 创建/更新内容区域
export async function POST(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
const permission = await requirePermission(request, 'content-zone', 'update');
if ('response' in permission) return permission.response;
try {
const body = await request.json();
@@ -67,7 +66,7 @@ export async function POST(request: NextRequest) {
module: 'zone',
targetId: id,
action: 'update',
operator: payload.username,
operator: permission.user.username,
beforeData: JSON.stringify(existing),
afterData: JSON.stringify(zone),
},
@@ -80,7 +79,7 @@ export async function POST(request: NextRequest) {
module: 'zone',
targetId: zone.id,
action: 'create',
operator: payload.username,
operator: permission.user.username,
afterData: JSON.stringify(zone),
},
});
@@ -105,8 +104,8 @@ export async function PUT(request: NextRequest) {
// DELETE /api/admin/zones/[id] - 删除内容区域
export async function DELETE(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
const permission = await requirePermission(request, 'content-zone', 'delete');
if ('response' in permission) return permission.response;
try {
const { searchParams } = new URL(request.url);
@@ -123,7 +122,7 @@ export async function DELETE(request: NextRequest) {
module: 'zone',
targetId: id,
action: 'delete',
operator: payload.username,
operator: permission.user.username,
beforeData: JSON.stringify(existing),
},
});
@@ -133,4 +132,4 @@ export async function DELETE(request: NextRequest) {
console.error('Delete zone error:', error);
return internalError();
}
}
}
+4 -1
View File
@@ -44,7 +44,10 @@ export async function POST(request: NextRequest) {
refreshToken,
});
setTokenCookie(response, accessToken, refreshToken);
// 仅在实际 https 请求时设置 Secure 标志,本地 http 预览/开发环境不设置,
// 否则浏览器会拒绝保存 HttpOnly cookie,导致中间件无法读取认证状态。
const secure = request.nextUrl.protocol === 'https:';
setTokenCookie(response, accessToken, refreshToken, secure);
return response;
} catch (error) {
+5 -3
View File
@@ -1,8 +1,10 @@
import { NextRequest } from 'next/server';
import { clearTokenCookie } from '@/lib/auth';
import { success } from '@/lib/api-response';
export async function POST() {
export async function POST(request: NextRequest) {
const response = success({ message: '已登出' });
clearTokenCookie(response);
const secure = request.nextUrl.protocol === 'https:';
clearTokenCookie(response, secure);
return response;
}
}
+2 -1
View File
@@ -29,7 +29,8 @@ export async function POST(request: NextRequest) {
refreshToken: tokens.refreshToken,
});
setTokenCookie(response, tokens.accessToken, tokens.refreshToken);
const secure = request.nextUrl.protocol === 'https:';
setTokenCookie(response, tokens.accessToken, tokens.refreshToken, secure);
return response;
} catch (error) {
+2 -1
View File
@@ -8,7 +8,8 @@ export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}));
const redirect = typeof body.redirect === 'string' ? body.redirect : null;
draftMode().disable();
const draft = await draftMode();
draft.disable();
if (redirect) {
return NextResponse.redirect(new URL(redirect, request.url));
+2 -1
View File
@@ -19,7 +19,8 @@ export async function POST(request: NextRequest) {
);
}
draftMode().enable();
const draft = await draftMode();
draft.enable();
if (redirect) {
return NextResponse.redirect(new URL(redirect, request.url));
+260
View File
@@ -0,0 +1,260 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { NextRequest } from 'next/server';
const mockRevalidatePath = jest.fn();
const mockRevalidateTag = jest.fn();
jest.mock('next/cache', () => ({
revalidatePath: mockRevalidatePath,
revalidateTag: mockRevalidateTag,
}));
jest.unmock('./route');
import { POST } from './route';
function createMockRequest(body: Record<string, unknown>): NextRequest {
return {
json: async () => body,
headers: new Headers({ 'x-revalidate-secret': 'test-secret' }),
} as unknown as NextRequest;
}
beforeEach(() => {
jest.clearAllMocks();
process.env.CMS_REVALIDATE_SECRET = 'test-secret';
});
describe('POST /api/cms/revalidate', () => {
it('returns 401 when secret is invalid', async () => {
const request = createMockRequest({});
request.headers.set('x-revalidate-secret', 'wrong-secret');
const response = await POST(request);
const body = await response.json();
expect(response.status).toBe(401);
expect(body.message).toBe('Invalid secret');
});
it('returns 500 when secret is not configured', async () => {
delete process.env.CMS_REVALIDATE_SECRET;
const response = await POST(createMockRequest({}));
const body = await response.json();
expect(response.status).toBe(500);
expect(body.message).toBe('Revalidation secret is not configured');
});
it('revalidates explicit paths and tags', async () => {
const response = await POST(
createMockRequest({ paths: ['/privacy'], tags: ['cms:legal-page'] }),
);
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/privacy');
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:legal-page', {});
expect(body.data.revalidatedPaths).toContain('/privacy');
expect(body.data.revalidatedTags).toContain('cms:legal-page');
});
it('revalidates /privacy and /terms for legal-page model without slug', async () => {
const response = await POST(createMockRequest({ modelCode: 'legal-page' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/privacy');
expect(mockRevalidatePath).toHaveBeenCalledWith('/terms');
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:legal-page', {});
expect(body.data.revalidatedPaths).toEqual(
expect.arrayContaining(['/privacy', '/terms']),
);
});
it('revalidates only /terms for legal-page model when slug is terms', async () => {
const response = await POST(
createMockRequest({ modelCode: 'legal-page', slug: 'terms' }),
);
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/terms');
expect(body.data.revalidatedPaths).toContain('/terms');
expect(body.data.revalidatedPaths).not.toContain('/privacy');
});
it('revalidates only /privacy for legal-page model when slug is privacy', async () => {
const response = await POST(
createMockRequest({ modelCode: 'legal-page', slug: 'privacy' }),
);
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/privacy');
expect(body.data.revalidatedPaths).toContain('/privacy');
expect(body.data.revalidatedPaths).not.toContain('/terms');
});
it('revalidates all list pages on content.published event', async () => {
const response = await POST(
createMockRequest({ event: 'content.published', modelCode: 'news' }),
);
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:all', {});
expect(body.data.revalidatedTags).toContain('cms:all');
});
it('revalidates news list and detail pages for news model', async () => {
const response = await POST(
createMockRequest({ modelCode: 'news', slug: 'company-founded' }),
);
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/news');
expect(mockRevalidatePath).toHaveBeenCalledWith('/news/company-founded');
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:news', {});
expect(body.data.revalidatedPaths).toEqual(
expect.arrayContaining(['/news', '/news/company-founded']),
);
});
it('revalidates /team for team-page model', async () => {
const response = await POST(createMockRequest({ modelCode: 'team-page' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/team');
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:team-page', {});
expect(body.data.revalidatedPaths).toContain('/team');
});
it('revalidates /cases list and detail pages for case-study model', async () => {
const response = await POST(
createMockRequest({ modelCode: 'case-study', slug: 'manufacturing-erp-upgrade' }),
);
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/cases');
expect(mockRevalidatePath).toHaveBeenCalledWith('/cases/manufacturing-erp-upgrade');
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:case-study', {});
expect(body.data.revalidatedPaths).toEqual(
expect.arrayContaining(['/cases', '/cases/manufacturing-erp-upgrade']),
);
});
it('revalidates only /cases list page for case-study model without slug', async () => {
const response = await POST(createMockRequest({ modelCode: 'case-study' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/cases');
expect(body.data.revalidatedPaths).toContain('/cases');
expect(body.data.revalidatedPaths).not.toContain('/cases/manufacturing-erp-upgrade');
});
it('revalidates /services list and detail pages for service model', async () => {
const response = await POST(
createMockRequest({ modelCode: 'service', slug: 'consulting' }),
);
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/services');
expect(mockRevalidatePath).toHaveBeenCalledWith('/services/consulting');
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:service', {});
expect(body.data.revalidatedPaths).toEqual(
expect.arrayContaining(['/services', '/services/consulting']),
);
});
it('revalidates only /services list page for service model without slug', async () => {
const response = await POST(createMockRequest({ modelCode: 'service' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/services');
expect(body.data.revalidatedPaths).toContain('/services');
expect(body.data.revalidatedPaths).not.toContain('/services/consulting');
});
it('revalidates /solutions list and detail pages for solution model', async () => {
const response = await POST(
createMockRequest({ modelCode: 'solution', slug: 'manufacturing' }),
);
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/solutions');
expect(mockRevalidatePath).toHaveBeenCalledWith('/solutions/manufacturing');
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:solution', {});
expect(body.data.revalidatedPaths).toEqual(
expect.arrayContaining(['/solutions', '/solutions/manufacturing']),
);
});
it('revalidates only /solutions list page for solution model without slug', async () => {
const response = await POST(createMockRequest({ modelCode: 'solution' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/solutions');
expect(body.data.revalidatedPaths).toContain('/solutions');
expect(body.data.revalidatedPaths).not.toContain('/solutions/manufacturing');
});
it('revalidates /products list and detail pages for product model', async () => {
const response = await POST(
createMockRequest({ modelCode: 'product', slug: 'erp' }),
);
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/products');
expect(mockRevalidatePath).toHaveBeenCalledWith('/products/erp');
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:product', {});
expect(body.data.revalidatedPaths).toEqual(
expect.arrayContaining(['/products', '/products/erp']),
);
});
it('revalidates only /products list page for product model without slug', async () => {
const response = await POST(createMockRequest({ modelCode: 'product' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/products');
expect(body.data.revalidatedPaths).toContain('/products');
expect(body.data.revalidatedPaths).not.toContain('/products/erp');
});
it('revalidates /products list and standalone detail pages for standalone-product model', async () => {
const response = await POST(
createMockRequest({ modelCode: 'standalone-product', slug: 'novavis' }),
);
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/products');
expect(mockRevalidatePath).toHaveBeenCalledWith('/products/standalone/novavis');
expect(mockRevalidateTag).toHaveBeenCalledWith('cms:standalone-product', {});
expect(body.data.revalidatedPaths).toEqual(
expect.arrayContaining(['/products', '/products/standalone/novavis']),
);
});
it('revalidates only /products list page for standalone-product model without slug', async () => {
const response = await POST(createMockRequest({ modelCode: 'standalone-product' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(mockRevalidatePath).toHaveBeenCalledWith('/products');
expect(body.data.revalidatedPaths).toContain('/products');
expect(body.data.revalidatedPaths).not.toContain('/products/standalone/novavis');
});
});
+52 -4
View File
@@ -10,6 +10,16 @@ function getAllContentTypeConfigs() {
return Object.values(CONTENT_TYPE_CONFIGS);
}
// 首页受影响的模型:更新后需要同时刷新 /
const HOME_AFFECTED_MODELS = new Set([
'hero-banner',
'stat-item',
'service',
'case-study',
'solution',
'news',
]);
interface RevalidateRequestBody {
event?: string;
modelCode?: string;
@@ -27,7 +37,14 @@ export async function POST(request: NextRequest) {
const secret = body.secret ?? request.headers.get('x-revalidate-secret');
const expectedSecret = process.env.CMS_REVALIDATE_SECRET;
if (expectedSecret && secret !== expectedSecret) {
if (!expectedSecret) {
return NextResponse.json(
{ code: 500, message: 'Revalidation secret is not configured', data: null },
{ status: 500 }
);
}
if (secret !== expectedSecret) {
return NextResponse.json(
{ code: 401, message: 'Invalid secret', data: null },
{ status: 401 }
@@ -46,7 +63,7 @@ export async function POST(request: NextRequest) {
if (body.tags && body.tags.length > 0) {
for (const tag of body.tags) {
revalidateTag(tag);
revalidateTag(tag, {});
revalidatedTags.push(tag);
}
}
@@ -66,9 +83,40 @@ export async function POST(request: NextRequest) {
}
const tag = `cms:${body.modelCode}`;
revalidateTag(tag);
revalidateTag(tag, {});
revalidatedTags.push(tag);
}
// legal-page 为特殊页面类型,固定映射到 /privacy 和 /terms
if (body.modelCode === 'legal-page') {
const legalPaths = body.slug
? [body.slug === 'terms' ? '/terms' : '/privacy']
: ['/privacy', '/terms'];
for (const path of legalPaths) {
if (!revalidatedPaths.includes(path)) {
revalidatePath(path);
revalidatedPaths.push(path);
}
}
}
// team-page 为单页模型,固定映射到 /team
if (body.modelCode === 'team-page' && !revalidatedPaths.includes('/team')) {
revalidatePath('/team');
revalidatedPaths.push('/team');
}
// 首页相关模型变更时同步刷新 /
if (HOME_AFFECTED_MODELS.has(body.modelCode) && !revalidatedPaths.includes('/')) {
revalidatePath('/');
revalidatedPaths.push('/');
}
// ContentZone 变更时刷新关联页面
if (body.modelCode === 'content-zone' && !revalidatedPaths.includes('/')) {
revalidatePath('/');
revalidatedPaths.push('/');
}
}
if (body.event === 'content.published' || body.event === 'content.updated') {
@@ -81,7 +129,7 @@ export async function POST(request: NextRequest) {
}
}
}
revalidateTag('cms:all');
revalidateTag('cms:all', {});
revalidatedTags.push('cms:all');
}
+74 -7
View File
@@ -1,17 +1,83 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { COMPANY_INFO } from '@/lib/constants';
const contactSchema = z.object({
name: z.string().min(2, '姓名至少需要2个字符').max(50, '姓名不能超过50个字符'),
phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号码'),
email: z.string().email('请输入有效的邮箱地址').max(100, '邮箱地址过长'),
subject: z.string().min(2, '主题至少需要2个字符').max(120, '主题不能超过120个字符'),
message: z.string().min(10, '留言内容至少需要10个字符').max(2000, '留言内容不能超过2000个字符'),
});
// 简单内存限流:按 IP 地址限制提交频率
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 小时
const RATE_LIMIT_MAX_REQUESTS = 5;
function getClientIp(request: NextRequest): string {
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) {
return forwarded.split(',')[0]?.trim() ?? 'unknown';
}
return request.headers.get('x-real-ip') ?? 'unknown';
}
function isRateLimited(clientIp: string): boolean {
const now = Date.now();
const record = rateLimitMap.get(clientIp);
if (!record || now > record.resetAt) {
rateLimitMap.set(clientIp, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return false;
}
record.count += 1;
return record.count > RATE_LIMIT_MAX_REQUESTS;
}
function sanitizeInput(input: string): string {
// 去除首尾空白并过滤潜在危险标签
return input.trim().replace(/[<>]/g, '');
}
export async function POST(request: NextRequest) {
try {
const clientIp = getClientIp(request);
if (isRateLimited(clientIp)) {
return NextResponse.json(
{ success: false, message: '提交过于频繁,请稍后再试' },
{ status: 429 }
);
}
const body = await request.formData();
const parseResult = contactSchema.safeParse({
name: body.get('name'),
phone: body.get('phone'),
email: body.get('email'),
subject: body.get('subject'),
message: body.get('message'),
});
if (!parseResult.success) {
const firstError = parseResult.error.issues[0];
return NextResponse.json(
{ success: false, message: firstError?.message ?? '表单数据无效' },
{ status: 400 }
);
}
const { name, phone, email, subject, message } = parseResult.data;
const formBody = new URLSearchParams();
formBody.append('name', body.get('name') as string);
formBody.append('phone', body.get('phone') as string);
formBody.append('email', body.get('email') as string);
formBody.append('subject', body.get('subject') as string);
formBody.append('message', body.get('message') as string);
formBody.append('_subject', `网站留言: ${body.get('subject')}`);
formBody.append('name', sanitizeInput(name));
formBody.append('phone', sanitizeInput(phone));
formBody.append('email', sanitizeInput(email));
formBody.append('subject', sanitizeInput(subject));
formBody.append('message', sanitizeInput(message));
formBody.append('_subject', `网站留言: ${sanitizeInput(subject)}`);
formBody.append('_captcha', 'false');
formBody.append('_template', 'table');
@@ -24,7 +90,8 @@ export async function POST(request: NextRequest) {
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch {
} catch (error) {
console.error('Contact form error:', error);
return NextResponse.json(
{ success: false, message: '服务器内部错误' },
{ status: 500 }
+250 -3
View File
@@ -19,13 +19,17 @@ body {
--color-ink-lighter: #151B23;
--color-ink-rgb: 10, 14, 20;
/* 品牌信号色:朱砂红 */
/* 品牌信号色:朱砂红 — Committed 色彩策略(Bain 式自信表达) */
--color-brand: #C41E3A;
--color-brand-hover: #A01830;
--color-brand-light: #E04A68;
--color-brand-soft: rgba(196, 30, 58, 0.12);
--color-brand-bg: #FEF2F4;
--color-brand-rgb: 196, 30, 58;
/* 品牌色深色区块(Bain 式深红背景) */
--color-brand-section: #8B1530;
--color-brand-section-text: #F8FAFC;
--color-brand-section-muted: rgba(248, 250, 252, 0.7);
/* 辅助色:服务/行业编码 */
--color-accent-blue: #3B82F6;
@@ -152,13 +156,13 @@ body {
--line-height-relaxed: 1.85;
/* 字间距系统(按使用场景分层) */
--letter-spacing-tighter: -0.04em;
--letter-spacing-tighter: -0.03em; /* 展示标题,不低于 -0.04em 地板 */
--letter-spacing-tight: -0.02em;
--letter-spacing-normal: 0;
--letter-spacing-wide: 0.03em;
--letter-spacing-wider: 0.06em;
--letter-spacing-widest: 0.12em;
--letter-spacing-eyebrow: 0.18em;
--letter-spacing-eyebrow: 0.12em; /* 减小,避免过宽追踪 */
/* 段落间距 */
--paragraph-spacing: 1.25em;
@@ -235,6 +239,10 @@ body {
--card-padding-lg: 2.5rem;
--card-gap: 1.5rem;
--card-border-radius: var(--radius-lg);
/* Bain 式干净卡片:边框 + 阴影,无复杂光晕 */
--card-shadow: 0 1px 3px rgba(10, 14, 20, 0.06);
--card-shadow-hover: 0 8px 24px rgba(10, 14, 20, 0.1);
--card-border: 1px solid var(--color-border-primary);
/* 输入框 */
--input-height: 2.75rem;
@@ -245,6 +253,83 @@ body {
--section-gap: var(--spacing-3xl);
--section-gap-sm: var(--spacing-2xl);
--section-padding-y: var(--spacing-3xl);
/* === 组件令牌层(Component Tokens=== */
/* Badge 标签 */
--badge-height-sm: 1.25rem;
--badge-height-md: 1.5rem;
--badge-padding-x: 0.625rem;
--badge-font-size: 0.6875rem;
--badge-radius: var(--radius-full);
/* Tabs 标签页 */
--tab-height: 2.5rem;
--tab-padding-x: 1rem;
--tab-font-size: 0.875rem;
--tab-indicator-height: 2px;
/* Modal / Dialog 弹窗 */
--modal-padding: 2rem;
--modal-radius: var(--radius-xl);
--modal-max-width-sm: 420px;
--modal-max-width-md: 560px;
--modal-max-width-lg: 720px;
/* Tooltip 提示 */
--tooltip-padding: 0.5rem 0.75rem;
--tooltip-radius: var(--radius-sm);
--tooltip-font-size: 0.75rem;
/* Form 表单 */
--label-font-size: 0.875rem;
--label-margin-bottom: 0.5rem;
--form-group-gap: 1.25rem;
/* Avatar 头像 */
--avatar-size-sm: 2rem;
--avatar-size-md: 2.5rem;
--avatar-size-lg: 3rem;
--avatar-radius: var(--radius-full);
/* Table 表格 */
--table-header-bg: var(--color-bg-secondary);
--table-header-font-size: 0.75rem;
--table-cell-padding-y: 0.75rem;
--table-cell-padding-x: 1rem;
--table-border-color: var(--color-border-primary);
/* Breadcrumb 面包屑 */
--breadcrumb-font-size: 0.875rem;
--breadcrumb-separator-margin: 0.5rem;
/* Menu / Dropdown 菜单 */
--menu-item-height: 2.5rem;
--menu-padding-y: 0.5rem;
--menu-min-width: 180px;
/* Accordion 手风琴 */
--accordion-item-gap: 0.5rem;
--accordion-trigger-height: 3rem;
--accordion-trigger-padding-x: 1rem;
/* Skeleton 骨架屏 */
--skeleton-radius: var(--radius-sm);
--skeleton-bg: var(--color-bg-tertiary);
/* Stats Bar 数据条 */
--stats-bar-gap: var(--spacing-2xl);
--stats-number-size: var(--font-size-4xl);
--stats-label-size: var(--font-size-xs);
/* === 12 列网格系统(Grid System=== */
--grid-columns: 12;
--grid-gap-xs: 0.5rem;
--grid-gap-sm: 1rem;
--grid-gap-md: 1.5rem;
--grid-gap-lg: 2rem;
--grid-gap-xl: 2.5rem;
--grid-gap-2xl: 3rem;
}
@layer base {
@@ -600,6 +685,90 @@ body {
background: radial-gradient(ellipse at 50% 0%, rgba(var(--color-brand-rgb), 0.08) 0%, transparent 60%);
}
/* Bain 式干净卡片(替代旧墨韵光晕卡片) */
.bain-card {
background: var(--color-bg-primary);
border: var(--card-border);
border-radius: var(--card-border-radius);
box-shadow: var(--card-shadow);
transition: transform var(--transition-fast) var(--ease-out),
box-shadow var(--transition-fast) var(--ease-out);
}
.bain-card:hover {
transform: translateY(-4px);
box-shadow: var(--card-shadow-hover);
}
/* === Bain 式不对称布局系统 === */
/* 宽窄列:左宽右窄(65/35) */
.asymmetric-wide-narrow {
display: grid;
grid-template-columns: 1fr;
gap: var(--spacing-2xl);
}
@media (min-width: 1024px) {
.asymmetric-wide-narrow {
grid-template-columns: 5fr 3fr;
gap: var(--spacing-4xl);
}
}
/* 宽窄列:左窄右宽(35/65) */
.asymmetric-narrow-wide {
display: grid;
grid-template-columns: 1fr;
gap: var(--spacing-2xl);
}
@media (min-width: 1024px) {
.asymmetric-narrow-wide {
grid-template-columns: 3fr 5fr;
gap: var(--spacing-4xl);
}
}
/* 不对称三列:左宽 + 中窄 + 右窄(50/25/25 */
.asymmetric-three {
display: grid;
grid-template-columns: 1fr;
gap: var(--spacing-2xl);
}
@media (min-width: 1024px) {
.asymmetric-three {
grid-template-columns: 2fr 1fr 1fr;
gap: var(--spacing-3xl);
}
}
/* 大留白区块:用于 Hero 下方过渡 */
.section-breathing {
padding-top: var(--spacing-5xl);
padding-bottom: var(--spacing-5xl);
}
@media (min-width: 768px) {
.section-breathing {
padding-top: var(--spacing-6xl);
padding-bottom: var(--spacing-6xl);
}
}
/* 紧致区块:用于数据密集区 */
.section-compact {
padding-top: var(--spacing-2xl);
padding-bottom: var(--spacing-2xl);
}
@media (min-width: 768px) {
.section-compact {
padding-top: var(--spacing-3xl);
padding-bottom: var(--spacing-3xl);
}
}
/* 品牌色深色区块(Bain 式 Hero/CTA 深红背景) */
.bg-brand-section {
background-color: var(--color-brand-section);
color: var(--color-brand-section-text);
}
.bg-glow-hero {
background:
radial-gradient(ellipse at 20% 30%, rgba(var(--color-brand-rgb), 0.1) 0%, transparent 50%),
@@ -656,6 +825,84 @@ body {
user-select: none;
pointer-events: none;
}
/* === 12 列网格系统 === */
.grid-12 {
display: grid;
grid-template-columns: repeat(12, 1fr);
}
.grid-gap-xs { gap: var(--grid-gap-xs); }
.grid-gap-sm { gap: var(--grid-gap-sm); }
.grid-gap-md { gap: var(--grid-gap-md); }
.grid-gap-lg { gap: var(--grid-gap-lg); }
.grid-gap-xl { gap: var(--grid-gap-xl); }
.grid-gap-2xl { gap: var(--grid-gap-2xl); }
/* 列跨越(1-12 */
.col-span-1 { grid-column: span 1 / span 1; }
.col-span-2 { grid-column: span 2 / span 2; }
.col-span-3 { grid-column: span 3 / span 3; }
.col-span-4 { grid-column: span 4 / span 4; }
.col-span-5 { grid-column: span 5 / span 5; }
.col-span-6 { grid-column: span 6 / span 6; }
.col-span-7 { grid-column: span 7 / span 7; }
.col-span-8 { grid-column: span 8 / span 8; }
.col-span-9 { grid-column: span 9 / span 9; }
.col-span-10 { grid-column: span 10 / span 10; }
.col-span-11 { grid-column: span 11 / span 11; }
.col-span-12 { grid-column: span 12 / span 12; }
/* 响应式列跨越 */
@media (min-width: 640px) {
.sm\:col-span-1 { grid-column: span 1 / span 1; }
.sm\:col-span-2 { grid-column: span 2 / span 2; }
.sm\:col-span-3 { grid-column: span 3 / span 3; }
.sm\:col-span-4 { grid-column: span 4 / span 4; }
.sm\:col-span-5 { grid-column: span 5 / span 5; }
.sm\:col-span-6 { grid-column: span 6 / span 6; }
.sm\:col-span-7 { grid-column: span 7 / span 7; }
.sm\:col-span-8 { grid-column: span 8 / span 8; }
.sm\:col-span-9 { grid-column: span 9 / span 9; }
.sm\:col-span-10 { grid-column: span 10 / span 10; }
.sm\:col-span-11 { grid-column: span 11 / span 11; }
.sm\:col-span-12 { grid-column: span 12 / span 12; }
}
@media (min-width: 768px) {
.md\:col-span-1 { grid-column: span 1 / span 1; }
.md\:col-span-2 { grid-column: span 2 / span 2; }
.md\:col-span-3 { grid-column: span 3 / span 3; }
.md\:col-span-4 { grid-column: span 4 / span 4; }
.md\:col-span-5 { grid-column: span 5 / span 5; }
.md\:col-span-6 { grid-column: span 6 / span 6; }
.md\:col-span-7 { grid-column: span 7 / span 7; }
.md\:col-span-8 { grid-column: span 8 / span 8; }
.md\:col-span-9 { grid-column: span 9 / span 9; }
.md\:col-span-10 { grid-column: span 10 / span 10; }
.md\:col-span-11 { grid-column: span 11 / span 11; }
.md\:col-span-12 { grid-column: span 12 / span 12; }
}
@media (min-width: 1024px) {
.lg\:col-span-1 { grid-column: span 1 / span 1; }
.lg\:col-span-2 { grid-column: span 2 / span 2; }
.lg\:col-span-3 { grid-column: span 3 / span 3; }
.lg\:col-span-4 { grid-column: span 4 / span 4; }
.lg\:col-span-5 { grid-column: span 5 / span 5; }
.lg\:col-span-6 { grid-column: span 6 / span 6; }
.lg\:col-span-7 { grid-column: span 7 / span 7; }
.lg\:col-span-8 { grid-column: span 8 / span 8; }
.lg\:col-span-9 { grid-column: span 9 / span 9; }
.lg\:col-span-10 { grid-column: span 10 / span 10; }
.lg\:col-span-11 { grid-column: span 11 / span 11; }
.lg\:col-span-12 { grid-column: span 12 / span 12; }
}
/* 网格行跨越 */
.row-span-1 { grid-row: span 1 / span 1; }
.row-span-2 { grid-row: span 2 / span 2; }
.row-span-3 { grid-row: span 3 / span 3; }
}
/* ===== 动画关键帧 ===== */
+91
View File
@@ -0,0 +1,91 @@
'use client';
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { Home, ArrowLeft, Search } from 'lucide-react';
export function NotFoundContent() {
return (
<div className="min-h-screen bg-[var(--color-bg-primary)] flex items-center justify-center">
<div className="container-wide px-4 py-20">
<div className="max-w-xl mx-auto text-center">
<div className="mb-8">
<h1 className="text-8xl sm:text-[120px] font-bold text-[var(--color-brand)] leading-none mb-4 opacity-20">
404
</h1>
</div>
<h2 className="text-2xl sm:text-3xl font-bold text-[var(--color-text-primary)] mb-4">
</h2>
<p className="text-base text-[var(--color-text-muted)] mb-8 leading-relaxed">
访
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-12">
<Button size="lg" asChild>
<StaticLink href="/">
<Home className="w-5 h-5 mr-2" />
</StaticLink>
</Button>
<Button size="lg" variant="outline" onClick={() => window.history.back()}>
<ArrowLeft className="w-5 h-5 mr-2" />
</Button>
</div>
<div className="bg-[var(--color-bg-section)] rounded-xl p-6 sm:p-8">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-5">
</h3>
<div className="grid grid-cols-2 gap-3">
<StaticLink
href="/products"
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
>
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
<Search className="w-4 h-4 text-[var(--color-brand)]" />
</div>
<span className="font-medium text-sm text-[var(--color-text-primary)]"></span>
</StaticLink>
<StaticLink
href="/solutions"
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
>
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
<Search className="w-4 h-4 text-[var(--color-brand)]" />
</div>
<span className="font-medium text-sm text-[var(--color-text-primary)]"></span>
</StaticLink>
<StaticLink
href="/about"
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
>
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
<Search className="w-4 h-4 text-[var(--color-brand)]" />
</div>
<span className="font-medium text-sm text-[var(--color-text-primary)]"></span>
</StaticLink>
<StaticLink
href="/contact"
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
>
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
<Search className="w-4 h-4 text-[var(--color-brand)]" />
</div>
<span className="font-medium text-sm text-[var(--color-text-primary)]"></span>
</StaticLink>
</div>
</div>
</div>
</div>
</div>
);
}
+6 -99
View File
@@ -1,103 +1,10 @@
'use client';
import type { Metadata } from 'next';
import { NotFoundContent } from './not-found-content';
import { useEffect } from 'react';
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { Home, ArrowLeft, Search } from 'lucide-react';
export const metadata: Metadata = {
title: '页面未找到',
};
export default function NotFound() {
useEffect(() => {
document.title = '页面未找到 - 睿新致远';
}, []);
return (
<div className="min-h-screen bg-[var(--color-bg-primary)] flex items-center justify-center">
<div className="container-wide px-4 py-20">
<div className="max-w-xl mx-auto text-center">
<div className="mb-8">
<h1 className="text-8xl sm:text-[120px] font-bold text-[var(--color-brand)] leading-none mb-4 opacity-20">
404
</h1>
</div>
<h2 className="text-2xl sm:text-3xl font-bold text-[var(--color-text-primary)] mb-4">
</h2>
<p className="text-base text-[var(--color-text-muted)] mb-8 leading-relaxed">
访
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-12">
<Button
size="lg"
asChild
>
<StaticLink href="/">
<Home className="w-5 h-5 mr-2" />
</StaticLink>
</Button>
<Button
size="lg"
variant="outline"
onClick={() => window.history.back()}
>
<ArrowLeft className="w-5 h-5 mr-2" />
</Button>
</div>
<div className="bg-[var(--color-bg-section)] rounded-xl p-6 sm:p-8">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-5">
</h3>
<div className="grid grid-cols-2 gap-3">
<StaticLink
href="/products"
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
>
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
<Search className="w-4 h-4 text-[var(--color-brand)]" />
</div>
<span className="font-medium text-sm text-[var(--color-text-primary)]"></span>
</StaticLink>
<StaticLink
href="/solutions"
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
>
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
<Search className="w-4 h-4 text-[var(--color-brand)]" />
</div>
<span className="font-medium text-sm text-[var(--color-text-primary)]"></span>
</StaticLink>
<StaticLink
href="/about"
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
>
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
<Search className="w-4 h-4 text-[var(--color-brand)]" />
</div>
<span className="font-medium text-sm text-[var(--color-text-primary)]"></span>
</StaticLink>
<StaticLink
href="/contact"
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
>
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
<Search className="w-4 h-4 text-[var(--color-brand)]" />
</div>
<span className="font-medium text-sm text-[var(--color-text-primary)]"></span>
</StaticLink>
</div>
</div>
</div>
</div>
</div>
);
return <NotFoundContent />;
}
+54
View File
@@ -2,9 +2,17 @@ import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import PrivacyPolicyPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
}));
import { getPublishedItems } from '@/lib/cms/data-server';
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
describe('PrivacyPolicyPage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItems.mockResolvedValue([]);
});
describe('Rendering', () => {
@@ -49,4 +57,50 @@ describe('PrivacyPolicyPage', () => {
expect(h2s.length).toBeGreaterThan(0);
});
});
describe('CMS integration', () => {
it('should render CMS hero title and description when available', async () => {
mockedGetPublishedItems.mockResolvedValue([
{
id: 'legal-privacy',
modelId: 'model-legal-page',
modelCode: 'legal-page',
title: '隐私政策',
slug: 'privacy',
locale: 'zh-CN',
status: 'published',
data: {
pageType: 'privacy',
heroTitle: 'CMS 隐私政策标题',
heroDescription: 'CMS 隐私政策描述',
lastUpdated: '2026-07-17',
content: '<div data-testid="cms-content"><h2>CMS 内容</h2></div>',
},
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 PrivacyPolicyPage();
render(page);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('CMS 隐私政策标题');
expect(screen.getByText('CMS 隐私政策描述')).toBeInTheDocument();
expect(screen.getByTestId('cms-content')).toBeInTheDocument();
});
it('should fall back to inline content when CMS is unavailable', async () => {
mockedGetPublishedItems.mockRejectedValue(new Error('DB connection failed'));
const page = await PrivacyPolicyPage();
render(page);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('隐私政策');
expect(screen.getByText('引言')).toBeInTheDocument();
});
});
});
+13 -2
View File
@@ -7,6 +7,9 @@ export const metadata: Metadata = {
description: `${COMPANY_INFO.name}隐私政策`,
};
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
export const revalidate = 3600;
function PrivacyContent() {
return (
<div className="prose prose-lg max-w-none">
@@ -224,6 +227,8 @@ function PrivacyContent() {
export default async function PrivacyPolicyPage() {
let cmsContent: string | null = null;
let heroTitle = '隐私政策';
let heroDescription = '我们重视您的隐私,致力于保护您的个人信息安全';
try {
const items = await getPublishedItems('legal-page');
@@ -231,6 +236,12 @@ export default async function PrivacyPolicyPage() {
if (privacyItem && typeof privacyItem.data.content === 'string' && privacyItem.data.content.length > 0) {
cmsContent = privacyItem.data.content as string;
}
if (privacyItem && typeof privacyItem.data.heroTitle === 'string' && privacyItem.data.heroTitle.length > 0) {
heroTitle = privacyItem.data.heroTitle as string;
}
if (privacyItem && typeof privacyItem.data.heroDescription === 'string' && privacyItem.data.heroDescription.length > 0) {
heroDescription = privacyItem.data.heroDescription as string;
}
} catch {
// CMS not available, fall back to inline content
}
@@ -240,10 +251,10 @@ export default async function PrivacyPolicyPage() {
<div className="bg-gradient-to-br from-[var(--color-brand)] via-[var(--color-brand)]/80 to-[var(--color-hero-dark-end)] py-20">
<div className="container-wide">
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
{heroTitle}
</h1>
<p className="text-xl text-white/90 max-w-2xl">
{heroDescription}
</p>
</div>
</div>
+54
View File
@@ -2,9 +2,17 @@ import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import TermsOfServicePage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
}));
import { getPublishedItems } from '@/lib/cms/data-server';
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
describe('TermsOfServicePage', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetPublishedItems.mockResolvedValue([]);
});
describe('Rendering', () => {
@@ -49,4 +57,50 @@ describe('TermsOfServicePage', () => {
expect(h2s.length).toBeGreaterThan(0);
});
});
describe('CMS integration', () => {
it('should render CMS hero title and description when available', async () => {
mockedGetPublishedItems.mockResolvedValue([
{
id: 'legal-terms',
modelId: 'model-legal-page',
modelCode: 'legal-page',
title: '服务条款',
slug: 'terms',
locale: 'zh-CN',
status: 'published',
data: {
pageType: 'terms',
heroTitle: 'CMS 服务条款标题',
heroDescription: 'CMS 服务条款描述',
lastUpdated: '2026-07-17',
content: '<div data-testid="cms-content"><h2>CMS 条款内容</h2></div>',
},
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 TermsOfServicePage();
render(page);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('CMS 服务条款标题');
expect(screen.getByText('CMS 服务条款描述')).toBeInTheDocument();
expect(screen.getByTestId('cms-content')).toBeInTheDocument();
});
it('should fall back to inline content when CMS is unavailable', async () => {
mockedGetPublishedItems.mockRejectedValue(new Error('DB connection failed'));
const page = await TermsOfServicePage();
render(page);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('服务条款');
expect(screen.getByText('引言')).toBeInTheDocument();
});
});
});
+13 -2
View File
@@ -7,6 +7,9 @@ export const metadata: Metadata = {
description: `${COMPANY_INFO.name}服务条款`,
};
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
export const revalidate = 3600;
function TermsContent() {
return (
<div className="prose prose-lg max-w-none">
@@ -169,6 +172,8 @@ function TermsContent() {
export default async function TermsOfServicePage() {
let cmsContent: string | null = null;
let heroTitle = '服务条款';
let heroDescription = '请仔细阅读以下服务条款,使用我们的服务即表示您同意这些条款';
try {
const items = await getPublishedItems('legal-page');
@@ -176,6 +181,12 @@ export default async function TermsOfServicePage() {
if (termsItem && typeof termsItem.data.content === 'string' && termsItem.data.content.length > 0) {
cmsContent = termsItem.data.content as string;
}
if (termsItem && typeof termsItem.data.heroTitle === 'string' && termsItem.data.heroTitle.length > 0) {
heroTitle = termsItem.data.heroTitle as string;
}
if (termsItem && typeof termsItem.data.heroDescription === 'string' && termsItem.data.heroDescription.length > 0) {
heroDescription = termsItem.data.heroDescription as string;
}
} catch {
// CMS not available, fall back to inline content
}
@@ -185,10 +196,10 @@ export default async function TermsOfServicePage() {
<div className="bg-gradient-to-br from-[var(--color-brand)] via-[var(--color-brand)]/80 to-[var(--color-hero-dark-end)] py-20">
<div className="container-wide">
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
{heroTitle}
</h1>
<p className="text-xl text-white/90 max-w-2xl">
使
{heroDescription}
</p>
</div>
</div>
+8
View File
@@ -21,6 +21,7 @@ import {
Users,
Phone,
Shield,
UserCog,
} from 'lucide-react';
const menuItems = [
@@ -61,6 +62,13 @@ const menuItems = [
{ label: '媒体库', href: '/admin/media', icon: Image },
],
},
{
label: '系统管理',
icon: Shield,
children: [
{ label: '角色权限', href: '/admin/roles', icon: UserCog },
],
},
];
export function AdminLayout({ children }: { children: React.ReactNode }) {
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import { MethodologyFramework } from './sections';
describe('MethodologyFramework', () => {
const layers = [
{
title: '诊断',
description: '业务现状诊断',
items: ['访谈', '数据分析'],
},
{
title: '设计',
description: '解决方案设计',
items: ['架构设计'],
},
];
it('renders title and subtitle', () => {
render(<MethodologyFramework title="方法论" subtitle="分层推进" layers={layers} />);
expect(screen.getByText('方法论')).toBeInTheDocument();
expect(screen.getByText('分层推进')).toBeInTheDocument();
});
it('renders all layers and items', () => {
render(<MethodologyFramework title="方法论" layers={layers} />);
expect(screen.getByText('诊断')).toBeInTheDocument();
expect(screen.getByText('设计')).toBeInTheDocument();
expect(screen.getByText('业务现状诊断')).toBeInTheDocument();
expect(screen.getByText('数据分析')).toBeInTheDocument();
});
it('renders without subtitle', () => {
const { container } = render(<MethodologyFramework title="方法论" layers={layers} />);
expect(container.querySelector('h2')).toHaveTextContent('方法论');
});
});
+2
View File
@@ -327,6 +327,8 @@ export interface DataProofSectionProps {
}
export function DataProofSection({ title, subtitle, metrics }: DataProofSectionProps) {
if (!metrics || metrics.length === 0) return null;
return (
<section className="relative py-20 sm:py-28 md:py-32 lg:py-40 overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
@@ -0,0 +1,55 @@
import { describe, it, expect } from '@jest/globals';
import { render, screen, fireEvent } from '@testing-library/react';
import { TestimonialSection, LogoWall } from './testimonials';
describe('TestimonialSection', () => {
const testimonials = [
{
quote: '服务非常专业',
author: '张三',
title: 'CEO',
company: 'A 公司',
industry: '科技',
},
{
quote: '交付准时',
author: '李四',
title: 'CTO',
company: 'B 公司',
industry: '制造',
},
];
it('renders title and subtitle', () => {
render(<TestimonialSection title="客户评价" subtitle="来自客户的真实反馈" testimonials={testimonials} />);
expect(screen.getByText('客户评价')).toBeInTheDocument();
expect(screen.getByText('来自客户的真实反馈')).toBeInTheDocument();
});
it('renders first testimonial by default', () => {
render(<TestimonialSection title="客户评价" testimonials={testimonials} />);
expect(screen.getByText('张三')).toBeInTheDocument();
expect(screen.getByText('科技')).toBeInTheDocument();
});
it('switches testimonial on dot click', () => {
render(<TestimonialSection title="客户评价" testimonials={testimonials} />);
const dots = screen.getAllByRole('button');
fireEvent.click(dots[1]!);
expect(screen.getByText('李四')).toBeInTheDocument();
expect(screen.getByText('制造')).toBeInTheDocument();
});
it('renders without subtitle', () => {
const { container } = render(<TestimonialSection title="客户评价" testimonials={testimonials} />);
expect(container.querySelector('h2')).toHaveTextContent('客户评价');
});
});
describe('LogoWall (testimonials)', () => {
it('renders logo wall', () => {
render(<LogoWall title="合作企业" logos={[{ name: 'A 公司' }, { name: 'B 公司', industry: '制造' }]} />);
expect(screen.getByText('合作企业')).toBeInTheDocument();
expect(screen.getByText('A 公司')).toBeInTheDocument();
});
});
-43
View File
@@ -101,49 +101,6 @@ export function CalligraphyText({
);
}
interface InkDividerProps {
variant?: 'subtle' | 'bold' | 'decorative';
}
export function InkDivider({ variant = 'subtle' }: InkDividerProps) {
if (variant === 'subtle') {
return (
<div className="flex items-center justify-center gap-4 py-6">
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-gray-200 to-transparent" />
<div className="w-2 h-2 rounded-full bg-gray-300" />
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-gray-200 to-transparent" />
</div>
);
}
if (variant === 'bold') {
return (
<div className="flex items-center gap-6 py-8">
<div className="flex-1 h-0.5 bg-gradient-to-r from-transparent via-gray-300 to-[#C41E3A]" />
<BrandSeal size="sm" variant="red" text="墨" />
<div className="flex-1 h-0.5 bg-gradient-to-l from-transparent via-gray-300 to-[#C41E3A]" />
</div>
);
}
return (
<div className="flex items-center justify-center py-8">
<svg width="200" height="24" viewBox="0 0 200 24" fill="none" className="opacity-40">
<path
d="M0 12 Q 50 4, 100 12 T 200 12"
stroke="#C41E3A"
strokeWidth="1"
fill="none"
vectorEffect="non-scaling-stroke"
/>
<circle cx="100" cy="12" r="3" fill="#C41E3A" opacity="0.6" />
<circle cx="60" cy="9" r="1.5" fill="#C41E3A" opacity="0.3" />
<circle cx="140" cy="15" r="1.5" fill="#C41E3A" opacity="0.3" />
</svg>
</div>
);
}
interface SectionHeaderProps {
badge?: string;
title: ReactNode;
@@ -34,25 +34,28 @@ export function CrossRecommendGrid({ items, title = '相关推荐' }: CrossRecom
if (items.length === 0) return null;
return (
<section className="bg-white py-16 lg:py-20">
<div className="container-wide">
<motion.h2
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
<section className="bg-bg-secondary py-16 sm:py-20 md:py-28">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="mb-2 text-center text-2xl font-bold text-ink"
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
className="mb-12"
>
{title}
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: 0.1 }}
className="mx-auto mb-8 max-w-2xl text-center text-sm text-text-muted"
>
</motion.p>
<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">
{title}
</span>
</div>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-black text-ink tracking-tight leading-[0.95]">
</h2>
<p className="text-text-secondary mt-3">
</p>
</motion.div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item, index) => {
@@ -67,10 +70,10 @@ export function CrossRecommendGrid({ items, title = '相关推荐' }: CrossRecom
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: index * 0.08 }}
className="group flex items-start gap-4 rounded-xl border border-border-primary bg-bg-secondary/50 p-5 transition-all hover:border-border-secondary hover:bg-white hover:shadow-md"
className="bain-card flex items-start gap-4 p-5 group"
>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
className="flex h-10 w-10 shrink-0 items-center justify-center"
style={{ backgroundColor: config.bg, color: config.color }}
>
<Icon className="h-5 w-5" />
@@ -100,4 +103,4 @@ export function CrossRecommendGrid({ items, title = '相关推荐' }: CrossRecom
</div>
</section>
);
}
}
+28 -23
View File
@@ -1,9 +1,8 @@
'use client';
import { motion } from 'framer-motion';
import { ArrowRight, MessageCircle, Download } from 'lucide-react';
import { ArrowRight, MessageCircle } from 'lucide-react';
import type { HeroTheme } from '@/lib/constants/hero-themes';
import { DESIGN_SYSTEM } from '@/lib/constants/design-system';
interface DetailCTASectionProps {
theme: HeroTheme;
@@ -21,48 +20,54 @@ export function DetailCTASection({
secondaryAction,
}: DetailCTASectionProps) {
return (
<section className="relative py-24 lg:py-32 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" />
<section className="relative py-24 sm:py-32 md:py-40 overflow-hidden bg-brand-section">
{/* 背景纹理 */}
<div className="absolute inset-0 pointer-events-none opacity-[0.04]"
style={{
backgroundImage: `radial-gradient(circle at 25% 25%, rgba(255,255,255,0.3) 1px, transparent 1px)`,
backgroundSize: '48px 48px',
}}
/>
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-1/3 right-1/4 w-[500px] h-[500px] bg-white/[0.03] rounded-full blur-[140px]" />
</div>
<div className="container-wide relative">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<motion.div
initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-100px' }}
transition={{ duration: 0.8, ease: [0.4, 0, 0.2, 1] }}
className="max-w-3xl mx-auto text-center"
transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
className="max-w-3xl"
>
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-brand-soft text-brand text-sm font-medium mb-6 border border-brand/20">
{/* 标签 */}
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 text-white/80 text-sm font-medium mb-8 border border-white/20">
<MessageCircle className="w-4 h-4" />
</div>
<h2 className={`${DESIGN_SYSTEM.typography.hero.title} text-ink mb-6`}>
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-white tracking-tight leading-[0.95] mb-10">
{title}
</h2>
<p className="text-lg md:text-xl text-text-secondary mb-12 max-w-2xl mx-auto leading-relaxed">
<p className="text-lg md:text-xl text-white/70 max-w-2xl leading-relaxed mb-12">
{description}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-5">
<motion.a
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-5">
<a
href={primaryAction.href}
whileHover={{ scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }}
className="group relative inline-flex items-center gap-3 px-10 py-4 bg-brand text-white font-bold rounded-xl shadow-md hover:shadow-lg transition-all duration-300"
className="group inline-flex items-center gap-3 px-10 py-4 bg-white text-brand font-bold text-base hover:bg-white/90 transition-all duration-200"
>
<span>{primaryAction.label}</span>
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</motion.a>
<ArrowRight className="w-5 h-5 transition-transform duration-300 group-hover:translate-x-1" />
</a>
{secondaryAction && (
<a
href={secondaryAction.href}
className="group inline-flex items-center gap-2.5 px-8 py-4 text-ink font-semibold rounded-xl border-2 border-border-primary hover:bg-white hover:border-brand/30 transition-all duration-300"
className="group inline-flex items-center gap-2.5 px-8 py-4 text-white/80 font-semibold text-sm border border-white/30 hover:border-white/60 hover:text-white hover:bg-white/10 transition-all duration-200"
>
<Download className="w-5 h-5 transition-transform duration-300 group-hover:-translate-y-0.5" />
{secondaryAction.label}
</a>
)}
@@ -73,7 +78,7 @@ export function DetailCTASection({
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: 0.5, duration: 0.6 }}
className="mt-10 text-sm text-text-muted"
className="mt-10 text-sm text-white/50"
>
&lt; 2 · 7×24 ·
</motion.p>
@@ -81,4 +86,4 @@ export function DetailCTASection({
</div>
</section>
);
}
}
+18 -1
View File
@@ -13,6 +13,8 @@ interface DetailHeroProps {
description?: string;
primaryAction?: { label: string; href: string };
secondaryAction?: { label: string; href: string };
/** Bain 式变体:'light' | 'brand'(深红背景) */
variant?: 'light' | 'brand';
}
export function DetailHero({
@@ -23,11 +25,26 @@ export function DetailHero({
description,
primaryAction,
secondaryAction,
variant = 'light',
}: DetailHeroProps) {
const isCenterLayout = theme.layout === 'center' || theme.layout === 'wide';
const isBrand = variant === 'brand';
const bgClass = isBrand
? 'bg-brand-section text-white'
: 'bg-white text-ink';
return (
<section className="relative min-h-[700px] flex items-center overflow-hidden bg-white">
<section className={`relative min-h-[700px] flex items-center overflow-hidden ${bgClass}`}>
{/* 品牌色背景纹理 */}
{isBrand && (
<div className="absolute inset-0 pointer-events-none opacity-[0.04]"
style={{
backgroundImage: `radial-gradient(circle at 25% 25%, rgba(255,255,255,0.3) 1px, transparent 1px)`,
backgroundSize: '48px 48px',
}}
/>
)}
<div className={`container-wide relative z-10 ${theme.layout === 'wide' ? 'py-36 lg:py-44' : 'py-32 lg:py-40'}`}>
<motion.div
initial="hidden"
+111 -246
View File
@@ -15,7 +15,6 @@ import {
Users,
type LucideIcon,
} from 'lucide-react';
import { TiltCard, InkGlowCard } from './micro-interactions';
import type { Product } from '@/lib/constants/products';
interface ProductValueSectionProps {
@@ -36,76 +35,24 @@ function FeatureTags({ text }: { text: string }) {
<div className="space-y-3">
<h4 className="font-bold text-ink text-base">{title}</h4>
<div className="flex flex-wrap gap-2">
{tags.map((tag, i) => (
<motion.span
{tags.map((tag) => (
<span
key={tag}
initial={{ opacity: 0, scale: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ delay: i * 0.05, duration: 0.3 }}
className="px-3 py-1.5 bg-bg-secondary text-text-secondary text-xs font-medium rounded-lg border border-border-primary hover:border-brand/30 hover:bg-brand-soft/30 transition-colors cursor-default"
className="px-3 py-1.5 bg-bg-secondary text-text-secondary text-xs font-medium border border-border-primary"
>
{tag}
</motion.span>
</span>
))}
</div>
</div>
);
}
function CircularProgress({ value, label, color = '#C41E3A' }: { value: number; label: string; color?: string }) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once: true });
const [progress, setProgress] = useState(0);
if (isInView && progress === 0) {
setTimeout(() => setProgress(value), 300);
}
const circumference = 2 * Math.PI * 45;
const strokeDashoffset = circumference - (progress / 100) * circumference;
return (
<div ref={ref} className="text-center">
<div className="relative w-28 h-28 mx-auto mb-3">
<svg className="w-full h-full -rotate-90" viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="var(--color-bg-tertiary)"
strokeWidth="8"
/>
<motion.circle
cx="50"
cy="50"
r="45"
fill="none"
stroke={color}
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={circumference}
initial={{ strokeDashoffset: circumference }}
animate={{ strokeDashoffset }}
transition={{ duration: 1.5, ease: [0.22, 1, 0.36, 1] as const }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xl font-bold text-ink">{value}%</span>
</div>
</div>
<p className="text-xs font-medium text-text-muted">{label}</p>
</div>
);
}
function MetricCard({
icon: Icon,
value,
metric,
description,
index: _index,
accent = false,
}: {
icon: LucideIcon;
@@ -143,208 +90,132 @@ function MetricCard({
}
return (
<TiltCard
tiltAmount={8}
glareEnable={accent}
glareMaxOpacity={accent ? 0.12 : 0}
className={`p-8 rounded-2xl ${
accent
? 'bg-brand-soft/30 border border-brand/20'
: 'bg-white border border-border-primary hover:border-border-secondary'
}`}
>
<div className="bain-card p-8">
<div className="relative">
<div
className={`w-12 h-12 rounded-xl flex items-center justify-center mb-5 shadow-md ${
accent
? 'bg-gradient-to-br from-[#C41E3A] to-[#99182d] text-white'
: 'bg-bg-secondary text-text-secondary'
}`}
>
<div className={`w-12 h-12 flex items-center justify-center mb-5 ${
accent
? 'bg-brand text-white'
: 'bg-bg-secondary text-text-secondary'
}`}>
<Icon className="w-6 h-6" />
</div>
<div className="text-4xl md:text-5xl font-bold text-ink mb-2 tracking-tight">
<div ref={ref} className="text-3xl sm:text-4xl font-black text-brand tabular-nums leading-none mb-1">
{displayValue}
</div>
<h3 className="text-lg font-semibold text-ink mb-1.5">{metric}</h3>
<p className="text-sm text-text-muted leading-relaxed">{description}</p>
<h3 className="text-sm font-semibold text-ink mb-1.5">{metric}</h3>
<p className="text-sm text-text-secondary leading-relaxed">{description}</p>
</div>
</TiltCard>
</div>
);
}
export function ProductValueSection({ product }: ProductValueSectionProps) {
return (
<section className="relative bg-white py-24 lg:py-32 overflow-hidden">
<div className="container-wide relative">
<section className="relative bg-white py-20 sm:py-28 md:py-36 overflow-hidden">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative">
<motion.div
initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-120px' }}
transition={{ duration: 0.85, ease: [0.22, 1, 0.36, 1] as const }}
className="max-w-3xl mx-auto text-center mb-20"
transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
className="mb-16 sm:mb-20"
>
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold tracking-tight text-ink mb-5">
<span className="relative inline-block">
{product.title}
<motion.span
layoutId="underline"
className="absolute -bottom-1 left-0 right-0 h-1 bg-gradient-to-r from-[#C41E3A] to-[#99182d] rounded-full"
initial={{ scaleX: 0 }}
whileInView={{ scaleX: 1 }}
viewport={{ once: true }}
transition={{ delay: 0.7, duration: 0.6, ease: [0.22, 1, 0.36, 1] as const }}
/>
<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-[0.95] max-w-3xl">
{product.title}
<span className="text-brand"> </span>
</h2>
<p className="text-lg md:text-xl text-text-secondary max-w-2xl mx-auto leading-relaxed">
<p className="text-lg text-text-secondary max-w-2xl mt-6 leading-relaxed">
{product.overview}
</p>
</motion.div>
<div className="grid grid-cols-1 xl:grid-cols-5 gap-10 lg:gap-14">
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: '-80px' }}
variants={{
hidden: {},
visible: {
transition: {
staggerChildren: 0.08,
delayChildren: 0.2,
},
},
}}
className="xl:col-span-3 space-y-6"
>
<div className="flex items-center gap-4 mb-8">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-[#C41E3A] via-red-600 to-[#99182d] flex items-center justify-center shadow-md shadow-brand/20">
<Zap className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="text-2xl font-bold text-ink"></h3>
<p className="text-sm text-text-muted mt-0.5"></p>
</div>
{/* 功能模块网格 */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5 mb-16">
{product.features.map((feature, index) => {
const Icon = featureIcons[index % featureIcons.length]!;
return (
<motion.div
key={feature}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{
duration: 0.5,
delay: index * 0.06,
ease: [0.22, 1, 0.36, 1],
}}
className="bain-card p-6"
>
<div className="flex items-start gap-4">
<div className={`shrink-0 w-11 h-11 flex items-center justify-center ${
index === 0
? 'bg-brand text-white'
: 'bg-bg-secondary text-text-secondary'
}`}>
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0 pt-1">
<FeatureTags text={feature} />
</div>
</div>
</motion.div>
);
})}
</div>
{/* 核心优势 + 技术规格:不对称布局 */}
<div className="asymmetric-wide-narrow">
{/* 核心优势 */}
<div>
<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>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{product.features.map((feature, index) => {
const Icon = featureIcons[index % featureIcons.length]!;
return (
<InkGlowCard
key={feature}
active={index === 0}
className="group p-6 hover:border-brand/20"
>
<motion.div
variants={{
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.55,
ease: [0.22, 1, 0.36, 1] as const,
},
},
}}
>
<div className="flex items-start gap-4">
<div
className={`shrink-0 w-11 h-11 rounded-xl flex items-center justify-center transition-all duration-300 shadow-sm ${
index === 0
? 'bg-gradient-to-br from-[#C41E3A] to-[#99182d] text-white shadow-md shadow-brand/25'
: 'bg-bg-secondary text-text-secondary group-hover:from-[#C41E3A] group-hover:to-[#99182d] group-hover:text-white group-hover:shadow-md group-hover:shadow-brand/25'
}`}
>
<Icon className="w-5.5 h-5.5" />
</div>
<div className="flex-1 min-w-0 pt-1">
<FeatureTags text={feature} />
</div>
</div>
</motion.div>
</InkGlowCard>
);
})}
</div>
</motion.div>
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: '-80px' }}
variants={{
hidden: {},
visible: {
transition: {
staggerChildren: 0.06,
delayChildren: 0.35,
},
},
}}
className="xl:col-span-2 space-y-6"
>
<div className="flex items-center gap-4 mb-8">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center shadow-md shadow-blue-500/20">
<TrendingUp className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="text-2xl font-bold text-ink"></h3>
<p className="text-sm text-text-muted mt-0.5"></p>
</div>
</div>
<div className="space-y-4">
{product.benefits.map((benefit, index) => (
<motion.div
key={index}
variants={{
hidden: { opacity: 0, x: 20 },
visible: {
opacity: 1,
x: 0,
transition: {
duration: 0.5,
ease: [0.22, 1, 0.36, 1] as const,
},
},
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{
duration: 0.4,
delay: index * 0.06,
ease: [0.22, 1, 0.36, 1],
}}
className="group p-5 rounded-2xl bg-bg-secondary border border-border-primary hover:border-brand/30 hover:shadow-md hover:-translate-y-1 transition-all duration-300"
className="bain-card p-5 flex items-start gap-3"
>
<div className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-brand mt-0.5 shrink-0 group-hover:scale-110 transition-transform" />
<p className="text-sm font-medium text-text-secondary leading-relaxed">{benefit}</p>
</div>
<CheckCircle2 className="w-5 h-5 text-brand mt-0.5 shrink-0" />
<p className="text-sm font-medium text-text-secondary leading-relaxed">{benefit}</p>
</motion.div>
))}
</div>
</div>
<motion.div
variants={{
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
delay: 0.5,
duration: 0.55,
ease: [0.22, 1, 0.36, 1] as const,
},
},
}}
className="mt-8 p-6 rounded-2xl bg-bg-secondary border border-border-primary"
>
<h4 className="font-bold text-ink mb-5 flex items-center gap-2.5">
<Shield className="w-5 h-5 text-green-600" />
{/* 技术规格 */}
<div>
<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">
</h4>
<ul className="space-y-3.5">
</span>
</div>
<div className="bain-card p-6">
<div className="flex items-center gap-3 mb-4">
<Shield className="w-5 h-5 text-brand" />
<h4 className="font-bold text-ink text-sm"></h4>
</div>
<ul className="space-y-3">
{product.specs.slice(0, 5).map((spec, index) => (
<motion.li
key={index}
@@ -352,21 +223,22 @@ export function ProductValueSection({ product }: ProductValueSectionProps) {
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{
delay: 0.6 + index * 0.05,
delay: 0.3 + index * 0.05,
duration: 0.35,
ease: [0.22, 1, 0.36, 1] as const,
ease: [0.22, 1, 0.36, 1],
}}
className="flex items-start gap-3 text-sm text-text-secondary group/item"
className="flex items-start gap-3 text-sm text-text-secondary"
>
<ArrowRight className="w-4 h-4 text-brand mt-0.5 shrink-0 group-hover/item:translate-x-1 transition-transform" />
<ArrowRight className="w-4 h-4 text-brand mt-0.5 shrink-0" />
<span>{spec}</span>
</motion.li>
))}
</ul>
</motion.div>
</motion.div>
</div>
</div>
</div>
{/* 数据证明 */}
{product.dataProofs && product.dataProofs.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 32 }}
@@ -375,17 +247,22 @@ export function ProductValueSection({ product }: ProductValueSectionProps) {
transition={{ duration: 0.75, delay: 0.2 }}
className="mt-24"
>
<div className="text-center mb-14">
<h3 className="text-2xl md:text-3xl font-bold tracking-tight text-ink">
<div className="mb-12">
<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>
<h3 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[0.95]">
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-7 max-w-5xl mx-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl">
{product.dataProofs.map((proof, index) => {
const icons: LucideIcon[] = [TrendingUp, Clock, Award, Users];
const Icon = icons[index % icons.length]!;
return (
<MetricCard
key={proof.metric}
@@ -399,21 +276,9 @@ export function ProductValueSection({ product }: ProductValueSectionProps) {
);
})}
</div>
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ delay: 0.4, duration: 0.65 }}
className="mt-16 grid grid-cols-3 gap-8 max-w-3xl mx-auto"
>
<CircularProgress value={95} label="客户满意度" color="#3b82f6" />
<CircularProgress value={99} label="系统稳定性" color="#10b981" />
<CircularProgress value={98} label="交付准时率" color="#f59e0b" />
</motion.div>
</motion.div>
)}
</div>
</section>
);
}
}
+30 -35
View File
@@ -5,7 +5,6 @@ import { useRef, useEffect, useState } from 'react';
import { CaseStudyCard } from './detail-case-study';
import { CertificationList } from './detail-certification';
import type { CaseStudy, DataProof, Certification } from '@/lib/constants/products';
import { DESIGN_SYSTEM } from '@/lib/constants/design-system';
interface DetailTrustSectionProps {
caseStudies?: CaseStudy[];
@@ -55,7 +54,7 @@ function AnimatedCounter({ value, duration = 2000 }: { value: string; duration?:
}, [isInView, value, duration]);
return (
<div ref={ref} className="text-4xl md:text-5xl font-bold text-ink">
<div ref={ref} className="text-3xl sm:text-4xl font-black text-brand tabular-nums leading-none">
{displayValue}
</div>
);
@@ -71,29 +70,31 @@ export function DetailTrustSection({
if (!hasContent) return null;
return (
<section className="relative py-20 lg:py-28 bg-bg-secondary overflow-hidden">
<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="container-wide relative">
<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">
{dataProofs.length > 0 && (
<div className="mb-20">
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={DESIGN_SYSTEM.effects.scroll.fadeInUp.transition}
className="text-center mb-12"
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
className="mb-12"
>
<h2 className={`${DESIGN_SYSTEM.typography.section.title} text-ink`}>
<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-[0.95]">
使
<br />
</h2>
<p className={`${DESIGN_SYSTEM.typography.section.subtitle} mt-3 max-w-xl mx-auto text-text-secondary`}>
使
</p>
</motion.div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl">
{dataProofs.map((proof, index) => (
<motion.div
key={proof.metric}
@@ -101,22 +102,15 @@ export function DetailTrustSection({
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{
...DESIGN_SYSTEM.effects.scroll.fadeInUp.transition,
delay: index * DESIGN_SYSTEM.animation.delay.stagger,
duration: 0.5,
delay: index * 0.08,
ease: [0.22, 1, 0.36, 1],
}}
whileHover={{
y: -8,
transition: { duration: 0.3 },
}}
className={`relative p-8 rounded-2xl border transition-all duration-300 ${
index === 0
? 'bg-brand-soft/50 border-brand/20'
: 'bg-white border-border-primary hover:border-border-secondary hover:shadow-md'
}`}
className="bain-card p-8 sm:p-10"
>
<AnimatedCounter value={proof.value} />
<p className="text-lg font-semibold text-ink mt-2">{proof.metric}</p>
<p className="text-sm text-text-muted mt-1 leading-relaxed">{proof.description}</p>
<p className="text-sm font-semibold text-ink mt-2 mb-1">{proof.metric}</p>
<p className="text-sm text-text-secondary leading-relaxed">{proof.description}</p>
</motion.div>
))}
</div>
@@ -129,10 +123,10 @@ export function DetailTrustSection({
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={DESIGN_SYSTEM.effects.scroll.fadeInUp.transition}
className="text-center mb-12"
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
className="mb-12"
>
<h2 className={`${DESIGN_SYSTEM.typography.section.title} text-ink`}>
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[0.95]">
</h2>
</motion.div>
@@ -145,8 +139,9 @@ export function DetailTrustSection({
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{
...DESIGN_SYSTEM.effects.scroll.fadeInUp.transition,
delay: index * DESIGN_SYSTEM.animation.delay.stagger,
duration: 0.5,
delay: index * 0.08,
ease: [0.22, 1, 0.36, 1],
}}
>
<CaseStudyCard study={study} index={index} />
@@ -161,7 +156,7 @@ export function DetailTrustSection({
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={DESIGN_SYSTEM.effects.scroll.fadeInUp.transition}
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
className="text-center"
>
<h3 className="text-lg font-semibold text-text-secondary mb-6"></h3>
@@ -171,4 +166,4 @@ export function DetailTrustSection({
</div>
</section>
);
}
}
+4 -18
View File
@@ -123,15 +123,6 @@ jest.mock('./micro-interactions', () => ({
{children}
</button>
),
InkGlowCard: ({ children, className, active }: any) => (
<div
data-testid="ink-glow-card"
data-active={active}
className={className}
>
{children}
</div>
),
HoverLink: ({ children, href, className }: any) => (
<a data-testid="hover-link" href={href} className={className}>{children}</a>
),
@@ -225,7 +216,7 @@ jest.mock('@/lib/constants/design-system', () => ({
const mockHeroTheme = {
id: 'erp',
name: 'ERP 水墨雅致',
name: 'ERP',
gradientFrom: '#f8f6f3',
gradientTo: '#f0ebe4',
gradientAngle: 135,
@@ -404,14 +395,15 @@ describe('ProductValueSection', () => {
it('should render product title and overview', async () => {
const { ProductValueSection } = await import('./detail-product-value');
render(<ProductValueSection product={mockProduct} />);
expect(screen.getByRole('heading', { name: /为企业打造的/ })).toHaveTextContent(mockProduct.title);
const heading = screen.getByRole('heading', { name: new RegExp(mockProduct.title) });
expect(heading).toHaveTextContent('核心能力');
expect(screen.getByText(mockProduct.overview)).toBeInTheDocument();
});
it('should render feature items', async () => {
const { ProductValueSection } = await import('./detail-product-value');
render(<ProductValueSection product={mockProduct} />);
expect(screen.getByText('功能模块')).toBeInTheDocument();
expect(screen.getByText('产品功能')).toBeInTheDocument();
// Features are rendered via FeatureTags which splits on ""
expect(screen.getByText('财务管理')).toBeInTheDocument();
});
@@ -444,12 +436,6 @@ describe('ProductValueSection', () => {
expect(screen.queryByText('用真实效果证明价值')).not.toBeInTheDocument();
});
it('should render ink glow cards for features', async () => {
const { ProductValueSection } = await import('./detail-product-value');
render(<ProductValueSection product={mockProduct} />);
const glowCards = screen.getAllByTestId('ink-glow-card');
expect(glowCards.length).toBeGreaterThanOrEqual(mockProduct.features.length);
});
});
describe('DetailTrustSection', () => {
+2 -2
View File
@@ -17,6 +17,6 @@ export { ListPageHero } from './list-page-hero';
export { ProductCard } from './product-card';
export { SolutionCard } from './solution-service-card';
export { TiltCard, PressableButton, InkGlowCard, HoverLink } from './micro-interactions';
export { TiltCard, PressableButton, HoverLink } from './micro-interactions';
export { BrandSeal, CalligraphyText, InkDivider, SectionHeader } from './brand-elements';
export { BrandSeal, CalligraphyText, SectionHeader } from './brand-elements';
@@ -156,72 +156,6 @@ export function PressableButton({
return content;
}
interface InkGlowCardProps {
children: ReactNode;
className?: string;
active?: boolean;
color?: string;
glowSize?: number;
}
export function InkGlowCard({ children, className = '', active = false, color = '#C41E3A', glowSize = 280 }: InkGlowCardProps) {
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [isHovered, setIsHovered] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
setMousePos({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}, []);
const handleMouseEnter = useCallback(() => {
setIsHovered(true);
}, []);
const handleMouseLeave = useCallback(() => {
setIsHovered(false);
}, []);
return (
<motion.div
ref={ref}
onMouseMove={handleMouseMove}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
whileHover={{
y: -2,
transition: {
duration: 0.25,
ease: [0.22, 1, 0.36, 1],
},
}}
className={`relative bg-white overflow-hidden transition-shadow duration-fast ease-standard ${active ? 'border border-brand/20' : 'border border-border-primary'} ${className}`}
style={{
borderRadius: 'var(--radius-lg)',
boxShadow: isHovered
? '0 4px 12px rgba(10, 14, 20, 0.06), 0 2px 4px rgba(10, 14, 20, 0.03)'
: '0 1px 3px rgba(10, 14, 20, 0.04), 0 1px 2px rgba(10, 14, 20, 0.02)',
}}
>
{active && (
<div className="absolute inset-0 bg-gradient-to-br from-brand/[0.03] to-transparent pointer-events-none" />
)}
<div
className="absolute inset-0 pointer-events-none transition-opacity duration-normal ease-ink"
style={{
opacity: isHovered ? 1 : 0,
background: `radial-gradient(${glowSize}px circle at ${mousePos.x}px ${mousePos.y}px, ${color}08, transparent 60%)`,
}}
/>
<div className="relative z-10">{children}</div>
</motion.div>
);
}
interface HoverLinkProps {
children: ReactNode;
href: string;
+109 -129
View File
@@ -10,8 +10,6 @@ import {
TrendingUp,
type LucideIcon,
} from 'lucide-react';
import { TiltCard, InkGlowCard } from './micro-interactions';
import { SectionHeader, InkDivider } from './brand-elements';
import type { Service } from '@/lib/constants/services';
interface ServiceValueSectionProps {
@@ -22,164 +20,146 @@ export function ServiceValueSection({ service }: ServiceValueSectionProps) {
const icons: LucideIcon[] = [Zap, Clock, Users, Award, TrendingUp];
return (
<section className="relative bg-white py-24 lg:py-32 overflow-hidden">
<div className="container-wide relative">
<SectionHeader
title={`为什么选择我们的${service.title}`}
subtitle={service.overview}
/>
<section className="relative bg-white py-20 sm:py-28 md:py-36 overflow-hidden">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative">
<motion.div
initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-120px' }}
transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
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-[0.95] max-w-3xl">
{service.title}
</h2>
<p className="text-lg text-text-secondary max-w-2xl mt-6 leading-relaxed">
{service.overview}
</p>
</motion.div>
<div className="mt-16">
<div className="flex items-center gap-4 mb-8">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-[#C41E3A] to-[#99182d] flex items-center justify-center shadow-md shadow-brand/20">
<Zap className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="text-2xl font-bold text-ink"></h3>
<p className="text-sm text-text-muted mt-0.5"></p>
</div>
{/* 核心能力 */}
<div className="mb-16">
<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>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{service.features.map((feature, index) => {
const Icon = icons[index % icons.length]!;
return (
<InkGlowCard
<motion.div
key={feature}
active={index === 0}
className="group p-6 hover:border-brand/20"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{
delay: index * 0.06,
duration: 0.5,
ease: [0.22, 1, 0.36, 1],
}}
className="bain-card p-6"
>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{
delay: index * 0.08,
duration: 0.5,
ease: [0.22, 1, 0.36, 1] as const,
}}
>
<div className="flex items-start gap-4">
<div
className={`shrink-0 w-11 h-11 rounded-xl flex items-center justify-center transition-all duration-300 shadow-sm ${
index === 0
? 'bg-gradient-to-br from-[#C41E3A] to-[#99182d] text-white'
: 'bg-bg-secondary text-text-secondary group-hover:from-[#C41E3A] group-hover:to-[#99182d] group-hover:text-white'
}`}
>
<Icon className="w-5.5 h-5.5" />
</div>
<p className="text-sm font-medium text-text-secondary leading-relaxed pt-2">
{feature.split('')[1] || feature}
</p>
<div className="flex items-start gap-4">
<div className={`shrink-0 w-11 h-11 flex items-center justify-center ${
index === 0
? 'bg-brand text-white'
: 'bg-bg-secondary text-text-secondary'
}`}>
<Icon className="w-5 h-5" />
</div>
</motion.div>
</InkGlowCard>
<p className="text-sm font-medium text-text-secondary leading-relaxed pt-2">
{feature.split('')[1] || feature}
</p>
</div>
</motion.div>
);
})}
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.75, delay: 0.3 }}
className="mt-16"
>
<InkDivider variant="subtle" />
<div className="flex items-center gap-4 mb-8">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-green-500 to-emerald-600 flex items-center justify-center shadow-md shadow-green-500/20">
<Award className="w-6 h-6 text-white" />
{/* 服务优势 + 服务流程:不对称布局 */}
<div className="asymmetric-wide-narrow">
{/* 服务优势 */}
<div>
<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>
<div>
<h3 className="text-2xl font-bold text-ink"></h3>
<p className="text-sm text-text-muted mt-0.5"></p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{service.benefits.map((benefit, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: index % 2 === 0 ? -20 : 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{
delay: 0.4 + index * 0.06,
duration: 0.5,
ease: [0.22, 1, 0.36, 1] as const,
}}
className="group p-6 rounded-2xl bg-bg-secondary border border-border-primary hover:border-brand/30 hover:shadow-md hover:-translate-y-1 transition-all duration-300"
>
<div className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-green-600 mt-0.5 shrink-0 group-hover:scale-110 transition-transform" />
<div className="space-y-4">
{service.benefits.map((benefit, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{
delay: index * 0.06,
duration: 0.4,
ease: [0.22, 1, 0.36, 1],
}}
className="bain-card p-5 flex items-start gap-3"
>
<CheckCircle2 className="w-5 h-5 text-brand mt-0.5 shrink-0" />
<p className="text-sm font-medium text-text-secondary leading-relaxed">{benefit}</p>
</div>
</motion.div>
))}
</div>
</motion.div>
{service.process && service.process.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.75, delay: 0.45 }}
className="mt-20"
>
<InkDivider variant="decorative" />
<div className="text-center mb-12">
<h3 className="text-2xl md:text-3xl font-bold tracking-tight text-ink">
</h3>
<p className="text-text-secondary mt-3"></p>
</motion.div>
))}
</div>
</div>
<div className="relative">
<div className="absolute left-8 top-0 bottom-0 w-0.5 bg-gradient-to-b from-[#C41E3A] via-blue-400 to-green-400 hidden md:block" />
<div className="space-y-6">
{/* 服务流程 */}
{service.process && service.process.length > 0 && (
<div>
<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>
<div className="space-y-4">
{service.process.map((step, index) => (
<motion.div
key={step}
initial={{ opacity: 0, x: -30 }}
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{
delay: 0.5 + index * 0.1,
duration: 0.55,
ease: [0.22, 1, 0.36, 1] as const,
delay: index * 0.08,
duration: 0.45,
ease: [0.22, 1, 0.36, 1],
}}
className="relative flex gap-6 group"
className="bain-card p-5"
>
<div className="relative z-10 shrink-0 w-16 flex justify-center">
<div className="w-4 h-4 rounded-full bg-brand group-hover:scale-110 transition-transform duration-300" />
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-brand text-white flex items-center justify-center shrink-0 mt-0.5">
<span className="text-xs font-bold">{index + 1}</span>
</div>
<div>
<h4 className="font-bold text-ink text-sm mb-1">
{step.split('')[0]}
</h4>
<p className="text-xs text-text-secondary leading-relaxed">
{step.split('')[1] || step}
</p>
</div>
</div>
<TiltCard
tiltAmount={5}
glareEnable={false}
className="flex-1 p-6 rounded-2xl bg-white border border-border-primary hover:border-brand/30 hover:shadow-md transition-all"
>
<h4 className="font-bold text-ink mb-2">
{step.split('')[0]}
</h4>
<p className="text-sm text-text-secondary leading-relaxed">
{step.split('')[1] || step}
</p>
</TiltCard>
</motion.div>
))}
</div>
</div>
</motion.div>
)}
)}
</div>
</div>
</section>
);
}
}
+90 -77
View File
@@ -7,8 +7,6 @@ import {
Target,
TrendingUp,
} from 'lucide-react';
import { TiltCard } from './micro-interactions';
import { SectionHeader, InkDivider } from './brand-elements';
import type { Solution } from '@/lib/constants/solutions';
interface SolutionValueSectionProps {
@@ -17,32 +15,45 @@ interface SolutionValueSectionProps {
export function SolutionValueSection({ solution }: SolutionValueSectionProps) {
return (
<section className="relative bg-white py-24 lg:py-32 overflow-hidden">
<div className="container-wide relative">
<SectionHeader
title={
<>
<span className="text-brand">{solution.industry}</span>
</>
}
subtitle={solution.description}
/>
<section className="relative bg-white py-20 sm:py-28 md:py-36 overflow-hidden">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative">
<motion.div
initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-120px' }}
transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
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-[0.95] max-w-3xl">
<span className="text-brand">{solution.industry}</span>
</h2>
<p className="text-lg text-text-secondary max-w-2xl mt-6 leading-relaxed">
{solution.description}
</p>
</motion.div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10 mt-16">
{/* 痛点 + 方案:不对称布局 */}
<div className="asymmetric-wide-narrow mb-16">
{/* 行业痛点 */}
<motion.div
initial={{ opacity: 0, x: -30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] as const }}
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
>
<div className="bg-brand-soft/30 rounded-2xl p-8 border border-brand/20">
<div className="bain-card p-8">
<h3 className="text-xl font-bold text-ink mb-6 flex items-center gap-3">
<span className="w-10 h-10 rounded-xl bg-brand flex items-center justify-center">
<Target className="w-5 h-5 text-white" />
<span className="w-10 h-10 flex items-center justify-center bg-brand text-white">
<Target className="w-5 h-5" />
</span>
</h3>
<ul className="space-y-4">
{solution.challenges.map((challenge, index) => (
<motion.li
@@ -53,7 +64,7 @@ export function SolutionValueSection({ solution }: SolutionValueSectionProps) {
transition={{
delay: index * 0.08,
duration: 0.45,
ease: [0.22, 1, 0.36, 1] as const,
ease: [0.22, 1, 0.36, 1],
}}
className="flex items-start gap-3 text-text-secondary"
>
@@ -65,20 +76,20 @@ export function SolutionValueSection({ solution }: SolutionValueSectionProps) {
</div>
</motion.div>
{/* 解决方案 */}
<motion.div
initial={{ opacity: 0, x: 30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.7, delay: 0.15, ease: [0.22, 1, 0.36, 1] as const }}
transition={{ duration: 0.7, delay: 0.15, ease: [0.22, 1, 0.36, 1] }}
>
<div className="bg-blue-50/50 rounded-2xl p-8 border border-blue-100/60">
<div className="bain-card p-8">
<h3 className="text-xl font-bold text-ink mb-6 flex items-center gap-3">
<span className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center">
<Lightbulb className="w-5 h-5 text-white" />
<span className="w-10 h-10 flex items-center justify-center bg-accent-blue text-white">
<Lightbulb className="w-5 h-5" />
</span>
</h3>
<ul className="space-y-4">
{solution.solutions.map((solutionItem, index) => (
<motion.li
@@ -89,11 +100,11 @@ export function SolutionValueSection({ solution }: SolutionValueSectionProps) {
transition={{
delay: 0.25 + index * 0.08,
duration: 0.45,
ease: [0.22, 1, 0.36, 1] as const,
ease: [0.22, 1, 0.36, 1],
}}
className="flex items-start gap-3 text-text-secondary"
>
<CheckCircle2 className="w-4.5 h-4.5 text-blue-600 mt-0.5 shrink-0" />
<CheckCircle2 className="w-4 h-4 text-accent-blue mt-0.5 shrink-0" />
<span className="text-sm leading-relaxed">{solutionItem}</span>
</motion.li>
))}
@@ -102,40 +113,52 @@ export function SolutionValueSection({ solution }: SolutionValueSectionProps) {
</motion.div>
</div>
{/* 价值主张 */}
{solution.valueProposition && (
<motion.div
initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.75, delay: 0.3 }}
className="mt-20"
>
<InkDivider variant="decorative" />
<div className="text-center mb-12">
<h3 className="text-2xl md:text-3xl font-bold tracking-tight text-ink">
<div className="mb-12">
<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>
<h3 className="text-2xl sm:text-3xl md:text-4xl font-black text-ink tracking-tight leading-[0.95]">
{solution.valueProposition.headline}
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-7">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{solution.valueProposition.points.map((point, index) => (
<TiltCard
<motion.div
key={index}
tiltAmount={6}
className="p-7 rounded-2xl bg-white border border-border-primary hover:border-blue-200 hover:shadow-md transition-shadow"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{
delay: index * 0.08,
duration: 0.5,
ease: [0.22, 1, 0.36, 1],
}}
className="bain-card p-7"
>
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center mb-5 shadow-md shadow-blue-500/20">
<TrendingUp className="w-7 h-7 text-white" />
<div className="w-12 h-12 flex items-center justify-center bg-accent-blue text-white mb-5">
<TrendingUp className="w-6 h-6" />
</div>
<h4 className="text-lg font-bold text-ink mb-2">{point.title}</h4>
<p className="text-sm text-text-secondary leading-relaxed">{point.description}</p>
</TiltCard>
</motion.div>
))}
</div>
</motion.div>
)}
{/* 产品组合 */}
{solution.suiteCombination && (
<motion.div
initial={{ opacity: 0, y: 32 }}
@@ -144,76 +167,66 @@ export function SolutionValueSection({ solution }: SolutionValueSectionProps) {
transition={{ duration: 0.75, delay: 0.4 }}
className="mt-20"
>
<InkDivider variant="subtle" />
<div className="mb-12">
<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>
<h3 className="text-2xl sm:text-3xl font-black text-ink tracking-tight leading-[0.95]">
</h3>
</div>
<div className="bg-bg-secondary rounded-2xl p-10 border border-border-primary">
<div className="bain-card p-8 sm:p-10">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10">
<div>
<h4 className="text-lg font-bold text-ink mb-5 flex items-center gap-2.5">
<span className="w-8 h-8 rounded-lg bg-brand flex items-center justify-center">
<span className="text-white text-xs font-bold"></span>
<h4 className="text-sm font-bold text-ink mb-5 flex items-center gap-2.5">
<span className="w-8 h-8 flex items-center justify-center bg-brand text-white text-xs font-bold">
</span>
</h4>
<div className="flex flex-wrap gap-3">
{solution.suiteCombination.primaryProducts.map((product, index) => (
<motion.span
{solution.suiteCombination.primaryProducts.map((product) => (
<span
key={product}
initial={{ opacity: 0, scale: 0.9 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{
delay: 0.5 + index * 0.06,
duration: 0.35,
}}
className="px-5 py-2.5 bg-brand-soft/50 text-brand text-sm font-semibold rounded-xl border border-brand/20"
className="px-4 py-2 bg-brand-soft/50 text-brand text-sm font-semibold border border-brand/20"
>
{product}
</motion.span>
</span>
))}
</div>
</div>
<div>
<h4 className="text-lg font-bold text-ink mb-5 flex items-center gap-2.5">
<span className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center">
<span className="text-white text-xs font-bold"></span>
<h4 className="text-sm font-bold text-ink mb-5 flex items-center gap-2.5">
<span className="w-8 h-8 flex items-center justify-center bg-accent-blue text-white text-xs font-bold">
</span>
</h4>
<div className="flex flex-wrap gap-3">
{solution.suiteCombination.complementaryServices.map((service, index) => (
<motion.span
{solution.suiteCombination.complementaryServices.map((service) => (
<span
key={service}
initial={{ opacity: 0, scale: 0.9 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{
delay: 0.65 + index * 0.06,
duration: 0.35,
}}
className="px-5 py-2.5 bg-blue-50/80 text-blue-700 text-sm font-semibold rounded-xl border border-blue-200"
className="px-4 py-2 bg-blue-50 text-blue-700 text-sm font-semibold border border-blue-200"
>
{service}
</motion.span>
</span>
))}
</div>
</div>
</div>
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: 0.85, duration: 0.55 }}
className="mt-8 p-5 bg-white rounded-xl text-sm text-text-secondary leading-relaxed border border-border-primary"
>
<p className="mt-8 p-5 bg-bg-secondary text-sm text-text-secondary leading-relaxed">
<strong className="text-ink"></strong>{solution.suiteCombination.rationale}
</motion.p>
</p>
</div>
</motion.div>
)}
</div>
</section>
);
}
}
+9 -5
View File
@@ -2,6 +2,7 @@
import { ReactNode } from 'react';
import { PageTransition } from '@/components/ui/page-transition';
import { Toaster } from '@/components/ui/sonner';
interface ClientLayoutProps {
children: ReactNode;
@@ -9,10 +10,13 @@ interface ClientLayoutProps {
export function ClientLayout({ children }: ClientLayoutProps) {
return (
<PageTransition>
<main id="main-content">
{children}
</main>
</PageTransition>
<>
<PageTransition>
<main id="main-content">
{children}
</main>
</PageTransition>
<Toaster position="top-center" richColors closeButton />
</>
);
}
+1 -1
View File
@@ -105,7 +105,7 @@ export function Footer() {
<div className="border-t border-gray-800 pt-6 pb-[calc(8rem+env(safe-area-inset-bottom,0px))] md:pb-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 mb-6">
<p className="text-gray-300 text-xs">
© {new Date().getFullYear()} {config.name}. All rights reserved.
© {new Date().getFullYear()} {config.name}. .
</p>
<div className="flex gap-6">
<StaticLink href="/privacy" className="text-gray-300 hover:text-white text-xs transition-colors duration-200">
+12
View File
@@ -245,6 +245,18 @@ describe('Header', () => {
});
});
it('should not double-toggle menu on Enter or Space keydown', () => {
render(<Header />);
const menuButton = screen.getByTestId('mobile-menu-button');
// Enter/Space 由原生 button 的 click 处理,keydown 不应再次 toggle
fireEvent.keyDown(menuButton, { key: 'Enter' });
expect(screen.queryByTestId('mobile-navigation')).not.toBeInTheDocument();
fireEvent.keyDown(menuButton, { key: ' ' });
expect(screen.queryByTestId('mobile-navigation')).not.toBeInTheDocument();
});
it('should have proper navigation role', () => {
render(<Header />);
const nav = screen.getByTestId('desktop-navigation');
+21 -46
View File
@@ -1,6 +1,6 @@
'use client';
import { Suspense, useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { StaticLink } from '@/components/ui/static-link';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { useSiteConfig, type NavigationItem } from '@/lib/site-config';
import { MegaDropdown } from '@/components/layout/mega-dropdown';
import { useFocusTrap } from '@/hooks/use-focus-trap';
import { cn } from '@/lib/utils';
function HeaderContent() {
const [isOpen, setIsOpen] = useState(false);
@@ -39,18 +40,18 @@ function HeaderContent() {
}, [isOpen]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsOpen(!isOpen);
}
// Escape 关闭菜单;Enter/Space 已由原生 button 的 onClick 处理,
// 此处不再拦截,避免键盘操作时重复触发 toggle。
if (e.key === 'Escape' && isOpen) {
e.preventDefault();
setIsOpen(false);
}
}, [isOpen]);
const config = useSiteConfig();
const handleNavClick = useCallback((_e: React.MouseEvent<HTMLAnchorElement>, item: NavigationItem) => {
const handleNavClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>, item: NavigationItem) => {
e.preventDefault();
setIsOpen(false);
window.location.href = item.href;
}, []);
@@ -85,10 +86,7 @@ function HeaderContent() {
height: isScrolled ? 64 : 80,
}}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
className={`
fixed top-0 left-0 right-0 z-50
bg-white border-b border-border-primary
`}
className="fixed top-0 left-0 right-0 z-50 transition-colors duration-300 bg-white border-b border-border-primary"
>
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<div className="flex items-center justify-between h-16 md:h-full">
@@ -125,30 +123,28 @@ function HeaderContent() {
onClose={() => setOpenDropdown((prev) => prev === item.id ? null : prev)}
/>
) : (
<div key={item.id} className="px-1 py-2">
<div key={item.id} className="relative -mx-2 px-2 py-2">
<StaticLink
href={item.href}
onClick={(e) => handleNavClick(e, item)}
className={`
relative block px-3 py-2 text-sm font-medium
transition-all duration-300 ease-out
${isActive(item)
className={cn(
'relative inline-flex items-center px-3 py-2 text-sm font-medium',
'transition-all duration-300 ease-out',
isActive(item)
? 'text-text-primary'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-secondary'
}
`}
)}
aria-current={isActive(item) ? 'page' : undefined}
>
{item.label}
<span
className={`
absolute -bottom-0.5 left-3 right-3 h-0.5 bg-brand
transition-all duration-300 ease-out
${isActive(item)
className={cn(
'absolute -bottom-0.5 left-3 right-3 h-0.5 bg-brand',
'transition-all duration-300 ease-out',
isActive(item)
? 'opacity-100 scale-x-100'
: 'opacity-0 scale-x-0'
}
`}
)}
/>
</StaticLink>
</div>
@@ -160,6 +156,7 @@ function HeaderContent() {
<Button
size="sm"
asChild
className=""
>
<StaticLink href="/contact" data-testid="consult-button">
<MessageCircle className="w-4 h-4" />
@@ -257,28 +254,6 @@ function HeaderContent() {
);
}
function HeaderFallback() {
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-transparent">
<div className="container-x">
<div className="flex items-center justify-between h-16">
<div className="h-8 w-24 bg-bg-tertiary animate-skeleton-pulse rounded-sm" />
<nav className="hidden md:flex items-center gap-1">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="h-6 w-16 bg-bg-tertiary animate-skeleton-pulse rounded-sm mx-1" />
))}
</nav>
<div className="h-9 w-20 bg-bg-tertiary animate-skeleton-pulse rounded-sm" />
</div>
</div>
</header>
);
}
export function Header() {
return (
<Suspense fallback={<HeaderFallback />}>
<HeaderContent />
</Suspense>
);
return <HeaderContent />;
}
+27 -18
View File
@@ -38,12 +38,6 @@ export function MegaDropdown({ label, groups, isOpen, isActive, onToggle, onOpen
<div className="-mx-2 px-2 py-2">
<button
onClick={onToggle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onToggle();
}
}}
className={`
relative flex items-center gap-1 px-3 py-2 text-sm font-medium
transition-all duration-300 ease-out
@@ -96,16 +90,9 @@ export function MegaDropdown({ label, groups, isOpen, isActive, onToggle, onOpen
</div>
<div className={`grid gap-2 ${group.items.length > 4 ? 'grid-cols-3' : 'grid-cols-2'}`}>
{group.items.map((item) => (
<StaticLink
key={item.id}
href={item.href}
className={`block p-3 border transition-all duration-300 ease-out group ${
item.href === '#'
? 'border-border-primary bg-bg-secondary cursor-not-allowed opacity-50'
: 'border-transparent hover:border-border-primary hover:bg-bg-secondary'
}`}
>
{group.items.map((item) => {
const isPlaceholder = item.href === '#';
const content = (
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
@@ -124,8 +111,30 @@ export function MegaDropdown({ label, groups, isOpen, isActive, onToggle, onOpen
<div className="text-xs text-text-secondary mt-1 leading-relaxed">{item.description}</div>
</div>
</div>
</StaticLink>
))}
);
if (isPlaceholder) {
return (
<div
key={item.id}
className="block p-3 border border-border-primary bg-bg-secondary cursor-not-allowed opacity-50"
aria-disabled="true"
>
{content}
</div>
);
}
return (
<StaticLink
key={item.id}
href={item.href}
className="block p-3 border border-transparent transition-all duration-300 ease-out group hover:border-border-primary hover:bg-bg-secondary"
>
{content}
</StaticLink>
);
})}
</div>
</div>
))}
+12 -8
View File
@@ -156,29 +156,33 @@ describe('MobileMenu', () => {
});
describe('Keyboard Navigation', () => {
it('should open menu with Enter key', () => {
it('should open menu with button click', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
it('should open menu with Space key', () => {
it('should not double-toggle menu on Enter or Space keydown', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
expect(screen.getByRole('navigation')).toBeInTheDocument();
// Enter/Space 由原生 button 的 click 处理,keydown 不应再次 toggle
fireEvent.keyDown(button, { key: 'Enter' });
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
fireEvent.keyDown(button, { key: ' ' });
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
it('should close menu with Escape key', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
fireEvent.keyDown(button, { key: 'Escape' });
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
});

Some files were not shown because too many files have changed in this diff Show More