From 2870e01610df10205e5b98d06afcabe518854936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=A8=E9=9C=96=E9=93=83?= Date: Wed, 20 May 2026 21:59:57 +0800 Subject: [PATCH] =?UTF-8?q?refactor(qrcode):=20=E6=8F=90=E5=8F=96=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E7=BB=84=E4=BB=B6=E5=B9=B6=E6=B7=BB=E5=8A=A0=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建 pages/QrCode/types.ts 定义状态类型接口 - 提取 QrCodePreview 组件用于二维码预览和操作 - 提取 ImageUploader 组件封装图片上传、拖拽、粘贴逻辑 - 重构 UrlToQrCodeSection 状态提升到父组件 - 重构 QrCodeToUrlSection 通过回调传递解析结果 - 更新主页面 index.tsx 集中管理所有状态 - 为 QrCodePreview 和 ImageUploader 添加单元测试 (23 个用例) --- components/ImageUploader.tsx | 188 +++++++++++++++++ components/QrCodePreview.tsx | 56 +++++ components/__tests__/ImageUploader.test.tsx | 208 +++++++++++++++++++ components/__tests__/QrCodePreview.test.tsx | 92 ++++++++ pages/QrCode/QrCodeToUrlSection.tsx | 219 ++++---------------- pages/QrCode/UrlToQrCodeSection.tsx | 95 +++------ pages/QrCode/index.tsx | 124 ++++++++++- pages/QrCode/types.ts | 124 +++++++++++ 8 files changed, 865 insertions(+), 241 deletions(-) create mode 100644 components/ImageUploader.tsx create mode 100644 components/QrCodePreview.tsx create mode 100644 components/__tests__/ImageUploader.test.tsx create mode 100644 components/__tests__/QrCodePreview.test.tsx create mode 100644 pages/QrCode/types.ts diff --git a/components/ImageUploader.tsx b/components/ImageUploader.tsx new file mode 100644 index 0000000..fbecd52 --- /dev/null +++ b/components/ImageUploader.tsx @@ -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(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) => { + if (e.target.files && e.target.files.length > 0) { + handleFileChange(e.target.files[0]); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + onDraggingChange(true); + }; + + const handleDragLeave = () => { + onDraggingChange(false); + }; + + const handleDrop = (e: React.DragEvent) => { + 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 ( + + + + + ); +}; + +export default ImageUploader; diff --git a/components/QrCodePreview.tsx b/components/QrCodePreview.tsx new file mode 100644 index 0000000..109ecd8 --- /dev/null +++ b/components/QrCodePreview.tsx @@ -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 ( + + + {t('qrCode:qrCodeWillShow')} + + + ); + } + + return ( + + + QR Code + + + + + + + ); +}; + +export default QrCodePreview; diff --git a/components/__tests__/ImageUploader.test.tsx b/components/__tests__/ImageUploader.test.tsx new file mode 100644 index 0000000..b618578 --- /dev/null +++ b/components/__tests__/ImageUploader.test.tsx @@ -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(); + expect(screen.getByText('qrCode:clickToUpload')).toBeInTheDocument(); + expect(screen.getByText('qrCode:supportFormats')).toBeInTheDocument(); + }); + + it('当没有选中文件时应显示 ImageIcon', () => { + render(); + expect(screen.getByTestId('ImageIcon')).toBeInTheDocument(); + }); + + it('当选中文件时应显示文件预览', () => { + const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); + render( + , + ); + expect(screen.getByText('test.png')).toBeInTheDocument(); + expect(screen.getByText('qrCode:clickToChange')).toBeInTheDocument(); + }); + + it('当选中文件时应显示预览图片', () => { + const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); + render( + , + ); + 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( + , + ); + expect(screen.getByTestId('ClearIcon')).toBeInTheDocument(); + }); + + it('应包含隐藏的文件输入框', () => { + render(); + 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(); + 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(); + const dropzone = container.firstChild as HTMLElement; + + fireEvent.dragOver(dropzone); + expect(mockOnDraggingChange).toHaveBeenCalledWith(true); + }); + + it('拖拽离开时应调用 onDraggingChange(false)', () => { + const { container } = render(); + const dropzone = container.firstChild as HTMLElement; + + fireEvent.dragLeave(dropzone); + expect(mockOnDraggingChange).toHaveBeenCalledWith(false); + }); + + it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => { + const { container } = render(); + 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( + , + ); + + 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( + , + ); + + 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(); + + 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)); + }); + }); + + describe('样式测试', () => { + it('拖拽状态时应应用拖拽样式', () => { + const { container } = render(); + const dropzone = container.firstChild as HTMLElement; + expect(dropzone).toBeInTheDocument(); + }); + + it('有文件时应应用有文件样式', () => { + const mockFile = new File(['test'], 'test.png', { type: 'image/png' }); + const { container } = render( + , + ); + const dropzone = container.firstChild as HTMLElement; + expect(dropzone).toBeInTheDocument(); + }); + }); +}); diff --git a/components/__tests__/QrCodePreview.test.tsx b/components/__tests__/QrCodePreview.test.tsx new file mode 100644 index 0000000..efe3d20 --- /dev/null +++ b/components/__tests__/QrCodePreview.test.tsx @@ -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(); + expect(screen.getByText('qrCode:qrCodeWillShow')).toBeInTheDocument(); + }); + + it('当 qrCodeDataUrl 有值时应显示二维码图片', () => { + const testDataUrl = 'data:image/png;base64,test123'; + render( + , + ); + const img = screen.getByAltText('QR Code'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', testDataUrl); + }); + + it('当 qrCodeDataUrl 有值时应显示下载按钮', () => { + render( + , + ); + expect(screen.getByText('qrCode:downloadButton')).toBeInTheDocument(); + }); + + it('当 qrCodeDataUrl 有值时应显示复制按钮', () => { + render( + , + ); + expect(screen.getByText('qrCode:copyQrButton')).toBeInTheDocument(); + }); + + it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => { + render(); + expect(screen.queryByText('qrCode:downloadButton')).not.toBeInTheDocument(); + expect(screen.queryByText('qrCode:copyQrButton')).not.toBeInTheDocument(); + }); + }); + + describe('交互测试', () => { + it('点击下载按钮时应调用 onDownload 回调', () => { + render( + , + ); + fireEvent.click(screen.getByText('qrCode:downloadButton')); + expect(mockOnDownload).toHaveBeenCalledTimes(1); + }); + + it('点击复制按钮时应调用 onCopy 回调', () => { + render( + , + ); + fireEvent.click(screen.getByText('qrCode:copyQrButton')); + expect(mockOnCopy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/pages/QrCode/QrCodeToUrlSection.tsx b/pages/QrCode/QrCodeToUrlSection.tsx index 1bc99ed..e17a2fc 100644 --- a/pages/QrCode/QrCodeToUrlSection.tsx +++ b/pages/QrCode/QrCodeToUrlSection.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useCallback } from 'react'; import { Accordion, AccordionDetails, @@ -7,116 +7,66 @@ import { Box, Button, CircularProgress, - IconButton, InputAdornment, Stack, TextField, Typography, } from '@mui/material'; import LinkIcon from '@mui/icons-material/Link'; -import ImageIcon from '@mui/icons-material/Image'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import ClearIcon from '@mui/icons-material/Clear'; import CopyButton from '@/components/CopyButton'; +import ImageUploader from '@/components/ImageUploader'; import { qrCodePageStyles } from '@/config/pageTheme'; import { useSnackbar } from '@/components/GlobalSnackbar'; import { parseQrCodeFromFile } from '@/utils/qrCodeParser'; import { useTranslation } from 'react-i18next'; - -interface QrCodeToUrlSectionProps { - expanded: boolean; - onExpandedChange: (expanded: boolean) => void; - /** 桌面端强制展开(隐藏折叠交互) */ - forceExpanded?: boolean; -} +import type { QrCodeParserProps } from './types'; const QrCodeToUrlSection = ({ + decodedResult, + onDecodedResultChange, + parsing, + onParsingChange, + parseError, + onParseErrorChange, + selectedFile, + onFileChange, + onClearFile, + previewUrl, + onPreviewUrlChange, + dragging, + onDraggingChange, expanded, onExpandedChange, forceExpanded = false, -}: QrCodeToUrlSectionProps) => { +}: QrCodeParserProps) => { const { t } = useTranslation(['qrCode']); const { showMessage } = useSnackbar(); - const [qrCodeFile, setQrCodeFile] = useState(null); - const [previewUrl, setPreviewUrl] = useState(''); - const [parsedUrl, setParsedUrl] = useState(''); - const [parseError, setParseError] = useState(''); - const [parsing, setParsing] = useState(false); - const [dragging, setDragging] = useState(false); - const fileInputRef = useRef(null); - - // 清理预览 URL,防止内存泄漏 - useEffect(() => { - return () => { - if (previewUrl) { - URL.revokeObjectURL(previewUrl); - } - }; - }, [previewUrl]); - - const handleFileChange = useCallback((file: File) => { - setQrCodeFile(file); - setPreviewUrl(URL.createObjectURL(file)); - setParseError(''); - setParsedUrl(''); - }, []); - - const handleClearFile = () => { - setQrCodeFile(null); - if (previewUrl) { - URL.revokeObjectURL(previewUrl); - } - setPreviewUrl(''); - setParsedUrl(''); - setParseError(''); - showMessage(t('qrCode:imageCleared'), { - severity: 'success', - autoHideDuration: 1000, - }); - }; - - const handleInputChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - handleFileChange(e.target.files[0]); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setDragging(true); - }; - - const handleDragLeave = () => { - setDragging(false); - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setDragging(false); - const droppedFile = e.dataTransfer.files?.[0]; - if (droppedFile) { - handleFileChange(droppedFile); - } - }; + const handleClearFile = useCallback(() => { + onClearFile(); + onDecodedResultChange(''); + onParseErrorChange(''); + }, [onClearFile, onDecodedResultChange, onParseErrorChange]); const parseQrCode = async () => { - if (!qrCodeFile) { + if (!selectedFile) { showMessage(t('qrCode:selectImage'), { severity: 'error', autoHideDuration: 300 }); return; } try { - setParsing(true); - setParseError(''); - setParsedUrl(''); + onParsingChange(true); + onParseErrorChange(''); + onDecodedResultChange(''); - const result = await parseQrCodeFromFile(qrCodeFile); + const result = await parseQrCodeFromFile(selectedFile); if (result.success && result.data) { - setParsedUrl(result.data); + onDecodedResultChange(result.data); showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 }); } else { + onParseErrorChange(result.error || t('qrCode:noQrDetected')); showMessage(result.error || t('qrCode:noQrDetected'), { severity: 'error', autoHideDuration: 1000, @@ -124,49 +74,13 @@ const QrCodeToUrlSection = ({ } } catch (error) { console.error('解析二维码失败:', error); + onParseErrorChange(t('qrCode:parseError')); showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 }); } finally { - setParsing(false); + onParsingChange(false); } }; - // 监听粘贴事件 - useEffect(() => { - const handlePaste = async (e: ClipboardEvent) => { - if (!expanded) return; - - 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); - }; - }, [expanded, showMessage, handleFileChange, t]); - return ( - - - - + - - {qrCodeDataUrl ? ( - - QR Code - - - - - - ) : ( - - {t('qrCode:qrCodeWillShow')} - - )} + + diff --git a/pages/QrCode/index.tsx b/pages/QrCode/index.tsx index ecc58c7..a03c75e 100644 --- a/pages/QrCode/index.tsx +++ b/pages/QrCode/index.tsx @@ -1,3 +1,4 @@ +import { useState, useCallback } from 'react'; import { Box, CircularProgress, Container, Stack, useMediaQuery, useTheme } from '@mui/material'; import QrCodeIcon from '@mui/icons-material/QrCode'; import UrlToQrCodeSection from '@/pages/QrCode/UrlToQrCodeSection'; @@ -6,6 +7,7 @@ import { useStorageState } from '@/utils/useStorageState'; import { qrCodePageStyles } from '@/config/pageTheme'; import PageHeader from '@/components/PageHeader'; import { useLazyTranslation } from '@/utils/useLazyTranslation'; +import type { QrCodeGeneratorState, QrCodeParserState } from './types'; export default function Index() { const { t } = useLazyTranslation('qrCode'); @@ -16,6 +18,76 @@ export default function Index() { const [urlExpanded, setUrlExpanded, urlInitialized] = useStorageState('qrCode/urlExpanded', true); const [qrExpanded, setQrExpanded, qrInitialized] = useStorageState('qrCode/qrExpanded', false); + // 二维码生成器状态 + const [generatorState, setGeneratorState] = useState({ + textToEncode: '', + qrCodeDataUrl: '', + generating: false, + inputError: '', + }); + + // 二维码解析器状态 + const [parserState, setParserState] = useState({ + decodedResult: '', + parsing: false, + parseError: '', + selectedFile: null, + previewUrl: '', + dragging: false, + }); + + // 生成器状态更新函数 + const setTextToEncode = useCallback((text: string) => { + setGeneratorState((prev) => ({ ...prev, textToEncode: text })); + }, []); + + const setQrCodeDataUrl = useCallback((dataUrl: string) => { + setGeneratorState((prev) => ({ ...prev, qrCodeDataUrl: dataUrl })); + }, []); + + const setGenerating = useCallback((generating: boolean) => { + setGeneratorState((prev) => ({ ...prev, generating })); + }, []); + + const setInputError = useCallback((error: string) => { + setGeneratorState((prev) => ({ ...prev, inputError: error })); + }, []); + + // 解析器状态更新函数 + const setDecodedResult = useCallback((result: string) => { + setParserState((prev) => ({ ...prev, decodedResult: result })); + }, []); + + const setParsing = useCallback((parsing: boolean) => { + setParserState((prev) => ({ ...prev, parsing })); + }, []); + + const setParseError = useCallback((error: string) => { + setParserState((prev) => ({ ...prev, parseError: error })); + }, []); + + const setSelectedFile = useCallback((file: File | null) => { + setParserState((prev) => ({ ...prev, selectedFile: file })); + }, []); + + const setPreviewUrl = useCallback((url: string) => { + setParserState((prev) => ({ ...prev, previewUrl: url })); + }, []); + + const setDragging = useCallback((dragging: boolean) => { + setParserState((prev) => ({ ...prev, dragging })); + }, []); + + const handleClearFile = useCallback(() => { + setParserState((prev) => ({ + ...prev, + selectedFile: null, + previewUrl: '', + decodedResult: '', + parseError: '', + })); + }, []); + // 初始化未完成时显示加载状态 if (!urlInitialized || !qrInitialized) { return ( @@ -29,6 +101,14 @@ export default function Index() { <> ) : ( <> - - + + ); diff --git a/pages/QrCode/types.ts b/pages/QrCode/types.ts new file mode 100644 index 0000000..fdcf842 --- /dev/null +++ b/pages/QrCode/types.ts @@ -0,0 +1,124 @@ +/** + * 二维码工具页面的状态类型定义 + */ + +/** 二维码生成模式 */ +export type QrCodeMode = 'generate' | 'parse'; + +/** 二维码生成器的状态 */ +export interface QrCodeGeneratorState { + /** 输入文本(URL 或任意文本) */ + textToEncode: string; + /** 生成的二维码 Data URL */ + qrCodeDataUrl: string; + /** 是否正在生成 */ + generating: boolean; + /** 输入错误信息 */ + inputError: string; +} + +/** 二维码解析器的状态 */ +export interface QrCodeParserState { + /** 解析结果文本 */ + decodedResult: string; + /** 是否正在解析 */ + parsing: boolean; + /** 解析错误信息 */ + parseError: string; + /** 当前选中的文件 */ + selectedFile: File | null; + /** 文件预览 URL */ + previewUrl: string; + /** 是否正在拖拽 */ + dragging: boolean; +} + +/** 二维码生成器组件的 Props */ +export interface QrCodeGeneratorProps { + /** 输入文本 */ + textToEncode: string; + /** 输入文本变更回调 */ + onTextChange: (text: string) => void; + /** 生成的二维码 Data URL */ + qrCodeDataUrl: string; + /** 二维码 Data URL 变更回调 */ + onQrCodeDataUrlChange: (dataUrl: string) => void; + /** 是否正在生成 */ + generating: boolean; + /** 生成状态变更回调 */ + onGeneratingChange: (generating: boolean) => void; + /** 输入错误信息 */ + inputError: string; + /** 输入错误变更回调 */ + onInputErrorChange: (error: string) => void; + /** 展开状态 */ + expanded: boolean; + /** 展开状态变更回调 */ + onExpandedChange: (expanded: boolean) => void; + /** 是否强制展开(桌面端) */ + forceExpanded?: boolean; +} + +/** 二维码预览组件的 Props */ +export interface QrCodePreviewProps { + /** 二维码 Data URL */ + qrCodeDataUrl: string; + /** 下载回调 */ + onDownload: () => void; + /** 复制回调 */ + onCopy: () => void; + /** 是否正在生成 */ + generating?: boolean; +} + +/** 图片上传器组件的 Props */ +export interface ImageUploaderProps { + /** 选中的文件 */ + selectedFile: File | null; + /** 文件变更回调 */ + onFileChange: (file: File) => void; + /** 清除文件回调 */ + onClearFile: () => void; + /** 文件预览 URL */ + previewUrl: string; + /** 是否正在拖拽 */ + dragging: boolean; + /** 拖拽状态变更回调 */ + onDraggingChange: (dragging: boolean) => void; +} + +/** 二维码解析器组件的 Props */ +export interface QrCodeParserProps { + /** 解析结果文本 */ + decodedResult: string; + /** 解析结果变更回调 */ + onDecodedResultChange: (result: string) => void; + /** 是否正在解析 */ + parsing: boolean; + /** 解析状态变更回调 */ + onParsingChange: (parsing: boolean) => void; + /** 解析错误信息 */ + parseError: string; + /** 解析错误变更回调 */ + onParseErrorChange: (error: string) => void; + /** 选中的文件 */ + selectedFile: File | null; + /** 文件变更回调 */ + onFileChange: (file: File) => void; + /** 清除文件回调 */ + onClearFile: () => void; + /** 文件预览 URL */ + previewUrl: string; + /** 预览 URL 变更回调 */ + onPreviewUrlChange: (url: string) => void; + /** 是否正在拖拽 */ + dragging: boolean; + /** 拖拽状态变更回调 */ + onDraggingChange: (dragging: boolean) => void; + /** 展开状态 */ + expanded: boolean; + /** 展开状态变更回调 */ + onExpandedChange: (expanded: boolean) => void; + /** 是否强制展开(桌面端) */ + forceExpanded?: boolean; +}