Files
novalon-website/src/app/(marketing)/services/services-content-v3.tsx
T
zhangxiang ad0b265ef2 refactor(motion): 收敛末批 P1 入场时长与变量递增 stagger 步进
动效合规专项(Task #16)收尾。

改动:
- page-transition.tsx:入场 duration 0.4/0.5 → 0.3(末处真 P1,由 client-layout 消费)
- 17 文件 21 处子元素 stagger 步进 80/100ms → 60ms
  (CONTEXT.md 原则4「子元素 stagger 30-60ms」;属已批准决策 04e91e3 的漏网,
   因当时审计脚本看不见 delay: i * N 的乘法写法)
- 审计脚本补盲区:新增变量递增 stagger 识别 + 明细表(此前漏掉全站 33 处)
- 审计脚本新增 NON_ENTRANCE 排除:flip-clock 机械翻转、design-system 死代码令牌
- delay 判据自我纠正:硬禁令约束的是 duration(动效时长),
  delay 属调度而非动效本身,一律判 P2(偏离指引),不得判 P0

门禁(全绿):
- tsc 0 error
- eslint 0 error(50 warnings 均为既有)
- jest 130/130 套件,1573 通过 / 2 跳过 / 0 失败
- 运行时冒烟 16/16 PASS(8 路由 × 浅深双版,0 控制台报错)
- 审计:P0=0 · P1=0 · stagger 步进 P2=0
2026-09-20 11:50:58 +08:00

384 lines
17 KiB
TypeScript

'use client';
import { motion } from 'framer-motion';
import { ArrowUpRight, CheckCircle2 } from 'lucide-react';
import { StaticLink } from '@/components/ui/static-link';
import { CTAButton } from '@/components/ui/cta-button';
import type { Service } from '@/lib/constants/services';
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
/** 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: '10+', label: '人核心团队' }, { value: '6', label: '行业场景' }] },
software: { metrics: [{ value: '6', label: '款自研产品' }, { value: 'A+', label: '代码质量' }] },
data: { metrics: [{ value: '99.9%', label: '系统可用性' }, { value: '24/7', label: '运维支持' }] },
solutions: { metrics: [{ value: '6', label: '行业场景' }, { value: '100%', label: '结果导向交付' }] },
};
function mapServicesToCards(services: Service[]) {
return services.map((svc) => {
const uiMeta = SERVICE_UI_META[svc.id];
return {
id: svc.id,
title: svc.title,
description: svc.description,
capabilities: svc.capabilities?.slice(0, 4) ?? [],
metrics: svc.metrics ?? uiMeta?.metrics ?? [],
};
});
}
const SERVICE_PROCESS = [
{
phase: '需求分析',
duration: '2-3 周',
deliverables: ['业务痛点清单', '现状评估报告', '项目范围说明书'],
},
{
phase: '方案设计',
duration: '4-6 周',
deliverables: ['系统架构设计', '实施路线图', '投资测算模型'],
},
{
phase: '敏捷交付',
duration: '3-6 个月',
deliverables: ['分阶段上线版本', '用户培训材料', '数据迁移方案'],
},
{
phase: '持续支持',
duration: '长期',
deliverables: ['运维监控体系', '迭代优化计划', '知识转移文档'],
},
];
const SERVICE_MODES = [
{
title: '项目制',
description: '针对明确的需求和交付目标,以项目为单位进行合作。适合有清晰边界的一次性建设类项目。',
highlight: '固定范围 · 固定周期',
},
{
title: '订阅制',
description: '按月或按年持续提供技术支持和迭代优化。适合需要长期维护、持续演进的产品和系统。',
highlight: '持续迭代 · 弹性调整',
},
{
title: '长期陪跑',
description: '以合作伙伴身份深度参与您的数字化进程,从规划到执行全程陪伴。适合正在系统性转型的企业。',
highlight: '深度参与 · 共同成长',
},
];
type PageCopy = Record<string, unknown>;
function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
const heroTitle1 = (pageCopy?.servicesHeroTitle1 as string) || '从规划到运维';
const heroTitle2 = (pageCopy?.servicesHeroTitle2 as string) || '全程陪伴';
const heroDescriptionLines = ((pageCopy?.servicesHeroDescription as string) ||
'不只是技术供应商,更是您数字化转型的同行者。\n从第一次沟通到系统稳定运行,我们始终在您身边。').split('\n');
const ctaPrimary = (pageCopy?.servicesCtaPrimary as string) || '聊聊您的需求';
const ctaSecondary = (pageCopy?.servicesCtaSecondary as string) || '查看产品矩阵';
return (
<section className="relative min-h-[85vh] flex items-center overflow-hidden bg-bg-primary">
<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.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, 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 md:mb-16"
>
<div className="block">{heroTitle1}</div>
<div className="block mt-4 sm:mt-6">
<span className="relative inline-block">
<span className="relative text-brand font-black">
{heroTitle2}
<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.3, 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.3, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12 sm:mb-16"
>
{heroDescriptionLines.map((line, i) => (
<span key={i}>
{i > 0 && <br className="hidden sm:block" />}
{line}
</span>
))}
</motion.p>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.7, ease: EASE_OUT }}
className="flex flex-col sm:flex-row gap-4 sm:gap-6"
>
<CTAButton href="/contact" dataTestId="services-hero-primary" className="min-h-[52px]">
{ctaPrimary}
</CTAButton>
<CTAButton href="/products" variant="secondary" dataTestId="services-hero-secondary" className="min-h-[52px]">
{ctaSecondary}
</CTAButton>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.9, ease: EASE_OUT }}
className="grid grid-cols-3 gap-8 mt-20 pt-12 border-t border-border-primary max-w-xl"
>
<div>
<div className="text-3xl sm:text-4xl font-black text-ink tracking-tight leading-none mb-2">
4<span className="text-brand"></span>
</div>
<div className="text-sm text-text-muted">服务领域</div>
</div>
<div>
<div className="text-3xl sm:text-4xl font-black text-ink tracking-tight leading-none mb-2">
3<span className="text-brand"></span>
</div>
<div className="text-sm text-text-muted">合作模式</div>
</div>
<div>
<div className="text-3xl sm:text-4xl font-black text-ink tracking-tight leading-none mb-2">
<span className="text-brand">流程</span>
</div>
<div className="text-sm text-text-muted">服务保障</div>
</div>
</motion.div>
</div>
</div>
</section>
);
}
function ServicesGridSection({ services, pageCopy }: { services?: Service[]; pageCopy?: PageCopy | null }) {
const displayServices = services?.length ? mapServicesToCards(services) : [];
const gridTitle = (pageCopy?.servicesGridTitle as string) || '四大核心服务,覆盖数字化全链路';
const emptyState = (pageCopy?.servicesEmptyState as string) || '暂无服务数据';
if (displayServices.length === 0) {
return (
<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="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 text-center">
<p className="text-text-muted text-lg">{emptyState}</p>
</div>
</section>
);
}
return (
<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="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">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }}
className="max-w-3xl mb-16 sm:mb-20 md:mb-24"
>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]">
{gridTitle}
</h2>
</motion.div>
<div className="grid md:grid-cols-2 gap-px bg-border-primary">
{displayServices.map((service, i) => (
<motion.div
key={service.id}
data-testid={`service-card-${service.id}`}
data-featured={i === 0 ? 'true' : 'false'}
className={i === 0 ? 'md:col-span-2 bg-bg-primary' : 'md:col-span-1 bg-bg-primary'}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.3, delay: i * 0.06, ease: EASE_OUT }}
>
<StaticLink
href={`/services/${service.id}`}
className="group relative block p-8 sm:p-10 md:p-12 transition-all duration-300 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-bg-primary border border-transparent hover:border-border-primary after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-brand after:transition-transform after:duration-300 group-hover:after:scale-x-100"
>
<div className="relative z-10">
<h3 className="text-xl sm:text-2xl font-bold text-ink group-hover:text-brand transition-colors duration-300 mb-3">
{service.title}
</h3>
<p className="text-text-secondary leading-relaxed mb-6 text-[15px]">
{service.description}
</p>
<ul className="space-y-2 mb-6">
{service.capabilities.map((cap) => (
<li key={cap} className="flex items-start gap-2 text-sm text-text-secondary">
<CheckCircle2 className="w-4 h-4 text-brand shrink-0 mt-0.5" />
{cap}
</li>
))}
</ul>
<div className="flex gap-6 pt-5 border-t border-border-primary mb-6">
{service.metrics.map((m) => (
<div key={m.label}>
<div className="text-xl font-bold text-ink">{m.value}</div>
<div className="text-xs text-text-muted mt-1">{m.label}</div>
</div>
))}
</div>
<div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors duration-300">
<span>了解详情</span>
<ArrowUpRight className="w-4 h-4 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform duration-300" />
</div>
</div>
</StaticLink>
</motion.div>
))}
</div>
</div>
</section>
);
}
function ProcessModesSection() {
return (
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<div className="grid lg:grid-cols-12 gap-12 sm:gap-16 md:gap-20">
{/* Process */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }}
className="lg:col-span-7"
>
<h2 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-ink tracking-tight leading-[0.95] mb-10 sm:mb-14">
我们的做事方式
</h2>
<div className="relative">
{SERVICE_PROCESS.map((step) => (
<div key={step.phase} className="relative pl-8 sm:pl-12 pb-10 sm:pb-12 last:pb-0">
<div className="absolute left-0 top-1.5 bottom-0 w-px bg-border-primary last:hidden" />
<div className="absolute left-0 top-1.5 w-2.5 h-2.5 rounded-full -translate-x-1/2 bg-brand" />
<div className="flex items-center gap-3 mb-3">
<h3 className="text-lg sm:text-xl font-bold text-ink">{step.phase}</h3>
<span className="text-xs text-text-muted">{step.duration}</span>
</div>
<div className="text-xs text-text-muted mb-2">阶段产出</div>
<ul className="space-y-1">
{step.deliverables.map((d) => (
<li key={d} className="text-sm text-text-secondary">{d}</li>
))}
</ul>
</div>
))}
</div>
</motion.div>
{/* Modes */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: 0.15, ease: EASE_OUT }}
className="lg:col-span-5"
>
<h2 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-ink tracking-tight leading-[0.95] mb-10 sm:mb-14">
合作方式
</h2>
<div className="space-y-4 sm:space-y-5">
{SERVICE_MODES.map((mode) => (
<div
key={mode.title}
className="group relative p-6 sm:p-7 border border-border-primary bg-bg-primary hover:bg-bg-secondary hover:border-brand/20 hover:shadow-md transition-all duration-300"
>
<div className="relative z-10">
<h3 className="text-xl font-bold text-ink mb-3 group-hover:text-brand transition-colors duration-300">
{mode.title}
</h3>
<p className="text-text-secondary leading-relaxed text-sm sm:text-[15px] mb-4">{mode.description}</p>
<div className="text-sm font-medium text-ink">{mode.highlight}</div>
</div>
</div>
))}
</div>
</motion.div>
</div>
</div>
</section>
);
}
function CTASection({ pageCopy }: { pageCopy?: PageCopy | null }) {
const ctaTitle = (pageCopy?.servicesCtaTitle as string) || '准备好开始了吗?';
const ctaDescriptionLines = ((pageCopy?.servicesCtaDescription as string) ||
'无论是一个明确的项目,还是一个模糊的想法,我们都愿意坐下来和您一起理清楚。\n首次咨询免费,没有任何销售压力。').split('\n');
return (
<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.3, 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">
{ctaTitle}
</h2>
<p className="text-lg text-text-secondary leading-relaxed mb-10">
{ctaDescriptionLines.map((line, i) => (
<span key={i}>
{i > 0 && <br className="hidden sm:block" />}
{line}
</span>
))}
</p>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
<CTAButton href="/contact" dataTestId="services-bottom-primary" className="min-h-[52px]">
预约免费咨询
</CTAButton>
<CTAButton href="/team" variant="secondary" dataTestId="services-bottom-secondary" className="min-h-[52px]">
了解我们的团队
</CTAButton>
</div>
</motion.div>
</div>
</section>
);
}
export default function ServicesContentV3({ services, pageCopy }: { services?: Service[]; pageCopy?: PageCopy | null }) {
return (
<main className="bg-bg-primary text-ink">
<HeroSection pageCopy={pageCopy} />
<ServicesGridSection services={services} pageCopy={pageCopy} />
<ProcessModesSection />
<CTASection pageCopy={pageCopy} />
</main>
);
}