feat(design): Vibe design optimization — Hero, Bento grids, trust layer, narrative
升级全站体验:首页 Hero 改为产品视觉 + 单一 CTA 转化布局;产品矩阵/服务/方案改 为 Bento 非对称网格(ERP 2x2 大卡 + BI 1x2);补齐信任层(可验证信号 + 来源标注) 与「问题→方法→结果」章节式叙事;新增 hero-product-visual 与 bento-grid 可复用组件; 统一动效与品牌视觉资产。新增对应单测、UJ-11 用户旅程测试并更新视觉回归基线快照。
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user