动效合规专项(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
384 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|