feat(home): 首页对标埃森哲重构 V15(深色 Hero + Insights/FounderQuote/News 支柱 + 案例 Client Spotlight)删除 v14
This commit is contained in:
@@ -1,138 +0,0 @@
|
||||
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 abstract data-driven visual without product UI shell', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, jest } from '@jest/globals';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import HomeContentV15 from './home-content-v15';
|
||||
|
||||
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'),
|
||||
Quote: mockIcon('quote'),
|
||||
Newspaper: mockIcon('newspaper'),
|
||||
Calendar: mockIcon('calendar'),
|
||||
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('HomeContentV15 - 深色 Hero(对标 Accenture 纯黑 Hero)', () => {
|
||||
it('renders the dark canvas hero with a white logo variant', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
expect(screen.getByTestId('hero-section')).toBeInTheDocument();
|
||||
const heroLogo = screen.getByTestId('mock-next-image');
|
||||
expect(heroLogo).toHaveAttribute('src', '/logo-white.svg');
|
||||
});
|
||||
|
||||
it('keeps a single primary CTA and demotes the secondary action to a text link', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
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(<HomeContentV15 stats={[{ value: '100%', label: '私有化部署' }]} />);
|
||||
|
||||
expect(screen.getByTestId('hero-stats')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the abstract data-driven visual without product UI shell', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
expect(screen.getByTestId('hero-product-visual')).toBeInTheDocument();
|
||||
expect(screen.getByText('数据驱动 · 让转型可量化')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HomeContentV15 - 信任层与章节式叙事', () => {
|
||||
it('renders verifiable trust signals with a source annotation', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
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(<HomeContentV15 />);
|
||||
|
||||
expect(screen.getByTestId('early-access-banner')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('首批客户共创中').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders the problem-method-result narrative with chapter numbers', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
expect(screen.getByTestId('narrative-section')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('01').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('02').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('03').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/从问题到结果/).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HomeContentV15 - 行业洞察区(对标 Accenture Insights)', () => {
|
||||
it('renders the insights pillar with typed tags and verifiable methodology content', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
expect(screen.getByTestId('insights-section')).toBeInTheDocument();
|
||||
// 类型标签(对标 Research Report / Perspective / Case Study)
|
||||
expect(screen.getAllByText('方法论').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText('观点')).toBeInTheDocument();
|
||||
// 全部链接真实页面,不虚构研究报告
|
||||
expect(screen.getByText('数字化转型诊断:从哪里开始,如何衡量')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HomeContentV15 - 创始人观点区(对标 Accenture CEO Quote)', () => {
|
||||
it('renders the founder quote with attribution (no fabricated persona)', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
expect(screen.getByTestId('founder-quote-section')).toBeInTheDocument();
|
||||
expect(screen.getByText(/数字化不是一次性项目/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Novalon 创始团队')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HomeContentV15 - 新闻动态区(对标 Accenture News)', () => {
|
||||
it('renders the news section with latest items and a view-all entry', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
expect(screen.getByTestId('news-section')).toBeInTheDocument();
|
||||
expect(screen.getByText('四川睿新致远科技有限公司正式成立')).toBeInTheDocument();
|
||||
expect(screen.getByText('查看全部新闻')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders CMS-provided news when available', () => {
|
||||
render(<HomeContentV15 news={[{
|
||||
id: 'cms-news-1',
|
||||
title: 'CMS 新闻条目',
|
||||
excerpt: 'CMS 提供的新闻摘要',
|
||||
date: '2026-08-30',
|
||||
category: '研发动态',
|
||||
image: '',
|
||||
content: '',
|
||||
}]} />);
|
||||
|
||||
expect(screen.getByText('CMS 新闻条目')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HomeContentV15 - 案例区 Client Spotlight(对标 Accenture Client Spotlight)', () => {
|
||||
it('renders a co-creation spotlight card (verifiable, no fabricated clients) when no cases exist', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
// 空态 spotlight:共创流程 3 步 + 深色卡 CTA
|
||||
expect(screen.getAllByText('首批客户共创中').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('业务诊断').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('方案共创')).toBeInTheDocument();
|
||||
expect(screen.getByText('验证发布')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('01').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByTestId('cases-co-creation-cta')).toHaveAttribute('href', '/contact');
|
||||
});
|
||||
|
||||
it('renders a dark spotlight card with metrics for the featured case when data exists', () => {
|
||||
const mockCases = [
|
||||
{
|
||||
slug: 'spotlight-demo',
|
||||
industry: '制造业',
|
||||
title: 'Spotlight 案例标题',
|
||||
href: '/cases/spotlight-demo',
|
||||
verified: true,
|
||||
testimonial: {
|
||||
quote: '这是一段客户引言,用于验证 spotlight 渲染。',
|
||||
author: '客户 CIO',
|
||||
role: '某制造企业',
|
||||
},
|
||||
metrics: [
|
||||
{ value: '38%', label: '交付效率提升' },
|
||||
{ value: '2x', label: '数据查询提速' },
|
||||
{ value: '3周', label: '上线周期' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'secondary-case',
|
||||
industry: '贸易零售',
|
||||
title: '次要案例标题',
|
||||
href: '/cases/secondary-case',
|
||||
metrics: [
|
||||
{ value: '15%', label: '库存周转改善' },
|
||||
{ value: '4h', label: '对账耗时' },
|
||||
{ value: '9成', label: '预测准确率' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(<HomeContentV15 cases={mockCases as any} />);
|
||||
|
||||
// spotlight 大卡:深色画布 + 引言 + 授权 badge + 量化指标
|
||||
expect(screen.getByText('Spotlight 案例标题')).toBeInTheDocument();
|
||||
expect(screen.getByText('这是一段客户引言,用于验证 spotlight 渲染。')).toBeInTheDocument();
|
||||
expect(screen.getByText('已获客户授权')).toBeInTheDocument();
|
||||
expect(screen.getByText('38%')).toBeInTheDocument();
|
||||
expect(screen.getByText('查看完整案例')).toBeInTheDocument();
|
||||
|
||||
// 次要案例保持轻量卡片
|
||||
expect(screen.getByText('次要案例标题')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('了解详情').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
+436
-69
@@ -7,11 +7,13 @@ import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { HeroProductVisual } from '@/components/sections/hero-product-visual';
|
||||
import { ArrowRight, Lightbulb, Database, Layers, Code, Puzzle, Server } from 'lucide-react';
|
||||
import { ArrowRight, Lightbulb, Database, Layers, Code, Puzzle, Server, Quote, Calendar } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { EASE_OUT } from '@/components/ui/page-decoration';
|
||||
import { COMPANY_INFO, TRUST_SIGNALS, EARLY_ACCESS } from '@/lib/constants';
|
||||
import { useSiteConfig } from '@/lib/site-config';
|
||||
import { type NewsItem } from '@/lib/constants/news';
|
||||
import { NEWS } from '@/lib/constants/news';
|
||||
|
||||
// ===== 服务图标映射:将字符串键名映射为 Lucide 组件 =====
|
||||
const SERVICE_ICON_MAP: Record<string, React.ReactNode> = {
|
||||
@@ -73,6 +75,47 @@ const FALLBACK_SERVICES = [
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 行业洞察兜底数据(对标 Accenture Insights:类型标签 + 卡片)=====
|
||||
// 零编造原则:全部为可验证的方法论/观点类内容,链接真实页面;不做虚构研究报告
|
||||
const FALLBACK_INSIGHTS = [
|
||||
{
|
||||
tag: '方法论',
|
||||
title: '数字化转型诊断:从哪里开始,如何衡量',
|
||||
desc: '从业务目标出发的四步诊断法——梳理断点、量化差距、排定优先级、定义验收指标。',
|
||||
date: '2026-08',
|
||||
href: '/methodology',
|
||||
},
|
||||
{
|
||||
tag: '观点',
|
||||
title: '私有化部署:为什么核心数据需要留在企业内',
|
||||
desc: '合规要求之外,数据主权正在成为企业数字化决策的第一优先级。',
|
||||
date: '2026-08',
|
||||
href: '/services/data',
|
||||
},
|
||||
{
|
||||
tag: '方法论',
|
||||
title: '从需求到交付:全流程陪伴式实施如何降低转型风险',
|
||||
desc: '诊断 → 设计 → 交付 → 运维,每一阶段都有明确的交付物与衡量指标。',
|
||||
date: '2026-07',
|
||||
href: '/services/software',
|
||||
},
|
||||
{
|
||||
tag: '共创',
|
||||
title: '首批客户共创计划:与早期客户共同打磨产品',
|
||||
desc: '以真实运营数据验证业务改善,成果在获得客户授权后公开。',
|
||||
date: '2026-06',
|
||||
href: '/contact',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 创始人观点兜底(对标 Accenture CEO Quote,人格化背书)=====
|
||||
// 零编造原则:不虚构具体人名/原话;姓名职务为空时仅展示观点立场,真实引言可在 CMS page-copy 配置
|
||||
const FALLBACK_FOUNDER_QUOTE = {
|
||||
text: '我们相信,数字化不是一次性项目,而是持续迭代的能力建设。每一份投入,都应当经得起衡量。',
|
||||
name: 'Novalon 创始团队',
|
||||
role: '技术咨询 · 数字化转型同行者',
|
||||
};
|
||||
|
||||
// ===== 首页结构性文案类型(page-copy CMS 覆盖,未配置时走硬编码兜底)=====
|
||||
type HomePageCopy = Record<string, any>;
|
||||
|
||||
@@ -90,7 +133,7 @@ function StyledTitle({ lines }: { lines: (string | undefined)[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 品牌 CTA 按钮 =====
|
||||
// ===== 品牌 CTA 按钮(Pill 胶囊化,对标 Accenture > CTA)=====
|
||||
function CTAButton({ children, href, variant = 'primary', className, dataTestId }: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
@@ -103,7 +146,7 @@ function CTAButton({ children, href, variant = 'primary', className, dataTestId
|
||||
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',
|
||||
'group inline-flex items-center gap-3 px-10 py-4 font-semibold text-base transition-all duration-200 rounded-full',
|
||||
variant === 'primary'
|
||||
? 'bg-brand text-white hover:bg-brand-hover'
|
||||
: 'text-ink border border-ink/20 hover:border-ink/40 hover:bg-ink/5',
|
||||
@@ -116,7 +159,7 @@ function CTAButton({ children, href, variant = 'primary', className, dataTestId
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Hero 区:克制白底 + 品牌红点缀 =====
|
||||
// ===== Hero 区:全出血深色画布(对标 Accenture 纯黑 Hero)+ 品牌红单电压 =====
|
||||
function HeroSection({ heroData, stats }: {
|
||||
heroData?: Record<string, any> | null;
|
||||
stats?: Record<string, any>[];
|
||||
@@ -138,12 +181,13 @@ function HeroSection({ heroData, stats }: {
|
||||
return (
|
||||
<section
|
||||
ref={containerRef}
|
||||
className="relative min-h-[90vh] flex items-center overflow-hidden bg-white"
|
||||
className="relative min-h-[90vh] flex items-center overflow-hidden bg-ink"
|
||||
data-testid="hero-section"
|
||||
>
|
||||
{/* 背景纹理:极淡的品牌色点阵 */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.03]"
|
||||
{/* 背景纹理:极淡的品牌色点阵(深色画布上可见) */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.06]"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 25% 25%, rgba(196,30,58,0.4) 1px, transparent 1px)`,
|
||||
backgroundImage: `radial-gradient(circle at 25% 25%, rgba(196,30,58,0.6) 1px, transparent 1px)`,
|
||||
backgroundSize: '48px 48px',
|
||||
}}
|
||||
/>
|
||||
@@ -151,7 +195,7 @@ 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-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 */}
|
||||
{/* 品牌标识:深色画布使用白色版 Logo(浅色版见 header/footer) */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -159,7 +203,7 @@ function HeroSection({ heroData, stats }: {
|
||||
className="mb-6"
|
||||
>
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
src="/logo-white.svg"
|
||||
alt={`${COMPANY_INFO.name} Logo`}
|
||||
width={224}
|
||||
height={56}
|
||||
@@ -168,7 +212,7 @@ function HeroSection({ heroData, stats }: {
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* 品牌口号 Badge:消费站点配置 slogan,后台可改 */}
|
||||
{/* 品牌口号 Badge:深色画布上的品牌红电压 */}
|
||||
{slogan && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -176,23 +220,23 @@ function HeroSection({ heroData, stats }: {
|
||||
transition={{ duration: 0.35, delay: 0.03, ease: EASE_OUT }}
|
||||
className="mb-6"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-brand/5 text-brand text-sm font-medium border border-brand/15">
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-brand/15 text-brand-light text-sm font-medium border border-brand/30">
|
||||
{slogan}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 主标题:大胆、直接 */}
|
||||
{/* 主标题:大胆、直接(深色画布上的白色宣言) */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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"
|
||||
className="text-4xl sm:text-5xl md:text-6xl lg:text-6xl 2xl:text-7xl font-black text-white leading-[1.05] tracking-tight mb-8 sm:mb-10"
|
||||
>
|
||||
{heading}
|
||||
</motion.h1>
|
||||
|
||||
{/* 数据指标条:答案优先,数据先行 */}
|
||||
{/* 数据指标条:答案优先,数据先行(品牌红仅用于第一个数字) */}
|
||||
{displayStats.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
@@ -203,10 +247,10 @@ function HeroSection({ heroData, stats }: {
|
||||
>
|
||||
{displayStats.map((stat, i) => (
|
||||
<div key={i} className="flex items-baseline gap-2">
|
||||
<span className={cn('text-3xl sm:text-4xl md:text-5xl font-black tabular-nums leading-none', i === 0 ? 'text-brand' : 'text-ink')}>
|
||||
<span className={cn('text-3xl sm:text-4xl md:text-5xl font-black tabular-nums leading-none', i === 0 ? 'text-brand-light' : 'text-white')}>
|
||||
{stat.value}
|
||||
</span>
|
||||
<span className="text-sm text-text-secondary whitespace-nowrap">
|
||||
<span className="text-sm text-white/60 whitespace-nowrap">
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
@@ -220,7 +264,7 @@ function HeroSection({ heroData, stats }: {
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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"
|
||||
className="text-lg sm:text-xl text-white/70 max-w-2xl leading-relaxed mb-10"
|
||||
>
|
||||
{subheading}
|
||||
</motion.p>
|
||||
@@ -238,7 +282,7 @@ function HeroSection({ heroData, stats }: {
|
||||
<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"
|
||||
className="group inline-flex items-center gap-2 text-sm font-medium text-white/70 hover:text-white transition-colors duration-200 py-4"
|
||||
>
|
||||
{secondaryCtaLabel}
|
||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
@@ -247,7 +291,7 @@ function HeroSection({ heroData, stats }: {
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:产品视觉(SVG 数据看板示意) */}
|
||||
{/* 右侧:产品视觉(SVG 数据看板示意,浅色卡片浮于深色画布) */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -430,6 +474,84 @@ function NarrativeSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 行业洞察区(对标 Accenture Insights:类型标签 + 内容卡片,思想领导力支柱)=====
|
||||
function InsightsSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
|
||||
const eyebrow = (pageCopy?.insightsEyebrow as string) || 'Insights';
|
||||
const titleLines = ((pageCopy?.insightsTitle as string) || '我们的思考,\n以实践为锚').split('\n');
|
||||
const desc = (pageCopy?.insightsDescription as string) || '方法论、观点与共创进展——可验证的思考,不空谈趋势。';
|
||||
const items = (pageCopy?.insightsItems?.length ? pageCopy.insightsItems : FALLBACK_INSIGHTS) as Array<{
|
||||
tag: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
date?: string;
|
||||
href: string;
|
||||
}>;
|
||||
|
||||
return (
|
||||
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-white" data-testid="insights-section">
|
||||
<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">
|
||||
<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">
|
||||
{eyebrow}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
|
||||
<StyledTitle lines={titleLines} />
|
||||
</h2>
|
||||
<p className="mt-6 text-base sm:text-lg text-text-secondary leading-relaxed max-w-2xl">
|
||||
{desc}
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
<StaticLink
|
||||
href="/news"
|
||||
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" />
|
||||
</StaticLink>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-px bg-border-primary border border-border-primary">
|
||||
{items.slice(0, 4).map((insight, i) => (
|
||||
<StaggerReveal key={i} staggerDelay={0.07}>
|
||||
<a
|
||||
href={insight.href}
|
||||
className="group flex flex-col h-full bg-white p-8 sm:p-10 md:p-12 transition-colors duration-300 hover:bg-bg-secondary"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<span className="text-[13px] tracking-[0.12em] font-semibold text-brand">
|
||||
{insight.tag}
|
||||
</span>
|
||||
{insight.date && (
|
||||
<span className="text-xs text-text-muted tabular-nums">{insight.date}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl lg:text-2xl font-bold text-ink mb-4 leading-[1.25] group-hover:text-brand transition-colors duration-300">
|
||||
{insight.title}
|
||||
</h3>
|
||||
<p className="text-text-secondary leading-relaxed mb-8 text-sm flex-1">
|
||||
{insight.desc}
|
||||
</p>
|
||||
<div className="pt-6 border-t border-border-primary">
|
||||
<span className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors">
|
||||
阅读全文
|
||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</StaggerReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 服务区:等宽三列卡片 + 兜底数据 =====
|
||||
function ServicesSection({ services, pageCopy }: { services?: Record<string, any>[]; pageCopy?: HomePageCopy | null }) {
|
||||
const hasValidData = services?.length && (services![0] as any)?.subtitle;
|
||||
@@ -500,7 +622,59 @@ function ServicesSection({ services, pageCopy }: { services?: Record<string, any
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 案例区:轻量快照卡片 =====
|
||||
// ===== 创始人观点区(对标 Accenture CEO Quote:大引言 + 署名,人格化背书)=====
|
||||
function FounderQuoteSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
|
||||
const eyebrow = (pageCopy?.founderQuoteEyebrow as string) || '创始团队观点';
|
||||
const quoteText = (pageCopy?.founderQuoteText as string) || FALLBACK_FOUNDER_QUOTE.text;
|
||||
const quoteName = (pageCopy?.founderQuoteName as string) || FALLBACK_FOUNDER_QUOTE.name;
|
||||
const quoteRole = (pageCopy?.founderQuoteRole as string) || FALLBACK_FOUNDER_QUOTE.role;
|
||||
|
||||
return (
|
||||
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-brand-section" data-testid="founder-quote-section">
|
||||
{/* 背景纹理:极淡白色点阵(Bain 式深红区块 + 克制质感) */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.05]"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 75% 25%, rgba(255,255,255,0.5) 1px, transparent 1px)`,
|
||||
backgroundSize: '48px 48px',
|
||||
}}
|
||||
/>
|
||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
|
||||
<ScrollReveal className="max-w-4xl mx-auto text-center">
|
||||
<div className="flex items-center justify-center gap-3 mb-8">
|
||||
<div className="w-10 h-px bg-white/40" />
|
||||
<span className="text-[13px] tracking-[0.12em] font-semibold text-white/80">
|
||||
{eyebrow}
|
||||
</span>
|
||||
<div className="w-10 h-px bg-white/40" />
|
||||
</div>
|
||||
<Quote className="w-10 h-10 mx-auto mb-8 text-white/40" aria-hidden="true" />
|
||||
<blockquote className="text-2xl sm:text-3xl md:text-4xl lg:text-[2.75rem] font-bold text-white leading-[1.2] tracking-tight mb-10">
|
||||
{quoteText}
|
||||
</blockquote>
|
||||
{(quoteName || quoteRole) && (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{quoteName && (
|
||||
<div className="text-base font-semibold text-white">{quoteName}</div>
|
||||
)}
|
||||
{quoteRole && (
|
||||
<div className="text-sm text-white/70">{quoteRole}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 共创流程(可验证方法论,不虚构客户)=====
|
||||
const CO_CREATION_STEPS = [
|
||||
{ step: '01', title: '业务诊断', desc: '深入理解业务场景,识别高价值切入点' },
|
||||
{ step: '02', title: '方案共创', desc: '与核心团队共同设计解决方案与实施路径' },
|
||||
{ step: '03', title: '验证发布', desc: '以真实运营数据验证成效,经授权后公开案例' },
|
||||
];
|
||||
|
||||
// ===== 案例区:Client Spotlight(对标 Accenture:深色大卡 spotlight + 其余小卡)=====
|
||||
function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; pageCopy?: HomePageCopy | null }) {
|
||||
const hasValidData = cases?.length && cases[0]?.metrics;
|
||||
const CASE_ITEMS = hasValidData ? cases! : [];
|
||||
@@ -537,11 +711,46 @@ function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; page
|
||||
</StaticLink>
|
||||
</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">
|
||||
{earlyLabel}
|
||||
<div className="relative overflow-hidden rounded-[2rem] bg-ink p-10 sm:p-12 h-full flex flex-col shadow-card">
|
||||
{/* 背景纹理:极淡白色点阵(对标埃森哲深色 spotlight 画布) */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-[0.05]"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 75% 25%, rgba(255,255,255,0.5) 1px, transparent 1px)`,
|
||||
backgroundSize: '48px 48px',
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 flex flex-col h-full">
|
||||
<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-light">
|
||||
{casesEyebrow}
|
||||
</span>
|
||||
</div>
|
||||
<Quote className="w-8 h-8 mb-6 text-white/30" aria-hidden="true" />
|
||||
<div className="text-4xl sm:text-5xl font-black text-white tabular-nums leading-none mb-4">
|
||||
{earlyLabel}
|
||||
</div>
|
||||
<p className="text-white/70 leading-relaxed mb-10">{earlyDesc}</p>
|
||||
<ol className="space-y-5 mb-10">
|
||||
{CO_CREATION_STEPS.map((s) => (
|
||||
<li key={s.step} className="flex items-start gap-4">
|
||||
<span className="text-sm font-black text-brand-light tabular-nums leading-6 shrink-0">
|
||||
{s.step}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white mb-0.5">{s.title}</div>
|
||||
<div className="text-sm text-white/60 leading-relaxed">{s.desc}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="mt-auto">
|
||||
<CTAButton href="/contact" dataTestId="cases-co-creation-cta">
|
||||
成为首批共创客户
|
||||
</CTAButton>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-text-secondary leading-relaxed">{earlyDesc}</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
@@ -550,6 +759,9 @@ function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; page
|
||||
);
|
||||
}
|
||||
|
||||
const [spotlightCase, ...restCases] = CASE_ITEMS;
|
||||
const spotlightTestimonial = spotlightCase?.testimonial as Record<string, any> | undefined;
|
||||
|
||||
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">
|
||||
@@ -570,46 +782,191 @@ function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; page
|
||||
</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" />
|
||||
{/* Client Spotlight 大卡:深色画布 + 客户引言 + 量化成果(对标 Accenture) */}
|
||||
{spotlightCase && (
|
||||
<ScrollReveal className="mb-6">
|
||||
<a
|
||||
href={spotlightCase.href || `/cases/${spotlightCase.slug || ''}`}
|
||||
className="group relative block overflow-hidden rounded-[2rem] bg-ink p-10 sm:p-14 h-full flex flex-col shadow-card"
|
||||
>
|
||||
{/* 背景纹理:极淡白色点阵 */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-[0.05]"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 75% 25%, rgba(255,255,255,0.5) 1px, transparent 1px)`,
|
||||
backgroundSize: '48px 48px',
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 flex flex-col lg:flex-row lg:items-stretch gap-10 lg:gap-16">
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<Badge className="bg-white/10 text-white border-transparent">
|
||||
{spotlightCase.industry}
|
||||
</Badge>
|
||||
{spotlightCase.verified && (
|
||||
<Badge className="bg-brand/15 text-brand-light border-brand/30">
|
||||
已获客户授权
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-2xl sm:text-3xl lg:text-4xl font-black text-white leading-[1.15] mb-8">
|
||||
{spotlightCase.title}
|
||||
</h3>
|
||||
{spotlightTestimonial?.quote && (
|
||||
<blockquote className="max-w-2xl">
|
||||
<Quote className="w-6 h-6 text-brand-light mb-4" aria-hidden="true" />
|
||||
<p className="text-white/80 text-lg leading-relaxed">
|
||||
{spotlightTestimonial.quote}
|
||||
</p>
|
||||
{(spotlightTestimonial.author || spotlightTestimonial.name) && (
|
||||
<footer className="mt-4 text-sm text-white/60">
|
||||
{spotlightTestimonial.author || spotlightTestimonial.name}
|
||||
{spotlightTestimonial.role && ` · ${spotlightTestimonial.role}`}
|
||||
</footer>
|
||||
)}
|
||||
</blockquote>
|
||||
)}
|
||||
<div className="mt-auto pt-6 border-t border-white/10 flex items-center justify-between gap-6 flex-wrap">
|
||||
<span className="inline-flex items-center gap-2 text-sm font-semibold text-white group-hover:text-brand-light transition-colors">
|
||||
查看完整案例
|
||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div className="lg:w-[38%] shrink-0">
|
||||
<div className="grid grid-cols-3 lg:grid-cols-1 gap-6 lg:gap-8 h-full">
|
||||
{spotlightCase.metrics?.slice(0, 3).map((metric: Record<string, any>, j: number) => (
|
||||
<div key={j} className={cn('lg:py-4', j < 2 && 'lg:border-b lg:border-white/10')}>
|
||||
<div className={cn(
|
||||
'text-3xl sm:text-4xl font-black leading-none mb-2 tabular-nums',
|
||||
j === 0 ? 'text-brand-light' : 'text-white'
|
||||
)}>
|
||||
{metric.value}
|
||||
</div>
|
||||
<div className="text-sm text-white/60 font-medium leading-tight">
|
||||
{metric.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
)}
|
||||
|
||||
{restCases.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{restCases.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>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 新闻动态区(对标 Accenture News:最近 3 条 + 查看全部入口)=====
|
||||
function NewsSection({ news, pageCopy }: { news?: NewsItem[]; pageCopy?: HomePageCopy | null }) {
|
||||
const eyebrow = (pageCopy?.newsEyebrow as string) || '新闻动态';
|
||||
const titleLines = ((pageCopy?.newsTitle as string) || '公司要闻\n与研发动态').split('\n');
|
||||
const ctaLabel = (pageCopy?.newsCtaLabel as string) || '查看全部新闻';
|
||||
const NEWS_ITEMS = (news?.length ? news.slice(0, 3) : NEWS.slice(0, 3)) as NewsItem[];
|
||||
|
||||
return (
|
||||
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-bg-secondary" data-testid="news-section">
|
||||
<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">
|
||||
<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">
|
||||
{eyebrow}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
|
||||
<StyledTitle lines={titleLines} />
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
<StaticLink
|
||||
href="/news"
|
||||
className="group inline-flex items-center gap-3 text-ink font-semibold text-sm"
|
||||
>
|
||||
{ctaLabel}
|
||||
<ArrowRight className="w-4 h-5 transition-transform duration-300 group-hover:translate-x-2 text-text-secondary group-hover:text-brand" />
|
||||
</StaticLink>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{NEWS_ITEMS.map((item, i) => (
|
||||
<ScrollReveal key={item.id} delay={i * 0.1}>
|
||||
<StaticLink
|
||||
href={`/news/${item.id}`}
|
||||
className="group bain-card block p-8 sm:p-9 h-full flex flex-col"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Badge variant="outline" className="text-xs border-brand/30 text-brand">
|
||||
{item.category}
|
||||
</Badge>
|
||||
<span className="flex items-center gap-1.5 text-xs text-text-muted">
|
||||
<Calendar className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
{item.date}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg lg:text-xl font-bold text-ink mb-4 leading-[1.3] line-clamp-2 group-hover:text-brand transition-colors duration-300">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary leading-relaxed mb-6 line-clamp-2 flex-1">
|
||||
{item.excerpt}
|
||||
</p>
|
||||
<div className="pt-4 border-t border-border-primary">
|
||||
<span className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors">
|
||||
阅读全文
|
||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</span>
|
||||
</div>
|
||||
</StaticLink>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
@@ -642,16 +999,17 @@ function CTASection({ heroData, pageCopy }: { heroData?: Record<string, any> | n
|
||||
}
|
||||
|
||||
// ===== 主页面组件 =====
|
||||
export default function HomeContentV14({ services, cases, stats, heroData, pageCopy }: {
|
||||
export default function HomeContentV15({ services, cases, stats, heroData, news, pageCopy }: {
|
||||
services?: Record<string, any>[];
|
||||
cases?: Record<string, any>[];
|
||||
stats?: Record<string, any>[];
|
||||
heroData?: Record<string, any> | null;
|
||||
news?: NewsItem[];
|
||||
pageCopy?: HomePageCopy | null;
|
||||
}) {
|
||||
return (
|
||||
<main>
|
||||
{/* 1. Hero:克制白底 + 品牌红点缀 */}
|
||||
{/* 1. Hero:全出血深色画布 + 品牌红单电压(对标 Accenture 纯黑 Hero) */}
|
||||
<HeroSection heroData={heroData} stats={stats} />
|
||||
|
||||
{/* 2. 信任区:量化数据成果展示 */}
|
||||
@@ -660,14 +1018,23 @@ export default function HomeContentV14({ services, cases, stats, heroData, pageC
|
||||
{/* 3. 章节式叙事:问题 → 方法 → 结果 */}
|
||||
<NarrativeSection pageCopy={pageCopy} />
|
||||
|
||||
{/* 4. 服务区:等宽卡片 + 兜底数据 */}
|
||||
{/* 4. 行业洞察:思想领导力支柱(对标 Accenture Insights) */}
|
||||
<InsightsSection pageCopy={pageCopy} />
|
||||
|
||||
{/* 5. 服务区:等宽卡片 + 兜底数据 */}
|
||||
<ServicesSection services={services} pageCopy={pageCopy} />
|
||||
|
||||
{/* 5. 案例区:轻量快照卡片 */}
|
||||
{/* 6. 创始人观点:人格化背书(对标 Accenture CEO Quote) */}
|
||||
<FounderQuoteSection pageCopy={pageCopy} />
|
||||
|
||||
{/* 7. 案例区:轻量快照卡片 */}
|
||||
<CasesSection cases={cases} pageCopy={pageCopy} />
|
||||
|
||||
{/* 6. CTA:克制强调,品牌红仅用于按钮 */}
|
||||
{/* 8. 新闻动态:持续动态(对标 Accenture News) */}
|
||||
<NewsSection news={news} pageCopy={pageCopy} />
|
||||
|
||||
{/* 9. CTA:克制强调,品牌红仅用于按钮 */}
|
||||
<CTASection heroData={heroData} pageCopy={pageCopy} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,15 @@ jest.mock('@/lib/cms/data-server', () => ({
|
||||
getHomePageCopy: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./home-content-v14', () => ({
|
||||
jest.mock('./home-content-v15', () => ({
|
||||
__esModule: true,
|
||||
default: function MockHomeContentV14(props: Record<string, unknown>) {
|
||||
default: function MockHomeContentV15(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="news-count">{(props.news as unknown[])?.length ?? 0}</div>
|
||||
<div data-testid="hero-data">{props.heroData ? JSON.stringify(props.heroData) : 'null'}</div>
|
||||
<div data-testid="page-copy-data">{props.pageCopy ? JSON.stringify(props.pageCopy) : 'null'}</div>
|
||||
</div>
|
||||
@@ -90,6 +91,9 @@ describe('HomePage', () => {
|
||||
if (modelCode === 'hero-banner') {
|
||||
return Promise.resolve([createMockItem('hero-banner', { heading: 'Direct Hero' })]);
|
||||
}
|
||||
if (modelCode === 'news') {
|
||||
return Promise.resolve([createMockItem('news', { title: '公司新闻' })]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
@@ -98,6 +102,7 @@ describe('HomePage', () => {
|
||||
|
||||
expect(screen.getByTestId('services-count')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('hero-data')).toHaveTextContent('Direct Hero');
|
||||
expect(screen.getByTestId('news-count')).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('handles empty zones gracefully', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getPublishedItems, getResolvedHomeZones, getHomePageCopy } from '@/lib/cms/data-server';
|
||||
import HomeContentV14 from './home-content-v14';
|
||||
import type { NewsItem } from '@/lib/constants/news';
|
||||
import HomeContentV15 from './home-content-v15';
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
@@ -11,6 +12,7 @@ export default async function HomePage() {
|
||||
let cases: Record<string, unknown>[] = [];
|
||||
let stats: Record<string, unknown>[] = [];
|
||||
let heroData: Record<string, unknown> | null = null;
|
||||
let news: NewsItem[] = [];
|
||||
|
||||
if (hasZones) {
|
||||
services = zones.services?.map((item) => item.data) ?? [];
|
||||
@@ -18,27 +20,30 @@ export default async function HomePage() {
|
||||
stats = zones.stats?.map((item) => item.data) ?? [];
|
||||
heroData = zones.hero?.[0]?.data ?? null;
|
||||
} else {
|
||||
const [serviceItems, caseItems, statItems, heroItems] = await Promise.all([
|
||||
const [serviceItems, caseItems, statItems, heroItems, newsItems] = await Promise.all([
|
||||
getPublishedItems('service'),
|
||||
getPublishedItems('case-study'),
|
||||
getPublishedItems('stat-item'),
|
||||
getPublishedItems('hero-banner'),
|
||||
getPublishedItems('news'),
|
||||
]);
|
||||
|
||||
services = serviceItems.map((item) => item.data);
|
||||
cases = caseItems.map((item) => item.data);
|
||||
stats = statItems.map((item) => item.data);
|
||||
heroData = heroItems[0]?.data ?? null;
|
||||
news = newsItems.map((item) => item.data as unknown as NewsItem);
|
||||
}
|
||||
|
||||
// 首页各章节结构性文案(信任信号/叙事情节/标题/CTA),CMS 未配置时为 null(组件内部兜底)
|
||||
const homePageCopy = await getHomePageCopy().catch(() => null);
|
||||
|
||||
return <HomeContentV14
|
||||
return <HomeContentV15
|
||||
services={services}
|
||||
cases={cases}
|
||||
stats={stats}
|
||||
heroData={heroData}
|
||||
news={news}
|
||||
pageCopy={homePageCopy?.data ?? null}
|
||||
/>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user