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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-05 19:27:31 +08:00
co-authored by Claude Opus 4.5
parent d52509d630
commit e4959d584f
132 changed files with 58539 additions and 21353 deletions
+205
View File
@@ -0,0 +1,205 @@
/**
* Button 组件测试
* 测试覆盖: variants, sizes, icons, loading, disabled, fullWidth
*/
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Search, ArrowRight } from 'lucide-react';
import { Button } from './Button';
describe('Button', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染按钮文本', () => {
render(<Button></Button>);
expect(screen.getByRole('button', { name: '点击我' })).toBeInTheDocument();
});
it('默认使用 primary variant 和 md size', () => {
render(<Button></Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('bg-accent-indigo');
expect(button).toHaveClass('px-4', 'py-2.5');
});
});
// ==================== Variant 测试 ====================
describe('Variant 样式', () => {
it('primary variant 应用正确样式', () => {
render(<Button variant="primary">Primary</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-accent-indigo', 'text-white');
});
it('secondary variant 应用正确样式', () => {
render(<Button variant="secondary">Secondary</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-bg-elevated', 'text-text-secondary');
});
it('danger variant 应用正确样式', () => {
render(<Button variant="danger">Danger</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-accent-coral', 'text-white');
});
it('success variant 应用正确样式', () => {
render(<Button variant="success">Success</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-accent-green', 'text-white');
});
it('ghost variant 应用正确样式', () => {
render(<Button variant="ghost">Ghost</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-transparent', 'text-text-secondary');
});
});
// ==================== Size 测试 ====================
describe('Size 样式', () => {
it('sm size 应用正确样式', () => {
render(<Button size="sm">Small</Button>);
expect(screen.getByRole('button')).toHaveClass('px-3', 'py-1.5', 'text-small');
});
it('md size 应用正确样式', () => {
render(<Button size="md">Medium</Button>);
expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2.5', 'text-body');
});
it('lg size 应用正确样式', () => {
render(<Button size="lg">Large</Button>);
expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3', 'text-section-title');
});
});
// ==================== Icon 测试 ====================
describe('Icon 渲染', () => {
// 使用 innerHTML 正则匹配验证图标和文本的相对位置
// 这种方式对 DOM 结构变化(如添加 wrapper)更健壮
it('左侧图标正确渲染(图标在文本之前)', () => {
render(<Button icon={Search} iconPosition="left"></Button>);
const button = screen.getByRole('button');
expect(button.querySelector('svg')).toBeInTheDocument();
// 验证 SVG 在 "搜索" 文本之前
const html = button.innerHTML;
const svgPos = html.indexOf('<svg');
const textPos = html.indexOf('搜索');
expect(svgPos).toBeLessThan(textPos);
});
it('右侧图标正确渲染(图标在文本之后)', () => {
render(<Button icon={ArrowRight} iconPosition="right"></Button>);
const button = screen.getByRole('button');
expect(button.querySelector('svg')).toBeInTheDocument();
// 验证 SVG 在 "下一步" 文本之后
const html = button.innerHTML;
const svgPos = html.indexOf('<svg');
const textPos = html.indexOf('下一步');
expect(svgPos).toBeGreaterThan(textPos);
});
it('默认图标位置为左侧', () => {
render(<Button icon={Search}></Button>);
const button = screen.getByRole('button');
expect(button.querySelector('svg')).toBeInTheDocument();
// 验证默认情况下 SVG 在 "搜索" 文本之前
const html = button.innerHTML;
const svgPos = html.indexOf('<svg');
const textPos = html.indexOf('搜索');
expect(svgPos).toBeLessThan(textPos);
});
});
// ==================== Loading 状态测试 ====================
describe('Loading 状态', () => {
it('loading 状态显示加载动画', () => {
render(<Button loading></Button>);
const button = screen.getByRole('button');
const spinner = button.querySelector('svg.animate-spin');
expect(spinner).toBeInTheDocument();
});
it('loading 状态禁用按钮', () => {
render(<Button loading></Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('loading 状态应用 opacity-50 样式', () => {
render(<Button loading></Button>);
expect(screen.getByRole('button')).toHaveClass('opacity-50');
});
it('loading 状态隐藏原有图标', () => {
render(<Button loading icon={Search}></Button>);
const button = screen.getByRole('button');
// 应该只有 spinner,没有 Search 图标
const svgs = button.querySelectorAll('svg');
expect(svgs).toHaveLength(1);
expect(svgs[0]).toHaveClass('animate-spin');
});
});
// ==================== Disabled 状态测试 ====================
describe('Disabled 状态', () => {
it('disabled 属性禁用按钮', () => {
render(<Button disabled></Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('disabled 状态应用正确样式', () => {
render(<Button disabled></Button>);
expect(screen.getByRole('button')).toHaveClass('opacity-50', 'cursor-not-allowed');
});
it('disabled 状态不触发点击事件', () => {
const handleClick = vi.fn();
render(<Button disabled onClick={handleClick}></Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
});
// ==================== FullWidth 测试 ====================
describe('FullWidth 属性', () => {
it('fullWidth 应用 w-full 样式', () => {
render(<Button fullWidth></Button>);
expect(screen.getByRole('button')).toHaveClass('w-full');
});
it('非 fullWidth 不应用 w-full 样式', () => {
render(<Button></Button>);
expect(screen.getByRole('button')).not.toHaveClass('w-full');
});
});
// ==================== 事件处理测试 ====================
describe('事件处理', () => {
it('点击触发 onClick 事件', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}></Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('多次点击触发多次事件', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}></Button>);
fireEvent.click(screen.getByRole('button'));
fireEvent.click(screen.getByRole('button'));
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(3);
});
});
// ==================== 自定义属性测试 ====================
describe('自定义属性', () => {
it('支持自定义 className', () => {
render(<Button className="custom-class"></Button>);
expect(screen.getByRole('button')).toHaveClass('custom-class');
});
it('支持原生 button 属性', () => {
render(<Button type="submit" data-testid="submit-btn"></Button>);
const button = screen.getByTestId('submit-btn');
expect(button).toHaveAttribute('type', 'submit');
});
});
});
+188
View File
@@ -0,0 +1,188 @@
/**
* Card 组件测试
* 测试覆盖: Card, CardHeader, CardTitle, CardContent, CardFooter
*/
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from './Card';
describe('Card', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染子元素', () => {
render(<Card></Card>);
expect(screen.getByText('卡片内容')).toBeInTheDocument();
});
it('默认使用 default variant 和 mobile padding', () => {
render(<Card data-testid="card"></Card>);
const card = screen.getByTestId('card');
expect(card).toHaveClass('bg-bg-card', 'rounded-card');
});
});
// ==================== Variant 测试 ====================
describe('Variant 样式', () => {
it('default variant 应用基础样式', () => {
render(<Card variant="default">Default</Card>);
const card = screen.getByText('Default').closest('div');
expect(card).toHaveClass('bg-bg-card');
expect(card).not.toHaveClass('shadow-elevated');
});
it('elevated variant 应用阴影样式', () => {
render(<Card variant="elevated">Elevated</Card>);
const card = screen.getByText('Elevated').closest('div');
expect(card).toHaveClass('bg-bg-elevated', 'shadow-elevated');
});
});
// ==================== Padding 测试 ====================
describe('Padding 样式', () => {
it('mobile padding 应用正确样式', () => {
render(<Card padding="mobile">Mobile</Card>);
const card = screen.getByText('Mobile').closest('div');
expect(card).toHaveClass('p-[14px_16px]');
});
it('desktop padding 应用正确样式', () => {
render(<Card padding="desktop">Desktop</Card>);
const card = screen.getByText('Desktop').closest('div');
expect(card).toHaveClass('p-[16px_20px]');
});
it('none padding 应用正确样式', () => {
render(<Card padding="none">No Padding</Card>);
const card = screen.getByText('No Padding').closest('div');
expect(card).toHaveClass('p-0');
});
});
// ==================== Hoverable 测试 ====================
describe('Hoverable 属性', () => {
it('hoverable 应用 hover 样式', () => {
render(<Card hoverable>Hoverable</Card>);
const card = screen.getByText('Hoverable').closest('div');
expect(card).toHaveClass('cursor-pointer', 'transition-all');
});
it('非 hoverable 不应用 hover 样式', () => {
render(<Card>Not Hoverable</Card>);
const card = screen.getByText('Not Hoverable').closest('div');
expect(card).not.toHaveClass('hover:bg-bg-elevated');
});
});
// ==================== onClick 测试 ====================
describe('onClick 事件', () => {
it('点击触发 onClick', () => {
const handleClick = vi.fn();
render(<Card onClick={handleClick}>Clickable</Card>);
fireEvent.click(screen.getByText('Clickable'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('有 onClick 时显示 pointer 样式', () => {
const handleClick = vi.fn();
render(<Card onClick={handleClick}>Clickable</Card>);
const card = screen.getByText('Clickable').closest('div');
expect(card).toHaveClass('cursor-pointer');
});
});
// ==================== 自定义 className 测试 ====================
describe('自定义 className', () => {
it('支持自定义 className', () => {
render(<Card className="custom-card">Custom</Card>);
const card = screen.getByText('Custom').closest('div');
expect(card).toHaveClass('custom-card');
});
});
});
describe('CardHeader', () => {
it('渲染子元素', () => {
render(<CardHeader></CardHeader>);
expect(screen.getByText('头部内容')).toBeInTheDocument();
});
it('应用 flex 布局', () => {
render(<CardHeader>Header</CardHeader>);
const header = screen.getByText('Header').closest('div');
expect(header).toHaveClass('flex', 'items-center', 'justify-between');
});
it('支持自定义 className', () => {
render(<CardHeader className="custom-header">Header</CardHeader>);
const header = screen.getByText('Header').closest('div');
expect(header).toHaveClass('custom-header');
});
});
describe('CardTitle', () => {
it('渲染为 h3 标签', () => {
render(<CardTitle></CardTitle>);
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('标题');
});
it('应用标题样式', () => {
render(<CardTitle>Title</CardTitle>);
const title = screen.getByRole('heading');
expect(title).toHaveClass('text-section-title', 'text-text-primary', 'font-semibold');
});
it('支持自定义 className', () => {
render(<CardTitle className="custom-title">Title</CardTitle>);
expect(screen.getByRole('heading')).toHaveClass('custom-title');
});
});
describe('CardContent', () => {
it('渲染子元素', () => {
render(<CardContent></CardContent>);
expect(screen.getByText('内容区域')).toBeInTheDocument();
});
it('支持自定义 className', () => {
render(<CardContent className="custom-content">Content</CardContent>);
const content = screen.getByText('Content').closest('div');
expect(content).toHaveClass('custom-content');
});
});
describe('CardFooter', () => {
it('渲染子元素', () => {
render(<CardFooter></CardFooter>);
expect(screen.getByText('页脚内容')).toBeInTheDocument();
});
it('应用边框和间距样式', () => {
render(<CardFooter>Footer</CardFooter>);
const footer = screen.getByText('Footer').closest('div');
expect(footer).toHaveClass('mt-4', 'pt-4', 'border-t', 'border-border-subtle');
});
it('支持自定义 className', () => {
render(<CardFooter className="custom-footer">Footer</CardFooter>);
const footer = screen.getByText('Footer').closest('div');
expect(footer).toHaveClass('custom-footer');
});
});
describe('Card 组合使用', () => {
it('完整卡片结构渲染正确', () => {
render(
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent></CardContent>
<CardFooter></CardFooter>
</Card>
);
expect(screen.getByRole('heading', { name: '卡片标题' })).toBeInTheDocument();
expect(screen.getByText('卡片内容')).toBeInTheDocument();
expect(screen.getByText('卡片页脚')).toBeInTheDocument();
});
});
+4 -4
View File
@@ -4,12 +4,10 @@
*/
import React from 'react';
export interface CardProps {
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
className?: string;
variant?: 'default' | 'elevated';
padding?: 'mobile' | 'desktop' | 'none';
onClick?: () => void;
hoverable?: boolean;
}
@@ -24,8 +22,9 @@ export const Card: React.FC<CardProps> = ({
className = '',
variant = 'default',
padding = 'mobile',
onClick,
hoverable = false,
onClick,
...props
}) => {
return (
<div
@@ -38,6 +37,7 @@ export const Card: React.FC<CardProps> = ({
${className}
`}
onClick={onClick}
{...props}
>
{children}
</div>
+240
View File
@@ -0,0 +1,240 @@
/**
* Input 组件测试
* 测试覆盖: Input, SearchInput, PasswordInput
*/
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useRef } from 'react';
import { Mail, Lock } from 'lucide-react';
import { Input, SearchInput, PasswordInput } from './Input';
describe('Input', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染输入框', () => {
render(<Input placeholder="请输入" />);
expect(screen.getByPlaceholderText('请输入')).toBeInTheDocument();
});
it('默认全宽', () => {
render(<Input data-testid="input" />);
const wrapper = screen.getByTestId('input').closest('div')?.parentElement;
expect(wrapper).toHaveClass('w-full');
});
});
// ==================== Label 测试 ====================
describe('Label', () => {
it('渲染 label', () => {
render(<Input label="邮箱" />);
expect(screen.getByText('邮箱')).toBeInTheDocument();
});
it('label 使用正确样式', () => {
render(<Input label="用户名" />);
const label = screen.getByText('用户名');
expect(label).toHaveClass('text-caption', 'text-text-secondary');
});
});
// ==================== Error 测试 ====================
describe('Error 状态', () => {
it('显示错误信息', () => {
render(<Input error="邮箱格式不正确" />);
expect(screen.getByText('邮箱格式不正确')).toBeInTheDocument();
});
it('错误信息使用红色', () => {
render(<Input error="错误" />);
const errorText = screen.getByText('错误');
expect(errorText).toHaveClass('text-accent-coral');
});
it('错误状态输入框边框变红', () => {
render(<Input error="错误" data-testid="input" />);
const input = screen.getByTestId('input');
expect(input).toHaveClass('border-accent-coral');
});
});
// ==================== Hint 测试 ====================
describe('Hint 提示', () => {
it('显示提示信息', () => {
render(<Input hint="请输入有效邮箱" />);
expect(screen.getByText('请输入有效邮箱')).toBeInTheDocument();
});
it('有 error 时不显示 hint', () => {
render(<Input hint="提示" error="错误" />);
expect(screen.queryByText('提示')).not.toBeInTheDocument();
expect(screen.getByText('错误')).toBeInTheDocument();
});
});
// ==================== Icon 测试 ====================
describe('Icon 渲染', () => {
it('渲染左侧图标', () => {
render(<Input leftIcon={Mail} data-testid="input" />);
const wrapper = screen.getByTestId('input').closest('div');
expect(wrapper?.querySelector('svg')).toBeInTheDocument();
});
it('左侧图标增加左内边距', () => {
render(<Input leftIcon={Mail} data-testid="input" />);
expect(screen.getByTestId('input')).toHaveClass('pl-10');
});
it('渲染右侧图标', () => {
render(<Input rightIcon={Lock} data-testid="input" />);
const wrapper = screen.getByTestId('input').closest('div');
expect(wrapper?.querySelector('button')).toBeInTheDocument();
});
it('右侧图标增加右内边距', () => {
render(<Input rightIcon={Lock} data-testid="input" />);
expect(screen.getByTestId('input')).toHaveClass('pr-10');
});
it('点击右侧图标触发回调', () => {
const handleClick = vi.fn();
render(<Input rightIcon={Lock} onRightIconClick={handleClick} />);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
// ==================== Disabled 测试 ====================
describe('Disabled 状态', () => {
it('disabled 禁用输入框', () => {
render(<Input disabled data-testid="input" />);
const input = screen.getByTestId('input');
// 验证 disabled 属性
expect(input).toBeDisabled();
// 验证 disabled 时不可编辑
expect(input).toHaveAttribute('disabled');
});
it('非 disabled 输入框可编辑', () => {
render(<Input data-testid="input" />);
const input = screen.getByTestId('input');
expect(input).not.toBeDisabled();
expect(input).not.toHaveAttribute('disabled');
});
});
// ==================== FullWidth 测试 ====================
describe('FullWidth 属性', () => {
it('fullWidth=false 不应用全宽', () => {
render(<Input fullWidth={false} data-testid="input" />);
const wrapper = screen.getByTestId('input').closest('div')?.parentElement;
expect(wrapper).not.toHaveClass('w-full');
});
});
// ==================== ForwardRef 测试 ====================
describe('ForwardRef', () => {
it('正确转发 ref', () => {
const TestComponent = () => {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<Input ref={inputRef} data-testid="input" />
<button onClick={() => inputRef.current?.focus()}>Focus</button>
</>
);
};
render(<TestComponent />);
fireEvent.click(screen.getByText('Focus'));
expect(screen.getByTestId('input')).toHaveFocus();
});
});
// ==================== 事件处理测试 ====================
describe('事件处理', () => {
it('onChange 事件正常触发', () => {
const handleChange = vi.fn();
render(<Input onChange={handleChange} data-testid="input" />);
fireEvent.change(screen.getByTestId('input'), { target: { value: 'test' } });
expect(handleChange).toHaveBeenCalled();
});
it('输入值正确更新', () => {
render(<Input data-testid="input" />);
const input = screen.getByTestId('input');
fireEvent.change(input, { target: { value: 'hello' } });
expect(input).toHaveValue('hello');
});
});
});
describe('SearchInput', () => {
it('渲染搜索图标', () => {
render(<SearchInput data-testid="search" />);
const wrapper = screen.getByTestId('search').closest('div');
expect(wrapper?.querySelector('svg')).toBeInTheDocument();
});
it('默认 placeholder 为搜索...', () => {
render(<SearchInput />);
expect(screen.getByPlaceholderText('搜索...')).toBeInTheDocument();
});
it('支持自定义 placeholder', () => {
render(<SearchInput placeholder="搜索用户" />);
expect(screen.getByPlaceholderText('搜索用户')).toBeInTheDocument();
});
it('正确转发 ref', () => {
const TestComponent = () => {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<SearchInput ref={inputRef} data-testid="search" />
<button onClick={() => inputRef.current?.focus()}>Focus</button>
</>
);
};
render(<TestComponent />);
fireEvent.click(screen.getByText('Focus'));
expect(screen.getByTestId('search')).toHaveFocus();
});
});
describe('PasswordInput', () => {
it('默认隐藏密码', () => {
render(<PasswordInput data-testid="password" />);
expect(screen.getByTestId('password')).toHaveAttribute('type', 'password');
});
it('点击图标切换密码可见性', () => {
render(<PasswordInput data-testid="password" />);
const input = screen.getByTestId('password');
const toggleButton = screen.getByRole('button');
expect(input).toHaveAttribute('type', 'password');
fireEvent.click(toggleButton);
expect(input).toHaveAttribute('type', 'text');
fireEvent.click(toggleButton);
expect(input).toHaveAttribute('type', 'password');
});
it('正确转发 ref', () => {
const TestComponent = () => {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<PasswordInput ref={inputRef} data-testid="password" />
<button onClick={() => inputRef.current?.focus()}>Focus</button>
</>
);
};
render(<TestComponent />);
fireEvent.click(screen.getByText('Focus'));
expect(screen.getByTestId('password')).toHaveFocus();
});
});
+7 -2
View File
@@ -2,7 +2,8 @@
* Input 输入框组件
* 设计稿参考: UIDesignSpec.md
*/
import React, { forwardRef } from 'react';
'use client';
import React, { forwardRef, useId } from 'react';
import { LucideIcon, Search, Eye, EyeOff } from 'lucide-react';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
@@ -25,12 +26,15 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(({
fullWidth = true,
className = '',
disabled,
id,
...props
}, ref) => {
const inputId = id ?? useId();
return (
<div className={`${fullWidth ? 'w-full' : ''}`}>
{label && (
<label className="block mb-1.5 text-caption text-text-secondary">
<label htmlFor={inputId} className="block mb-1.5 text-caption text-text-secondary">
{label}
</label>
)}
@@ -43,6 +47,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(({
)}
<input
ref={ref}
id={inputId}
className={`
w-full bg-bg-elevated text-text-primary
border border-border-subtle rounded-btn
+416
View File
@@ -0,0 +1,416 @@
/**
* Modal 组件测试
* 测试覆盖: Modal, ConfirmModal, 副作用(ESC、overflow
*/
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Modal, ConfirmModal } from './Modal';
describe('Modal', () => {
const mockOnClose = vi.fn();
beforeEach(() => {
mockOnClose.mockClear();
document.body.style.overflow = '';
});
afterEach(() => {
// 确保副作用被清理
document.body.style.overflow = '';
});
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('isOpen=true 时渲染内容', () => {
render(
<Modal isOpen={true} onClose={mockOnClose}>
<p></p>
</Modal>
);
expect(screen.getByText('模态框内容')).toBeInTheDocument();
});
it('isOpen=false 时不渲染', () => {
render(
<Modal isOpen={false} onClose={mockOnClose}>
<p></p>
</Modal>
);
expect(screen.queryByText('模态框内容')).not.toBeInTheDocument();
});
it('渲染标题', () => {
render(
<Modal isOpen={true} onClose={mockOnClose} title="弹窗标题">
</Modal>
);
expect(screen.getByText('弹窗标题')).toBeInTheDocument();
});
it('渲染页脚', () => {
render(
<Modal isOpen={true} onClose={mockOnClose} footer={<button></button>}>
</Modal>
);
expect(screen.getByText('确定')).toBeInTheDocument();
});
});
// ==================== 关闭按钮测试 ====================
describe('关闭按钮', () => {
it('默认显示关闭按钮', () => {
render(
<Modal isOpen={true} onClose={mockOnClose} title="标题">
</Modal>
);
// 使用 aria-label 精确选择关闭按钮
expect(screen.getByLabelText('关闭')).toBeInTheDocument();
});
it('点击关闭按钮触发 onClose', () => {
render(
<Modal isOpen={true} onClose={mockOnClose} title="标题">
</Modal>
);
// 使用 aria-label 精确选择关闭按钮
const closeButton = screen.getByLabelText('关闭');
fireEvent.click(closeButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('showCloseButton=false 隐藏关闭按钮', () => {
render(
<Modal isOpen={true} onClose={mockOnClose} title="标题" showCloseButton={false}>
</Modal>
);
// 关闭按钮不存在
expect(screen.queryByLabelText('关闭')).not.toBeInTheDocument();
});
});
// ==================== 遮罩点击测试 ====================
describe('遮罩点击', () => {
it('点击遮罩默认关闭', () => {
render(
<Modal isOpen={true} onClose={mockOnClose}>
</Modal>
);
const overlay = document.querySelector('.bg-black\\/60');
fireEvent.click(overlay!);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('closeOnOverlay=false 禁用遮罩点击关闭', () => {
render(
<Modal isOpen={true} onClose={mockOnClose} closeOnOverlay={false}>
</Modal>
);
const overlay = document.querySelector('.bg-black\\/60');
fireEvent.click(overlay!);
expect(mockOnClose).not.toHaveBeenCalled();
});
});
// ==================== ESC 键测试 ====================
describe('ESC 键关闭', () => {
it('按 ESC 键默认关闭', async () => {
render(
<Modal isOpen={true} onClose={mockOnClose}>
</Modal>
);
await act(async () => {
fireEvent.keyDown(document, { key: 'Escape' });
});
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('closeOnEsc=false 禁用 ESC 关闭', async () => {
render(
<Modal isOpen={true} onClose={mockOnClose} closeOnEsc={false}>
</Modal>
);
await act(async () => {
fireEvent.keyDown(document, { key: 'Escape' });
});
expect(mockOnClose).not.toHaveBeenCalled();
});
it('其他按键不触发关闭', async () => {
render(
<Modal isOpen={true} onClose={mockOnClose}>
</Modal>
);
await act(async () => {
fireEvent.keyDown(document, { key: 'Enter' });
});
expect(mockOnClose).not.toHaveBeenCalled();
});
});
// ==================== Body Overflow 副作用测试 ====================
describe('Body overflow 副作用', () => {
it('打开时锁定 body 滚动', () => {
render(
<Modal isOpen={true} onClose={mockOnClose}>
</Modal>
);
expect(document.body.style.overflow).toBe('hidden');
});
it('关闭时解锁 body 滚动', () => {
const { rerender } = render(
<Modal isOpen={true} onClose={mockOnClose}>
</Modal>
);
expect(document.body.style.overflow).toBe('hidden');
rerender(
<Modal isOpen={false} onClose={mockOnClose}>
</Modal>
);
expect(document.body.style.overflow).toBe('');
});
it('卸载时清理 overflow', () => {
const { unmount } = render(
<Modal isOpen={true} onClose={mockOnClose}>
</Modal>
);
expect(document.body.style.overflow).toBe('hidden');
unmount();
expect(document.body.style.overflow).toBe('');
});
});
// ==================== Size 测试 ====================
describe('Size 样式', () => {
it('默认 md size', () => {
const { container } = render(
<Modal isOpen={true} onClose={mockOnClose}>
</Modal>
);
expect(container.querySelector('.max-w-md')).toBeInTheDocument();
});
it('sm size', () => {
const { container } = render(
<Modal isOpen={true} onClose={mockOnClose} size="sm">
</Modal>
);
expect(container.querySelector('.max-w-sm')).toBeInTheDocument();
});
it('lg size', () => {
const { container } = render(
<Modal isOpen={true} onClose={mockOnClose} size="lg">
</Modal>
);
expect(container.querySelector('.max-w-lg')).toBeInTheDocument();
});
it('xl size', () => {
const { container } = render(
<Modal isOpen={true} onClose={mockOnClose} size="xl">
</Modal>
);
expect(container.querySelector('.max-w-xl')).toBeInTheDocument();
});
});
// ==================== ClassName 测试 ====================
describe('ClassName', () => {
it('支持自定义 className', () => {
const { container } = render(
<Modal isOpen={true} onClose={mockOnClose} className="custom-modal">
</Modal>
);
expect(container.querySelector('.custom-modal')).toBeInTheDocument();
});
});
});
describe('ConfirmModal', () => {
const mockOnClose = vi.fn();
const mockOnConfirm = vi.fn();
beforeEach(() => {
mockOnClose.mockClear();
mockOnConfirm.mockClear();
});
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染标题和消息', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="确认删除"
message="确定要删除吗?"
/>
);
expect(screen.getByText('确认删除')).toBeInTheDocument();
expect(screen.getByText('确定要删除吗?')).toBeInTheDocument();
});
it('渲染确认和取消按钮', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="操作确认"
message="消息"
confirmText="确定"
/>
);
// Modal 有关闭按钮(X)ConfirmModal 有确认和取消按钮,共 3 个
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('确定')).toBeInTheDocument();
expect(screen.getByText('取消')).toBeInTheDocument();
});
});
// ==================== 按钮文本自定义测试 ====================
describe('按钮文本自定义', () => {
it('支持自定义确认按钮文本', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="确认"
message="消息"
confirmText="删除"
/>
);
expect(screen.getByText('删除')).toBeInTheDocument();
});
it('支持自定义取消按钮文本', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="确认"
message="消息"
cancelText="返回"
/>
);
expect(screen.getByText('返回')).toBeInTheDocument();
});
});
// ==================== 事件处理测试 ====================
describe('事件处理', () => {
it('点击确认按钮触发 onConfirm', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="操作确认"
message="消息"
confirmText="确定"
/>
);
fireEvent.click(screen.getByText('确定'));
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
});
it('点击取消按钮触发 onClose', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="操作确认"
message="消息"
/>
);
fireEvent.click(screen.getByText('取消'));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
// ==================== Variant 测试 ====================
describe('Variant 样式', () => {
it('danger variant 使用红色确认按钮', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="确认删除"
message="消息"
variant="danger"
/>
);
const confirmButton = screen.getByText('确认').closest('button');
expect(confirmButton).toHaveClass('bg-accent-coral');
});
});
// ==================== Loading 测试 ====================
describe('Loading 状态', () => {
it('loading 时确认按钮显示加载状态', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="操作确认"
message="消息"
confirmText="确定"
loading={true}
/>
);
const confirmButton = screen.getByText('确定').closest('button');
expect(confirmButton).toBeDisabled();
});
it('loading 时取消按钮也被禁用', () => {
render(
<ConfirmModal
isOpen={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="操作确认"
message="消息"
loading={true}
/>
);
const cancelButton = screen.getByText('取消').closest('button');
expect(cancelButton).toBeDisabled();
});
});
});
+13 -6
View File
@@ -2,7 +2,8 @@
* Modal 弹窗组件
* 设计稿参考: UIDesignSpec.md
*/
import React, { useEffect, useCallback } from 'react';
'use client';
import React, { useEffect, useCallback, useRef } from 'react';
import { X } from 'lucide-react';
import { Button } from './Button';
@@ -39,6 +40,8 @@ export const Modal: React.FC<ModalProps> = ({
showCloseButton = true,
className = '',
}) => {
const previousOverflowRef = useRef<string>('');
// Handle ESC key
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (closeOnEsc && e.key === 'Escape') {
@@ -47,13 +50,15 @@ export const Modal: React.FC<ModalProps> = ({
}, [closeOnEsc, onClose]);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
}
if (!isOpen) return;
document.addEventListener('keydown', handleKeyDown);
previousOverflowRef.current = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
document.body.style.overflow = previousOverflowRef.current || '';
};
}, [isOpen, handleKeyDown]);
@@ -89,6 +94,8 @@ export const Modal: React.FC<ModalProps> = ({
{showCloseButton && (
<button
onClick={onClose}
type="button"
aria-label="关闭"
className="p-1 text-text-tertiary hover:text-text-primary transition-colors"
>
<X size={20} />
+250
View File
@@ -0,0 +1,250 @@
/**
* ProgressBar 组件测试
* 测试覆盖: ProgressBar, CircularProgress
*/
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { ProgressBar, CircularProgress } from './ProgressBar';
describe('ProgressBar', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染进度条', () => {
const { container } = render(<ProgressBar value={50} />);
expect(container.querySelector('.bg-bg-elevated')).toBeInTheDocument();
});
it('正确计算进度百分比', () => {
const { container } = render(<ProgressBar value={75} />);
const progressFill = container.querySelector('.bg-accent-indigo');
expect(progressFill).toHaveStyle({ width: '75%' });
});
});
// ==================== Value 边界测试 ====================
describe('Value 边界值', () => {
it('0% 进度', () => {
const { container } = render(<ProgressBar value={0} />);
const progressFill = container.querySelector('.bg-accent-indigo');
expect(progressFill).toHaveStyle({ width: '0%' });
});
it('100% 进度', () => {
const { container } = render(<ProgressBar value={100} />);
const progressFill = container.querySelector('.bg-accent-indigo');
expect(progressFill).toHaveStyle({ width: '100%' });
});
it('超过 100% 限制为 100%', () => {
const { container } = render(<ProgressBar value={150} />);
const progressFill = container.querySelector('.bg-accent-indigo');
expect(progressFill).toHaveStyle({ width: '100%' });
});
it('负值限制为 0%', () => {
const { container } = render(<ProgressBar value={-10} />);
const progressFill = container.querySelector('.bg-accent-indigo');
expect(progressFill).toHaveStyle({ width: '0%' });
});
});
// ==================== Max 测试 ====================
describe('Max 属性', () => {
it('自定义 max 值计算正确', () => {
const { container } = render(<ProgressBar value={25} max={50} />);
const progressFill = container.querySelector('.bg-accent-indigo');
expect(progressFill).toHaveStyle({ width: '50%' }); // 25/50 = 50%
});
it('默认 max 为 100', () => {
const { container } = render(<ProgressBar value={30} />);
const progressFill = container.querySelector('.bg-accent-indigo');
expect(progressFill).toHaveStyle({ width: '30%' });
});
});
// ==================== ShowLabel 测试 ====================
describe('ShowLabel 属性', () => {
it('showLabel=true 显示标签', () => {
render(<ProgressBar value={50} showLabel />);
expect(screen.getByText('进度')).toBeInTheDocument();
expect(screen.getByText('50%')).toBeInTheDocument();
});
it('showLabel=false 不显示标签(默认)', () => {
render(<ProgressBar value={50} />);
expect(screen.queryByText('进度')).not.toBeInTheDocument();
});
it('标签显示四舍五入的百分比', () => {
render(<ProgressBar value={33.7} showLabel />);
expect(screen.getByText('34%')).toBeInTheDocument();
});
});
// ==================== Size 测试 ====================
describe('Size 样式', () => {
it('sm size', () => {
const { container } = render(<ProgressBar value={50} size="sm" />);
expect(container.querySelector('.h-1')).toBeInTheDocument();
});
it('md size(默认)', () => {
const { container } = render(<ProgressBar value={50} size="md" />);
expect(container.querySelector('.h-2')).toBeInTheDocument();
});
it('lg size', () => {
const { container } = render(<ProgressBar value={50} size="lg" />);
expect(container.querySelector('.h-3')).toBeInTheDocument();
});
});
// ==================== Variant 测试 ====================
describe('Variant 样式', () => {
it('default variant(默认)', () => {
const { container } = render(<ProgressBar value={50} />);
expect(container.querySelector('.bg-accent-indigo')).toBeInTheDocument();
});
it('success variant', () => {
const { container } = render(<ProgressBar value={50} variant="success" />);
expect(container.querySelector('.bg-accent-green')).toBeInTheDocument();
});
it('warning variant', () => {
const { container } = render(<ProgressBar value={50} variant="warning" />);
expect(container.querySelector('.bg-accent-amber')).toBeInTheDocument();
});
it('error variant', () => {
const { container } = render(<ProgressBar value={50} variant="error" />);
expect(container.querySelector('.bg-accent-coral')).toBeInTheDocument();
});
});
// ==================== ClassName 测试 ====================
describe('ClassName', () => {
it('支持自定义 className', () => {
const { container } = render(<ProgressBar value={50} className="custom-progress" />);
expect(container.firstChild).toHaveClass('custom-progress');
});
});
});
describe('CircularProgress', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染 SVG 环形进度', () => {
const { container } = render(<CircularProgress value={50} />);
expect(container.querySelector('svg')).toBeInTheDocument();
});
it('渲染两个圆(背景和进度)', () => {
const { container } = render(<CircularProgress value={50} />);
const circles = container.querySelectorAll('circle');
expect(circles).toHaveLength(2);
});
});
// ==================== Value 测试 ====================
describe('Value 边界值', () => {
it('显示正确的百分比', () => {
render(<CircularProgress value={75} />);
expect(screen.getByText('75%')).toBeInTheDocument();
});
it('四舍五入百分比', () => {
render(<CircularProgress value={33.6} />);
expect(screen.getByText('34%')).toBeInTheDocument();
});
it('超过 100% 限制为 100%', () => {
render(<CircularProgress value={150} />);
expect(screen.getByText('100%')).toBeInTheDocument();
});
it('负值限制为 0%', () => {
render(<CircularProgress value={-10} />);
expect(screen.getByText('0%')).toBeInTheDocument();
});
});
// ==================== Size 测试 ====================
describe('Size 属性', () => {
it('默认 size 为 120', () => {
const { container } = render(<CircularProgress value={50} />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('width', '120');
expect(svg).toHaveAttribute('height', '120');
});
it('支持自定义 size', () => {
const { container } = render(<CircularProgress value={50} size={80} />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('width', '80');
expect(svg).toHaveAttribute('height', '80');
});
});
// ==================== ShowLabel 测试 ====================
describe('ShowLabel 属性', () => {
it('showLabel=true 显示百分比(默认)', () => {
render(<CircularProgress value={50} />);
expect(screen.getByText('50%')).toBeInTheDocument();
});
it('showLabel=false 隐藏百分比', () => {
render(<CircularProgress value={50} showLabel={false} />);
expect(screen.queryByText('50%')).not.toBeInTheDocument();
});
});
// ==================== Label 测试 ====================
describe('Label 属性', () => {
it('显示自定义 label', () => {
render(<CircularProgress value={50} label="审核中" />);
expect(screen.getByText('审核中')).toBeInTheDocument();
});
it('showLabel=false 时不显示 label', () => {
render(<CircularProgress value={50} label="审核中" showLabel={false} />);
expect(screen.queryByText('审核中')).not.toBeInTheDocument();
});
});
// ==================== Variant 测试 ====================
describe('Variant 样式', () => {
it('default variant 使用正确颜色', () => {
const { container } = render(<CircularProgress value={50} variant="default" />);
const progressCircle = container.querySelectorAll('circle')[1];
expect(progressCircle).toHaveAttribute('stroke', '#6366F1');
});
it('success variant 使用正确颜色', () => {
const { container } = render(<CircularProgress value={50} variant="success" />);
const progressCircle = container.querySelectorAll('circle')[1];
expect(progressCircle).toHaveAttribute('stroke', '#32D583');
});
it('warning variant 使用正确颜色', () => {
const { container } = render(<CircularProgress value={50} variant="warning" />);
const progressCircle = container.querySelectorAll('circle')[1];
expect(progressCircle).toHaveAttribute('stroke', '#F59E0B');
});
it('error variant 使用正确颜色', () => {
const { container } = render(<CircularProgress value={50} variant="error" />);
const progressCircle = container.querySelectorAll('circle')[1];
expect(progressCircle).toHaveAttribute('stroke', '#E85A4F');
});
});
// ==================== ClassName 测试 ====================
describe('ClassName', () => {
it('支持自定义 className', () => {
const { container } = render(<CircularProgress value={50} className="custom-circular" />);
expect(container.firstChild).toHaveClass('custom-circular');
});
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* ReviewSteps 审核进度条组件
* 设计稿参考: pencil-new.pen 达人端任务列表
* 显示4个步骤:已提交 → AI审核中 → 代理商审核 → 审核通过
*/
import React from 'react'
import { Check, X, Loader2, Users } from 'lucide-react'
export type StepStatus = 'done' | 'current' | 'failed' | 'pending'
export interface ReviewStep {
key: string
label: string
status: StepStatus
}
interface ReviewStepsProps {
steps: ReviewStep[]
className?: string
}
function StepIcon({ status, isLast }: { status: StepStatus; isLast: boolean }) {
const baseClass = 'w-7 h-7 rounded-full flex items-center justify-center'
switch (status) {
case 'done':
return (
<div className={`${baseClass} bg-accent-green`}>
<Check size={14} className="text-white" />
</div>
)
case 'current':
return (
<div className={`${baseClass} bg-accent-indigo`}>
<Loader2 size={14} className="text-white animate-spin" />
</div>
)
case 'failed':
return (
<div className={`${baseClass} bg-accent-coral`}>
<X size={14} className="text-white" />
</div>
)
case 'pending':
default:
return (
<div className={`${baseClass} bg-bg-elevated border-[1.5px] border-border-subtle`}>
{isLast ? (
<Check size={14} className="text-text-tertiary" />
) : (
<Users size={14} className="text-text-tertiary" />
)}
</div>
)
}
}
function StepLine({ active }: { active: boolean }) {
return (
<div
className={`flex-1 h-0.5 mx-1 ${active ? 'bg-accent-green' : 'bg-border-subtle'}`}
/>
)
}
export const ReviewSteps: React.FC<ReviewStepsProps> = ({ steps, className = '' }) => {
return (
<div className={`flex items-center w-full py-2 ${className}`}>
{steps.map((step, index) => {
const isLast = index === steps.length - 1
const nextStepActive = index < steps.length - 1 &&
(steps[index + 1].status === 'done' || steps[index + 1].status === 'current' || steps[index + 1].status === 'failed')
return (
<React.Fragment key={step.key}>
<div className="flex flex-col items-center w-[70px]">
<StepIcon status={step.status} isLast={isLast} />
<span
className={`text-[11px] mt-1 font-medium ${
step.status === 'done' ? 'text-text-secondary' :
step.status === 'current' ? 'text-accent-indigo' :
step.status === 'failed' ? 'text-accent-coral' :
'text-text-tertiary'
}`}
>
{step.label}
</span>
</div>
{!isLast && <StepLine active={step.status === 'done' || nextStepActive} />}
</React.Fragment>
)
})}
</div>
)
}
// 根据任务状态生成步骤数据 (达人端视角)
export function getReviewSteps(taskStatus: string): ReviewStep[] {
switch (taskStatus) {
case 'pending_upload':
return [
{ key: 'submitted', label: '已提交', status: 'pending' },
{ key: 'ai_review', label: 'AI审核', status: 'pending' },
{ key: 'agent_review', label: '代理商审核', status: 'pending' },
{ key: 'passed', label: '审核通过', status: 'pending' },
]
case 'ai_reviewing':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核中', status: 'current' },
{ key: 'agent_review', label: '代理商审核', status: 'pending' },
{ key: 'passed', label: '审核通过', status: 'pending' },
]
case 'agent_reviewing':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'done' },
{ key: 'agent_review', label: '代理商审核', status: 'current' },
{ key: 'passed', label: '审核通过', status: 'pending' },
]
case 'need_revision':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: '需修改', status: 'failed' },
{ key: 'agent_review', label: '代理商审核', status: 'pending' },
{ key: 'passed', label: '审核通过', status: 'pending' },
]
case 'passed':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'done' },
{ key: 'agent_review', label: '代理商审核', status: 'done' },
{ key: 'passed', label: '审核通过', status: 'done' },
]
case 'rejected':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'done' },
{ key: 'agent_review', label: '已驳回', status: 'failed' },
{ key: 'passed', label: '审核通过', status: 'pending' },
]
default:
return [
{ key: 'submitted', label: '已提交', status: 'pending' },
{ key: 'ai_review', label: 'AI审核', status: 'pending' },
{ key: 'agent_review', label: '代理商审核', status: 'pending' },
{ key: 'passed', label: '审核通过', status: 'pending' },
]
}
}
// 代理商/品牌方视角的审核步骤 (包含品牌终审)
export function getAgencyReviewSteps(taskStatus: string): ReviewStep[] {
switch (taskStatus) {
case 'ai_reviewing':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'current' },
{ key: 'agent_review', label: '代理商', status: 'pending' },
{ key: 'brand_review', label: '品牌终审', status: 'pending' },
]
case 'agent_reviewing':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'done' },
{ key: 'agent_review', label: '代理商', status: 'current' },
{ key: 'brand_review', label: '品牌终审', status: 'pending' },
]
case 'brand_reviewing':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'done' },
{ key: 'agent_review', label: '代理商', status: 'done' },
{ key: 'brand_review', label: '品牌终审', status: 'current' },
]
case 'need_revision':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: '需修改', status: 'failed' },
{ key: 'agent_review', label: '代理商', status: 'pending' },
{ key: 'brand_review', label: '品牌终审', status: 'pending' },
]
case 'passed':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'done' },
{ key: 'agent_review', label: '代理商', status: 'done' },
{ key: 'brand_review', label: '品牌终审', status: 'done' },
]
case 'rejected':
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'done' },
{ key: 'agent_review', label: '已驳回', status: 'failed' },
{ key: 'brand_review', label: '品牌终审', status: 'pending' },
]
default:
return [
{ key: 'submitted', label: '已提交', status: 'done' },
{ key: 'ai_review', label: 'AI审核', status: 'done' },
{ key: 'agent_review', label: '代理商', status: 'current' },
{ key: 'brand_review', label: '品牌终审', status: 'pending' },
]
}
}
export default ReviewSteps
+170
View File
@@ -0,0 +1,170 @@
/**
* Select 组件测试
* 测试覆盖: options, label, error, hint, placeholder, disabled, forwardRef
*/
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useRef } from 'react';
import { Select } from './Select';
const mockOptions = [
{ value: 'option1', label: '选项一' },
{ value: 'option2', label: '选项二' },
{ value: 'option3', label: '选项三', disabled: true },
];
describe('Select', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染下拉选择框', () => {
render(<Select options={mockOptions} />);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('渲染所有选项', () => {
render(<Select options={mockOptions} />);
expect(screen.getByText('选项一')).toBeInTheDocument();
expect(screen.getByText('选项二')).toBeInTheDocument();
expect(screen.getByText('选项三')).toBeInTheDocument();
});
it('渲染下拉箭头图标', () => {
render(<Select options={mockOptions} data-testid="select" />);
const wrapper = screen.getByTestId('select').closest('div');
expect(wrapper?.querySelector('svg')).toBeInTheDocument();
});
});
// ==================== Placeholder 测试 ====================
describe('Placeholder', () => {
it('显示 placeholder 选项', () => {
render(<Select options={mockOptions} placeholder="请选择" />);
expect(screen.getByText('请选择')).toBeInTheDocument();
});
it('placeholder 选项禁用', () => {
render(<Select options={mockOptions} placeholder="请选择" />);
const placeholderOption = screen.getByText('请选择');
expect(placeholderOption).toHaveAttribute('disabled');
});
});
// ==================== Label 测试 ====================
describe('Label', () => {
it('渲染 label', () => {
render(<Select options={mockOptions} label="选择类型" />);
expect(screen.getByText('选择类型')).toBeInTheDocument();
});
it('label 使用正确样式', () => {
render(<Select options={mockOptions} label="类型" />);
const label = screen.getByText('类型');
expect(label).toHaveClass('text-caption', 'text-text-secondary');
});
});
// ==================== Error 测试 ====================
describe('Error 状态', () => {
it('显示错误信息', () => {
render(<Select options={mockOptions} error="请选择一个选项" />);
expect(screen.getByText('请选择一个选项')).toBeInTheDocument();
});
it('错误信息使用红色', () => {
render(<Select options={mockOptions} error="错误" />);
expect(screen.getByText('错误')).toHaveClass('text-accent-coral');
});
it('错误状态边框变红', () => {
render(<Select options={mockOptions} error="错误" data-testid="select" />);
expect(screen.getByTestId('select')).toHaveClass('border-accent-coral');
});
});
// ==================== Hint 测试 ====================
describe('Hint 提示', () => {
it('显示提示信息', () => {
render(<Select options={mockOptions} hint="选择您的偏好" />);
expect(screen.getByText('选择您的偏好')).toBeInTheDocument();
});
it('有 error 时不显示 hint', () => {
render(<Select options={mockOptions} hint="提示" error="错误" />);
expect(screen.queryByText('提示')).not.toBeInTheDocument();
expect(screen.getByText('错误')).toBeInTheDocument();
});
});
// ==================== Disabled 测试 ====================
describe('Disabled 状态', () => {
it('disabled 禁用选择框', () => {
render(<Select options={mockOptions} disabled data-testid="select" />);
expect(screen.getByTestId('select')).toBeDisabled();
});
it('选项可以单独禁用', () => {
render(<Select options={mockOptions} />);
const disabledOption = screen.getByText('选项三');
expect(disabledOption).toHaveAttribute('disabled');
});
});
// ==================== FullWidth 测试 ====================
describe('FullWidth 属性', () => {
it('默认全宽', () => {
render(<Select options={mockOptions} data-testid="select" />);
const wrapper = screen.getByTestId('select').closest('div')?.parentElement;
expect(wrapper).toHaveClass('w-full');
});
it('fullWidth=false 不应用全宽', () => {
render(<Select options={mockOptions} fullWidth={false} data-testid="select" />);
const wrapper = screen.getByTestId('select').closest('div')?.parentElement;
expect(wrapper).not.toHaveClass('w-full');
});
});
// ==================== ForwardRef 测试 ====================
describe('ForwardRef', () => {
it('正确转发 ref', () => {
const TestComponent = () => {
const selectRef = useRef<HTMLSelectElement>(null);
return (
<>
<Select ref={selectRef} options={mockOptions} data-testid="select" />
<button onClick={() => selectRef.current?.focus()}>Focus</button>
</>
);
};
render(<TestComponent />);
fireEvent.click(screen.getByText('Focus'));
expect(screen.getByTestId('select')).toHaveFocus();
});
});
// ==================== 事件处理测试 ====================
describe('事件处理', () => {
it('onChange 事件正常触发', () => {
const handleChange = vi.fn();
render(<Select options={mockOptions} onChange={handleChange} data-testid="select" />);
fireEvent.change(screen.getByTestId('select'), { target: { value: 'option2' } });
expect(handleChange).toHaveBeenCalled();
});
it('选择值正确更新', () => {
render(<Select options={mockOptions} data-testid="select" />);
const select = screen.getByTestId('select');
fireEvent.change(select, { target: { value: 'option2' } });
expect(select).toHaveValue('option2');
});
});
// ==================== 自定义属性测试 ====================
describe('自定义属性', () => {
it('支持自定义 className', () => {
render(<Select options={mockOptions} className="custom-select" data-testid="select" />);
expect(screen.getByTestId('select')).toHaveClass('custom-select');
});
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* Tag 组件测试
* 测试覆盖: Tag, SuccessTag, PendingTag, WarningTag, ErrorTag
*/
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Star } from 'lucide-react';
import { Tag, SuccessTag, PendingTag, WarningTag, ErrorTag } from './Tag';
describe('Tag', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染标签文本', () => {
render(<Tag status="success"></Tag>);
expect(screen.getByText('通过')).toBeInTheDocument();
});
it('默认显示图标', () => {
render(<Tag status="success"></Tag>);
const tag = screen.getByText('成功').closest('span');
expect(tag?.querySelector('svg')).toBeInTheDocument();
});
});
// ==================== Status 样式测试 ====================
describe('Status 样式', () => {
it('success 状态应用绿色样式', () => {
render(<Tag status="success"></Tag>);
const tag = screen.getByText('成功').closest('span');
expect(tag).toHaveClass('bg-status-success', 'text-accent-green');
});
it('pending 状态应用蓝色样式', () => {
render(<Tag status="pending"></Tag>);
const tag = screen.getByText('待处理').closest('span');
expect(tag).toHaveClass('bg-status-pending', 'text-accent-indigo');
});
it('warning 状态应用黄色样式', () => {
render(<Tag status="warning"></Tag>);
const tag = screen.getByText('警告').closest('span');
expect(tag).toHaveClass('bg-status-warning', 'text-accent-amber');
});
it('error 状态应用红色样式', () => {
render(<Tag status="error"></Tag>);
const tag = screen.getByText('错误').closest('span');
expect(tag).toHaveClass('bg-status-error', 'text-accent-coral');
});
});
// ==================== Size 测试 ====================
describe('Size 样式', () => {
it('sm size 应用小尺寸样式', () => {
render(<Tag status="success" size="sm"></Tag>);
const tag = screen.getByText('小标签').closest('span');
expect(tag).toHaveClass('px-1.5', 'py-0.5', 'text-[11px]');
});
it('md size 应用中等尺寸样式(默认)', () => {
render(<Tag status="success" size="md"></Tag>);
const tag = screen.getByText('中标签').closest('span');
expect(tag).toHaveClass('px-2', 'py-1', 'text-small');
});
it('默认使用 md size', () => {
render(<Tag status="success"></Tag>);
const tag = screen.getByText('默认').closest('span');
expect(tag).toHaveClass('px-2', 'py-1');
});
});
// ==================== Icon 测试 ====================
describe('Icon 渲染', () => {
it('默认显示状态对应的图标', () => {
render(<Tag status="success"></Tag>);
const tag = screen.getByText('成功').closest('span');
expect(tag?.querySelector('svg')).toBeInTheDocument();
});
it('icon={false} 隐藏图标', () => {
render(<Tag status="success" icon={false}></Tag>);
const tag = screen.getByText('无图标').closest('span');
expect(tag?.querySelector('svg')).not.toBeInTheDocument();
});
it('icon={true} 显示默认图标', () => {
render(<Tag status="success" icon={true}></Tag>);
const tag = screen.getByText('有图标').closest('span');
expect(tag?.querySelector('svg')).toBeInTheDocument();
});
it('支持自定义图标', () => {
render(<Tag status="success" icon={Star}></Tag>);
const tag = screen.getByText('自定义').closest('span');
expect(tag?.querySelector('svg')).toBeInTheDocument();
});
});
// ==================== 自定义 className 测试 ====================
describe('自定义 className', () => {
it('支持自定义 className', () => {
render(<Tag status="success" className="custom-tag"></Tag>);
const tag = screen.getByText('自定义').closest('span');
expect(tag).toHaveClass('custom-tag');
});
});
});
// ==================== 预定义标签组件测试 ====================
describe('SuccessTag', () => {
it('渲染 success 状态', () => {
render(<SuccessTag></SuccessTag>);
const tag = screen.getByText('通过').closest('span');
expect(tag).toHaveClass('bg-status-success', 'text-accent-green');
});
it('支持 size 属性', () => {
render(<SuccessTag size="sm"></SuccessTag>);
const tag = screen.getByText('小').closest('span');
expect(tag).toHaveClass('px-1.5', 'py-0.5');
});
});
describe('PendingTag', () => {
it('渲染 pending 状态', () => {
render(<PendingTag></PendingTag>);
const tag = screen.getByText('处理中').closest('span');
expect(tag).toHaveClass('bg-status-pending', 'text-accent-indigo');
});
it('支持 size 属性', () => {
render(<PendingTag size="sm"></PendingTag>);
const tag = screen.getByText('小').closest('span');
expect(tag).toHaveClass('px-1.5', 'py-0.5');
});
});
describe('WarningTag', () => {
it('渲染 warning 状态', () => {
render(<WarningTag></WarningTag>);
const tag = screen.getByText('注意').closest('span');
expect(tag).toHaveClass('bg-status-warning', 'text-accent-amber');
});
it('支持 size 属性', () => {
render(<WarningTag size="sm"></WarningTag>);
const tag = screen.getByText('小').closest('span');
expect(tag).toHaveClass('px-1.5', 'py-0.5');
});
});
describe('ErrorTag', () => {
it('渲染 error 状态', () => {
render(<ErrorTag></ErrorTag>);
const tag = screen.getByText('失败').closest('span');
expect(tag).toHaveClass('bg-status-error', 'text-accent-coral');
});
it('支持 size 属性', () => {
render(<ErrorTag size="sm"></ErrorTag>);
const tag = screen.getByText('小').closest('span');
expect(tag).toHaveClass('px-1.5', 'py-0.5');
});
});