feat(design): Vibe design optimization — Hero, Bento grids, trust layer, narrative

升级全站体验:首页 Hero 改为产品视觉 + 单一 CTA 转化布局;产品矩阵/服务/方案改
为 Bento 非对称网格(ERP 2x2 大卡 + BI 1x2);补齐信任层(可验证信号 + 来源标注)
与「问题→方法→结果」章节式叙事;新增 hero-product-visual 与 bento-grid 可复用组件;
统一动效与品牌视觉资产。新增对应单测、UJ-11 用户旅程测试并更新视觉回归基线快照。
This commit is contained in:
2026-08-19 14:43:27 +08:00
parent f3f4e78c51
commit 713186d552
35 changed files with 1576 additions and 79 deletions
+25 -4
View File
@@ -173,8 +173,14 @@ function WhoWeAreSection({ data }: { data: AboutData }) {
transition={{ duration: 0.8, ease: EASE_OUT }}
className="lg:col-span-5"
>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6">
Who We Are
<div className="flex items-center gap-4 mb-6">
<span className="text-4xl md:text-5xl font-black text-brand/80 font-mono leading-none tabular-nums">01</span>
<div>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase">
Why We Started
</div>
<div className="text-xs text-text-muted mt-1">为什么成立</div>
</div>
</div>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8">
{data.whoWeAreTitle || ''}
@@ -196,6 +202,15 @@ function WhoWeAreSection({ data }: { data: AboutData }) {
transition={{ duration: 0.8, delay: 0.15, ease: EASE_OUT }}
className="lg:col-span-7"
>
<div className="flex items-center gap-4 mb-6">
<span className="text-4xl md:text-5xl font-black text-brand/80 font-mono leading-none tabular-nums">02</span>
<div>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase">
How We Work
</div>
<div className="text-xs text-text-muted mt-1">怎么做事</div>
</div>
</div>
<div className="space-y-6 md:space-y-8">
{values.map((value) => (
<div
@@ -236,8 +251,14 @@ function MilestonesSection({ data }: { data: AboutData }) {
transition={{ duration: 0.8, ease: EASE_OUT }}
className="max-w-3xl mb-16 sm:mb-20 md:mb-24"
>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6">
Our Journey
<div className="flex items-center gap-4 mb-6">
<span className="text-4xl md:text-5xl font-black text-brand/80 font-mono leading-none tabular-nums">03</span>
<div>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase">
Where We Are Heading
</div>
<div className="text-xs text-text-muted mt-1">走到哪里</div>
</div>
</div>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]">
一路走来,<br className="sm:hidden" />步步扎实
@@ -0,0 +1,138 @@
import { describe, it, expect, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import HomeContentV14 from './home-content-v14';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, initial, animate, className, style, ...props }: any) => (
<div
data-testid="motion-div"
data-initial={JSON.stringify(initial)}
data-animate={JSON.stringify(animate)}
className={className}
style={style}
{...props}
>
{children}
</div>
),
h1: ({ children, className, ...props }: any) => (
<h1 data-testid="motion-h1" className={className} {...props}>{children}</h1>
),
p: ({ children, className, ...props }: any) => (
<p data-testid="motion-p" className={className} {...props}>{children}</p>
),
span: ({ children, className, ...props }: any) => (
<span data-testid="motion-span" className={className} {...props}>{children}</span>
),
a: ({ children, href, className, ...props }: any) => (
<a href={href} className={className} data-testid="motion-a" {...props}>{children}</a>
),
},
useInView: jest.fn(() => true),
AnimatePresence: ({ children }: any) => <>{children}</>,
}));
jest.mock('next/image', () => ({
__esModule: true,
default: ({ src, alt, className }: any) => (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt} className={className} data-testid="mock-next-image" />
),
}));
jest.mock('@/components/ui/scroll-reveal', () => ({
ScrollReveal: ({ children, className }: any) => (
<div data-testid="scroll-reveal" className={className}>{children}</div>
),
StaggerReveal: ({ children, className }: any) => (
<div data-testid="stagger-reveal" className={className}>{children}</div>
),
}));
jest.mock('@/components/ui/badge', () => ({
Badge: ({ children, className }: any) => (
<span data-testid="mock-badge" className={className}>{children}</span>
),
}));
jest.mock('lucide-react', () => {
const mockIcon = (name: string) => {
const Icon = (props: any) => <svg data-testid={`icon-${name.toLowerCase()}`} className={props.className} />;
Icon.displayName = name;
return Icon;
};
return {
ArrowRight: mockIcon('arrow-right'),
ArrowUpRight: mockIcon('arrow-up-right'),
ArrowDownRight: mockIcon('arrow-down-right'),
Lightbulb: mockIcon('lightbulb'),
Database: mockIcon('database'),
Layers: mockIcon('layers'),
Code: mockIcon('code'),
Puzzle: mockIcon('puzzle'),
Server: mockIcon('server'),
LayoutDashboard: mockIcon('layout-dashboard'),
BarChart3: mockIcon('bar-chart3'),
Package: mockIcon('package'),
Users: mockIcon('users'),
Settings: mockIcon('settings'),
Search: mockIcon('search'),
Bell: mockIcon('bell'),
TrendingUp: mockIcon('trending-up'),
AlertCircle: mockIcon('alert-circle'),
};
});
describe('HomeContentV14 - 首页 Hero 优化', () => {
it('renders an SVG product visual with an interface disclaimer', () => {
render(<HomeContentV14 />);
expect(screen.getByTestId('hero-product-visual')).toBeInTheDocument();
expect(screen.getByText('产品界面示意')).toBeInTheDocument();
});
it('keeps a single primary CTA and demotes the secondary action to a text link', () => {
render(<HomeContentV14 />);
expect(screen.getByTestId('hero-primary-cta')).toBeInTheDocument();
expect(screen.getByTestId('hero-primary-cta')).toHaveAttribute('href', '/contact');
expect(screen.getByTestId('hero-secondary-link')).toBeInTheDocument();
});
it('keeps the answer-first stats bar visible', () => {
render(<HomeContentV14 stats={[{ value: '100%', label: '私有化部署' }]} />);
expect(screen.getByTestId('hero-stats')).toBeInTheDocument();
});
});
describe('HomeContentV14 - 信任层与章节式叙事', () => {
it('renders verifiable trust signals with a source annotation', () => {
render(<HomeContentV14 />);
expect(screen.getByTestId('trust-section')).toBeInTheDocument();
expect(screen.getByText('100%')).toBeInTheDocument();
expect(screen.getByText('成都')).toBeInTheDocument();
// 可验证来源标注存在(每个数据卡一个)
expect(screen.getAllByText(/数据来源:/i).length).toBeGreaterThanOrEqual(4);
});
it('renders an early-access co-creation label instead of fabricated testimonials', () => {
render(<HomeContentV14 />);
expect(screen.getByTestId('early-access-banner')).toBeInTheDocument();
expect(screen.getAllByText('首批客户共创中').length).toBeGreaterThanOrEqual(1);
});
it('renders the problem-method-result narrative with chapter numbers', () => {
render(<HomeContentV14 />);
expect(screen.getByTestId('narrative-section')).toBeInTheDocument();
expect(screen.getByText('01')).toBeInTheDocument();
expect(screen.getByText('02')).toBeInTheDocument();
expect(screen.getByText('03')).toBeInTheDocument();
expect(screen.getAllByText(/从问题到结果/).length).toBeGreaterThan(0);
});
});
+201 -44
View File
@@ -5,10 +5,11 @@ import Image from 'next/image';
import { motion } from 'framer-motion';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { Badge } from '@/components/ui/badge';
import { HeroProductVisual } from '@/components/sections/hero-product-visual';
import { ArrowRight, Lightbulb, Database, Layers, Code, Puzzle, Server } from 'lucide-react';
import { cn } from '@/lib/utils';
import { EASE_OUT } from '@/components/ui/page-decoration';
import { COMPANY_INFO } from '@/lib/constants';
import { COMPANY_INFO, TRUST_SIGNALS, EARLY_ACCESS } from '@/lib/constants';
// ===== 服务图标映射:将字符串键名映射为 Lucide 组件 =====
const SERVICE_ICON_MAP: Record<string, React.ReactNode> = {
@@ -18,7 +19,7 @@ const SERVICE_ICON_MAP: Record<string, React.ReactNode> = {
Puzzle: <Puzzle className="w-7 h-7" />,
};
// ===== 服务数据降级兜底 =====
// ===== 服务数据降级兜底(4 条,保证 sm:grid-cols-2 网格完整,避免孤儿格)=====
const FALLBACK_SERVICES = [
{
subtitle: 'Strategy Consulting',
@@ -29,9 +30,21 @@ const FALLBACK_SERVICES = [
{ value: '40%', label: '平均运营效率提升' },
{ value: '6个月', label: '战略到试点周期' },
],
href: '/services/strategy',
href: '/services/consulting',
icon: <Lightbulb className="w-7 h-7" />,
},
{
subtitle: 'Software Delivery',
title: '企业软件定制开发',
desc: '基于自研产品矩阵与工程化交付流程,提供从需求分析、架构设计到持续运维的全栈软件开发服务,支撑业务数字化落地。',
highlights: ['需求分析', '架构设计', '敏捷开发', '持续运维'],
metrics: [
{ value: '6', label: '款自研产品' },
{ value: '24/7', label: '运维支持' },
],
href: '/services/software',
icon: <Code className="w-7 h-7" />,
},
{
subtitle: 'Data Intelligence',
title: '数据智能与 AI 应用',
@@ -53,21 +66,23 @@ const FALLBACK_SERVICES = [
{ value: '6', label: '行业场景覆盖' },
{ value: '99.9%', label: '系统可用性' },
],
href: '/services/solution',
href: '/services/solutions',
icon: <Layers className="w-7 h-7" />,
},
];
// ===== 品牌 CTA 按钮 =====
function CTAButton({ children, href, variant = 'primary', className }: {
function CTAButton({ children, href, variant = 'primary', className, dataTestId }: {
children: React.ReactNode;
href: string;
variant?: 'primary' | 'secondary';
className?: string;
dataTestId?: string;
}) {
return (
<a
href={href}
data-testid={dataTestId}
className={cn(
'group inline-flex items-center gap-3 px-10 py-4 font-semibold text-base transition-all duration-200',
variant === 'primary'
@@ -93,8 +108,8 @@ function HeroSection({ heroData, stats }: {
const subheading = heroData?.subheading || heroData?.description || '';
const ctaLabel = heroData?.ctaLabel || '预约咨询';
const ctaHref = heroData?.ctaHref || '/contact';
const secondaryCtaLabel = heroData?.secondaryCtaLabel || '';
const secondaryCtaHref = heroData?.secondaryCtaHref || '#';
const secondaryCtaLabel = heroData?.secondaryCtaLabel || '查看产品矩阵';
const secondaryCtaHref = heroData?.secondaryCtaHref || '/products';
const hasValidStats = stats?.length && stats[0]?.value;
const displayStats = hasValidStats ? stats!.slice(0, 3) : [];
@@ -112,14 +127,15 @@ function HeroSection({ heroData, stats }: {
}}
/>
<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-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-16 sm:py-20 md:py-24 lg:py-24">
<div className="grid items-center gap-16 lg:grid-cols-[1.05fr_0.95fr]">
<div className="max-w-4xl">
{/* 品牌标识:Logo 本身已包含印章 + 公司名 + NOVALON */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0, ease: EASE_OUT }}
className="mb-8"
transition={{ duration: 0.35, delay: 0, ease: EASE_OUT }}
className="mb-6"
>
<Image
src="/logo.svg"
@@ -135,8 +151,8 @@ function HeroSection({ heroData, stats }: {
<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"
transition={{ duration: 0.45, delay: 0.06, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-6xl 2xl:text-7xl font-black text-ink leading-[1.05] tracking-tight mb-8 sm:mb-10"
>
{heading}
</motion.h1>
@@ -146,12 +162,13 @@ function HeroSection({ heroData, stats }: {
<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"
transition={{ duration: 0.35, delay: 0.16, ease: EASE_OUT }}
className="flex flex-wrap gap-8 sm:gap-12 md:gap-16 mb-8"
data-testid="hero-stats"
>
{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">
<span className={cn('text-3xl sm:text-4xl md:text-5xl font-black tabular-nums leading-none', i === 0 ? 'text-brand' : 'text-ink')}>
{stat.value}
</span>
<span className="text-sm text-text-secondary whitespace-nowrap">
@@ -167,8 +184,8 @@ function HeroSection({ heroData, stats }: {
<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"
transition={{ duration: 0.35, delay: 0.24, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10"
>
{subheading}
</motion.p>
@@ -178,13 +195,14 @@ function HeroSection({ heroData, stats }: {
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.5, ease: EASE_OUT }}
transition={{ duration: 0.35, delay: 0.3, ease: EASE_OUT }}
className="flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6"
>
<CTAButton href={ctaHref}>{ctaLabel}</CTAButton>
<CTAButton href={ctaHref} dataTestId="hero-primary-cta">{ctaLabel}</CTAButton>
{secondaryCtaLabel && (
<a
href={secondaryCtaHref}
data-testid="hero-secondary-link"
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}
@@ -193,22 +211,26 @@ function HeroSection({ heroData, stats }: {
)}
</motion.div>
</div>
{/* 右侧:产品视觉(SVG 数据看板示意) */}
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.36, ease: EASE_OUT }}
className="relative mt-16 lg:mt-0"
>
<HeroProductVisual className="max-w-xl w-full lg:ml-auto" />
</motion.div>
</div>
</div>
</section>
);
}
// ===== 信任/成果区:Bain 式量化数据展示 =====
// ===== 信任/成果区:可验证信任信号 + 首批客户共创标签 =====
function TrustSection() {
const items = [
{ value: '2026', label: '年正式成立', desc: '1 月 15 日于成都龙泉驿区启航' },
{ value: '10+', label: '人核心团队', desc: '复合型技术团队,覆盖咨询与研发' },
{ value: '6', label: '款自研产品', desc: '覆盖核心数字化业务场景' },
{ value: '100%', label: '私有化部署', desc: '核心数据不出境,满足合规要求' },
];
return (
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-white">
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-bg-secondary" data-testid="trust-section">
<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">
@@ -224,15 +246,113 @@ function TrustSection() {
</h2>
</ScrollReveal>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{items.map((item, i) => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
{TRUST_SIGNALS.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 className="bain-card p-6 sm:p-7 h-full flex flex-col">
<div className="flex items-baseline gap-2 mb-3">
<span className="text-xl sm:text-2xl font-black text-ink leading-none">
{item.value}
</span>
<span className="text-sm font-semibold text-text-secondary">{item.label}</span>
</div>
<p className="text-sm text-text-secondary leading-relaxed mb-4 flex-1">{item.desc}</p>
<div className="text-xs text-text-muted border-t border-border-primary pt-3">
数据来源:{item.source}
</div>
</div>
</StaggerReveal>
))}
</div>
{/* 首批客户共创标签:无真实案例时如实标注,不虚构客户 */}
<StaggerReveal staggerDelay={0.08}>
<div className="flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6 p-6 sm:p-8 bg-brand-bg border border-[rgba(196,30,58,0.15)]" data-testid="early-access-banner">
<div>
<div className="flex items-center gap-3 mb-1">
<span className="text-[13px] tracking-[0.12em] font-semibold text-brand">
{EARLY_ACCESS.label}
</span>
</div>
<p className="text-sm text-text-secondary leading-relaxed">
{EARLY_ACCESS.desc}
</p>
</div>
<a
href="/contact"
className="group inline-flex shrink-0 items-center gap-2 text-sm font-semibold text-ink hover:text-brand transition-colors sm:ml-auto"
>
<span>成为首批共创客户</span>
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
</div>
</StaggerReveal>
</div>
</section>
);
}
// ===== 章节式叙事区:问题 → 方法 → 结果(Storytelling-Driven)=====
const NARRATIVE_ACTS = [
{
number: '01',
title: '诊断现状',
desc: '从业务目标出发,梳理流程断点、数据孤岛与瓶颈环节,形成可量化的诊断结论。',
href: '/services/consulting',
cta: '了解诊断方法',
},
{
number: '02',
title: '设计路径',
desc: '基于诊断结论设计系统架构与实施路线图,明确每一阶段的交付物与衡量指标。',
href: '/services/solutions',
cta: '查看方案设计',
},
{
number: '03',
title: '交付成果',
desc: '以自研产品组合与专业实施团队完成交付,用真实运营数据验证业务改善,持续迭代。',
href: '/products',
cta: '浏览产品矩阵',
},
];
function NarrativeSection() {
return (
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-white" data-testid="narrative-section">
<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">
<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 md:grid-cols-3 gap-px bg-border-primary">
{NARRATIVE_ACTS.map((act) => (
<StaggerReveal key={act.number} staggerDelay={0.08}>
<div className="group relative h-full bg-white p-8 sm:p-10 md:p-12 transition-colors duration-300 hover:bg-bg-secondary">
<div className="flex items-start justify-between mb-8">
<span className="text-4xl sm:text-5xl font-black text-text-hint tabular-nums leading-none">
{act.number}
</span>
</div>
<h3 className="text-xl lg:text-2xl font-bold text-ink mb-4 group-hover:text-brand transition-colors duration-300">
{act.title}
</h3>
<p className="text-text-secondary leading-relaxed mb-8 text-sm">
{act.desc}
</p>
<div className="pt-6 border-t border-border-primary">
<a
href={act.href}
className="group/link inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors"
>
<span>{act.cta}</span>
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover/link:translate-x-1" />
</a>
</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>
))}
@@ -265,7 +385,7 @@ function ServicesSection({ services }: { services?: Record<string, any>[] }) {
</ScrollReveal>
</div>
<div className="grid md:grid-cols-3 gap-6">
<div className="grid gap-6 sm:grid-cols-2">
{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">
@@ -291,8 +411,8 @@ function ServicesSection({ services }: { services?: Record<string, any>[] }) {
<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 className="text-2xl sm:text-3xl font-black text-ink leading-none mb-1">{m.value}</div>
<div className="text-xs text-text-muted font-medium">{m.label}</div>
</div>
))}
</div>
@@ -320,9 +440,43 @@ function CasesSection({ cases }: { cases?: Record<string, any>[] }) {
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>
<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="grid lg:grid-cols-2 gap-12 items-center">
<ScrollReveal>
<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] mb-8">
每一个项目,
<br />
都以业务改善为衡量标准
</h2>
<p className="text-text-secondary leading-relaxed text-lg mb-8">
我们正与首批客户共同打磨产品与方案,以真实运营数据验证业务改善。
<br className="hidden sm:block" />
公开案例将在验证后发布——合作成果,经得起衡量。
</p>
<a
href="/contact"
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>
<ScrollReveal delay={0.15}>
<div className="bain-card p-10 sm:p-12 bg-bg-secondary">
<div className="text-5xl sm:text-6xl font-black text-brand tabular-nums leading-none mb-4">
{EARLY_ACCESS.label}
</div>
<p className="text-text-secondary leading-relaxed">{EARLY_ACCESS.desc}</p>
</div>
</ScrollReveal>
</div>
</div>
</section>
);
@@ -436,13 +590,16 @@ export default function HomeContentV14({ services, cases, stats, heroData }: {
{/* 2. 信任区:量化数据成果展示 */}
<TrustSection />
{/* 3. 服务区:等宽卡片 + 兜底数据 */}
{/* 3. 章节式叙事:问题 → 方法 → 结果 */}
<NarrativeSection />
{/* 4. 服务区:等宽卡片 + 兜底数据 */}
<ServicesSection services={services} />
{/* 4. 案例区:轻量快照卡片 */}
{/* 5. 案例区:轻量快照卡片 */}
<CasesSection cases={cases} />
{/* 5. CTA:克制强调,品牌红仅用于按钮 */}
{/* 6. CTA:克制强调,品牌红仅用于按钮 */}
<CTASection heroData={heroData} />
</main>
);
@@ -144,6 +144,33 @@ const mockEnterpriseProducts: Product[] = [
dataProofs: [],
certifications: [],
},
{
id: 'bi',
title: '睿新商业智能分析平台',
category: '企业旗舰系列',
categoryId: 'enterprise',
description: '企业级数据智能分析平台',
overview: 'BI 平台概述',
features: ['数据建模', '可视化分析'],
benefits: ['数据驱动决策'],
tags: ['BI', '数据分析'],
status: '已发布',
image: '/bi.png',
heroThemeId: 'bi',
bundle: 'enterprise',
scenario: '面向中大型企业的数据驱动决策平台',
metrics: [
{ value: '10x', label: '分析效率提升' },
{ value: '98%', label: '数据质量达标率' },
{ value: '24h', label: '实时更新' },
],
capabilities: ['数据建模', '可视化报表', '智能预警', '移动看板'],
process: [],
specs: [],
caseStudies: [],
dataProofs: [],
certifications: [],
},
];
const mockSpecializedProducts: Product[] = [
@@ -251,6 +278,22 @@ describe('ProductsContentV3 - 产品列表页集成测试', () => {
expect(erpDetailLink).toBeTruthy();
});
it('should render ERP as the 2x2 Bento large card', async () => {
const { default: ProductsContentV3 } = await import('./products-content-v3');
render(<ProductsContentV3 products={allMockProducts} />);
const erpCard = screen.getByTestId('bento-product-card-erp');
expect(erpCard).toHaveAttribute('data-bento-size', 'large');
});
it('should render BI as the 1x2 Bento wide card', async () => {
const { default: ProductsContentV3 } = await import('./products-content-v3');
render(<ProductsContentV3 products={allMockProducts} />);
const biCard = screen.getByTestId('bento-product-card-bi');
expect(biCard).toHaveAttribute('data-bento-size', 'wide');
});
it('should render suite combos section', async () => {
const { default: ProductsContentV3 } = await import('./products-content-v3');
render(<ProductsContentV3 products={allMockProducts} />);
@@ -6,9 +6,11 @@ import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { EASE_OUT } from '@/components/ui/page-decoration';
import { Button } from '@/components/ui/button';
import { StaticLink } from '@/components/ui/static-link';
import { BentoItem } from '@/components/sections/bento-grid';
import { ArrowRight, Package, Cpu, TrendingUp, Users, Settings, FileText } from 'lucide-react';
import type { Product } from '@/lib/constants/products';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { cn } from '@/lib/utils';
const EASE_SPRING = { type: 'spring', stiffness: 300, damping: 30 } as const;
@@ -146,40 +148,45 @@ function HeroSection() {
);
}
function ProductCard({ product }: { product: Product }) {
function ProductCard({ product, size = 'small' }: { product: Product; size?: 'large' | 'wide' | 'small' }) {
const bundleLabel = product.bundle === 'enterprise' ? '企业套装' : '专业产品';
const linkHref = product.externalUrl || `/products/${product.id}`;
const isExternal = !!product.externalUrl;
const isLarge = size === 'large';
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.6, ease: EASE_OUT }}
>
<BentoItem size={size} testId={`bento-product-card-${product.id}`}>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.6, ease: EASE_OUT }}
className="h-full"
>
<StaticLink
href={linkHref}
className="group 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"
className="group relative block overflow-hidden border border-border-primary hover:border-border-secondary bg-white hover:bg-bg-secondary hover:-translate-y-1 hover:shadow-lg transition-all duration-300 flex flex-col h-full 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"
{...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
<div className="p-6 sm:p-7 lg:p-8 flex flex-col flex-1">
<div className={cn('p-6 sm:p-7 lg:p-8 flex flex-col flex-1', isLarge && 'lg:p-10')}>
<div className="flex items-start justify-between mb-4">
<div>
<div className="text-xs text-text-muted mb-1">{bundleLabel}</div>
<h3 className="text-lg font-bold text-text-primary">{product.title}</h3>
<h3 className={cn('font-bold text-text-primary', isLarge ? 'text-2xl lg:text-3xl' : 'text-lg')}>
{product.title}
</h3>
</div>
<span className="px-2 py-1 text-xs bg-brand/10 text-brand" aria-hidden="true">{bundleLabel}</span>
</div>
<p className="text-sm text-text-secondary mb-4 leading-relaxed flex-1">
<p className={cn('text-text-secondary mb-4 leading-relaxed flex-1', isLarge ? 'text-base' : 'text-sm')}>
{product.scenario || product.description}
</p>
<div className="grid grid-cols-3 gap-2 mb-4">
{product.metrics?.map((m) => (
<div key={m.label} className="text-center p-2 bg-bg-secondary">
<div className="text-lg font-bold text-text-primary">{m.value}</div>
<div className={cn('font-bold text-text-primary', isLarge ? 'text-xl' : 'text-lg')}>{m.value}</div>
<div className="text-xs text-text-muted">{m.label}</div>
</div>
))}
@@ -194,7 +201,8 @@ function ProductCard({ product }: { product: Product }) {
</div>
</div>
</StaticLink>
</motion.div>
</motion.div>
</BentoItem>
);
}
@@ -299,15 +307,15 @@ function EnterpriseProductsSection({ products }: { products: Product[] }) {
</ScrollReveal>
</div>
<StaggerReveal
className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-border-primary"
staggerDelay={0.08}
delayChildren={0.05}
>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-border-primary">
{enterpriseProducts.map((product) => (
<ProductCard key={product.id} product={product} />
<ProductCard
key={product.id}
product={product}
size={product.id === 'erp' ? 'large' : product.id === 'bi' ? 'wide' : 'small'}
/>
))}
</StaggerReveal>
</div>
</div>
</section>
);
@@ -0,0 +1,105 @@
import { describe, it, expect, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import ServicesContentV3 from './services-content-v3';
import type { Service } from '@/lib/constants/services';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, initial, animate, whileInView, viewport, className, ...props }: any) => (
<div
data-testid="motion-div"
data-initial={JSON.stringify(initial)}
data-animate={JSON.stringify(animate)}
data-while-inview={JSON.stringify(whileInView)}
data-viewport={JSON.stringify(viewport)}
className={className}
{...props}
>
{children}
</div>
),
h1: ({ children, className, ...props }: any) => (
<h1 data-testid="motion-h1" className={className} {...props}>{children}</h1>
),
p: ({ children, className, ...props }: any) => (
<p data-testid="motion-p" className={className} {...props}>{children}</p>
),
span: ({ children, className, ...props }: any) => (
<span data-testid="motion-span" className={className} {...props}>{children}</span>
),
a: ({ children, href, className, ...props }: any) => (
<a href={href} className={className} data-testid="motion-a" {...props}>{children}</a>
),
},
useInView: jest.fn(() => true),
AnimatePresence: ({ children }: any) => <>{children}</>,
}));
jest.mock('@/components/ui/static-link', () => ({
StaticLink: ({ children, href, className, ...props }: any) => (
<a href={href} className={className} data-testid="static-link" {...props}>{children}</a>
),
}));
jest.mock('lucide-react', () => {
const mockIcon = (name: string) => {
const Icon = (props: any) => <svg data-testid={`icon-${name.toLowerCase()}`} className={props.className} />;
Icon.displayName = name;
return Icon;
};
return {
ArrowUpRight: mockIcon('arrow-up-right'),
CheckCircle2: mockIcon('check-circle2'),
};
});
const mockServices: Service[] = [
{
id: 'consulting',
title: '数字化转型战略咨询',
description: '从业务诊断到数字化路线图',
icon: 'Lightbulb',
overview: '战略咨询概述',
features: ['业务诊断'],
benefits: ['ROI 评估'],
process: ['诊断'],
heroThemeId: 'consulting',
caseStudies: [],
dataProofs: [],
capabilities: ['业务诊断', '数字化蓝图'],
metrics: [{ value: '40%', label: '运营效率提升' }],
},
{
id: 'data',
title: '数据智能与 AI 应用',
description: '构建企业级数据平台',
icon: 'Database',
overview: '数据智能概述',
features: ['数据中台'],
benefits: ['数据驱动'],
process: ['建模'],
heroThemeId: 'data',
caseStudies: [],
dataProofs: [],
capabilities: ['数据中台', 'AI 建模'],
metrics: [{ value: '98%', label: '数据质量达标率' }],
},
];
describe('ServicesContentV3 - 非对称服务卡片', () => {
it('makes the first service a featured full-width card', () => {
render(<ServicesContentV3 services={mockServices} />);
expect(screen.getByTestId('service-card-consulting')).toHaveAttribute('data-featured', 'true');
expect(screen.getByTestId('service-card-consulting').className).toContain('md:col-span-2');
expect(screen.getByTestId('service-card-data')).toHaveAttribute('data-featured', 'false');
});
it('renders service titles and descriptions', () => {
render(<ServicesContentV3 services={mockServices} />);
expect(screen.getByText('数字化转型战略咨询')).toBeInTheDocument();
expect(screen.getByText('数据智能与 AI 应用')).toBeInTheDocument();
});
});
@@ -195,6 +195,9 @@ function ServicesGridSection({ services }: { services?: Service[] }) {
{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-white' : 'md:col-span-1 bg-white'}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
@@ -202,7 +205,7 @@ function ServicesGridSection({ services }: { services?: Service[] }) {
>
<StaticLink
href={`/services/${service.id}`}
className="group relative block p-8 sm:p-10 md:p-12 transition-all duration-500 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-white border border-transparent hover:border-border-primary"
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-white 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">
@@ -0,0 +1,125 @@
import { describe, it, expect, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import SolutionDetailContentV3 from './solution-detail-content-v3';
import type { Solution } from '@/lib/constants/solutions';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, className, ...props }: any) => (
<div data-testid="motion-div" className={className} {...props}>{children}</div>
),
h1: ({ children, className, ...props }: any) => (
<h1 data-testid="motion-h1" className={className} {...props}>{children}</h1>
),
p: ({ children, className, ...props }: any) => (
<p data-testid="motion-p" className={className} {...props}>{children}</p>
),
span: ({ children, className, ...props }: any) => (
<span data-testid="motion-span" className={className} {...props}>{children}</span>
),
a: ({ children, href, className, ...props }: any) => (
<a href={href} className={className} {...props}>{children}</a>
),
},
useInView: jest.fn(() => true),
AnimatePresence: ({ children }: any) => <>{children}</>,
}));
jest.mock('@/components/ui/scroll-reveal', () => ({
ScrollReveal: ({ children, className }: any) => (
<div data-testid="scroll-reveal" className={className}>{children}</div>
),
StaggerReveal: ({ children, className }: any) => (
<div data-testid="stagger-reveal" className={className}>{children}</div>
),
}));
jest.mock('@/components/ui/button', () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
jest.mock('lucide-react', () => {
const mockIcon = (name: string) => {
const Icon = (props: any) => <svg data-testid={`icon-${name.toLowerCase()}`} className={props.className} />;
Icon.displayName = name;
return Icon;
};
return {
ArrowRight: mockIcon('arrow-right'),
Factory: mockIcon('factory'),
ShoppingCart: mockIcon('shopping-cart'),
Heart: mockIcon('heart'),
GraduationCap: mockIcon('graduation-cap'),
Lightbulb: mockIcon('lightbulb'),
Settings: mockIcon('settings'),
Users: mockIcon('users'),
TrendingUp: mockIcon('trending-up'),
Package: mockIcon('package'),
BookOpen: mockIcon('book-open'),
MessageSquare: mockIcon('message-square'),
Calendar: mockIcon('calendar'),
Smartphone: mockIcon('smartphone'),
Route: mockIcon('route'),
Shield: mockIcon('shield'),
Truck: mockIcon('truck'),
};
});
const baseSolution = {
id: 'manufacturing',
industry: '制造业',
title: '智能制造解决方案',
subtitle: '从传统制造到智慧工厂',
description: '测试描述',
challenges: ['数据孤岛'],
solutions: ['建设 MES'],
relatedProducts: ['erp'],
valueProposition: {
headline: '全链路数字化赋能',
points: [{ icon: 'Factory', title: '生产透明化', description: '实时掌握' }],
},
suiteCombination: {
primaryProducts: ['erp'],
complementaryServices: ['consulting'],
rationale: 'ERP 打通核心链路',
},
} as Solution;
describe('SolutionDetailContentV3 - L3 信任层三档策略', () => {
it('does not render L3 sections when no case/certification data exists', () => {
render(<SolutionDetailContentV3 solution={baseSolution} />);
expect(screen.queryByText('同行业落地案例')).not.toBeInTheDocument();
expect(screen.queryByText('资质认证')).not.toBeInTheDocument();
});
it('renders case studies when data is available', () => {
const withCases: Solution = {
...baseSolution,
caseStudies: [
{
client: '某制造集团',
industry: '制造业',
challenge: '生产数据孤岛',
solution: '部署 MES 与 BI',
result: '生产效率提升 40%',
},
] as Solution['caseStudies'],
};
render(<SolutionDetailContentV3 solution={withCases} />);
expect(screen.getByText('同行业落地案例')).toBeInTheDocument();
expect(screen.getByText('某制造集团')).toBeInTheDocument();
});
it('renders certifications when data is available', () => {
const withCerts: Solution = {
...baseSolution,
certifications: [{ name: 'ISO 27001', issuer: '认证机构' }],
};
render(<SolutionDetailContentV3 solution={withCerts} />);
expect(screen.getByText('ISO 27001')).toBeInTheDocument();
});
});
@@ -3,8 +3,7 @@
import { motion } from 'framer-motion';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { Button } from '@/components/ui/button';
import { ArrowRight, Factory, ShoppingCart, Heart, GraduationCap, Lightbulb, Settings, Users, TrendingUp, Package, BookOpen, MessageSquare, Calendar, Smartphone, Route, Shield, Truck } from 'lucide-react';
import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import { ArrowRight, Factory, ShoppingCart, Heart, GraduationCap, Lightbulb, Settings, Users, TrendingUp, Package, BookOpen, MessageSquare, Calendar, Smartphone, Route, Shield, Truck } from 'lucide-react';import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import type { Solution } from '@/lib/constants/solutions';
import type { Product } from '@/lib/constants/products';
@@ -341,6 +340,128 @@ interface CTASectionProps {
solution: Solution;
}
// ===== L3 信任层:客户案例(有数据才渲染,无数据不显示,不虚构)=====
interface CaseStudiesSectionProps {
caseStudies: Solution['caseStudies'];
}
function CaseStudiesSection({ caseStudies }: CaseStudiesSectionProps) {
if (!caseStudies || caseStudies.length === 0) return null;
return (
<section className="relative py-20 sm:py-28 md:py-32 lg: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">
<ScrollReveal className="text-center max-w-3xl mx-auto mb-16 sm:mb-20 md:mb-20">
<SectionLabel className="justify-center">
<div className="flex items-center gap-5">
<div className="w-14 h-px bg-brand" />
<span className="text-[11px] tracking-[0.4em] uppercase font-bold text-brand">
客户案例
</span>
<div className="w-14 h-px bg-brand" />
</div>
</SectionLabel>
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight">
同行业落地案例
</h2>
</ScrollReveal>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-border-primary">
<StaggerReveal staggerDelay={0.1} delayChildren={0.05}>
{caseStudies.map((study) => (
<div
key={study.client}
className="group relative block overflow-hidden bg-white transition-all duration-500 hover:bg-bg-secondary"
>
<div className="aspect-[4/3] bg-gradient-to-br from-bg-secondary to-bg-tertiary relative overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-6xl font-bold text-text-muted/10">
{study.client.charAt(0)}
</span>
</div>
<div className="absolute top-5 left-5">
<span className="px-3 py-1.5 text-xs font-bold text-brand bg-brand-soft/80 backdrop-blur-sm">
{study.industry}
</span>
</div>
</div>
<div className="p-8">
<h3 className="text-xl font-bold text-ink mb-3 group-hover:translate-x-2 transition-transform duration-500">
{study.client}
</h3>
<p className="text-text-secondary text-sm leading-relaxed mb-6">
{study.challenge}
</p>
<div className="pt-6 border-t border-border-primary">
<div className="text-2xl font-bold text-brand mb-1">{study.result}</div>
<div className="text-xs text-text-muted">核心成果</div>
</div>
</div>
</div>
))}
</StaggerReveal>
</div>
</div>
</section>
);
}
// ===== L3 信任层:资质认证(有数据才渲染,无数据不显示)=====
interface CertificationsSectionProps {
certifications: Solution['certifications'];
}
function CertificationsSection({ certifications }: CertificationsSectionProps) {
if (!certifications || certifications.length === 0) return null;
return (
<section className="relative py-16 sm:py-20 md: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" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<ScrollReveal className="text-center max-w-3xl mx-auto mb-16">
<SectionLabel className="justify-center">
<div className="flex items-center gap-5">
<div className="w-14 h-px bg-brand" />
<span className="text-[11px] tracking-[0.4em] uppercase font-bold text-brand">
资质认证
</span>
<div className="w-14 h-px bg-brand" />
</div>
</SectionLabel>
<h2 className="text-2xl md:text-3xl font-bold text-ink tracking-tight">
资质认证
</h2>
</ScrollReveal>
<div className="flex flex-wrap justify-center gap-px bg-border-primary">
<StaggerReveal staggerDelay={0.08} delayChildren={0.05}>
{certifications.map((cert, idx) => (
<div
key={idx}
className="group flex items-center gap-4 px-8 py-5 bg-white hover:bg-bg-secondary transition-all duration-500"
>
<div className="w-12 h-12 rounded-xl bg-brand-soft flex items-center justify-center text-brand">
<Shield className="w-6 h-6" />
</div>
<div>
<div className="text-ink font-bold">{cert.name}</div>
<div className="text-sm text-text-muted">{cert.issuer}</div>
</div>
</div>
))}
</StaggerReveal>
</div>
</div>
</section>
);
}
function CTASection({ solution }: CTASectionProps) {
return (
<section className="relative py-36 lg:py-44 overflow-hidden bg-bg-secondary">
@@ -398,6 +519,8 @@ export default function SolutionDetailContentV3({ solution, products = [] }: Sol
<SolutionsSection solutions={solution.solutions} />
<ValuePropositionSection valueProposition={solution.valueProposition} />
<SuiteCombinationSection suiteCombination={solution.suiteCombination} products={products} />
<CaseStudiesSection caseStudies={solution.caseStudies} />
<CertificationsSection certifications={solution.certifications} />
<CTASection solution={solution} />
</main>
);
@@ -0,0 +1,148 @@
import { describe, it, expect, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import SolutionsContentV3 from './solutions-content-v3';
import type { Solution } from '@/lib/constants/solutions';
import type { Product } from '@/lib/constants/products';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, initial, animate, whileInView, viewport, className, ...props }: any) => (
<div
data-testid="motion-div"
data-initial={JSON.stringify(initial)}
data-animate={JSON.stringify(animate)}
data-while-inview={JSON.stringify(whileInView)}
data-viewport={JSON.stringify(viewport)}
className={className}
{...props}
>
{children}
</div>
),
h1: ({ children, className, ...props }: any) => (
<h1 data-testid="motion-h1" className={className} {...props}>{children}</h1>
),
p: ({ children, className, ...props }: any) => (
<p data-testid="motion-p" className={className} {...props}>{children}</p>
),
span: ({ children, className, ...props }: any) => (
<span data-testid="motion-span" className={className} {...props}>{children}</span>
),
a: ({ children, href, className, ...props }: any) => (
<a href={href} className={className} data-testid="motion-a" {...props}>{children}</a>
),
},
useInView: jest.fn(() => true),
AnimatePresence: ({ children }: any) => <>{children}</>,
}));
jest.mock('@/components/ui/static-link', () => ({
StaticLink: ({ children, href, className, ...props }: any) => (
<a href={href} className={className} data-testid="static-link" {...props}>{children}</a>
),
}));
jest.mock('lucide-react', () => {
const mockIcon = (name: string) => {
const Icon = (props: any) => <svg data-testid={`icon-${name.toLowerCase()}`} className={props.className} />;
Icon.displayName = name;
return Icon;
};
return {
ArrowUpRight: mockIcon('arrow-up-right'),
Factory: mockIcon('factory'),
ShoppingCart: mockIcon('shopping-cart'),
Heart: mockIcon('heart'),
GraduationCap: mockIcon('graduation-cap'),
Shield: mockIcon('shield'),
Truck: mockIcon('truck'),
};
});
const mockSolutions: Solution[] = [
{
id: 'manufacturing',
industry: '制造业',
title: '智能制造数字化转型方案',
subtitle: '端到端制造数字化',
description: '面向制造企业的整体转型方案',
challenges: ['数据孤岛', '库存积压'],
solutions: ['ERP 集成', 'BI 分析'],
relatedProducts: ['erp', 'bi'],
valueProposition: {
headline: '价值主张',
points: [{ icon: 'Factory', title: '降本增效', description: '描述' }],
},
suiteCombination: {
primaryProducts: ['erp'],
complementaryServices: ['consulting'],
rationale: '理由',
},
painPoints: ['数据孤岛'],
outcomes: [{ value: '30%', label: '效率提升' }],
recommendedProducts: ['erp', 'bi'],
},
{
id: 'retail',
industry: '贸易零售',
title: '零售数字化解决方案',
subtitle: '全渠道零售数字化',
description: '面向零售企业的数字化方案',
challenges: ['渠道割裂'],
solutions: ['CRM 统一'],
relatedProducts: ['crm', 'bi'],
valueProposition: {
headline: '价值主张',
points: [{ icon: 'ShoppingCart', title: '提升转化', description: '描述' }],
},
suiteCombination: {
primaryProducts: ['crm'],
complementaryServices: ['consulting'],
rationale: '理由',
},
painPoints: ['渠道割裂'],
outcomes: [{ value: '3x', label: '转化率提升' }],
recommendedProducts: ['crm', 'bi'],
},
];
const mockProducts: Product[] = [
{
id: 'erp',
title: '睿新ERP管理系统',
description: 'ERP',
image: '/erp.png',
category: '企业旗舰系列',
categoryId: 'enterprise',
status: '已发布',
overview: '概述',
features: [],
benefits: [],
process: [],
specs: [],
tags: [],
heroThemeId: 'erp',
caseStudies: [],
dataProofs: [],
certifications: [],
bundle: 'enterprise',
},
];
describe('SolutionsContentV3 - 非对称方案卡片', () => {
it('makes the first solution a featured full-width card', () => {
render(<SolutionsContentV3 solutions={mockSolutions} products={mockProducts} />);
expect(screen.getByTestId('solution-card-manufacturing')).toHaveAttribute('data-featured', 'true');
expect(screen.getByTestId('solution-card-manufacturing').className).toContain('md:col-span-2');
expect(screen.getByTestId('solution-card-retail')).toHaveAttribute('data-featured', 'false');
});
it('renders solution industries', () => {
render(<SolutionsContentV3 solutions={mockSolutions} products={mockProducts} />);
expect(screen.getByText('制造业')).toBeInTheDocument();
expect(screen.getByText('贸易零售')).toBeInTheDocument();
});
});
@@ -108,7 +108,7 @@ function HeroSection() {
);
}
function SolutionCard({ solution, index, products }: { solution: Solution; index: number; products?: Product[] }) {
function SolutionCard({ solution, index, products, featured = false }: { solution: Solution; index: number; products?: Product[]; featured?: boolean }) {
const productLookup = products ?? [];
const Icon = INDUSTRY_ICONS[solution.industry] || Factory;
const recommendedProductNames = (solution.recommendedProducts ?? [])
@@ -117,6 +117,9 @@ function SolutionCard({ solution, index, products }: { solution: Solution; index
return (
<motion.div
data-testid={`solution-card-${solution.id}`}
data-featured={featured ? 'true' : 'false'}
className={featured ? 'md:col-span-2 bg-white' : 'md:col-span-1 bg-white'}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
@@ -124,7 +127,7 @@ function SolutionCard({ solution, index, products }: { solution: Solution; index
>
<StaticLink
href={`/solutions/${solution.id}`}
className="group relative block transition-all duration-500 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-white border border-border-primary hover:border-brand/20"
className="group relative block transition-all duration-300 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-white border border-border-primary hover:border-brand/20 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 p-8 sm:p-10 md:p-12">
<div className="flex items-center gap-3 mb-6">
@@ -195,7 +198,7 @@ function SolutionsGridSection({ solutions, products }: { solutions: Solution[];
<div className="grid md:grid-cols-2 gap-px bg-border-primary">
{solutions.map((solution, index) => (
<SolutionCard key={solution.id} solution={solution} index={index} products={products} />
<SolutionCard key={solution.id} solution={solution} index={index} products={products} featured={index === 0} />
))}
</div>
</div>