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