refactor(ImageUploader): 移除预览 URL 相关逻辑并简化粘贴事件处理

- 删除 ImageUploader 组件中的 onPreviewUrlChange 属性及相关逻辑,简化文件处理流程。
- 更新测试用例,确保只调用 onFileChange 方法,移除对预览 URL 的依赖。
- 新增 ParsePanel 组件的测试,确保粘贴功能的正确性和唯一性。
This commit is contained in:
雨霖铃
2026-06-19 20:29:19 +08:00
parent 9d6213e75e
commit e372fc6855
4 changed files with 160 additions and 56 deletions
@@ -23,7 +23,6 @@ vi.mock('sonner', () => ({
describe('ImageUploader 组件', () => { describe('ImageUploader 组件', () => {
const mockOnFileChange = vi.fn(); const mockOnFileChange = vi.fn();
const mockOnClearFile = vi.fn(); const mockOnClearFile = vi.fn();
const mockOnPreviewUrlChange = vi.fn();
const mockOnDraggingChange = vi.fn(); const mockOnDraggingChange = vi.fn();
const defaultProps = { const defaultProps = {
@@ -31,7 +30,6 @@ describe('ImageUploader 组件', () => {
onFileChange: mockOnFileChange, onFileChange: mockOnFileChange,
onClearFile: mockOnClearFile, onClearFile: mockOnClearFile,
previewUrl: '', previewUrl: '',
onPreviewUrlChange: mockOnPreviewUrlChange,
dragging: false, dragging: false,
onDraggingChange: mockOnDraggingChange, onDraggingChange: mockOnDraggingChange,
}; };
@@ -95,7 +93,7 @@ describe('ImageUploader 组件', () => {
}); });
describe('文件选择交互', () => { describe('文件选择交互', () => {
it('选择文件时应调用 onFileChange 和 onPreviewUrlChange', async () => { it('选择文件时应调用 onFileChange', async () => {
render(<ImageUploader {...defaultProps} />); render(<ImageUploader {...defaultProps} />);
const input = document.getElementById('qr-code-upload') as HTMLInputElement; const input = document.getElementById('qr-code-upload') as HTMLInputElement;
const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
@@ -105,8 +103,7 @@ describe('ImageUploader 组件', () => {
}); });
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile); expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockFile); expect(mockOnFileChange).toHaveBeenCalledTimes(1);
expect(mockOnPreviewUrlChange).toHaveBeenCalledWith('blob:test-url');
}); });
}); });
@@ -127,7 +124,7 @@ describe('ImageUploader 组件', () => {
expect(mockOnDraggingChange).toHaveBeenCalledWith(false); expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
}); });
it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => { it('放置文件时应调用 onFileChange', () => {
const { container } = render(<ImageUploader {...defaultProps} />); const { container } = render(<ImageUploader {...defaultProps} />);
const dropzone = container.firstChild as HTMLElement; const dropzone = container.firstChild as HTMLElement;
const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
@@ -146,6 +143,7 @@ describe('ImageUploader 组件', () => {
expect(mockOnDraggingChange).toHaveBeenCalledWith(false); expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile); expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
expect(mockOnFileChange).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -165,20 +163,12 @@ describe('ImageUploader 组件', () => {
}); });
describe('粘贴功能', () => { describe('粘贴功能', () => {
it('监听粘贴事件', () => { it('不应注册 document 粘贴事件监听(由 ParsePanel 统一处理)', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener'); const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
render(<ImageUploader {...defaultProps} />); render(<ImageUploader {...defaultProps} />);
expect(addEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function)); const pasteListeners = addEventListenerSpy.mock.calls.filter(([event]) => event === 'paste');
}); expect(pasteListeners).toHaveLength(0);
it('组件卸载时应移除粘贴事件监听', () => {
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
const { unmount } = render(<ImageUploader {...defaultProps} />);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
}); });
}); });
@@ -0,0 +1,151 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, screen } from '@testing-library/react';
import { toast } from 'sonner';
import ParsePanel from '../components/ParsePanel';
import type { QrCodeParserState } from '../types';
const mockHandleFileChange = vi.fn();
const mockHandleClearFile = vi.fn();
const mockSetParserState = vi.fn();
let mockParserState: QrCodeParserState = {
selectedFile: null,
previewUrl: '',
dragging: false,
decodedResult: '',
parsing: false,
parseError: '',
};
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../contexts/QrCodeContext', () => ({
useQrCodeContext: () => ({
parserState: mockParserState,
setParserState: mockSetParserState,
handleFileChange: mockHandleFileChange,
handleClearFile: mockHandleClearFile,
}),
}));
function createImagePasteEvent(file: File) {
const pasteEvent = new Event('paste', { bubbles: true }) as ClipboardEvent;
Object.defineProperty(pasteEvent, 'clipboardData', {
value: {
items: [
{
type: file.type,
getAsFile: () => file,
},
],
getData: () => '',
},
});
Object.defineProperty(pasteEvent, 'preventDefault', { value: vi.fn() });
return pasteEvent;
}
function createBase64PasteEvent(dataUrl: string) {
const pasteEvent = new Event('paste', { bubbles: true }) as ClipboardEvent;
Object.defineProperty(pasteEvent, 'clipboardData', {
value: {
items: [],
getData: (type: string) => (type === 'text/plain' ? dataUrl : ''),
},
});
Object.defineProperty(pasteEvent, 'preventDefault', { value: vi.fn() });
return pasteEvent;
}
describe('ParsePanel 组件', () => {
beforeEach(() => {
vi.clearAllMocks();
mockParserState = {
selectedFile: null,
previewUrl: '',
dragging: false,
decodedResult: '',
parsing: false,
parseError: '',
};
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('粘贴功能', () => {
it('粘贴图片时应只处理一次', async () => {
render(<ParsePanel />);
const mockFile = new File(['img'], 'paste.png', { type: 'image/png' });
await act(async () => {
document.dispatchEvent(createImagePasteEvent(mockFile));
});
expect(mockHandleFileChange).toHaveBeenCalledTimes(1);
expect(mockHandleFileChange).toHaveBeenCalledWith(mockFile);
expect(toast.success).toHaveBeenCalledTimes(1);
expect(toast.success).toHaveBeenCalledWith('图片粘贴成功,正在解析...');
});
it('与 ImageUploader 同时挂载时 document 上只应有一个 paste 监听', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
render(<ParsePanel />);
const pasteListeners = addEventListenerSpy.mock.calls.filter(([event]) => event === 'paste');
expect(pasteListeners).toHaveLength(1);
});
it('粘贴 Base64 data URI 时应转换为文件并只处理一次', async () => {
const dataUrl = 'data:image/png;base64,abcd';
const mockBlob = new Blob(['img'], { type: 'image/png' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
blob: () => Promise.resolve(mockBlob),
}),
);
render(<ParsePanel />);
await act(async () => {
document.dispatchEvent(createBase64PasteEvent(dataUrl));
});
expect(fetch).toHaveBeenCalledWith(dataUrl);
expect(mockHandleFileChange).toHaveBeenCalledTimes(1);
expect(mockHandleFileChange).toHaveBeenCalledWith(
expect.objectContaining({ name: 'pasted-image.png', type: 'image/png' }),
);
expect(toast.success).toHaveBeenCalledTimes(1);
});
it('已上传图片时粘贴仍应只处理一次', async () => {
mockParserState = {
selectedFile: new File(['existing'], 'existing.png', { type: 'image/png' }),
previewUrl: 'blob:existing',
dragging: false,
decodedResult: 'decoded-text',
parsing: false,
parseError: '',
};
render(<ParsePanel />);
expect(screen.getByText('已上传图片')).toBeInTheDocument();
const mockFile = new File(['new'], 'new.png', { type: 'image/png' });
await act(async () => {
document.dispatchEvent(createImagePasteEvent(mockFile));
});
expect(mockHandleFileChange).toHaveBeenCalledTimes(1);
expect(mockHandleFileChange).toHaveBeenCalledWith(mockFile);
});
});
});
+2 -38
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { Image, X } from 'lucide-react'; import { Image, X } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -13,8 +13,6 @@ interface ImageUploaderProps {
onClearFile: () => void; onClearFile: () => void;
/** 文件预览 URL */ /** 文件预览 URL */
previewUrl: string; previewUrl: string;
/** 预览 URL 变更回调 */
onPreviewUrlChange: (url: string) => void;
/** 是否正在拖拽 */ /** 是否正在拖拽 */
dragging: boolean; dragging: boolean;
/** 拖拽状态变更回调 */ /** 拖拽状态变更回调 */
@@ -26,7 +24,6 @@ const ImageUploader = ({
onFileChange, onFileChange,
onClearFile, onClearFile,
previewUrl, previewUrl,
onPreviewUrlChange,
dragging, dragging,
onDraggingChange, onDraggingChange,
}: ImageUploaderProps) => { }: ImageUploaderProps) => {
@@ -35,9 +32,8 @@ const ImageUploader = ({
const handleFileChange = useCallback( const handleFileChange = useCallback(
(file: File) => { (file: File) => {
onFileChange(file); onFileChange(file);
onPreviewUrlChange(URL.createObjectURL(file));
}, },
[onFileChange, onPreviewUrlChange], [onFileChange],
); );
const handleClearFile = useCallback(() => { const handleClearFile = useCallback(() => {
@@ -72,38 +68,6 @@ const ImageUploader = ({
} }
}; };
// 监听粘贴事件
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);
toast.success('图片粘贴成功,正在解析...');
} catch (error) {
console.error('处理粘贴图片失败:', error);
toast.error('粘贴图片失败,请重试');
}
}
break;
}
}
};
document.addEventListener('paste', handlePaste);
return () => {
document.removeEventListener('paste', handlePaste);
};
}, [handleFileChange]);
return ( return (
<div <div
className={`flex flex-col items-center justify-center h-[250px] border-2 border-dashed rounded-xl p-4 cursor-pointer transition-all duration-200 ${ className={`flex flex-col items-center justify-center h-[250px] border-2 border-dashed rounded-xl p-4 cursor-pointer transition-all duration-200 ${
@@ -65,7 +65,6 @@ export default function ParsePanel() {
onFileChange={handleFileChange} onFileChange={handleFileChange}
onClearFile={handleClearFile} onClearFile={handleClearFile}
previewUrl={parserState.previewUrl} previewUrl={parserState.previewUrl}
onPreviewUrlChange={(url) => setParserState((prev) => ({ ...prev, previewUrl: url }))}
dragging={parserState.dragging} dragging={parserState.dragging}
onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))} onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))}
/> />