feat: 完善代理商端业务逻辑与前后端框架
主要更新: - 更新代理商端文档,明确项目由品牌方分配流程 - 新增Brief配置详情页(已配置)设计稿 - 完善工作台紧急待办中品牌新任务功能 - 整理Pencil设计文件中代理商端页面顺序 - 新增后端FastAPI框架及核心API - 新增前端Next.js页面和组件库 - 添加.gitignore排除构建和缓存文件 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
d52509d630
commit
e4959d584f
@@ -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}</>
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user