Dev #22

Merged
zhangxiang merged 29 commits from dev into main 2026-08-20 12:16:07 +08:00
35 changed files with 1576 additions and 79 deletions
Showing only changes of commit 713186d552 - Show all commits
+197
View File
@@ -0,0 +1,197 @@
import { test, expect, type Page } from '@playwright/test';
/**
* UJ-11: 首页转化旅程(设计优化专项)
*
* 覆盖首页 Vibe Design 优化后的关键转化路径,作为封版验收依据:
* UJ-11a: 首屏说服链路 — Hero 产品视觉 + 单一 CTA + 数据条 → 信任层(可验证信号)→ 叙事区 → 转化 CTA
* UJ-11b: 信任验证旅程 — 数据来源标注 + 首批客户共创标签 → 联系转化
* UJ-11c: 移动端首屏转化旅程 — 390px 下 CTA 首屏可见、无横向滚动
*
* 关联设计优化计划:docs/plans/2026-08-18-vibe-design-optimization-plan.md
* 关联 critique.impeccable/critique/
*/
test.setTimeout(90000);
// ==================== 辅助函数 ====================
async function closeCookieBanner(page: Page) {
const acceptAllButton = page.locator('button:has-text("接受所有")').first();
if ((await acceptAllButton.count()) > 0 && (await acceptAllButton.isVisible())) {
await acceptAllButton.click();
await page.waitForTimeout(500);
}
}
/**
* 断言页面无横向滚动(scrollWidth <= clientWidth + 1px 容差)
*/
async function expectNoHorizontalOverflow(page: Page) {
const overflow = await page.evaluate(() => {
const doc = document.documentElement;
return { scrollWidth: doc.scrollWidth, clientWidth: doc.clientWidth };
});
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth + 1);
}
// ==================== UJ-11a: 首屏说服链路 ====================
test.describe('UJ-11a: 首屏说服链路(Hero → 信任 → 叙事 → CTA', () => {
test('@journey @critical 首页首屏产品视觉、单一 CTA、数据条渲染正常', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
await closeCookieBanner(page);
// === L1 Hero:产品视觉 Mockup 可见(设计优化核心交付) ===
const productVisual = page.locator('[data-testid="hero-product-visual"]').first();
await expect(productVisual).toBeVisible({ timeout: 15000 });
// 诚实标签「产品界面示意」「示例数据」存在(零编造原则)
const visualText = await productVisual.textContent();
expect(visualText).toContain('产品界面示意');
expect(visualText).toContain('示例数据');
// 产品视觉含可识别产品模块命名
expect(visualText).toContain('经营驾驶舱');
// === 单一主 CTA ===
const primaryCta = page.locator('[data-testid="hero-primary-cta"]').first();
await expect(primaryCta).toBeVisible();
await expect(primaryCta).toHaveAttribute('href', '/contact');
// 次级 CTA 降级为文字链接
const secondaryLink = page.locator('[data-testid="hero-secondary-link"]').first();
await expect(secondaryLink).toBeVisible();
// === 主标题可见 ===
const heroTitle = page.locator('h1').first();
await expect(heroTitle).toBeVisible();
// 无横向滚动
await expectNoHorizontalOverflow(page);
});
test('@journey @critical 首页信任层展示可验证信号与来源标注', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
await closeCookieBanner(page);
// 滚动到信任层
const trustSection = page.locator('[data-testid="trust-section"]').first();
await trustSection.scrollIntoViewIfNeeded();
await expect(trustSection).toBeVisible({ timeout: 10000 });
const trustText = await trustSection.textContent();
// 可验证信任信号存在(私有化部署 / 本地化 / 全流程 / 共创)
expect(trustText).toContain('100%');
expect(trustText).toContain('成都');
expect(trustText).toContain('全流程');
expect(trustText).toContain('共创');
// 数据来源标注存在(零编造,可追溯)
expect(trustText).toContain('数据来源');
});
test('@journey @critical 首页叙事区按 01/02/03 章节呈现问题→方法→结果', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
await closeCookieBanner(page);
const narrativeSection = page.locator('[data-testid="narrative-section"]').first();
await narrativeSection.scrollIntoViewIfNeeded();
await expect(narrativeSection).toBeVisible({ timeout: 10000 });
const narrativeText = await narrativeSection.textContent();
// 三幕章节编号存在
expect(narrativeText).toContain('01');
expect(narrativeText).toContain('02');
expect(narrativeText).toContain('03');
// 叙事主题存在
expect(narrativeText).toContain('诊断现状');
expect(narrativeText).toContain('设计路径');
expect(narrativeText).toContain('交付成果');
// 每章保留详情入口
const narrativeLinks = narrativeSection.locator('a');
expect(await narrativeLinks.count()).toBeGreaterThanOrEqual(3);
});
});
// ==================== UJ-11b: 信任验证旅程 ====================
test.describe('UJ-11b: 信任验证旅程(共创标签 → 转化)', () => {
test('@journey @critical 首页首批客户共创标签引导至联系转化', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
await closeCookieBanner(page);
// 首批客户共创横幅存在(无虚构客户,如实标注)
const earlyAccessBanner = page.locator('[data-testid="early-access-banner"]').first();
await earlyAccessBanner.scrollIntoViewIfNeeded();
await expect(earlyAccessBanner).toBeVisible({ timeout: 10000 });
const bannerText = await earlyAccessBanner.textContent();
expect(bannerText).toContain('首批客户共创中');
// 引导至联系转化
const cta = earlyAccessBanner.locator('a[href="/contact"]').first();
await expect(cta).toBeVisible();
await expect(cta).toHaveAttribute('href', '/contact');
});
test('@journey @critical 空案例区呈现共创内容块而非裸空态', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
await closeCookieBanner(page);
const bodyText = await page.locator('body').textContent();
// 无「暂无案例数据」裸空态
expect(bodyText).not.toContain('暂无案例数据');
// 出现「共创进行时」前瞻内容块
expect(bodyText).toContain('共创进行时');
// 共创块包含转化 CTA
expect(bodyText).toContain('成为首批共创客户');
});
});
// ==================== UJ-11c: 移动端首屏转化旅程 ====================
test.describe('UJ-11c: 移动端首屏转化旅程', () => {
test('@mobile @journey @critical 移动端 CTA 首屏可见且无横向滚动', async ({ page }) => {
// 模拟移动端视口
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
await closeCookieBanner(page);
// 无横向滚动
await expectNoHorizontalOverflow(page);
// CTA 首屏可达(在当前视口内可见)
const primaryCta = page.locator('[data-testid="hero-primary-cta"]').first();
await expect(primaryCta).toBeVisible({ timeout: 15000 });
// 产品视觉渲染正常
const productVisual = page.locator('[data-testid="hero-product-visual"]').first();
await expect(productVisual).toBeVisible();
});
test('@mobile @journey @critical 移动端信任层与叙事区单列堆叠无溢出', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
await closeCookieBanner(page);
// 信任层
const trustSection = page.locator('[data-testid="trust-section"]').first();
await trustSection.scrollIntoViewIfNeeded();
await expect(trustSection).toBeVisible();
const trustText = await trustSection.textContent();
expect(trustText).toContain('数据来源');
// 叙事区
const narrativeSection = page.locator('[data-testid="narrative-section"]').first();
await narrativeSection.scrollIntoViewIfNeeded();
await expect(narrativeSection).toBeVisible();
expect(await narrativeSection.textContent()).toContain('01');
// 全程无横向滚动
await expectNoHorizontalOverflow(page);
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 594 KiB

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 625 KiB

After

Width:  |  Height:  |  Size: 751 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 957 KiB

After

Width:  |  Height:  |  Size: 953 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 519 KiB

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 KiB

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 782 KiB

After

Width:  |  Height:  |  Size: 816 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 525 KiB

After

Width:  |  Height:  |  Size: 475 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 619 KiB

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 609 KiB

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 489 KiB

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 KiB

After

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 625 KiB

After

Width:  |  Height:  |  Size: 751 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 625 KiB

After

Width:  |  Height:  |  Size: 751 KiB

+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>
+3 -3
View File
@@ -38,7 +38,7 @@ export function Footer() {
</div>
<div data-testid="card-products" className="lg:col-span-2">
<h3 className="font-semibold text-sm mb-5 text-white tracking-widest uppercase"></h3>
<div className="font-semibold text-sm mb-5 text-white tracking-widest uppercase"></div>
<ul className="space-y-3 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
{productItems.map((item) => (
<li key={item.id}>
@@ -54,7 +54,7 @@ export function Footer() {
</div>
<div data-testid="card-solutions" className="lg:col-span-2">
<h3 className="font-semibold text-sm mb-5 text-white tracking-widest uppercase"></h3>
<div className="font-semibold text-sm mb-5 text-white tracking-widest uppercase"></div>
<ul className="space-y-3 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
{solutionItems.map((item) => (
<li key={item.id}>
@@ -70,7 +70,7 @@ export function Footer() {
</div>
<div data-testid="card-contact" className="col-span-2 lg:col-span-4">
<h3 className="font-semibold text-sm mb-5 text-white tracking-widest uppercase"></h3>
<div className="font-semibold text-sm mb-5 text-white tracking-widest uppercase"></div>
<ul className="space-y-4 mb-8 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
<li className="flex items-start gap-3">
<MapPin className="w-4 h-4 text-gray-400 mt-0.5 shrink-0" />
+1 -1
View File
@@ -158,7 +158,7 @@ function HeaderContent() {
asChild
className=""
>
<StaticLink href="/contact" data-testid="consult-button">
<StaticLink href="/contact" data-testid="consult-button" aria-label="立即咨询">
<MessageCircle className="w-4 h-4" />
<span className="hidden lg:inline"></span>
<span className="lg:hidden" aria-hidden="true"></span>
@@ -0,0 +1,55 @@
import { describe, it, expect } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { BentoGrid, BentoItem } from './bento-grid';
describe('BentoGrid', () => {
it('renders a list container with the 1px gap grid styling', () => {
render(
<BentoGrid>
<BentoItem></BentoItem>
</BentoGrid>
);
const grid = screen.getByRole('list');
expect(grid).toBeInTheDocument();
expect(grid).toHaveClass('grid');
expect(grid).toHaveClass('gap-px');
expect(grid).toHaveClass('bg-border-primary');
});
});
describe('BentoItem', () => {
it('renders listitem with default small size classes', () => {
render(<BentoItem></BentoItem>);
const item = screen.getByRole('listitem');
expect(item).toHaveAttribute('data-bento-size', 'small');
expect(item).toHaveClass('md:col-span-1');
expect(item).toHaveClass('lg:col-span-1');
});
it('applies large size spanning classes', () => {
render(<BentoItem size="large"></BentoItem>);
const item = screen.getByRole('listitem');
expect(item).toHaveAttribute('data-bento-size', 'large');
expect(item).toHaveClass('lg:col-span-2');
expect(item).toHaveClass('lg:row-span-2');
});
it('applies wide size spanning classes', () => {
render(<BentoItem size="wide"></BentoItem>);
const item = screen.getByRole('listitem');
expect(item).toHaveAttribute('data-bento-size', 'wide');
expect(item).toHaveClass('md:col-span-2');
expect(item).toHaveClass('lg:col-span-2');
});
it('forwards a custom test id', () => {
render(<BentoItem testId="custom-id"></BentoItem>);
expect(screen.getByTestId('custom-id')).toBeInTheDocument();
});
});
+61
View File
@@ -0,0 +1,61 @@
'use client';
import { type ReactNode } from 'react';
import { cn } from '@/lib/utils';
export type BentoSize = 'large' | 'wide' | 'small';
/**
* 通用 Bento 非对称网格容器(Bento Box Grid)。
*
* 设计约定(CONTEXT.md):
* - `large`:占 2 列 × 2 行,用于旗舰/主推内容
* - `wide`:占 2 列 × 1 行,用于次重点内容
* - `small`:占 1 列 × 1 行,用于普通条目
* - 使用 `gap-px + bg-border-primary` 实现 1px 细分隔线,延续全站网格语言
*
* 品牌约束:容器本身不上色,品牌红由卡片内元素控制(≤10% 面积)。
*/
export function BentoGrid({ children, className }: { children: ReactNode; className?: string }) {
return (
<div
className={cn('grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-border-primary', className)}
role="list"
>
{children}
</div>
);
}
/**
* Bento 网格中的单个卡片容器。
* 通过 `size` 控制跨列/跨行,配合统一的 hover 微交互(上移 + 品牌色条延展)。
*/
export function BentoItem({
children,
size = 'small',
className,
testId,
}: {
children: ReactNode;
size?: BentoSize;
className?: string;
testId?: string;
}) {
return (
<div
role="listitem"
data-testid={testId}
data-bento-size={size}
className={cn(
'h-full',
size === 'large' && 'md:col-span-2 lg:col-span-2 lg:row-span-2',
size === 'wide' && 'md:col-span-2 lg:col-span-2',
size === 'small' && 'md:col-span-1 lg:col-span-1',
className
)}
>
{children}
</div>
);
}
@@ -0,0 +1,259 @@
import {
LayoutDashboard,
BarChart3,
Package,
Users,
Settings,
Search,
Bell,
TrendingUp,
AlertCircle,
ArrowUpRight,
ArrowDownRight,
} from 'lucide-react';
import { cn } from '@/lib/utils';
const NAV_ITEMS = [
{ label: '经营驾驶舱', icon: LayoutDashboard, active: true },
{ label: '财务管理', icon: BarChart3 },
{ label: '供应链', icon: Package },
{ label: '客户管理', icon: Users },
{ label: '系统设置', icon: Settings },
];
const KPIS = [
{ label: '营收同比', value: '+32.6%', trend: 'up', note: '较上期' },
{ label: '订单履约率', value: '98.2%', trend: 'up', note: '目标 95%' },
{ label: '库存周转', value: '5.8 天', trend: 'down', note: '提速 1.2 天' },
{ label: '客户新增', value: '+18.4%', trend: 'up', note: '本月' },
] as const;
const ALERTS = [
{ title: '华东区库存预警', desc: '3 个 SKU 低于安全库存', level: 'high' },
{ title: '大客户合同待审批', desc: '2 份合同等待审批', level: 'medium' },
{ title: '本月目标完成 82%', desc: '距离目标还有 18%', level: 'normal' },
];
const TABLE_ROWS = [
{ name: '成都某制造集团', amount: '¥ 1,280,000', status: '已签约', date: '今日' },
{ name: '华南零售连锁', amount: '¥ 860,000', status: '洽谈中', date: '昨日' },
{ name: '华东物流企业', amount: '¥ 420,000', status: '方案确认', date: '07-18' },
];
function TrendBadge({ trend, label }: { trend: 'up' | 'down'; label: string }) {
const Icon = trend === 'up' ? ArrowUpRight : ArrowDownRight;
return (
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-text-muted">
<Icon className="h-3 w-3" />
{label}
</span>
);
}
function StatusBadge({ status }: { status: string }) {
const tone =
status === '已签约'
? 'bg-success-bg text-success'
: status === '洽谈中'
? 'bg-brand-bg text-brand'
: 'bg-bg-secondary text-text-secondary';
return (
<span className={cn('inline-flex rounded px-1.5 py-0.5 text-[10px] font-semibold', tone)}>
{status}
</span>
);
}
export function HeroProductVisual({ className }: { className?: string }) {
return (
<div
data-testid="hero-product-visual"
role="img"
aria-label="睿新经营驾驶舱界面示意(示例数据)"
className={cn('relative', className)}
>
<div aria-hidden="true" className="overflow-hidden rounded-xl border border-border-primary bg-white shadow-xl">
{/* 应用窗口头 */}
<div className="flex items-center justify-between border-b border-border-primary bg-bg-secondary px-4 py-2.5">
<div className="flex items-center gap-3">
<div className="flex gap-1.5" aria-hidden="true">
<span className="h-2.5 w-2.5 rounded-full bg-border-secondary" />
<span className="h-2.5 w-2.5 rounded-full bg-border-secondary" />
<span className="h-2.5 w-2.5 rounded-full bg-brand/60" />
</div>
<span className="text-sm font-semibold text-ink"> · </span>
</div>
<span className="border border-border-primary bg-white px-2 py-0.5 text-[11px] font-medium text-text-muted">
</span>
</div>
<div className="flex">
{/* 侧边导航(桌面) */}
<aside className="hidden w-40 shrink-0 flex-col border-r border-border-primary bg-bg-secondary/60 p-3 lg:flex" aria-hidden="true">
<div className="mb-5 border-b border-border-primary px-2 pb-4">
<div className="text-sm font-black text-ink"></div>
<div className="text-[10px] font-medium tracking-[0.18em] text-text-muted">NOVALON</div>
</div>
<nav className="space-y-1">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
return (
<div
key={item.label}
className={cn(
'flex items-center gap-2 rounded px-2 py-1.5 text-xs font-medium',
item.active
? 'bg-brand-bg text-brand'
: 'text-text-secondary'
)}
>
<Icon className="h-3.5 w-3.5" />
{item.label}
</div>
);
})}
</nav>
<div className="mt-auto rounded border border-border-primary bg-white p-2.5">
<div className="text-[10px] font-semibold text-ink"></div>
<div className="mt-0.5 text-[10px] leading-snug text-text-muted"> · </div>
</div>
</aside>
{/* 主内容区 */}
<div className="min-w-0 flex-1 p-3 sm:p-4">
{/* 顶部操作栏 */}
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 rounded border border-border-primary bg-bg-secondary px-2.5 py-1.5 text-text-muted">
<Search className="h-3.5 w-3.5 shrink-0" />
<span className="truncate text-xs">SKU</span>
</div>
<div className="flex items-center gap-2">
<div className="relative flex h-7 w-7 items-center justify-center rounded border border-border-primary bg-white text-text-secondary">
<Bell className="h-3.5 w-3.5" />
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-brand" />
</div>
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-ink text-[10px] font-bold text-white">
</div>
</div>
</div>
{/* KPI 卡 */}
<div className="grid grid-cols-2 gap-2 sm:gap-2.5 lg:grid-cols-4">
{KPIS.map((kpi) => (
<div key={kpi.label} className="border border-border-primary bg-bg-secondary p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-[11px] font-medium text-text-muted">{kpi.label}</span>
<TrendingUp className="h-3.5 w-3.5 shrink-0 text-brand" />
</div>
<div className="mt-1.5 text-lg font-black tabular-nums leading-none text-ink sm:text-xl">
{kpi.value}
</div>
<TrendBadge trend={kpi.trend} label={kpi.note} />
</div>
))}
</div>
{/* 图表 + 预警 */}
<div className="mt-2.5 grid grid-cols-1 gap-2.5 sm:mt-3 lg:grid-cols-3">
<div className="border border-border-primary p-3 lg:col-span-2">
<div className="mb-3 flex items-center justify-between">
<div>
<div className="text-xs font-semibold text-ink"></div>
<div className="text-[10px] text-text-muted"> · 12 </div>
</div>
<div className="flex gap-1">
{['月', '季', '年'].map((period, i) => (
<span
key={period}
className={cn(
'rounded px-1.5 py-0.5 text-[10px] font-medium',
i === 0 ? 'bg-brand text-white' : 'bg-bg-secondary text-text-muted'
)}
>
{period}
</span>
))}
</div>
</div>
<svg
viewBox="0 0 360 120"
className="h-24 w-full sm:h-28"
preserveAspectRatio="none"
aria-hidden="true"
>
<defs>
<linearGradient id="hero-visual-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-brand)" stopOpacity="0.16" />
<stop offset="100%" stopColor="var(--color-brand)" stopOpacity="0" />
</linearGradient>
</defs>
{[24, 56, 88].map((y) => (
<line key={y} x1="0" y1={y} x2="360" y2={y} stroke="var(--color-border-light)" strokeWidth="1" />
))}
<path
d="M0,104 C36,96 56,80 88,82 C120,84 144,60 176,58 C208,56 232,38 264,40 C296,42 320,24 360,20 L360,120 L0,120 Z"
fill="url(#hero-visual-fill)"
/>
<path
d="M0,104 C36,96 56,80 88,82 C120,84 144,60 176,58 C208,56 232,38 264,40 C296,42 320,24 360,20"
fill="none"
stroke="var(--color-brand)"
strokeWidth="2.5"
strokeLinecap="round"
/>
<circle cx="360" cy="20" r="4" fill="var(--color-brand)" />
</svg>
</div>
<div className="border border-border-primary p-3">
<div className="mb-3 flex items-center gap-1.5">
<AlertCircle className="h-3.5 w-3.5 text-brand" />
<span className="text-xs font-semibold text-ink"></span>
</div>
<ul className="space-y-2.5">
{ALERTS.map((alert) => (
<li key={alert.title} className="flex items-start gap-2">
<span
className={cn(
'mt-1 h-1.5 w-1.5 shrink-0 rounded-full',
alert.level === 'high' ? 'bg-brand' : 'bg-border-secondary'
)}
aria-hidden="true"
/>
<div className="min-w-0">
<div className="truncate text-xs font-medium text-ink">{alert.title}</div>
<div className="truncate text-[10px] text-text-muted">{alert.desc}</div>
</div>
</li>
))}
</ul>
</div>
</div>
{/* 数据表格 */}
<div className="mt-2.5 hidden overflow-hidden border border-border-primary 2xl:block">
<div className="flex items-center justify-between border-b border-border-primary bg-bg-secondary px-3 py-2">
<span className="text-xs font-semibold text-ink"></span>
<span className="text-[10px] text-text-muted"></span>
</div>
<div className="divide-y divide-border-light">
{TABLE_ROWS.map((row) => (
<div key={row.name} className="grid grid-cols-[1fr_auto_auto] items-center gap-3 px-3 py-2">
<span className="truncate text-xs font-medium text-ink">{row.name}</span>
<span className="text-xs font-semibold tabular-nums text-text-secondary">{row.amount}</span>
<div className="flex items-center gap-2">
<StatusBadge status={row.status} />
<span className="text-[10px] text-text-muted">{row.date}</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+2
View File
@@ -1,5 +1,7 @@
export {
COMPANY_INFO,
TRUST_SIGNALS,
EARLY_ACCESS,
NAVIGATION,
NAVIGATION_V2,
MEGA_DROPDOWN_DATA,
+49
View File
@@ -5,9 +5,58 @@ export const COMPANY_INFO = {
slogan: '企业数字化转型的同行者',
description: '专业核心团队,以结果导向的服务理念,做企业数字化转型的同行者与成长伙伴',
founded: '2026',
foundedFull: '2026 年 1 月',
location: '四川省成都市',
email: 'contact@novalon.cn',
address: '中国四川省成都市龙泉驿区幸福路12号',
icp: '蜀ICP备2026013658号',
police: '川公网安备51010602003285号',
} as const;
/**
* 首页信任层可验证信任信号(L3 Trust & Authority)。
*
* 零编造原则:以下每一项均可追溯来源,未取得真实资质/案例前不编造。
* - 私有化部署:产品规格(私有化部署能力)
* - 本地化服务:工商注册地址(成都龙泉驿区)
* - 全流程方法:服务方法论(诊断→设计→交付→运维)
* - 首批客户共创:无公开案例,如实标注「共创中」而非虚构客户
*
* 说明:2026 年成立 / 核心团队 / 自研产品数等基础信号由 Hero 数据条承载,
* 信任区聚焦「来源可验证 + 共创状态」,避免全站数字重复(critique P1)。
*/
export const TRUST_SIGNALS = [
{
value: '100%',
label: '私有化部署',
desc: '核心数据不出境,满足合规要求',
source: '产品规格',
},
{
value: '成都',
label: '本地化服务',
desc: '立足龙泉驿区,贴近服务西南企业',
source: '工商注册地址',
},
{
value: '全流程',
label: '陪伴式交付',
desc: '诊断 → 设计 → 交付 → 运维,全程陪伴',
source: '服务方法论',
},
{
value: '共创',
label: '首批客户共创',
desc: '与早期客户共同打磨产品,成果可验证',
source: '如实标注',
},
] as const;
/**
* 首批客户共创状态(无真实 quote 时的如实标注)。
* 有真实客户 quote / 共创伙伴后,可在 `src/components/content/testimonials.tsx` 启用 Testimonial 区块替换本标签。
*/
export const EARLY_ACCESS = {
label: '首批客户共创中',
desc: '我们正与首批客户共同打磨产品,公开案例将在验证后发布。',
} as const;
+1 -1
View File
@@ -1,4 +1,4 @@
export { COMPANY_INFO } from './company';
export { COMPANY_INFO, TRUST_SIGNALS, EARLY_ACCESS } from './company';
export { NAVIGATION, NAVIGATION_V2, MEGA_DROPDOWN_DATA } from './navigation';
export type { NavigationItem, NavigationItemV2, MegaDropdownItem, MegaDropdownGroup, MegaDropdownData } from './navigation';
export { STATS, type StatItem } from './stats';