refactor(qrcode): 提取独立组件并添加单元测试
- 创建 pages/QrCode/types.ts 定义状态类型接口 - 提取 QrCodePreview 组件用于二维码预览和操作 - 提取 ImageUploader 组件封装图片上传、拖拽、粘贴逻辑 - 重构 UrlToQrCodeSection 状态提升到父组件 - 重构 QrCodeToUrlSection 通过回调传递解析结果 - 更新主页面 index.tsx 集中管理所有状态 - 为 QrCodePreview 和 ImageUploader 添加单元测试 (23 个用例)
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Box, IconButton, Typography } from '@mui/material';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ImageUploaderProps {
|
||||
/** 选中的文件 */
|
||||
selectedFile: File | null;
|
||||
/** 文件变更回调 */
|
||||
onFileChange: (file: File) => void;
|
||||
/** 清除文件回调 */
|
||||
onClearFile: () => void;
|
||||
/** 文件预览 URL */
|
||||
previewUrl: string;
|
||||
/** 预览 URL 变更回调 */
|
||||
onPreviewUrlChange: (url: string) => void;
|
||||
/** 是否正在拖拽 */
|
||||
dragging: boolean;
|
||||
/** 拖拽状态变更回调 */
|
||||
onDraggingChange: (dragging: boolean) => void;
|
||||
}
|
||||
|
||||
const ImageUploader = ({
|
||||
selectedFile,
|
||||
onFileChange,
|
||||
onClearFile,
|
||||
previewUrl,
|
||||
onPreviewUrlChange,
|
||||
dragging,
|
||||
onDraggingChange,
|
||||
}: ImageUploaderProps) => {
|
||||
const { t } = useTranslation(['qrCode']);
|
||||
const { showMessage } = useSnackbar();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 清理预览 URL,防止内存泄漏
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(file: File) => {
|
||||
onFileChange(file);
|
||||
onPreviewUrlChange(URL.createObjectURL(file));
|
||||
},
|
||||
[onFileChange, onPreviewUrlChange],
|
||||
);
|
||||
|
||||
const handleClearFile = () => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
onClearFile();
|
||||
showMessage(t('qrCode:imageCleared'), {
|
||||
severity: 'success',
|
||||
autoHideDuration: 1000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleFileChange(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
onDraggingChange(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
onDraggingChange(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
onDraggingChange(false);
|
||||
const droppedFile = e.dataTransfer.files?.[0];
|
||||
if (droppedFile) {
|
||||
handleFileChange(droppedFile);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听粘贴事件
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
|
||||
const file = items[i].getAsFile();
|
||||
if (file) {
|
||||
try {
|
||||
handleFileChange(file);
|
||||
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
||||
} catch (error) {
|
||||
console.error('处理粘贴图片失败:', error);
|
||||
showMessage(t('qrCode:imagePasteError'), {
|
||||
severity: 'error',
|
||||
autoHideDuration: 3000,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('paste', handlePaste);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('paste', handlePaste);
|
||||
};
|
||||
}, [showMessage, handleFileChange, t]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="qr-flex-grow"
|
||||
sx={qrCodePageStyles.DROPZONE(dragging, !!selectedFile)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleInputChange}
|
||||
style={{ display: 'none' }}
|
||||
id="qr-code-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="qr-code-upload"
|
||||
style={{ cursor: 'pointer', textAlign: 'center', width: '100%' }}
|
||||
>
|
||||
{selectedFile ? (
|
||||
<Box sx={qrCodePageStyles.IMAGE_PREVIEW_WRAPPER}>
|
||||
<Box sx={qrCodePageStyles.IMAGE_PREVIEW_BOX}>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="QR Code Preview"
|
||||
style={qrCodePageStyles.IMAGE_PREVIEW_IMG}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearFile();
|
||||
}}
|
||||
sx={qrCodePageStyles.CLEAR_BUTTON}
|
||||
>
|
||||
<ClearIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
{selectedFile.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('qrCode:clickToChange')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<ImageIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('qrCode:clickToUpload')}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('qrCode:supportFormats')}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploader;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Box, Button, Typography } from '@mui/material';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface QrCodePreviewProps {
|
||||
/** 二维码 Data URL */
|
||||
qrCodeDataUrl: string;
|
||||
/** 下载回调 */
|
||||
onDownload: () => void;
|
||||
/** 复制回调 */
|
||||
onCopy: () => void;
|
||||
}
|
||||
|
||||
const QrCodePreview = ({ qrCodeDataUrl, onDownload, onCopy }: QrCodePreviewProps) => {
|
||||
const { t } = useTranslation(['qrCode']);
|
||||
|
||||
if (!qrCodeDataUrl) {
|
||||
return (
|
||||
<Box sx={qrCodePageStyles.QR_PREVIEW_CONTAINER}>
|
||||
<Typography variant="body2" color="text.secondary" sx={qrCodePageStyles.PLACEHOLDER_TEXT}>
|
||||
{t('qrCode:qrCodeWillShow')}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={qrCodePageStyles.QR_PREVIEW_CONTAINER}>
|
||||
<Box sx={qrCodePageStyles.QR_PREVIEW_INNER}>
|
||||
<img src={qrCodeDataUrl} alt="QR Code" style={qrCodePageStyles.QR_PREVIEW_IMAGE} />
|
||||
<Box sx={qrCodePageStyles.QR_PREVIEW_ACTIONS}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={onDownload}
|
||||
sx={qrCodePageStyles.DOWNLOAD_BUTTON}
|
||||
>
|
||||
{t('qrCode:downloadButton')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<ContentCopyIcon />}
|
||||
onClick={onCopy}
|
||||
sx={qrCodePageStyles.COPY_BUTTON}
|
||||
>
|
||||
{t('qrCode:copyQrButton')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default QrCodePreview;
|
||||
@@ -0,0 +1,208 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import ImageUploader from '@/components/ImageUploader';
|
||||
|
||||
// 模拟 URL API
|
||||
const mockCreateObjectURL = vi.fn();
|
||||
const mockRevokeObjectURL = vi.fn();
|
||||
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
|
||||
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
|
||||
|
||||
// 模拟 showMessage
|
||||
vi.mock('@/components/GlobalSnackbar', () => ({
|
||||
useSnackbar: () => ({
|
||||
showMessage: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ImageUploader 组件', () => {
|
||||
const mockOnFileChange = vi.fn();
|
||||
const mockOnClearFile = vi.fn();
|
||||
const mockOnPreviewUrlChange = vi.fn();
|
||||
const mockOnDraggingChange = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
selectedFile: null,
|
||||
onFileChange: mockOnFileChange,
|
||||
onClearFile: mockOnClearFile,
|
||||
previewUrl: '',
|
||||
onPreviewUrlChange: mockOnPreviewUrlChange,
|
||||
dragging: false,
|
||||
onDraggingChange: mockOnDraggingChange,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateObjectURL.mockReturnValue('blob:test-url');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('当没有选中文件时应显示上传提示', () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
expect(screen.getByText('qrCode:clickToUpload')).toBeInTheDocument();
|
||||
expect(screen.getByText('qrCode:supportFormats')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当没有选中文件时应显示 ImageIcon', () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
expect(screen.getByTestId('ImageIcon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当选中文件时应显示文件预览', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
expect(screen.getByText('test.png')).toBeInTheDocument();
|
||||
expect(screen.getByText('qrCode:clickToChange')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当选中文件时应显示预览图片', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
const img = screen.getByAltText('QR Code Preview');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', 'blob:test-url');
|
||||
});
|
||||
|
||||
it('当选中文件时应显示清除按钮', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
expect(screen.getByTestId('ClearIcon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应包含隐藏的文件输入框', () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute('type', 'file');
|
||||
expect(input).toHaveAttribute('accept', 'image/*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('文件选择交互', () => {
|
||||
it('选择文件时应调用 onFileChange 和 onPreviewUrlChange', async () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { files: [mockFile] } });
|
||||
});
|
||||
|
||||
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockFile);
|
||||
expect(mockOnPreviewUrlChange).toHaveBeenCalledWith('blob:test-url');
|
||||
});
|
||||
});
|
||||
|
||||
describe('拖拽交互', () => {
|
||||
it('拖拽进入时应调用 onDraggingChange(true)', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.dragOver(dropzone);
|
||||
expect(mockOnDraggingChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('拖拽离开时应调用 onDraggingChange(false)', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.dragLeave(dropzone);
|
||||
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
|
||||
const dropEvent = new Event('drop', { bubbles: true });
|
||||
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||
value: {
|
||||
files: [mockFile],
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
fireEvent(dropzone, dropEvent);
|
||||
|
||||
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||
});
|
||||
});
|
||||
|
||||
describe('清除文件功能', () => {
|
||||
it('点击清除按钮时应调用 onClearFile', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
|
||||
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
||||
expect(mockOnClearFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('清除文件时应撤销预览 URL', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
|
||||
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
||||
});
|
||||
});
|
||||
|
||||
describe('粘贴功能', () => {
|
||||
it('监听粘贴事件', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
||||
});
|
||||
|
||||
it('组件卸载时应移除粘贴事件监听', () => {
|
||||
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
|
||||
const { unmount } = render(<ImageUploader {...defaultProps} />);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('样式测试', () => {
|
||||
it('拖拽状态时应应用拖拽样式', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} dragging={true} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
expect(dropzone).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('有文件时应应用有文件样式', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
const { container } = render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
expect(dropzone).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import QrCodePreview from '@/components/QrCodePreview';
|
||||
|
||||
describe('QrCodePreview 组件', () => {
|
||||
const mockOnDownload = vi.fn();
|
||||
const mockOnCopy = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('当 qrCodeDataUrl 为空时应显示占位文本', () => {
|
||||
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||
expect(screen.getByText('qrCode:qrCodeWillShow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
|
||||
const testDataUrl = 'data:image/png;base64,test123';
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl={testDataUrl}
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
const img = screen.getByAltText('QR Code');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', testDataUrl);
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 有值时应显示下载按钮', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('qrCode:downloadButton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('qrCode:copyQrButton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
|
||||
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||
expect(screen.queryByText('qrCode:downloadButton')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('qrCode:copyQrButton')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('点击下载按钮时应调用 onDownload 回调', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('qrCode:downloadButton'));
|
||||
expect(mockOnDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('点击复制按钮时应调用 onCopy 回调', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('qrCode:copyQrButton'));
|
||||
expect(mockOnCopy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user