diff --git a/src/pages/QrCode/__tests__/ImageUploader.test.tsx b/src/pages/QrCode/__tests__/ImageUploader.test.tsx
index 96c9b49..5d4807a 100644
--- a/src/pages/QrCode/__tests__/ImageUploader.test.tsx
+++ b/src/pages/QrCode/__tests__/ImageUploader.test.tsx
@@ -23,7 +23,6 @@ vi.mock('sonner', () => ({
describe('ImageUploader 组件', () => {
const mockOnFileChange = vi.fn();
const mockOnClearFile = vi.fn();
- const mockOnPreviewUrlChange = vi.fn();
const mockOnDraggingChange = vi.fn();
const defaultProps = {
@@ -31,7 +30,6 @@ describe('ImageUploader 组件', () => {
onFileChange: mockOnFileChange,
onClearFile: mockOnClearFile,
previewUrl: '',
- onPreviewUrlChange: mockOnPreviewUrlChange,
dragging: false,
onDraggingChange: mockOnDraggingChange,
};
@@ -95,7 +93,7 @@ describe('ImageUploader 组件', () => {
});
describe('文件选择交互', () => {
- it('选择文件时应调用 onFileChange 和 onPreviewUrlChange', async () => {
+ it('选择文件时应调用 onFileChange', async () => {
render();
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
@@ -105,8 +103,7 @@ describe('ImageUploader 组件', () => {
});
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
- expect(mockCreateObjectURL).toHaveBeenCalledWith(mockFile);
- expect(mockOnPreviewUrlChange).toHaveBeenCalledWith('blob:test-url');
+ expect(mockOnFileChange).toHaveBeenCalledTimes(1);
});
});
@@ -127,7 +124,7 @@ describe('ImageUploader 组件', () => {
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
});
- it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => {
+ it('放置文件时应调用 onFileChange', () => {
const { container } = render();
const dropzone = container.firstChild as HTMLElement;
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
@@ -146,6 +143,7 @@ describe('ImageUploader 组件', () => {
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
+ expect(mockOnFileChange).toHaveBeenCalledTimes(1);
});
});
@@ -165,20 +163,12 @@ describe('ImageUploader 组件', () => {
});
describe('粘贴功能', () => {
- it('监听粘贴事件', () => {
+ it('不应注册 document 粘贴事件监听(由 ParsePanel 统一处理)', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
render();
- expect(addEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
- });
-
- it('组件卸载时应移除粘贴事件监听', () => {
- const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
- const { unmount } = render();
-
- unmount();
-
- expect(removeEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
+ const pasteListeners = addEventListenerSpy.mock.calls.filter(([event]) => event === 'paste');
+ expect(pasteListeners).toHaveLength(0);
});
});
diff --git a/src/pages/QrCode/__tests__/ParsePanel.test.tsx b/src/pages/QrCode/__tests__/ParsePanel.test.tsx
new file mode 100644
index 0000000..d724186
--- /dev/null
+++ b/src/pages/QrCode/__tests__/ParsePanel.test.tsx
@@ -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();
+ 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();
+
+ 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();
+
+ 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();
+ 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);
+ });
+ });
+});
diff --git a/src/pages/QrCode/components/ImageUploader.tsx b/src/pages/QrCode/components/ImageUploader.tsx
index 38645d3..01ebd79 100644
--- a/src/pages/QrCode/components/ImageUploader.tsx
+++ b/src/pages/QrCode/components/ImageUploader.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef } from 'react';
+import { useCallback, useRef } from 'react';
import { Image, X } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -13,8 +13,6 @@ interface ImageUploaderProps {
onClearFile: () => void;
/** 文件预览 URL */
previewUrl: string;
- /** 预览 URL 变更回调 */
- onPreviewUrlChange: (url: string) => void;
/** 是否正在拖拽 */
dragging: boolean;
/** 拖拽状态变更回调 */
@@ -26,7 +24,6 @@ const ImageUploader = ({
onFileChange,
onClearFile,
previewUrl,
- onPreviewUrlChange,
dragging,
onDraggingChange,
}: ImageUploaderProps) => {
@@ -35,9 +32,8 @@ const ImageUploader = ({
const handleFileChange = useCallback(
(file: File) => {
onFileChange(file);
- onPreviewUrlChange(URL.createObjectURL(file));
},
- [onFileChange, onPreviewUrlChange],
+ [onFileChange],
);
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 (
setParserState((prev) => ({ ...prev, previewUrl: url }))}
dragging={parserState.dragging}
onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))}
/>