feat: 完善代理商端业务逻辑与前后端框架

主要更新:
- 更新代理商端文档,明确项目由品牌方分配流程
- 新增Brief配置详情页(已配置)设计稿
- 完善工作台紧急待办中品牌新任务功能
- 整理Pencil设计文件中代理商端页面顺序
- 新增后端FastAPI框架及核心API
- 新增前端Next.js页面和组件库
- 添加.gitignore排除构建和缓存文件

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-05 19:27:31 +08:00
co-authored by Claude Opus 4.5
parent d52509d630
commit e4959d584f
132 changed files with 58539 additions and 21353 deletions
+63
View File
@@ -0,0 +1,63 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useAuth } from '@/contexts/AuthContext'
import { UserRole } from '@/types/auth'
interface AuthGuardProps {
children: React.ReactNode
allowedRoles?: UserRole[]
}
export function AuthGuard({ children, allowedRoles }: AuthGuardProps) {
const router = useRouter()
const { user, isAuthenticated, isLoading } = useAuth()
useEffect(() => {
if (!isLoading) {
if (!isAuthenticated) {
router.push('/login')
return
}
if (allowedRoles && user && !allowedRoles.includes(user.role)) {
// 重定向到用户对应的默认页面
switch (user.role) {
case 'creator':
router.push('/creator')
break
case 'agency':
router.push('/agency')
break
case 'brand':
router.push('/brand')
break
default:
router.push('/login')
}
}
}
}, [isLoading, isAuthenticated, user, allowedRoles, router])
// 加载中
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
)
}
// 未认证
if (!isAuthenticated) {
return null
}
// 角色不匹配
if (allowedRoles && user && !allowedRoles.includes(user.role)) {
return null
}
return <>{children}</>
}
+6 -5
View File
@@ -13,10 +13,11 @@ export { ProgressBar, CircularProgress, type ProgressBarProps, type CircularProg
export { Modal, ConfirmModal, type ModalProps, type ConfirmModalProps } from './ui/Modal';
// 导航组件
export { BottomNav, type BottomNavProps, type NavItem } from './navigation/BottomNav';
export { Sidebar, type SidebarProps, type SidebarItem, type SidebarSection } from './navigation/Sidebar';
export { StatusBar, type StatusBarProps } from './navigation/StatusBar';
export { BottomNav } from './navigation/BottomNav';
export { Sidebar } from './navigation/Sidebar';
export { StatusBar } from './navigation/StatusBar';
// 布局组件
export { MobileLayout, type MobileLayoutProps } from './layout/MobileLayout';
export { DesktopLayout, type DesktopLayoutProps } from './layout/DesktopLayout';
export { MobileLayout } from './layout/MobileLayout';
export { DesktopLayout } from './layout/DesktopLayout';
export { ResponsiveLayout } from './layout/ResponsiveLayout';
@@ -0,0 +1,70 @@
/**
* DesktopLayout 组件测试
* 测试覆盖: Sidebar 渲染、内容区域、基础样式
*/
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { DesktopLayout } from './DesktopLayout';
describe('DesktopLayout', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染子元素', () => {
render(
<DesktopLayout>
</DesktopLayout>
);
expect(screen.getByText('内容区域')).toBeInTheDocument();
});
it('渲染 Sidebar', () => {
const { container } = render(
<DesktopLayout>
</DesktopLayout>
);
expect(container.querySelector('aside')).toBeInTheDocument();
});
it('渲染默认 creator 导航项', () => {
render(
<DesktopLayout role="creator">
</DesktopLayout>
);
expect(screen.getByText('我的任务')).toBeInTheDocument();
});
});
// ==================== 样式测试 ====================
describe('样式', () => {
it('应用背景色', () => {
const { container } = render(
<DesktopLayout>
</DesktopLayout>
);
expect(container.firstChild).toHaveClass('bg-bg-page');
});
it('内容区域有左侧边距', () => {
const { container } = render(
<DesktopLayout>
</DesktopLayout>
);
const main = container.querySelector('main');
expect(main).toHaveClass('ml-[260px]');
});
it('支持自定义 className', () => {
const { container } = render(
<DesktopLayout className="custom-layout">
</DesktopLayout>
);
expect(container.firstChild).toHaveClass('custom-layout');
});
});
});
+18 -53
View File
@@ -1,61 +1,26 @@
/**
* DesktopLayout 桌面端布局组件
* 设计稿参考: UIDesignSpec.md 3.2
* 尺寸: 1440x900,侧边栏260px
*/
import React from 'react';
import { Sidebar, SidebarSection } from '../navigation/Sidebar';
'use client'
export interface DesktopLayoutProps {
children: React.ReactNode;
logo?: React.ReactNode;
sidebarSections: SidebarSection[];
activeNavId: string;
onNavItemClick?: (id: string) => void;
sidebarFooter?: React.ReactNode;
headerContent?: React.ReactNode;
className?: string;
contentClassName?: string;
import { Sidebar } from '../navigation/Sidebar'
interface DesktopLayoutProps {
children: React.ReactNode
role?: 'creator' | 'agency' | 'brand'
className?: string
}
export const DesktopLayout: React.FC<DesktopLayoutProps> = ({
export function DesktopLayout({
children,
logo,
sidebarSections,
activeNavId,
onNavItemClick,
sidebarFooter,
headerContent,
role = 'creator',
className = '',
contentClassName = '',
}) => {
}: DesktopLayoutProps) {
return (
<div className={`min-h-screen bg-bg-page ${className}`}>
{/* Sidebar */}
<Sidebar
logo={logo}
sections={sidebarSections}
activeId={activeNavId}
onItemClick={onNavItemClick}
footer={sidebarFooter}
/>
{/* Main Content */}
<div className="ml-sidebar">
{/* Header (optional) */}
{headerContent && (
<header className="px-8 py-4 border-b border-border-subtle bg-bg-page sticky top-0 z-10">
{headerContent}
</header>
)}
{/* Content Area */}
<main className={`p-8 ${contentClassName}`}>
{children}
</main>
</div>
<div className={`min-h-screen bg-bg-page flex ${className}`}>
<Sidebar role={role} />
<main className="flex-1 ml-[260px] p-8 overflow-auto">
{children}
</main>
</div>
);
};
)
}
export default DesktopLayout;
export default DesktopLayout
@@ -0,0 +1,88 @@
/**
* MobileLayout 组件测试
* 测试覆盖: StatusBar、BottomNav 显示、内容区域样式
*/
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { MobileLayout } from './MobileLayout';
describe('MobileLayout', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染子元素', () => {
render(<MobileLayout></MobileLayout>);
expect(screen.getByText('内容区域')).toBeInTheDocument();
});
it('默认显示状态栏', () => {
render(<MobileLayout></MobileLayout>);
expect(screen.getByText('9:41')).toBeInTheDocument();
});
it('默认显示底部导航', () => {
render(<MobileLayout role="creator"></MobileLayout>);
expect(screen.getByText('任务')).toBeInTheDocument();
});
});
// ==================== StatusBar 测试 ====================
describe('StatusBar', () => {
it('showStatusBar=true 显示状态栏', () => {
render(<MobileLayout showStatusBar={true}></MobileLayout>);
expect(screen.getByText('9:41')).toBeInTheDocument();
});
it('showStatusBar=false 隐藏状态栏', () => {
render(<MobileLayout showStatusBar={false}></MobileLayout>);
expect(screen.queryByText('9:41')).not.toBeInTheDocument();
});
});
// ==================== BottomNav 测试 ====================
describe('BottomNav', () => {
it('showBottomNav=false 隐藏底部导航', () => {
render(
<MobileLayout showBottomNav={false}>
</MobileLayout>
);
expect(screen.queryByText('任务')).not.toBeInTheDocument();
});
});
// ==================== 内容区域测试 ====================
describe('内容区域', () => {
it('showBottomNav=true 时内容区域有底部 padding', () => {
const { container } = render(
<MobileLayout showBottomNav={true}>
</MobileLayout>
);
const main = container.querySelector('main');
expect(main).toHaveClass('pb-[95px]');
});
it('showBottomNav=false 时内容区域无底部 padding', () => {
const { container } = render(
<MobileLayout showBottomNav={false}></MobileLayout>
);
const main = container.querySelector('main');
expect(main).not.toHaveClass('pb-[95px]');
});
});
// ==================== 样式测试 ====================
describe('样式', () => {
it('应用背景色', () => {
const { container } = render(<MobileLayout></MobileLayout>);
expect(container.firstChild).toHaveClass('bg-bg-page');
});
it('支持自定义 className', () => {
const { container } = render(
<MobileLayout className="custom-layout"></MobileLayout>
);
expect(container.firstChild).toHaveClass('custom-layout');
});
});
});
+19 -53
View File
@@ -1,66 +1,32 @@
/**
* MobileLayout 移动端布局组件
* 设计稿参考: UIDesignSpec.md 3.1
* 尺寸: 402x874
*/
import React from 'react';
import { StatusBar } from '../navigation/StatusBar';
import { BottomNav, NavItem } from '../navigation/BottomNav';
'use client'
export interface MobileLayoutProps {
children: React.ReactNode;
navItems?: NavItem[];
activeNavId?: string;
onNavItemClick?: (id: string) => void;
showStatusBar?: boolean;
showBottomNav?: boolean;
className?: string;
contentClassName?: string;
import { StatusBar } from '../navigation/StatusBar'
import { BottomNav } from '../navigation/BottomNav'
interface MobileLayoutProps {
children: React.ReactNode
role?: 'creator' | 'agency' | 'brand'
showStatusBar?: boolean
showBottomNav?: boolean
className?: string
}
export const MobileLayout: React.FC<MobileLayoutProps> = ({
export function MobileLayout({
children,
navItems = [],
activeNavId = '',
onNavItemClick,
role = 'creator',
showStatusBar = true,
showBottomNav = true,
className = '',
contentClassName = '',
}) => {
}: MobileLayoutProps) {
return (
<div
className={`
min-h-screen bg-bg-page
flex flex-col
${className}
`}
>
{/* Status Bar */}
<div className={`min-h-screen bg-bg-page flex flex-col overflow-x-hidden ${className}`}>
{showStatusBar && <StatusBar />}
{/* Content Area */}
<main
className={`
flex-1 overflow-y-auto
px-6 py-4
${showBottomNav ? 'pb-[99px]' : ''}
${contentClassName}
`}
>
<main className={`flex-1 ${showBottomNav ? 'pb-[95px]' : ''}`}>
{children}
</main>
{/* Bottom Navigation */}
{showBottomNav && navItems.length > 0 && (
<BottomNav
items={navItems}
activeId={activeNavId}
onItemClick={onNavItemClick}
/>
)}
{showBottomNav && <BottomNav role={role} />}
</div>
);
};
)
}
export default MobileLayout;
export default MobileLayout
@@ -0,0 +1,45 @@
'use client'
import { useEffect, useState } from 'react'
import { MobileLayout } from './MobileLayout'
import { DesktopLayout } from './DesktopLayout'
interface ResponsiveLayoutProps {
children: React.ReactNode
role?: 'creator' | 'agency' | 'brand'
showBottomNav?: boolean
}
export function ResponsiveLayout({
children,
role = 'creator',
showBottomNav = true,
}: ResponsiveLayoutProps) {
const [isMobile, setIsMobile] = useState(true)
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 1024)
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
if (isMobile) {
return (
<MobileLayout role={role} showBottomNav={showBottomNav}>
{children}
</MobileLayout>
)
}
return (
<DesktopLayout role={role}>
{children}
</DesktopLayout>
)
}
export default ResponsiveLayout
@@ -0,0 +1,61 @@
/**
* BottomNav 组件测试
* 测试覆盖: role 渲染、active 状态、基础样式
*/
import { render, screen } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { usePathname } from 'next/navigation';
import { BottomNav } from './BottomNav';
const mockedUsePathname = vi.mocked(usePathname);
describe('BottomNav', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染导航栏', () => {
const { container } = render(<BottomNav />);
expect(container.firstChild).toBeInTheDocument();
});
it('渲染所有导航项', () => {
render(<BottomNav role="creator" />);
expect(screen.getByText('任务')).toBeInTheDocument();
expect(screen.getByText('消息')).toBeInTheDocument();
expect(screen.getByText('我的')).toBeInTheDocument();
});
it('渲染图标', () => {
const { container } = render(<BottomNav />);
const icons = container.querySelectorAll('svg');
expect(icons.length).toBeGreaterThan(0);
});
});
// ==================== Active 状态测试 ====================
describe('Active 状态', () => {
beforeEach(() => {
mockedUsePathname.mockReturnValue('/creator/messages');
});
it('激活项使用高亮颜色', () => {
render(<BottomNav role="creator" />);
const activeLink = screen.getByText('消息').closest('a');
expect(activeLink).toHaveClass('text-text-primary');
});
it('非激活项使用次要颜色', () => {
render(<BottomNav role="creator" />);
const inactiveLink = screen.getByText('任务').closest('a');
expect(inactiveLink).toHaveClass('text-text-secondary');
});
});
// ==================== 样式测试 ====================
describe('样式', () => {
it('固定定位在底部', () => {
const { container } = render(<BottomNav />);
const root = container.firstChild as HTMLElement;
expect(root).toHaveClass('fixed', 'bottom-0', 'left-0', 'right-0');
});
});
});
+80 -72
View File
@@ -1,81 +1,89 @@
/**
* BottomNav 底部导航组件 (移动端)
* 设计稿参考: UIDesignSpec.md 3.6
*/
import React from 'react';
import { LucideIcon } from 'lucide-react';
'use client'
export interface NavItem {
id: string;
label: string;
icon: LucideIcon;
href?: string;
badge?: number;
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { ClipboardList, Bell, User, Scan, ListTodo, LayoutDashboard, Settings } from 'lucide-react'
import { cn } from '@/lib/utils'
interface NavItem {
icon: React.ElementType
label: string
href: string
}
export interface BottomNavProps {
items: NavItem[];
activeId: string;
onItemClick?: (id: string) => void;
className?: string;
// 达人端导航项
const creatorNavItems: NavItem[] = [
{ icon: ClipboardList, label: '任务', href: '/creator' },
{ icon: Bell, label: '消息', href: '/creator/messages' },
{ icon: User, label: '我的', href: '/creator/profile' },
]
// 代理商端导航项
const agencyNavItems: NavItem[] = [
{ icon: LayoutDashboard, label: '工作台', href: '/agency' },
{ icon: ListTodo, label: '任务', href: '/agency/tasks' },
{ icon: Scan, label: '审核', href: '/agency/review' },
{ icon: Bell, label: '消息', href: '/agency/messages' },
{ icon: User, label: '我的', href: '/agency/profile' },
]
// 品牌方端导航项
const brandNavItems: NavItem[] = [
{ icon: LayoutDashboard, label: '看板', href: '/brand' },
{ icon: Settings, label: '配置', href: '/brand/rules' },
{ icon: Bell, label: '消息', href: '/brand/messages' },
{ icon: User, label: '我的', href: '/brand/profile' },
]
interface BottomNavProps {
role?: 'creator' | 'agency' | 'brand'
}
export const BottomNav: React.FC<BottomNavProps> = ({
items,
activeId,
onItemClick,
className = '',
}) => {
export function BottomNav({ role = 'creator' }: BottomNavProps) {
const pathname = usePathname() || ''
const navItems = role === 'creator'
? creatorNavItems
: role === 'agency'
? agencyNavItems
: brandNavItems
const isActive = (href: string) => {
if (href === `/${role}`) {
return pathname === href || pathname === `/${role}/`
}
return pathname.startsWith(href)
}
return (
<nav
className={`
fixed bottom-0 left-0 right-0 z-bottom-nav
flex justify-around items-center
h-bottom-nav px-[21px] py-3
safe-area-bottom
${className}
`}
style={{
background: 'linear-gradient(180deg, transparent 0%, #0B0B0E 50%)',
}}
>
{items.map((item) => {
const isActive = item.id === activeId;
const Icon = item.icon;
<div className="fixed bottom-0 left-0 right-0 z-bottom-nav bottom-nav-gradient h-[95px] flex flex-col justify-end px-[21px] pb-[21px] pt-3">
<div className="flex items-center justify-around bg-bg-elevated rounded-[31px] h-[62px] p-1 nav-shadow border border-border-subtle">
{navItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
return (
<button
key={item.id}
onClick={() => onItemClick?.(item.id)}
className={`
flex flex-col items-center gap-1
transition-colors duration-200
${isActive ? 'text-accent-indigo' : 'text-text-secondary'}
`}
>
<div className="relative">
<Icon size={24} />
{item.badge !== undefined && item.badge > 0 && (
<span
className="
absolute -top-1 -right-1
min-w-[16px] h-4 px-1
flex items-center justify-center
bg-accent-coral text-white
text-[10px] font-semibold
rounded-full
"
>
{item.badge > 99 ? '99+' : item.badge}
</span>
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex flex-col items-center justify-center gap-1 w-14 h-full',
active ? 'text-text-primary' : 'text-text-secondary'
)}
</div>
<span className="text-nav">{item.label}</span>
</button>
);
})}
</nav>
);
};
>
<Icon className={cn('w-6 h-6', active && 'text-text-primary')} strokeWidth={active ? 2 : 1.5} />
<span className={cn(
'text-[10px]',
active ? 'font-semibold' : 'font-medium'
)}>
{item.label}
</span>
</Link>
)
})}
</div>
</div>
)
}
export default BottomNav;
export default BottomNav
@@ -0,0 +1,83 @@
/**
* Sidebar 组件测试
* 测试覆盖: role 渲染、active 状态、基础样式
*/
import { render, screen } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { usePathname } from 'next/navigation';
import { Sidebar } from './Sidebar';
const mockedUsePathname = vi.mocked(usePathname);
describe('Sidebar', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染侧边栏', () => {
const { container } = render(<Sidebar />);
expect(container.querySelector('aside')).toBeInTheDocument();
});
it('渲染默认 creator 导航项', () => {
render(<Sidebar role="creator" />);
expect(screen.getByText('我的任务')).toBeInTheDocument();
expect(screen.getByText('消息中心')).toBeInTheDocument();
expect(screen.getByText('个人中心')).toBeInTheDocument();
});
});
// ==================== Role 测试 ====================
describe('Role', () => {
it('渲染 agency 导航项', () => {
render(<Sidebar role="agency" />);
expect(screen.getByText('工作台')).toBeInTheDocument();
expect(screen.getByText('审核决策')).toBeInTheDocument();
expect(screen.getByText('Brief 配置')).toBeInTheDocument();
expect(screen.getByText('达人管理')).toBeInTheDocument();
expect(screen.getByText('数据报表')).toBeInTheDocument();
});
it('渲染 brand 导航项', () => {
render(<Sidebar role="brand" />);
expect(screen.getByText('数据看板')).toBeInTheDocument();
expect(screen.getByText('AI 配置')).toBeInTheDocument();
expect(screen.getByText('规则配置')).toBeInTheDocument();
expect(screen.getByText('终审台')).toBeInTheDocument();
expect(screen.getByText('代理商管理')).toBeInTheDocument();
});
});
// ==================== Active 状态测试 ====================
describe('Active 状态', () => {
beforeEach(() => {
mockedUsePathname.mockReturnValue('/creator/messages');
});
it('激活项使用高亮样式', () => {
render(<Sidebar role="creator" />);
const activeLink = screen.getByText('消息中心').closest('a');
expect(activeLink).toHaveClass('bg-bg-elevated', 'text-text-primary', 'font-semibold');
});
it('非激活项使用默认样式', () => {
render(<Sidebar role="creator" />);
const inactiveLink = screen.getByText('我的任务').closest('a');
expect(inactiveLink).toHaveClass('text-text-secondary');
expect(inactiveLink).not.toHaveClass('text-text-primary');
});
});
// ==================== 样式测试 ====================
describe('样式', () => {
it('固定定位在左侧', () => {
const { container } = render(<Sidebar />);
const aside = container.querySelector('aside');
expect(aside).toHaveClass('fixed', 'left-0', 'top-0', 'bottom-0');
});
it('应用正确宽度', () => {
const { container } = render(<Sidebar />);
const aside = container.querySelector('aside');
expect(aside).toHaveClass('w-[260px]');
});
});
});
+85 -123
View File
@@ -1,136 +1,98 @@
/**
* Sidebar 侧边栏导航组件 (桌面端)
* 设计稿参考: UIDesignSpec.md 3.7
*/
import React from 'react';
import { LucideIcon } from 'lucide-react';
'use client'
export interface SidebarItem {
id: string;
label: string;
icon: LucideIcon;
href?: string;
badge?: number;
children?: SidebarItem[];
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { ShieldCheck, ListTodo, User, LayoutDashboard, Scan, BarChart3, Settings, FileText, Users, Bell } from 'lucide-react'
import { cn } from '@/lib/utils'
interface NavItem {
icon: React.ElementType
label: string
href: string
}
export interface SidebarSection {
title?: string;
items: SidebarItem[];
// 达人端导航项
const creatorNavItems: NavItem[] = [
{ icon: ListTodo, label: '我的任务', href: '/creator' },
{ icon: Bell, label: '消息中心', href: '/creator/messages' },
{ icon: User, label: '个人中心', href: '/creator/profile' },
]
// 代理商端导航项
const agencyNavItems: NavItem[] = [
{ icon: LayoutDashboard, label: '工作台', href: '/agency' },
{ icon: Scan, label: '审核决策', href: '/agency/review' },
{ icon: FileText, label: 'Brief 配置', href: '/agency/briefs' },
{ icon: Users, label: '达人管理', href: '/agency/creators' },
{ icon: BarChart3, label: '数据报表', href: '/agency/reports' },
]
// 品牌方端导航项
const brandNavItems: NavItem[] = [
{ icon: LayoutDashboard, label: '数据看板', href: '/brand' },
{ icon: Settings, label: 'AI 配置', href: '/brand/ai-config' },
{ icon: FileText, label: '规则配置', href: '/brand/rules' },
{ icon: FileCheck, label: '终审台', href: '/brand/final-review' },
{ icon: Users, label: '代理商管理', href: '/brand/agencies' },
]
interface SidebarProps {
role?: 'creator' | 'agency' | 'brand'
}
export interface SidebarProps {
logo?: React.ReactNode;
sections: SidebarSection[];
activeId: string;
onItemClick?: (id: string) => void;
footer?: React.ReactNode;
className?: string;
}
export function Sidebar({ role = 'creator' }: SidebarProps) {
const pathname = usePathname() || ''
const navItems = role === 'creator'
? creatorNavItems
: role === 'agency'
? agencyNavItems
: brandNavItems
const isActive = (href: string) => {
if (href === `/${role}`) {
return pathname === href || pathname === `/${role}/`
}
return pathname.startsWith(href)
}
export const Sidebar: React.FC<SidebarProps> = ({
logo,
sections,
activeId,
onItemClick,
footer,
className = '',
}) => {
return (
<aside
className={`
fixed left-0 top-0 bottom-0 z-sidebar
w-sidebar bg-bg-card
flex flex-col
border-r border-border-subtle
${className}
`}
>
{/* Logo */}
{logo && (
<div className="px-4 py-5 border-b border-border-subtle">
{logo}
<aside className="fixed left-0 top-0 bottom-0 z-sidebar w-[260px] bg-bg-card flex flex-col">
{/* Logo 区域 */}
<div className="flex items-center gap-3 px-6 py-6">
<div className="w-9 h-9 rounded-[10px] bg-gradient-to-br from-accent-indigo to-[#4F46E5] flex items-center justify-center">
<ShieldCheck className="w-5 h-5 text-white" />
</div>
)}
<span className="text-xl font-bold text-text-primary"></span>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4 px-3">
{sections.map((section, sectionIndex) => (
<div key={sectionIndex} className="mb-6">
{section.title && (
<h4 className="px-3 mb-2 text-small text-text-tertiary uppercase tracking-wider">
{section.title}
</h4>
)}
<ul className="space-y-1">
{section.items.map((item) => (
<SidebarNavItem
key={item.id}
item={item}
isActive={item.id === activeId}
onClick={() => onItemClick?.(item.id)}
/>
))}
</ul>
</div>
))}
{/* 导航列表 */}
<nav className="flex-1 px-4 py-2">
<div className="flex flex-col gap-1">
{navItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-[10px] transition-colors',
active
? 'bg-bg-elevated text-text-primary font-semibold'
: 'text-text-secondary hover:bg-bg-elevated/50'
)}
>
<Icon className="w-5 h-5" />
<span className="text-[15px]">{item.label}</span>
</Link>
)
})}
</div>
</nav>
{/* Footer */}
{footer && (
<div className="px-4 py-4 border-t border-border-subtle">
{footer}
</div>
)}
</aside>
);
};
interface SidebarNavItemProps {
item: SidebarItem;
isActive: boolean;
onClick: () => void;
)
}
const SidebarNavItem: React.FC<SidebarNavItemProps> = ({
item,
isActive,
onClick,
}) => {
const Icon = item.icon;
return (
<li>
<button
onClick={onClick}
className={`
w-full flex items-center gap-2.5
px-3 py-2.5 rounded-btn
transition-colors duration-200
${isActive
? 'bg-bg-elevated text-accent-indigo font-semibold'
: 'text-text-secondary hover:bg-bg-elevated'
}
`}
>
<Icon size={20} className="flex-shrink-0" />
<span className="flex-1 text-left text-body">{item.label}</span>
{item.badge !== undefined && item.badge > 0 && (
<span
className="
min-w-[20px] h-5 px-1.5
flex items-center justify-center
bg-accent-coral text-white
text-[11px] font-semibold
rounded-full
"
>
{item.badge > 99 ? '99+' : item.badge}
</span>
)}
</button>
</li>
);
};
export default Sidebar;
export default Sidebar
@@ -0,0 +1,55 @@
/**
* StatusBar 组件测试
* 测试覆盖: 时间显示、状态图标、自定义样式
*/
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { StatusBar } from './StatusBar';
describe('StatusBar', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染状态栏', () => {
const { container } = render(<StatusBar />);
expect(container.firstChild).toBeInTheDocument();
});
it('默认显示时间 9:41', () => {
render(<StatusBar />);
expect(screen.getByText('9:41')).toBeInTheDocument();
});
it('渲染状态图标(信号、WiFi、电池)', () => {
const { container } = render(<StatusBar />);
const icons = container.querySelectorAll('svg');
expect(icons).toHaveLength(3);
});
});
// ==================== Time 属性测试 ====================
describe('Time 属性', () => {
it('支持自定义时间', () => {
render(<StatusBar time="10:30" />);
expect(screen.getByText('10:30')).toBeInTheDocument();
});
it('时间使用正确样式', () => {
render(<StatusBar time="12:00" />);
const timeElement = screen.getByText('12:00');
expect(timeElement).toHaveClass('text-text-primary', 'font-semibold', 'text-[17px]');
});
});
// ==================== 样式测试 ====================
describe('样式', () => {
it('应用固定高度', () => {
const { container } = render(<StatusBar />);
expect(container.firstChild).toHaveClass('h-[44px]');
});
it('支持自定义 className', () => {
const { container } = render(<StatusBar className="custom-status" />);
expect(container.firstChild).toHaveClass('custom-status');
});
});
});
+16 -32
View File
@@ -1,41 +1,25 @@
/**
* StatusBar 状态栏组件 (移动端)
* 设计稿参考: UIDesignSpec.md 3.1
*/
import React from 'react';
import { Signal, Wifi, BatteryFull } from 'lucide-react';
'use client'
export interface StatusBarProps {
time?: string;
className?: string;
import { Signal, Wifi, BatteryFull } from 'lucide-react'
interface StatusBarProps {
time?: string
className?: string
}
export const StatusBar: React.FC<StatusBarProps> = ({
time = '9:41',
className = '',
}) => {
export function StatusBar({ time = '9:41', className = '' }: StatusBarProps) {
return (
<div
className={`
flex items-center justify-between
h-status-bar px-6
bg-bg-page safe-area-top
${className}
`}
>
{/* Time */}
<span className="text-body font-semibold text-text-primary">
<div className={`flex items-center justify-between h-[44px] px-6 w-full ${className}`}>
<span className="text-text-primary font-semibold text-[17px]" style={{ fontFamily: 'Inter' }}>
{time}
</span>
{/* Status Icons */}
<div className="flex items-center gap-1">
<Signal size={16} className="text-text-primary" />
<Wifi size={16} className="text-text-primary" />
<BatteryFull size={16} className="text-text-primary" />
<div className="flex items-center gap-1.5">
<Signal className="w-[18px] h-[18px] text-text-primary" />
<Wifi className="w-[18px] h-[18px] text-text-primary" />
<BatteryFull className="w-6 h-[18px] text-text-primary" />
</div>
</div>
);
};
)
}
export default StatusBar;
export default StatusBar
+205
View File
@@ -0,0 +1,205 @@
/**
* Button 组件测试
* 测试覆盖: variants, sizes, icons, loading, disabled, fullWidth
*/
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Search, ArrowRight } from 'lucide-react';
import { Button } from './Button';
describe('Button', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染按钮文本', () => {
render(<Button></Button>);
expect(screen.getByRole('button', { name: '点击我' })).toBeInTheDocument();
});
it('默认使用 primary variant 和 md size', () => {
render(<Button></Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('bg-accent-indigo');
expect(button).toHaveClass('px-4', 'py-2.5');
});
});
// ==================== Variant 测试 ====================
describe('Variant 样式', () => {
it('primary variant 应用正确样式', () => {
render(<Button variant="primary">Primary</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-accent-indigo', 'text-white');
});
it('secondary variant 应用正确样式', () => {
render(<Button variant="secondary">Secondary</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-bg-elevated', 'text-text-secondary');
});
it('danger variant 应用正确样式', () => {
render(<Button variant="danger">Danger</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-accent-coral', 'text-white');
});
it('success variant 应用正确样式', () => {
render(<Button variant="success">Success</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-accent-green', 'text-white');
});
it('ghost variant 应用正确样式', () => {
render(<Button variant="ghost">Ghost</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-transparent', 'text-text-secondary');
});
});
// ==================== Size 测试 ====================
describe('Size 样式', () => {
it('sm size 应用正确样式', () => {
render(<Button size="sm">Small</Button>);
expect(screen.getByRole('button')).toHaveClass('px-3', 'py-1.5', 'text-small');
});
it('md size 应用正确样式', () => {
render(<Button size="md">Medium</Button>);
expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2.5', 'text-body');
});
it('lg size 应用正确样式', () => {
render(<Button size="lg">Large</Button>);
expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3', 'text-section-title');
});
});
// ==================== Icon 测试 ====================
describe('Icon 渲染', () => {
// 使用 innerHTML 正则匹配验证图标和文本的相对位置
// 这种方式对 DOM 结构变化(如添加 wrapper)更健壮
it('左侧图标正确渲染(图标在文本之前)', () => {
render(<Button icon={Search} iconPosition="left"></Button>);
const button = screen.getByRole('button');
expect(button.querySelector('svg')).toBeInTheDocument();
// 验证 SVG 在 "搜索" 文本之前
const html = button.innerHTML;
const svgPos = html.indexOf('<svg');
const textPos = html.indexOf('搜索');
expect(svgPos).toBeLessThan(textPos);
});
it('右侧图标正确渲染(图标在文本之后)', () => {
render(<Button icon={ArrowRight} iconPosition="right"></Button>);
const button = screen.getByRole('button');
expect(button.querySelector('svg')).toBeInTheDocument();
// 验证 SVG 在 "下一步" 文本之后
const html = button.innerHTML;
const svgPos = html.indexOf('<svg');
const textPos = html.indexOf('下一步');
expect(svgPos).toBeGreaterThan(textPos);
});
it('默认图标位置为左侧', () => {
render(<Button icon={Search}></Button>);
const button = screen.getByRole('button');
expect(button.querySelector('svg')).toBeInTheDocument();
// 验证默认情况下 SVG 在 "搜索" 文本之前
const html = button.innerHTML;
const svgPos = html.indexOf('<svg');
const textPos = html.indexOf('搜索');
expect(svgPos).toBeLessThan(textPos);
});
});
// ==================== Loading 状态测试 ====================
describe('Loading 状态', () => {
it('loading 状态显示加载动画', () => {
render(<Button loading></Button>);
const button = screen.getByRole('button');
const spinner = button.querySelector('svg.animate-spin');
expect(spinner).toBeInTheDocument();
});
it('loading 状态禁用按钮', () => {
render(<Button loading></Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('loading 状态应用 opacity-50 样式', () => {
render(<Button loading></Button>);
expect(screen.getByRole('button')).toHaveClass('opacity-50');
});
it('loading 状态隐藏原有图标', () => {
render(<Button loading icon={Search}></Button>);
const button = screen.getByRole('button');
// 应该只有 spinner,没有 Search 图标
const svgs = button.querySelectorAll('svg');
expect(svgs).toHaveLength(1);
expect(svgs[0]).toHaveClass('animate-spin');
});
});
// ==================== Disabled 状态测试 ====================
describe('Disabled 状态', () => {
it('disabled 属性禁用按钮', () => {
render(<Button disabled></Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('disabled 状态应用正确样式', () => {
render(<Button disabled></Button>);
expect(screen.getByRole('button')).toHaveClass('opacity-50', 'cursor-not-allowed');
});
it('disabled 状态不触发点击事件', () => {
const handleClick = vi.fn();
render(<Button disabled onClick={handleClick}></Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
});
// ==================== FullWidth 测试 ====================
describe('FullWidth 属性', () => {
it('fullWidth 应用 w-full 样式', () => {
render(<Button fullWidth></Button>);
expect(screen.getByRole('button')).toHaveClass('w-full');
});
it('非 fullWidth 不应用 w-full 样式', () => {
render(<Button></Button>);
expect(screen.getByRole('button')).not.toHaveClass('w-full');
});
});
// ==================== 事件处理测试 ====================
describe('事件处理', () => {
it('点击触发 onClick 事件', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}></Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('多次点击触发多次事件', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}></Button>);
fireEvent.click(screen.getByRole('button'));
fireEvent.click(screen.getByRole('button'));
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(3);
});
});
// ==================== 自定义属性测试 ====================
describe('自定义属性', () => {
it('支持自定义 className', () => {
render(<Button className="custom-class"></Button>);
expect(screen.getByRole('button')).toHaveClass('custom-class');
});
it('支持原生 button 属性', () => {
render(<Button type="submit" data-testid="submit-btn"></Button>);
const button = screen.getByTestId('submit-btn');
expect(button).toHaveAttribute('type', 'submit');
});
});
});
+188
View File
@@ -0,0 +1,188 @@
/**
* Card 组件测试
* 测试覆盖: Card, CardHeader, CardTitle, CardContent, CardFooter
*/
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from './Card';
describe('Card', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染子元素', () => {
render(<Card></Card>);
expect(screen.getByText('卡片内容')).toBeInTheDocument();
});
it('默认使用 default variant 和 mobile padding', () => {
render(<Card data-testid="card"></Card>);
const card = screen.getByTestId('card');
expect(card).toHaveClass('bg-bg-card', 'rounded-card');
});
});
// ==================== Variant 测试 ====================
describe('Variant 样式', () => {
it('default variant 应用基础样式', () => {
render(<Card variant="default">Default</Card>);
const card = screen.getByText('Default').closest('div');
expect(card).toHaveClass('bg-bg-card');
expect(card).not.toHaveClass('shadow-elevated');
});
it('elevated variant 应用阴影样式', () => {
render(<Card variant="elevated">Elevated</Card>);
const card = screen.getByText('Elevated').closest('div');
expect(card).toHaveClass('bg-bg-elevated', 'shadow-elevated');
});
});
// ==================== Padding 测试 ====================
describe('Padding 样式', () => {
it('mobile padding 应用正确样式', () => {
render(<Card padding="mobile">Mobile</Card>);
const card = screen.getByText('Mobile').closest('div');
expect(card).toHaveClass('p-[14px_16px]');
});
it('desktop padding 应用正确样式', () => {
render(<Card padding="desktop">Desktop</Card>);
const card = screen.getByText('Desktop').closest('div');
expect(card).toHaveClass('p-[16px_20px]');
});
it('none padding 应用正确样式', () => {
render(<Card padding="none">No Padding</Card>);
const card = screen.getByText('No Padding').closest('div');
expect(card).toHaveClass('p-0');
});
});
// ==================== Hoverable 测试 ====================
describe('Hoverable 属性', () => {
it('hoverable 应用 hover 样式', () => {
render(<Card hoverable>Hoverable</Card>);
const card = screen.getByText('Hoverable').closest('div');
expect(card).toHaveClass('cursor-pointer', 'transition-all');
});
it('非 hoverable 不应用 hover 样式', () => {
render(<Card>Not Hoverable</Card>);
const card = screen.getByText('Not Hoverable').closest('div');
expect(card).not.toHaveClass('hover:bg-bg-elevated');
});
});
// ==================== onClick 测试 ====================
describe('onClick 事件', () => {
it('点击触发 onClick', () => {
const handleClick = vi.fn();
render(<Card onClick={handleClick}>Clickable</Card>);
fireEvent.click(screen.getByText('Clickable'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('有 onClick 时显示 pointer 样式', () => {
const handleClick = vi.fn();
render(<Card onClick={handleClick}>Clickable</Card>);
const card = screen.getByText('Clickable').closest('div');
expect(card).toHaveClass('cursor-pointer');
});
});
// ==================== 自定义 className 测试 ====================
describe('自定义 className', () => {
it('支持自定义 className', () => {
render(<Card className="custom-card">Custom</Card>);
const card = screen.getByText('Custom').closest('div');
expect(card).toHaveClass('custom-card');
});
});
});
describe('CardHeader', () => {
it('渲染子元素', () => {
render(<CardHeader></CardHeader>);
expect(screen.getByText('头部内容')).toBeInTheDocument();
});
it('应用 flex 布局', () => {
render(<CardHeader>Header</CardHeader>);
const header = screen.getByText('Header').closest('div');
expect(header).toHaveClass('flex', 'items-center', 'justify-between');
});
it('支持自定义 className', () => {
render(<CardHeader className="custom-header">Header</CardHeader>);
const header = screen.getByText('Header').closest('div');
expect(header).toHaveClass('custom-header');
});
});
describe('CardTitle', () => {
it('渲染为 h3 标签', () => {
render(<CardTitle></CardTitle>);
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('标题');
});
it('应用标题样式', () => {
render(<CardTitle>Title</CardTitle>);
const title = screen.getByRole('heading');
expect(title).toHaveClass('text-section-title', 'text-text-primary', 'font-semibold');
});
it('支持自定义 className', () => {
render(<CardTitle className="custom-title">Title</CardTitle>);
expect(screen.getByRole('heading')).toHaveClass('custom-title');
});
});
describe('CardContent', () => {
it('渲染子元素', () => {
render(<CardContent></CardContent>);
expect(screen.getByText('内容区域')).toBeInTheDocument();
});
it('支持自定义 className', () => {
render(<CardContent className="custom-content">Content</CardContent>);
const content = screen.getByText('Content').closest('div');
expect(content).toHaveClass('custom-content');
});
});
describe('CardFooter', () => {
it('渲染子元素', () => {
render(<CardFooter></CardFooter>);
expect(screen.getByText('页脚内容')).toBeInTheDocument();
});
it('应用边框和间距样式', () => {
render(<CardFooter>Footer</CardFooter>);
const footer = screen.getByText('Footer').closest('div');
expect(footer).toHaveClass('mt-4', 'pt-4', 'border-t', 'border-border-subtle');
});
it('支持自定义 className', () => {
render(<CardFooter className="custom-footer">Footer</CardFooter>);
const footer = screen.getByText('Footer').closest('div');
expect(footer).toHaveClass('custom-footer');
});
});
describe('Card 组合使用', () => {
it('完整卡片结构渲染正确', () => {
render(
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent></CardContent>
<CardFooter></CardFooter>
</Card>
);
expect(screen.getByRole('heading', { name: '卡片标题' })).toBeInTheDocument();
expect(screen.getByText('卡片内容')).toBeInTheDocument();
expect(screen.getByText('卡片页脚')).toBeInTheDocument();
});
});
+4 -4
View File
@@ -4,12 +4,10 @@
*/
import React from 'react';
export interface CardProps {
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
className?: string;
variant?: 'default' | 'elevated';
padding?: 'mobile' | 'desktop' | 'none';
onClick?: () => void;
hoverable?: boolean;
}
@@ -24,8 +22,9 @@ export const Card: React.FC<CardProps> = ({
className = '',
variant = 'default',
padding = 'mobile',
onClick,
hoverable = false,
onClick,
...props
}) => {
return (
<div
@@ -38,6 +37,7 @@ export const Card: React.FC<CardProps> = ({
${className}
`}
onClick={onClick}
{...props}
>
{children}
</div>
+240
View File
@@ -0,0 +1,240 @@
/**
* Input 组件测试
* 测试覆盖: Input, SearchInput, PasswordInput
*/
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useRef } from 'react';
import { Mail, Lock } from 'lucide-react';
import { Input, SearchInput, PasswordInput } from './Input';
describe('Input', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染输入框', () => {
render(<Input placeholder="请输入" />);
expect(screen.getByPlaceholderText('请输入')).toBeInTheDocument();
});
it('默认全宽', () => {
render(<Input data-testid="input" />);
const wrapper = screen.getByTestId('input').closest('div')?.parentElement;
expect(wrapper).toHaveClass('w-full');
});
});
// ==================== Label 测试 ====================
describe('Label', () => {
it('渲染 label', () => {
render(<Input label="邮箱" />);
expect(screen.getByText('邮箱')).toBeInTheDocument();
});
it('label 使用正确样式', () => {
render(<Input label="用户名" />);
const label = screen.getByText('用户名');
expect(label).toHaveClass('text-caption', 'text-text-secondary');
});
});
// ==================== Error 测试 ====================
describe('Error 状态', () => {
it('显示错误信息', () => {
render(<Input error="邮箱格式不正确" />);
expect(screen.getByText('邮箱格式不正确')).toBeInTheDocument();
});
it('错误信息使用红色', () => {
render(<Input error="错误" />);
const errorText = screen.getByText('错误');
expect(errorText).toHaveClass('text-accent-coral');
});
it('错误状态输入框边框变红', () => {
render(<Input error="错误" data-testid="input" />);
const input = screen.getByTestId('input');
expect(input).toHaveClass('border-accent-coral');
});
});
// ==================== Hint 测试 ====================
describe('Hint 提示', () => {
it('显示提示信息', () => {
render(<Input hint="请输入有效邮箱" />);
expect(screen.getByText('请输入有效邮箱')).toBeInTheDocument();
});
it('有 error 时不显示 hint', () => {
render(<Input hint="提示" error="错误" />);
expect(screen.queryByText('提示')).not.toBeInTheDocument();
expect(screen.getByText('错误')).toBeInTheDocument();
});
});
// ==================== Icon 测试 ====================
describe('Icon 渲染', () => {
it('渲染左侧图标', () => {
render(<Input leftIcon={Mail} data-testid="input" />);
const wrapper = screen.getByTestId('input').closest('div');
expect(wrapper?.querySelector('svg')).toBeInTheDocument();
});
it('左侧图标增加左内边距', () => {
render(<Input leftIcon={Mail} data-testid="input" />);
expect(screen.getByTestId('input')).toHaveClass('pl-10');
});
it('渲染右侧图标', () => {
render(<Input rightIcon={Lock} data-testid="input" />);
const wrapper = screen.getByTestId('input').closest('div');
expect(wrapper?.querySelector('button')).toBeInTheDocument();
});
it('右侧图标增加右内边距', () => {
render(<Input rightIcon={Lock} data-testid="input" />);
expect(screen.getByTestId('input')).toHaveClass('pr-10');
});
it('点击右侧图标触发回调', () => {
const handleClick = vi.fn();
render(<Input rightIcon={Lock} onRightIconClick={handleClick} />);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
// ==================== Disabled 测试 ====================
describe('Disabled 状态', () => {
it('disabled 禁用输入框', () => {
render(<Input disabled data-testid="input" />);
const input = screen.getByTestId('input');
// 验证 disabled 属性
expect(input).toBeDisabled();
// 验证 disabled 时不可编辑
expect(input).toHaveAttribute('disabled');
});
it('非 disabled 输入框可编辑', () => {
render(<Input data-testid="input" />);
const input = screen.getByTestId('input');
expect(input).not.toBeDisabled();
expect(input).not.toHaveAttribute('disabled');
});
});
// ==================== FullWidth 测试 ====================
describe('FullWidth 属性', () => {
it('fullWidth=false 不应用全宽', () => {
render(<Input fullWidth={false} data-testid="input" />);
const wrapper = screen.getByTestId('input').closest('div')?.parentElement;
expect(wrapper).not.toHaveClass('w-full');
});
});
// ==================== ForwardRef 测试 ====================
describe('ForwardRef', () => {
it('正确转发 ref', () => {
const TestComponent = () => {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<Input ref={inputRef} data-testid="input" />
<button onClick={() => inputRef.current?.focus()}>Focus</button>
</>
);
};
render(<TestComponent />);
fireEvent.click(screen.getByText('Focus'));
expect(screen.getByTestId('input')).toHaveFocus();
});
});
// ==================== 事件处理测试 ====================
describe('事件处理', () => {
it('onChange 事件正常触发', () => {
const handleChange = vi.fn();
render(<Input onChange={handleChange} data-testid="input" />);
fireEvent.change(screen.getByTestId('input'), { target: { value: 'test' } });
expect(handleChange).toHaveBeenCalled();
});
it('输入值正确更新', () => {
render(<Input data-testid="input" />);
const input = screen.getByTestId('input');
fireEvent.change(input, { target: { value: 'hello' } });
expect(input).toHaveValue('hello');
});
});
});
describe('SearchInput', () => {
it('渲染搜索图标', () => {
render(<SearchInput data-testid="search" />);
const wrapper = screen.getByTestId('search').closest('div');
expect(wrapper?.querySelector('svg')).toBeInTheDocument();
});
it('默认 placeholder 为搜索...', () => {
render(<SearchInput />);
expect(screen.getByPlaceholderText('搜索...')).toBeInTheDocument();
});
it('支持自定义 placeholder', () => {
render(<SearchInput placeholder="搜索用户" />);
expect(screen.getByPlaceholderText('搜索用户')).toBeInTheDocument();
});
it('正确转发 ref', () => {
const TestComponent = () => {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<SearchInput ref={inputRef} data-testid="search" />
<button onClick={() => inputRef.current?.focus()}>Focus</button>
</>
);
};
render(<TestComponent />);
fireEvent.click(screen.getByText('Focus'));
expect(screen.getByTestId('search')).toHaveFocus();
});
});
describe('PasswordInput', () => {
it('默认隐藏密码', () => {
render(<PasswordInput data-testid="password" />);
expect(screen.getByTestId('password')).toHaveAttribute('type', 'password');
});
it('点击图标切换密码可见性', () => {
render(<PasswordInput data-testid="password" />);
const input = screen.getByTestId('password');
const toggleButton = screen.getByRole('button');
expect(input).toHaveAttribute('type', 'password');
fireEvent.click(toggleButton);
expect(input).toHaveAttribute('type', 'text');
fireEvent.click(toggleButton);
expect(input).toHaveAttribute('type', 'password');
});
it('正确转发 ref', () => {
const TestComponent = () => {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<PasswordInput ref={inputRef} data-testid="password" />
<button onClick={() => inputRef.current?.focus()}>Focus</button>
</>
);
};
render(<TestComponent />);
fireEvent.click(screen.getByText('Focus'));
expect(screen.getByTestId('password')).toHaveFocus();
});
});
+7 -2
View File
@@ -2,7 +2,8 @@
* Input 输入框组件
* 设计稿参考: UIDesignSpec.md
*/
import React, { forwardRef } from 'react';
'use client';
import React, { forwardRef, useId } from 'react';
import { LucideIcon, Search, Eye, EyeOff } from 'lucide-react';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
@@ -25,12 +26,15 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(({
fullWidth = true,
className = '',
disabled,
id,
...props
}, ref) => {
const inputId = id ?? useId();
return (
<div className={`${fullWidth ? 'w-full' : ''}`}>
{label && (
<label className="block mb-1.5 text-caption text-text-secondary">
<label htmlFor={inputId} className="block mb-1.5 text-caption text-text-secondary">
{label}
</label>
)}
@@ -43,6 +47,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(({
)}
<input
ref={ref}
id={inputId}
className={`
w-full bg-bg-elevated text-text-primary
border border-border-subtle rounded-btn
+416
View File
@@ -0,0 +1,416 @@
/**
* Modal 组件测试
* 测试覆盖: Modal, ConfirmModal, 副作用(ESC、overflow
*/
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Modal, ConfirmModal } from './Modal';
describe('Modal', () => {
const mockOnClose = vi.fn();
beforeEach(() => {
mockOnClose.mockClear();
document.body.style.overflow = '';
});
afterEach(() => {
// 确保副作用被清理
document.body.style.overflow = '';
});
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('isOpen=true 时渲染内容', () => {
render(
<Modal isOpen={true} onClose={mockOnClose}>
<p></p>
</Modal>
);
expect(screen.getByText('模态框内容')).toBeInTheDocument();
});
it('isOpen=false 时不渲染', () => {
render(
<Modal isOpen={false} onClose={mockOnClose}>
<p></p>
</Modal>
);
expect(screen.queryByText('模态框内容')).not.toBeInTheDocument();
});
it('渲染标题', () => {
render(
<Modal isOpen={true} onClose={mockOnClose} title="弹窗标题">
</Modal>
);
expect(screen.getByText('弹窗标题')).toBeInTheDocument();
});
it('渲染页脚', () => {
render(
<Modal isOpen={true} onClose={mockOnClose} footer={<button></button>}>
</Modal>
);
expect(screen.getByText('确定')).toBeInTheDocument();
});
});
// ==================== 关闭按钮测试 ====================
describe('关闭按钮', () => {
it('默认显示关闭按钮', () => {
render(
<Modal isOpen={true} onClose={mockOnClose} title="标题">
</Modal>
);
// 使用 aria-label 精确选择关闭按钮
expect(screen.getByLabelText('关闭')).toBeInTheDocument();
});
it('点击关闭按钮触发 onClose', () => {
render(
<Modal isOpen={true} onClose={mockOnClose} title="标题">
</Modal>
);
// 使用 aria-label 精确选择关闭按钮
const closeButton = screen.getByLabelText('关闭');
fireEvent.click(closeButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('showCloseButton=false 隐藏关闭按钮', () => {
render(
<Modal isOpen={true} onClose={mockOnClose} title="标题" showCloseButton={false}>
</Modal>
);
// 关闭按钮不存在
expect(screen.queryByLabelText('关闭')).not.toBeInTheDocument();
});
});
// ==================== 遮罩点击测试 ====================
describe('遮罩点击', () => {
it('点击遮罩默认关闭', () => {
render(
<Modal isOpen={true} onClose={mockOnClose}>
</Modal>
);
const overlay = document.querySelector('.bg-black\\/60');
fireEvent.click(overlay!);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('closeOnOverlay=false 禁用遮罩点击关闭', () => {
render(
<Modal isOpen={true} onClose={mockOnClose} closeOnOverlay={false}>
</Modal>
);
const overlay = document.querySelector('.bg-black\\/60');
fireEvent.click(overlay!);
expect(mockOnClose).not.toHaveBeenCalled();
});
});
// ==================== ESC 键测试 ====================
describe('ESC 键关闭', () => {
it('按 ESC 键默认关闭', async () => {
render(
<Modal isOpen={true} onClose={mockOnClose}>
</Modal>
);
await act(async () => {
fireEvent.keyDown(document, { key: 'Escape' });
});
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('closeOnEsc=false 禁用 ESC 关闭', async () => {
render(
<Modal isOpen={true} onClose={mockOnClose} closeOnEsc={false}>
</Modal>
);
await act(async () => {
fireEvent.keyDown(document, { key: 'Escape' });
});
expect(mockOnClose).not.toHaveBeenCalled();
});
it('其他按键不触发关闭', async () => {
render(
<Modal isOpen={true} onClose={mockOnClose}>
</Modal>
);
await act(async () => {
fireEvent.keyDown(document, { key: 'Enter' });
});
expect(mockOnClose).not.toHaveBeenCalled();
});
});
// ==================== Body Overflow 副作用测试 ====================
describe('Body overflow 副作用', () => {
it('打开时锁定 body 滚动', () => {
render(
<Modal isOpen={true} onClose={mockOnClose}>
</Modal>
);
expect(document.body.style.overflow).toBe('hidden');
});
it('关闭时解锁 body 滚动', () => {
const { rerender } = render(
<Modal isOpen={true} onClose={mockOnClose}>
</Modal>
);
expect(document.body.style.overflow).toBe('hidden');
rerender(
<Modal isOpen={false} onClose={mockOnClose}>
</Modal>
);
expect(document.body.style.overflow).toBe('');
});
it('卸载时清理 overflow', () => {
const { unmount } = render(
<Modal isOpen={true} onClose={mockOnClose}>
</Modal>
);
expect(document.body.style.overflow).toBe('hidden');
unmount();
expect(document.body.style.overflow).toBe('');
});
});
// ==================== Size 测试 ====================
describe('Size 样式', () => {
it('默认 md size', () => {
const { container } = render(
<Modal isOpen={true} onClose={mockOnClose}>
</Modal>
);
expect(container.querySelector('.max-w-md')).toBeInTheDocument();
});
it('sm size', () => {
const { container } = render(
<Modal isOpen={true} onClose={mockOnClose} size="sm">
</Modal>
);
expect(container.querySelector('.max-w-sm')).toBeInTheDocument();
});
it('lg size', () => {
const { container } = render(
<Modal isOpen={true} onClose={mockOnClose} size="lg">
</Modal>
);
expect(container.querySelector('.max-w-lg')).toBeInTheDocument();
});
it('xl size', () => {
const { container } = render(
<Modal isOpen={true} onClose={mockOnClose} size="xl">
</Modal>
);
expect(container.querySelector('.max-w-xl')).toBeInTheDocument();
});
});
// ==================== ClassName 测试 ====================
describe('ClassName', () => {
it('支持自定义 className', () => {
const { container } = render(
<Modal isOpen={true} onClose={mockOnClose} className="custom-modal">
</Modal>
);
expect(container.querySelector('.custom-modal')).toBeInTheDocument();
});
});
});
describe('ConfirmModal', () => {
const mockOnClose = vi.fn();
const mockOnConfirm = vi.fn();
beforeEach(() => {
mockOnClose.mockClear();
mockOnConfirm.mockClear();
});
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染标题和消息', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="确认删除"
message="确定要删除吗?"
/>
);
expect(screen.getByText('确认删除')).toBeInTheDocument();
expect(screen.getByText('确定要删除吗?')).toBeInTheDocument();
});
it('渲染确认和取消按钮', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="操作确认"
message="消息"
confirmText="确定"
/>
);
// Modal 有关闭按钮(X)ConfirmModal 有确认和取消按钮,共 3 个
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('确定')).toBeInTheDocument();
expect(screen.getByText('取消')).toBeInTheDocument();
});
});
// ==================== 按钮文本自定义测试 ====================
describe('按钮文本自定义', () => {
it('支持自定义确认按钮文本', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="确认"
message="消息"
confirmText="删除"
/>
);
expect(screen.getByText('删除')).toBeInTheDocument();
});
it('支持自定义取消按钮文本', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="确认"
message="消息"
cancelText="返回"
/>
);
expect(screen.getByText('返回')).toBeInTheDocument();
});
});
// ==================== 事件处理测试 ====================
describe('事件处理', () => {
it('点击确认按钮触发 onConfirm', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="操作确认"
message="消息"
confirmText="确定"
/>
);
fireEvent.click(screen.getByText('确定'));
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
});
it('点击取消按钮触发 onClose', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="操作确认"
message="消息"
/>
);
fireEvent.click(screen.getByText('取消'));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
// ==================== Variant 测试 ====================
describe('Variant 样式', () => {
it('danger variant 使用红色确认按钮', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="确认删除"
message="消息"
variant="danger"
/>
);
const confirmButton = screen.getByText('确认').closest('button');
expect(confirmButton).toHaveClass('bg-accent-coral');
});
});
// ==================== Loading 测试 ====================
describe('Loading 状态', () => {
it('loading 时确认按钮显示加载状态', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="操作确认"
message="消息"
confirmText="确定"
loading={true}
/>
);
const confirmButton = screen.getByText('确定').closest('button');
expect(confirmButton).toBeDisabled();
});
it('loading 时取消按钮也被禁用', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="操作确认"
message="消息"
loading={true}
/>
);
const cancelButton = screen.getByText('取消').closest('button');
expect(cancelButton).toBeDisabled();
});
});
});
+13 -6
View File
@@ -2,7 +2,8 @@
* Modal 弹窗组件
* 设计稿参考: UIDesignSpec.md
*/
import React, { useEffect, useCallback } from 'react';
'use client';
import React, { useEffect, useCallback, useRef } from 'react';
import { X } from 'lucide-react';
import { Button } from './Button';
@@ -39,6 +40,8 @@ export const Modal: React.FC<ModalProps> = ({
showCloseButton = true,
className = '',
}) => {
const previousOverflowRef = useRef<string>('');
// Handle ESC key
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (closeOnEsc && e.key === 'Escape') {
@@ -47,13 +50,15 @@ export const Modal: React.FC<ModalProps> = ({
}, [closeOnEsc, onClose]);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
}
if (!isOpen) return;
document.addEventListener('keydown', handleKeyDown);
previousOverflowRef.current = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
document.body.style.overflow = previousOverflowRef.current || '';
};
}, [isOpen, handleKeyDown]);
@@ -89,6 +94,8 @@ export const Modal: React.FC<ModalProps> = ({
{showCloseButton && (
<button
onClick={onClose}
type="button"
aria-label="关闭"
className="p-1 text-text-tertiary hover:text-text-primary transition-colors"
>
<X size={20} />
+250
View File
@@ -0,0 +1,250 @@
/**
* ProgressBar 组件测试
* 测试覆盖: ProgressBar, CircularProgress
*/
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { ProgressBar, CircularProgress } from './ProgressBar';
describe('ProgressBar', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染进度条', () => {
const { container } = render(<ProgressBar value={50} />);
expect(container.querySelector('.bg-bg-elevated')).toBeInTheDocument();
});
it('正确计算进度百分比', () => {
const { container } = render(<ProgressBar value={75} />);
const progressFill = container.querySelector('.bg-accent-indigo');
expect(progressFill).toHaveStyle({ width: '75%' });
});
});
// ==================== Value 边界测试 ====================
describe('Value 边界值', () => {
it('0% 进度', () => {
const { container } = render(<ProgressBar value={0} />);
const progressFill = container.querySelector('.bg-accent-indigo');
expect(progressFill).toHaveStyle({ width: '0%' });
});
it('100% 进度', () => {
const { container } = render(<ProgressBar value={100} />);
const progressFill = container.querySelector('.bg-accent-indigo');
expect(progressFill).toHaveStyle({ width: '100%' });
});
it('超过 100% 限制为 100%', () => {
const { container } = render(<ProgressBar value={150} />);
const progressFill = container.querySelector('.bg-accent-indigo');
expect(progressFill).toHaveStyle({ width: '100%' });
});
it('负值限制为 0%', () => {
const { container } = render(<ProgressBar value={-10} />);
const progressFill = container.querySelector('.bg-accent-indigo');
expect(progressFill).toHaveStyle({ width: '0%' });
});
});
// ==================== Max 测试 ====================
describe('Max 属性', () => {
it('自定义 max 值计算正确', () => {
const { container } = render(<ProgressBar value={25} max={50} />);
const progressFill = container.querySelector('.bg-accent-indigo');
expect(progressFill).toHaveStyle({ width: '50%' }); // 25/50 = 50%
});
it('默认 max 为 100', () => {
const { container } = render(<ProgressBar value={30} />);
const progressFill = container.querySelector('.bg-accent-indigo');
expect(progressFill).toHaveStyle({ width: '30%' });
});
});
// ==================== ShowLabel 测试 ====================
describe('ShowLabel 属性', () => {
it('showLabel=true 显示标签', () => {
render(<ProgressBar value={50} showLabel />);
expect(screen.getByText('进度')).toBeInTheDocument();
expect(screen.getByText('50%')).toBeInTheDocument();
});
it('showLabel=false 不显示标签(默认)', () => {
render(<ProgressBar value={50} />);
expect(screen.queryByText('进度')).not.toBeInTheDocument();
});
it('标签显示四舍五入的百分比', () => {
render(<ProgressBar value={33.7} showLabel />);
expect(screen.getByText('34%')).toBeInTheDocument();
});
});
// ==================== Size 测试 ====================
describe('Size 样式', () => {
it('sm size', () => {
const { container } = render(<ProgressBar value={50} size="sm" />);
expect(container.querySelector('.h-1')).toBeInTheDocument();
});
it('md size(默认)', () => {
const { container } = render(<ProgressBar value={50} size="md" />);
expect(container.querySelector('.h-2')).toBeInTheDocument();
});
it('lg size', () => {
const { container } = render(<ProgressBar value={50} size="lg" />);
expect(container.querySelector('.h-3')).toBeInTheDocument();
});
});
// ==================== Variant 测试 ====================
describe('Variant 样式', () => {
it('default variant(默认)', () => {
const { container } = render(<ProgressBar value={50} />);
expect(container.querySelector('.bg-accent-indigo')).toBeInTheDocument();
});
it('success variant', () => {
const { container } = render(<ProgressBar value={50} variant="success" />);
expect(container.querySelector('.bg-accent-green')).toBeInTheDocument();
});
it('warning variant', () => {
const { container } = render(<ProgressBar value={50} variant="warning" />);
expect(container.querySelector('.bg-accent-amber')).toBeInTheDocument();
});
it('error variant', () => {
const { container } = render(<ProgressBar value={50} variant="error" />);
expect(container.querySelector('.bg-accent-coral')).toBeInTheDocument();
});
});
// ==================== ClassName 测试 ====================
describe('ClassName', () => {
it('支持自定义 className', () => {
const { container } = render(<ProgressBar value={50} className="custom-progress" />);
expect(container.firstChild).toHaveClass('custom-progress');
});
});
});
describe('CircularProgress', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染 SVG 环形进度', () => {
const { container } = render(<CircularProgress value={50} />);
expect(container.querySelector('svg')).toBeInTheDocument();
});
it('渲染两个圆(背景和进度)', () => {
const { container } = render(<CircularProgress value={50} />);
const circles = container.querySelectorAll('circle');
expect(circles).toHaveLength(2);
});
});
// ==================== Value 测试 ====================
describe('Value 边界值', () => {
it('显示正确的百分比', () => {
render(<CircularProgress value={75} />);
expect(screen.getByText('75%')).toBeInTheDocument();
});
it('四舍五入百分比', () => {
render(<CircularProgress value={33.6} />);
expect(screen.getByText('34%')).toBeInTheDocument();
});
it('超过 100% 限制为 100%', () => {
render(<CircularProgress value={150} />);
expect(screen.getByText('100%')).toBeInTheDocument();
});
it('负值限制为 0%', () => {
render(<CircularProgress value={-10} />);
expect(screen.getByText('0%')).toBeInTheDocument();
});
});
// ==================== Size 测试 ====================
describe('Size 属性', () => {
it('默认 size 为 120', () => {
const { container } = render(<CircularProgress value={50} />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('width', '120');
expect(svg).toHaveAttribute('height', '120');
});
it('支持自定义 size', () => {
const { container } = render(<CircularProgress value={50} size={80} />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('width', '80');
expect(svg).toHaveAttribute('height', '80');
});
});
// ==================== ShowLabel 测试 ====================
describe('ShowLabel 属性', () => {
it('showLabel=true 显示百分比(默认)', () => {
render(<CircularProgress value={50} />);
expect(screen.getByText('50%')).toBeInTheDocument();
});
it('showLabel=false 隐藏百分比', () => {
render(<CircularProgress value={50} showLabel={false} />);
expect(screen.queryByText('50%')).not.toBeInTheDocument();
});
});
// ==================== Label 测试 ====================
describe('Label 属性', () => {
it('显示自定义 label', () => {
render(<CircularProgress value={50} label="审核中" />);
expect(screen.getByText('审核中')).toBeInTheDocument();
});
it('showLabel=false 时不显示 label', () => {
render(<CircularProgress value={50} label="审核中" showLabel={false} />);
expect(screen.queryByText('审核中')).not.toBeInTheDocument();
});
});
// ==================== Variant 测试 ====================
describe('Variant 样式', () => {
it('default variant 使用正确颜色', () => {
const { container } = render(<CircularProgress value={50} variant="default" />);
const progressCircle = container.querySelectorAll('circle')[1];
expect(progressCircle).toHaveAttribute('stroke', '#6366F1');
});
it('success variant 使用正确颜色', () => {
const { container } = render(<CircularProgress value={50} variant="success" />);
const progressCircle = container.querySelectorAll('circle')[1];
expect(progressCircle).toHaveAttribute('stroke', '#32D583');
});
it('warning variant 使用正确颜色', () => {
const { container } = render(<CircularProgress value={50} variant="warning" />);
const progressCircle = container.querySelectorAll('circle')[1];
expect(progressCircle).toHaveAttribute('stroke', '#F59E0B');
});
it('error variant 使用正确颜色', () => {
const { container } = render(<CircularProgress value={50} variant="error" />);
const progressCircle = container.querySelectorAll('circle')[1];
expect(progressCircle).toHaveAttribute('stroke', '#E85A4F');
});
});
// ==================== ClassName 测试 ====================
describe('ClassName', () => {
it('支持自定义 className', () => {
const { container } = render(<CircularProgress value={50} className="custom-circular" />);
expect(container.firstChild).toHaveClass('custom-circular');
});
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* ReviewSteps 审核进度条组件
* 设计稿参考: pencil-new.pen 达人端任务列表
* 显示4个步骤:已提交 → AI审核中 → 代理商审核 → 审核通过
*/
import React from 'react'
import { Check, X, Loader2, Users } from 'lucide-react'
export type StepStatus = 'done' | 'current' | 'failed' | 'pending'
export interface ReviewStep {
key: string
label: string
status: StepStatus
}
interface ReviewStepsProps {
steps: ReviewStep[]
className?: string
}
function StepIcon({ status, isLast }: { status: StepStatus; isLast: boolean }) {
const baseClass = 'w-7 h-7 rounded-full flex items-center justify-center'
switch (status) {
case 'done':
return (
<div className={`${baseClass} bg-accent-green`}>
<Check size={14} className="text-white" />
</div>
)
case 'current':
return (
<div className={`${baseClass} bg-accent-indigo`}>
<Loader2 size={14} className="text-white animate-spin" />
</div>
)
case 'failed':
return (
<div className={`${baseClass} bg-accent-coral`}>
<X size={14} className="text-white" />
</div>
)
case 'pending':
default:
return (
<div className={`${baseClass} bg-bg-elevated border-[1.5px] border-border-subtle`}>
{isLast ? (
<Check size={14} className="text-text-tertiary" />
) : (
<Users size={14} className="text-text-tertiary" />
)}
</div>
)
}
}
function StepLine({ active }: { active: boolean }) {
return (
<div
className={`flex-1 h-0.5 mx-1 ${active ? 'bg-accent-green' : 'bg-border-subtle'}`}
/>
)
}
export const ReviewSteps: React.FC<ReviewStepsProps> = ({ steps, className = '' }) => {
return (
<div className={`flex items-center w-full py-2 ${className}`}>
{steps.map((step, index) => {
const isLast = index === steps.length - 1
const nextStepActive = index < steps.length - 1 &&
(steps[index + 1].status === 'done' || steps[index + 1].status === 'current' || steps[index + 1].status === 'failed')
return (
<React.Fragment key={step.key}>
<div className="flex flex-col items-center w-[70px]">
<StepIcon status={step.status} isLast={isLast} />
<span
className={`text-[11px] mt-1 font-medium ${
step.status === 'done' ? 'text-text-secondary' :
step.status === 'current' ? 'text-accent-indigo' :
step.status === 'failed' ? 'text-accent-coral' :
'text-text-tertiary'
}`}
>
{step.label}
</span>
</div>
{!isLast && <StepLine active={step.status === 'done' || nextStepActive} />}
</React.Fragment>
)
})}
</div>
)
}
// 根据任务状态生成步骤数据 (达人端视角)
export function getReviewSteps(taskStatus: string): ReviewStep[] {
switch (taskStatus) {
case 'pending_upload':
return [
{ key: 'submitted', label: '已提交', status: 'pending' },
{ key: 'ai_review', label: 'AI审核', status: 'pending' },
{ key: 'agent_review', label: '代理商审核', status: 'pending' },
{ key: 'passed', label: '审核通过', status: 'pending' },
]
case 'ai_reviewing':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核中', status: 'current' },
{ key: 'agent_review', label: '代理商审核', status: 'pending' },
{ key: 'passed', label: '审核通过', status: 'pending' },
]
case 'agent_reviewing':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'done' },
{ key: 'agent_review', label: '代理商审核', status: 'current' },
{ key: 'passed', label: '审核通过', status: 'pending' },
]
case 'need_revision':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: '需修改', status: 'failed' },
{ key: 'agent_review', label: '代理商审核', status: 'pending' },
{ key: 'passed', label: '审核通过', status: 'pending' },
]
case 'passed':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'done' },
{ key: 'agent_review', label: '代理商审核', status: 'done' },
{ key: 'passed', label: '审核通过', status: 'done' },
]
case 'rejected':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'done' },
{ key: 'agent_review', label: '已驳回', status: 'failed' },
{ key: 'passed', label: '审核通过', status: 'pending' },
]
default:
return [
{ key: 'submitted', label: '已提交', status: 'pending' },
{ key: 'ai_review', label: 'AI审核', status: 'pending' },
{ key: 'agent_review', label: '代理商审核', status: 'pending' },
{ key: 'passed', label: '审核通过', status: 'pending' },
]
}
}
// 代理商/品牌方视角的审核步骤 (包含品牌终审)
export function getAgencyReviewSteps(taskStatus: string): ReviewStep[] {
switch (taskStatus) {
case 'ai_reviewing':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'current' },
{ key: 'agent_review', label: '代理商', status: 'pending' },
{ key: 'brand_review', label: '品牌终审', status: 'pending' },
]
case 'agent_reviewing':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'done' },
{ key: 'agent_review', label: '代理商', status: 'current' },
{ key: 'brand_review', label: '品牌终审', status: 'pending' },
]
case 'brand_reviewing':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'done' },
{ key: 'agent_review', label: '代理商', status: 'done' },
{ key: 'brand_review', label: '品牌终审', status: 'current' },
]
case 'need_revision':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: '需修改', status: 'failed' },
{ key: 'agent_review', label: '代理商', status: 'pending' },
{ key: 'brand_review', label: '品牌终审', status: 'pending' },
]
case 'passed':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'done' },
{ key: 'agent_review', label: '代理商', status: 'done' },
{ key: 'brand_review', label: '品牌终审', status: 'done' },
]
case 'rejected':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'done' },
{ key: 'agent_review', label: '已驳回', status: 'failed' },
{ key: 'brand_review', label: '品牌终审', status: 'pending' },
]
default:
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'done' },
{ key: 'agent_review', label: '代理商', status: 'current' },
{ key: 'brand_review', label: '品牌终审', status: 'pending' },
]
}
}
export default ReviewSteps
+170
View File
@@ -0,0 +1,170 @@
/**
* Select 组件测试
* 测试覆盖: options, label, error, hint, placeholder, disabled, forwardRef
*/
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useRef } from 'react';
import { Select } from './Select';
const mockOptions = [
{ value: 'option1', label: '选项一' },
{ value: 'option2', label: '选项二' },
{ value: 'option3', label: '选项三', disabled: true },
];
describe('Select', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染下拉选择框', () => {
render(<Select options={mockOptions} />);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('渲染所有选项', () => {
render(<Select options={mockOptions} />);
expect(screen.getByText('选项一')).toBeInTheDocument();
expect(screen.getByText('选项二')).toBeInTheDocument();
expect(screen.getByText('选项三')).toBeInTheDocument();
});
it('渲染下拉箭头图标', () => {
render(<Select options={mockOptions} data-testid="select" />);
const wrapper = screen.getByTestId('select').closest('div');
expect(wrapper?.querySelector('svg')).toBeInTheDocument();
});
});
// ==================== Placeholder 测试 ====================
describe('Placeholder', () => {
it('显示 placeholder 选项', () => {
render(<Select options={mockOptions} placeholder="请选择" />);
expect(screen.getByText('请选择')).toBeInTheDocument();
});
it('placeholder 选项禁用', () => {
render(<Select options={mockOptions} placeholder="请选择" />);
const placeholderOption = screen.getByText('请选择');
expect(placeholderOption).toHaveAttribute('disabled');
});
});
// ==================== Label 测试 ====================
describe('Label', () => {
it('渲染 label', () => {
render(<Select options={mockOptions} label="选择类型" />);
expect(screen.getByText('选择类型')).toBeInTheDocument();
});
it('label 使用正确样式', () => {
render(<Select options={mockOptions} label="类型" />);
const label = screen.getByText('类型');
expect(label).toHaveClass('text-caption', 'text-text-secondary');
});
});
// ==================== Error 测试 ====================
describe('Error 状态', () => {
it('显示错误信息', () => {
render(<Select options={mockOptions} error="请选择一个选项" />);
expect(screen.getByText('请选择一个选项')).toBeInTheDocument();
});
it('错误信息使用红色', () => {
render(<Select options={mockOptions} error="错误" />);
expect(screen.getByText('错误')).toHaveClass('text-accent-coral');
});
it('错误状态边框变红', () => {
render(<Select options={mockOptions} error="错误" data-testid="select" />);
expect(screen.getByTestId('select')).toHaveClass('border-accent-coral');
});
});
// ==================== Hint 测试 ====================
describe('Hint 提示', () => {
it('显示提示信息', () => {
render(<Select options={mockOptions} hint="选择您的偏好" />);
expect(screen.getByText('选择您的偏好')).toBeInTheDocument();
});
it('有 error 时不显示 hint', () => {
render(<Select options={mockOptions} hint="提示" error="错误" />);
expect(screen.queryByText('提示')).not.toBeInTheDocument();
expect(screen.getByText('错误')).toBeInTheDocument();
});
});
// ==================== Disabled 测试 ====================
describe('Disabled 状态', () => {
it('disabled 禁用选择框', () => {
render(<Select options={mockOptions} disabled data-testid="select" />);
expect(screen.getByTestId('select')).toBeDisabled();
});
it('选项可以单独禁用', () => {
render(<Select options={mockOptions} />);
const disabledOption = screen.getByText('选项三');
expect(disabledOption).toHaveAttribute('disabled');
});
});
// ==================== FullWidth 测试 ====================
describe('FullWidth 属性', () => {
it('默认全宽', () => {
render(<Select options={mockOptions} data-testid="select" />);
const wrapper = screen.getByTestId('select').closest('div')?.parentElement;
expect(wrapper).toHaveClass('w-full');
});
it('fullWidth=false 不应用全宽', () => {
render(<Select options={mockOptions} fullWidth={false} data-testid="select" />);
const wrapper = screen.getByTestId('select').closest('div')?.parentElement;
expect(wrapper).not.toHaveClass('w-full');
});
});
// ==================== ForwardRef 测试 ====================
describe('ForwardRef', () => {
it('正确转发 ref', () => {
const TestComponent = () => {
const selectRef = useRef<HTMLSelectElement>(null);
return (
<>
<Select ref={selectRef} options={mockOptions} data-testid="select" />
<button onClick={() => selectRef.current?.focus()}>Focus</button>
</>
);
};
render(<TestComponent />);
fireEvent.click(screen.getByText('Focus'));
expect(screen.getByTestId('select')).toHaveFocus();
});
});
// ==================== 事件处理测试 ====================
describe('事件处理', () => {
it('onChange 事件正常触发', () => {
const handleChange = vi.fn();
render(<Select options={mockOptions} onChange={handleChange} data-testid="select" />);
fireEvent.change(screen.getByTestId('select'), { target: { value: 'option2' } });
expect(handleChange).toHaveBeenCalled();
});
it('选择值正确更新', () => {
render(<Select options={mockOptions} data-testid="select" />);
const select = screen.getByTestId('select');
fireEvent.change(select, { target: { value: 'option2' } });
expect(select).toHaveValue('option2');
});
});
// ==================== 自定义属性测试 ====================
describe('自定义属性', () => {
it('支持自定义 className', () => {
render(<Select options={mockOptions} className="custom-select" data-testid="select" />);
expect(screen.getByTestId('select')).toHaveClass('custom-select');
});
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* Tag 组件测试
* 测试覆盖: Tag, SuccessTag, PendingTag, WarningTag, ErrorTag
*/
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Star } from 'lucide-react';
import { Tag, SuccessTag, PendingTag, WarningTag, ErrorTag } from './Tag';
describe('Tag', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染标签文本', () => {
render(<Tag status="success"></Tag>);
expect(screen.getByText('通过')).toBeInTheDocument();
});
it('默认显示图标', () => {
render(<Tag status="success"></Tag>);
const tag = screen.getByText('成功').closest('span');
expect(tag?.querySelector('svg')).toBeInTheDocument();
});
});
// ==================== Status 样式测试 ====================
describe('Status 样式', () => {
it('success 状态应用绿色样式', () => {
render(<Tag status="success"></Tag>);
const tag = screen.getByText('成功').closest('span');
expect(tag).toHaveClass('bg-status-success', 'text-accent-green');
});
it('pending 状态应用蓝色样式', () => {
render(<Tag status="pending"></Tag>);
const tag = screen.getByText('待处理').closest('span');
expect(tag).toHaveClass('bg-status-pending', 'text-accent-indigo');
});
it('warning 状态应用黄色样式', () => {
render(<Tag status="warning"></Tag>);
const tag = screen.getByText('警告').closest('span');
expect(tag).toHaveClass('bg-status-warning', 'text-accent-amber');
});
it('error 状态应用红色样式', () => {
render(<Tag status="error"></Tag>);
const tag = screen.getByText('错误').closest('span');
expect(tag).toHaveClass('bg-status-error', 'text-accent-coral');
});
});
// ==================== Size 测试 ====================
describe('Size 样式', () => {
it('sm size 应用小尺寸样式', () => {
render(<Tag status="success" size="sm"></Tag>);
const tag = screen.getByText('小标签').closest('span');
expect(tag).toHaveClass('px-1.5', 'py-0.5', 'text-[11px]');
});
it('md size 应用中等尺寸样式(默认)', () => {
render(<Tag status="success" size="md"></Tag>);
const tag = screen.getByText('中标签').closest('span');
expect(tag).toHaveClass('px-2', 'py-1', 'text-small');
});
it('默认使用 md size', () => {
render(<Tag status="success"></Tag>);
const tag = screen.getByText('默认').closest('span');
expect(tag).toHaveClass('px-2', 'py-1');
});
});
// ==================== Icon 测试 ====================
describe('Icon 渲染', () => {
it('默认显示状态对应的图标', () => {
render(<Tag status="success"></Tag>);
const tag = screen.getByText('成功').closest('span');
expect(tag?.querySelector('svg')).toBeInTheDocument();
});
it('icon={false} 隐藏图标', () => {
render(<Tag status="success" icon={false}></Tag>);
const tag = screen.getByText('无图标').closest('span');
expect(tag?.querySelector('svg')).not.toBeInTheDocument();
});
it('icon={true} 显示默认图标', () => {
render(<Tag status="success" icon={true}></Tag>);
const tag = screen.getByText('有图标').closest('span');
expect(tag?.querySelector('svg')).toBeInTheDocument();
});
it('支持自定义图标', () => {
render(<Tag status="success" icon={Star}></Tag>);
const tag = screen.getByText('自定义').closest('span');
expect(tag?.querySelector('svg')).toBeInTheDocument();
});
});
// ==================== 自定义 className 测试 ====================
describe('自定义 className', () => {
it('支持自定义 className', () => {
render(<Tag status="success" className="custom-tag"></Tag>);
const tag = screen.getByText('自定义').closest('span');
expect(tag).toHaveClass('custom-tag');
});
});
});
// ==================== 预定义标签组件测试 ====================
describe('SuccessTag', () => {
it('渲染 success 状态', () => {
render(<SuccessTag></SuccessTag>);
const tag = screen.getByText('通过').closest('span');
expect(tag).toHaveClass('bg-status-success', 'text-accent-green');
});
it('支持 size 属性', () => {
render(<SuccessTag size="sm"></SuccessTag>);
const tag = screen.getByText('小').closest('span');
expect(tag).toHaveClass('px-1.5', 'py-0.5');
});
});
describe('PendingTag', () => {
it('渲染 pending 状态', () => {
render(<PendingTag></PendingTag>);
const tag = screen.getByText('处理中').closest('span');
expect(tag).toHaveClass('bg-status-pending', 'text-accent-indigo');
});
it('支持 size 属性', () => {
render(<PendingTag size="sm"></PendingTag>);
const tag = screen.getByText('小').closest('span');
expect(tag).toHaveClass('px-1.5', 'py-0.5');
});
});
describe('WarningTag', () => {
it('渲染 warning 状态', () => {
render(<WarningTag></WarningTag>);
const tag = screen.getByText('注意').closest('span');
expect(tag).toHaveClass('bg-status-warning', 'text-accent-amber');
});
it('支持 size 属性', () => {
render(<WarningTag size="sm"></WarningTag>);
const tag = screen.getByText('小').closest('span');
expect(tag).toHaveClass('px-1.5', 'py-0.5');
});
});
describe('ErrorTag', () => {
it('渲染 error 状态', () => {
render(<ErrorTag></ErrorTag>);
const tag = screen.getByText('失败').closest('span');
expect(tag).toHaveClass('bg-status-error', 'text-accent-coral');
});
it('支持 size 属性', () => {
render(<ErrorTag size="sm"></ErrorTag>);
const tag = screen.getByText('小').closest('span');
expect(tag).toHaveClass('px-1.5', 'py-0.5');
});
});