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

升级全站体验:首页 Hero 改为产品视觉 + 单一 CTA 转化布局;产品矩阵/服务/方案改
为 Bento 非对称网格(ERP 2x2 大卡 + BI 1x2);补齐信任层(可验证信号 + 来源标注)
与「问题→方法→结果」章节式叙事;新增 hero-product-visual 与 bento-grid 可复用组件;
统一动效与品牌视觉资产。新增对应单测、UJ-11 用户旅程测试并更新视觉回归基线快照。
This commit is contained in:
2026-08-19 14:43:27 +08:00
parent f3f4e78c51
commit 713186d552
35 changed files with 1576 additions and 79 deletions
@@ -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>
);
}