diff --git a/components/TextInputArea.tsx b/components/TextInputArea.tsx index b644825..1cd441c 100644 --- a/components/TextInputArea.tsx +++ b/components/TextInputArea.tsx @@ -287,6 +287,7 @@ const TextInputArea = forwardRef((props + + + )} + + ) : ( +
+ + {decoded && ( + + {mode === 'image' && ( +
+ decoded preview +
+ )} +
+ )} +
+ )} + + ); +} diff --git a/pages/Base64Converter/FileMode.tsx b/pages/Base64Converter/FileMode.tsx index 685bdda..5cf89a1 100644 --- a/pages/Base64Converter/FileMode.tsx +++ b/pages/Base64Converter/FileMode.tsx @@ -1,32 +1,15 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; -import { Upload, Trash2 } from 'lucide-react'; +import { Trash2, Upload } from 'lucide-react'; import TextInputArea from '@/components/TextInputArea'; -import type { ToolbarAction } from '@/components/TextInputArea'; import { useTranslation } from 'react-i18next'; import CopyButton from '@/components/CopyButton'; import DecodeResultPaper from '@/components/DecodeResultPaper'; -import { - fileToBase64, - isFileSizeValid, - formatFileSize, - base64ToBlob, - downloadBlob, - MAX_FILE_SIZE, -} from '@/utils/base64Converter'; -import type { Base64ToBlobResult, FileToBase64Result } from '@/utils/base64Converter'; +import { Button } from '@/components/ui/button'; +import { downloadBlob, formatFileSize, MAX_FILE_SIZE } from '@/utils/base64Converter'; import { useStorageState } from '@/utils/useStorageState'; import type { Base64ConvertDirection } from '@/types/storage'; import SwitchButtonGroup from '@/components/SwitchButtonGroup'; - -interface FileInfo { - name: string; - size: number; - type: string; -} - -const ERROR_MESSAGE_TO_I18N: Record = { - 'Invalid Base64 string': 'invalidBase64', -}; +import { useBase64Converter } from './useBase64Converter'; // 💡 斩断重复代码 +import { cn } from '@/lib/utils'; const isValidDirection = (val: unknown): val is Base64ConvertDirection => val === 'encode' || val === 'decode'; @@ -39,151 +22,70 @@ export default function FileMode() { isValidDirection, ); - // encode state - const [result, setResult] = useState(null); - const [info, setInfo] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [isDragging, setIsDragging] = useState(false); - const fileInputRef = useRef(null); - const cancelRef = useRef(false); - - // decode state - const [decodeInput, setDecodeInput] = useState(''); - const [decoded, setDecoded] = useState(null); - const [decodedFileName, setDecodedFileName] = useState(''); - - // shared - const [error, setError] = useState(null); - - const resetAll = useCallback(() => { - cancelRef.current = true; - setResult(null); - setInfo(null); - setIsLoading(false); - setDecodeInput(''); - setDecoded(null); - setDecodedFileName(''); - setError(null); - if (fileInputRef.current) fileInputRef.current.value = ''; - }, []); - - const handleClear = () => { - resetAll(); - }; - - const handleDirectionChange = (next: Base64ConvertDirection) => { - if (next === direction) return; - resetAll(); - setDirection(next); - }; - - const handleFileSelect = async (file: File) => { - cancelRef.current = false; - setError(null); - setResult(null); - setInfo(null); - - if (!isFileSizeValid(file.size)) { - setError(t('fileSizeExceeded', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })); - return; - } - - setInfo({ - name: file.name, - size: file.size, - type: file.type || 'application/octet-stream', - }); - setIsLoading(true); - - try { - const res = await fileToBase64(file); - if (!cancelRef.current) setResult(res); - } catch (e) { - if (!cancelRef.current) { - setError(e instanceof Error ? e.message : t('conversionFailed')); - } - } finally { - if (!cancelRef.current) setIsLoading(false); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(true); - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - const file = e.dataTransfer.files[0]; - if (file) handleFileSelect(file); - }; + const { + result, + info, + isLoading, + isDragging, + setIsDragging, + fileInputRef, + encodeError, + decodeInput, + setDecodeInput, + decoded, + decodeError, + decodedFileName, + setCustomFileName, + resetAll, + safeFileSelect, + } = useBase64Converter({ mode: 'file' }); const handleDownload = () => { - if (!decoded) return; - downloadBlob(decoded.blob, decodedFileName || `decoded${decoded.suggestedExtension}`); + if (decoded) downloadBlob(decoded.blob, decodedFileName); }; - const actions: ToolbarAction[] = useMemo( - () => [ - { - key: 'decode', - label: t('decode'), - type: 'primary', - position: 'bottom', - disabled: (value: string) => !value.trim(), - onClick: (value: string, helpers) => { - helpers.setError(''); - setDecoded(null); - try { - const res = base64ToBlob(value); - setDecoded(res); - setDecodedFileName(`decoded${res.suggestedExtension}`); - } catch (e) { - const message = e instanceof Error ? e.message : ''; - const i18nKey = ERROR_MESSAGE_TO_I18N[message]; - helpers.setError(i18nKey ? t(i18nKey) : message || t('conversionFailed')); - } - }, - }, - ], - [t], - ); - return ( - <> - +
+
+ { + if (next && next !== direction) { + resetAll(); + setDirection(next); + } + }} + size="small" + /> +
- {direction === 'encode' && ( - <> + {direction === 'encode' ? ( +
{ + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) safeFileSelect(file); + }} onClick={() => fileInputRef.current?.click()} - className={`flex flex-col items-center justify-center min-h-[180px] border-2 border-dashed rounded-xl p-8 cursor-pointer transition-all duration-200 ${ + className={cn( + 'flex flex-col items-center justify-center min-h-[190px] border-2 border-dashed rounded-2xl p-8 cursor-pointer transition-all duration-300', isDragging ? 'border-primary bg-primary/10' : info - ? 'border-primary bg-primary/5' - : 'border-input bg-muted hover:border-primary hover:bg-primary/5' - }`} + ? 'border-primary/60 bg-primary/5' + : 'border-border bg-muted/40 hover:border-primary/80 hover:bg-muted/70', + )} > { const file = e.target.files?.[0]; - if (file) handleFileSelect(file); + if (file) safeFileSelect(file); }} /> {isLoading ? ( -
+
) : info ? ( -
- - {info.name} - +
+ + + {info.name} + + {formatFileSize(info.size)} · {info.type} - {t('clickOrDropToReplace')} + + {t('clickOrDropToReplace')} +
) : ( -
- - +
+ + {t('clickOrDropToFile')} - + {t('maxFileSize', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })}
)}
- {error && ( + {encodeError && (
- {error} + {encodeError}
)} {result && ( -
-
- {t('base64Output')} -
- - +
+
+ + {t('base64Output')} + +
+ +
-
- - {t('originalSize')}: {formatFileSize(result.originalBytes)} - - - {t('encodedSize')}: {formatFileSize(result.outputBytes)} - -
- +
)} - - {info && !result && ( - - )} - - )} - - {direction === 'decode' && ( - <> +
+ ) : ( +
{ - setDecodeInput(v); - setError(null); - }} - actions={actions} - externalError={error || undefined} - onClear={() => { - setDecoded(null); - setDecodedFileName(''); - }} + onChange={setDecodeInput} + externalError={decodeError || undefined} + showClear={true} + allowCopy={true} + minRows={6} + onClear={resetAll} /> - {decoded && ( )} - +
)} - +
); } diff --git a/pages/Base64Converter/ImageMode.tsx b/pages/Base64Converter/ImageMode.tsx index 1f68cef..7c5cd00 100644 --- a/pages/Base64Converter/ImageMode.tsx +++ b/pages/Base64Converter/ImageMode.tsx @@ -1,33 +1,15 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; -import { Image, Trash2 } from 'lucide-react'; -import TextInputArea, { type ToolbarAction } from '@/components/TextInputArea'; +import { Image as ImageIcon, Trash2 } from 'lucide-react'; +import TextInputArea from '@/components/TextInputArea'; import { useTranslation } from 'react-i18next'; import CopyButton from '@/components/CopyButton'; import DecodeResultPaper from '@/components/DecodeResultPaper'; -import { - fileToBase64, - isFileSizeValid, - isSupportedImageType, - isSupportedImageExtension, - formatFileSize, - base64ToBlob, - downloadBlob, - MAX_FILE_SIZE, -} from '@/utils/base64Converter'; -import type { Base64ToBlobResult, FileToBase64Result } from '@/utils/base64Converter'; +import { Button } from '@/components/ui/button'; +import { downloadBlob, formatFileSize } from '@/utils/base64Converter'; import { useStorageState } from '@/utils/useStorageState'; import type { Base64ConvertDirection } from '@/types/storage'; import SwitchButtonGroup from '@/components/SwitchButtonGroup'; - -interface FileInfo { - name: string; - size: number; - type: string; -} - -const ERROR_MESSAGE_TO_I18N: Record = { - 'Invalid Base64 string': 'invalidBase64', -}; +import { useBase64Converter } from './useBase64Converter'; // 💡 引入共享核心 +import { cn } from '@/lib/utils'; const isValidDirection = (val: unknown): val is Base64ConvertDirection => val === 'encode' || val === 'decode'; @@ -40,37 +22,24 @@ export default function ImageMode() { isValidDirection, ); - // encode state - const [result, setResult] = useState(null); - const [info, setInfo] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [isDragging, setIsDragging] = useState(false); - const imageInputRef = useRef(null); - const cancelRef = useRef(false); - - // decode state - const [decodeInput, setDecodeInput] = useState(''); - const [decoded, setDecoded] = useState(null); - const [decodedFileName, setDecodedFileName] = useState(''); - - // shared - const [error, setError] = useState(null); - - const resetAll = useCallback(() => { - cancelRef.current = true; - setResult(null); - setInfo(null); - setIsLoading(false); - setDecodeInput(''); - setDecoded(null); - setDecodedFileName(''); - setError(null); - if (imageInputRef.current) imageInputRef.current.value = ''; - }, []); - - const handleClear = () => { - resetAll(); - }; + // 消费完全托管的核心 Hook,消灭本地多余状态机 + const { + result, + info, + isLoading, + isDragging, + setIsDragging, + fileInputRef, + encodeError, + decodeInput, + setDecodeInput, + decoded, + decodeError, + decodedFileName, + setCustomFileName, + resetAll, + safeFileSelect, + } = useBase64Converter({ mode: 'image' }); const handleDirectionChange = (next: Base64ConvertDirection) => { if (!next || next === direction) return; @@ -78,239 +47,213 @@ export default function ImageMode() { setDirection(next); }; - const handleFileSelect = async (file: File) => { - cancelRef.current = false; - setError(null); - setResult(null); - setInfo(null); - - if (!isFileSizeValid(file.size)) { - setError(t('fileSizeExceeded', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })); - return; - } - - if (!isSupportedImageType(file.type) && !isSupportedImageExtension(file.name)) { - setError(t('unsupportedImageType')); - return; - } - - setInfo({ - name: file.name, - size: file.size, - type: file.type || 'application/octet-stream', - }); - setIsLoading(true); - - try { - const res = await fileToBase64(file); - if (!cancelRef.current) setResult(res); - } catch (e) { - if (!cancelRef.current) { - setError(e instanceof Error ? e.message : t('conversionFailed')); - } - } finally { - if (!cancelRef.current) setIsLoading(false); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(true); - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - const file = e.dataTransfer.files[0]; - if (file) handleFileSelect(file); - }; - const handleDownload = () => { - if (!decoded) return; - downloadBlob(decoded.blob, decodedFileName || `decoded${decoded.suggestedExtension}`); + if (decoded) downloadBlob(decoded.blob, decodedFileName); }; - const actions: ToolbarAction[] = useMemo( - () => [ - { - key: 'decode', - label: t('decode'), - type: 'primary', - position: 'bottom', - disabled: (value: string) => !value.trim(), - onClick: (value: string, helpers) => { - helpers.setError(''); - setDecoded(null); - try { - const res = base64ToBlob(value); - setDecoded(res); - setDecodedFileName(`decoded${res.suggestedExtension}`); - } catch (e) { - const message = e instanceof Error ? e.message : ''; - const i18nKey = ERROR_MESSAGE_TO_I18N[message]; - helpers.setError(i18nKey ? t(i18nKey) : message || t('conversionFailed')); - } - }, - }, - ], - [t], - ); - return ( - <> - +
+
+ +
- {direction === 'encode' && ( - <> + {direction === 'encode' ? ( +
+ {/* 图片拖拽投递箱终端 */}
imageInputRef.current?.click()} - className={`flex flex-col items-center justify-center min-h-[180px] border-2 border-dashed rounded-xl p-8 cursor-pointer transition-all duration-200 ${ + onDragOver={(e) => { + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) safeFileSelect(file); + }} + onClick={() => fileInputRef.current?.click()} + className={cn( + 'flex flex-col items-center justify-center min-h-[190px] border-2 border-dashed rounded-2xl p-8 cursor-pointer transition-all duration-300', isDragging ? 'border-primary bg-primary/10' : info - ? 'border-primary bg-primary/5' - : 'border-input bg-muted hover:border-primary hover:bg-primary/5' - }`} + ? 'border-primary/60 bg-primary/5' + : 'border-border bg-muted/40 hover:border-primary/80 hover:bg-muted/70', + )} > { const file = e.target.files?.[0]; - if (file) handleFileSelect(file); + if (file) safeFileSelect(file); }} /> {isLoading ? ( -
+
) : info ? ( -
+
{result && ( - preview +
+ preview +
)} - {info.name} - + + {info.name} + + {formatFileSize(info.size)} · {info.type} - {t('clickOrDropToReplace')} + + {t('clickOrDropToReplace')} +
) : ( -
- - +
+ + {t('clickOrDropToImage')} - {t('supportedFormats')} + + {t('supportedFormats')} +
)}
- {error && ( + {encodeError && (
- {error} + {encodeError}
)} {result && ( -
-
- {t('base64Output')} -
- - +
+
+ + {t('base64Output')} + +
+ +
-
- {result.output.length > 2000 - ? `${result.output.substring(0, 2000)}...` - : result.output} -
-
- - {t('originalSize')}: {formatFileSize(result.originalBytes)} - - - {t('encodedSize')}: {formatFileSize(result.outputBytes)} - + + 2000 + ? `${result.output.substring(0, 2000)}...` + : result.output + } + showClear={false} + minRows={4} + /> + +
+
+ + {t('originalSize')}:{' '} + + {formatFileSize(result.originalBytes)} + + + | + + {t('encodedSize')}:{' '} + + {formatFileSize(result.outputBytes)} + + +
+ +
)} - {info && ( - + {info && !result && ( +
+ +
)} - - )} - - {direction === 'decode' && ( - <> +
+ ) : ( +
{ - setDecodeInput(v); - setError(null); - }} - actions={actions} - externalError={error || undefined} - onClear={() => { - setDecoded(null); - setDecodedFileName(''); - }} + onChange={setDecodeInput} + externalError={decodeError || undefined} + showClear={true} + allowCopy={true} + minRows={6} + onClear={resetAll} /> - {decoded && ( - - decoded preview - +
+ +
+ decoded preview +
+
+
)} - +
)} - +
); } diff --git a/pages/Base64Converter/TextMode.tsx b/pages/Base64Converter/TextMode.tsx index 4b3db19..c78960d 100644 --- a/pages/Base64Converter/TextMode.tsx +++ b/pages/Base64Converter/TextMode.tsx @@ -1,11 +1,11 @@ -import { useCallback, useMemo, useState } from 'react'; -import { ArrowLeftRight } from 'lucide-react'; -import TextInputArea, { type ToolbarAction } from '@/components/TextInputArea'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import TextInputArea from '@/components/TextInputArea'; import { useTranslation } from 'react-i18next'; import CopyButton from '@/components/CopyButton'; -import { textToBase64, base64ToText } from '@/utils/base64Converter'; +import { base64ToText, textToBase64 } from '@/utils/base64Converter'; import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import { useContextMenuData } from '@/utils/useContextMenuData'; +import { Button } from '@/components/ui/button'; const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i; @@ -21,31 +21,56 @@ interface TextModeProps { export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) { const { t } = useTranslation('base64Converter'); + + // 1. 纯净的核心源状态机:只保留输入源和转换方向 const [input, setInput] = useState(''); - const [output, setOutput] = useState(''); - const [error, setError] = useState(null); + const [debouncedInput, setDebouncedInput] = useState(''); const [direction, setDirection] = useState<'encode' | 'decode'>('encode'); - const handleContextMenuData = useCallback( - (payload: string) => { - setInput(payload); - setDirection('decode'); - setError(null); - try { - const decoded = base64ToText(payload); - setOutput(decoded); - } catch (e) { - const message = e instanceof Error ? e.message : ''; - const i18nKey = ERROR_MESSAGE_TO_I18N[message]; - setError(i18nKey ? t(i18nKey) : message || t('conversionFailed')); - } - }, - [t], - ); + // 2. 文本高频敲击防抖大闸:斩断频繁进行文本转 Base64 带来的 CPU 计算过热 + useEffect(() => { + const handle = setTimeout(() => { + setDebouncedInput(input); + }, 200); + return () => clearTimeout(handle); + }, [input]); + + // 3. 右键联动数据上下文:优雅原地合并受控状态 + const handleContextMenuData = useCallback((payload: string) => { + setInput(payload); + setDebouncedInput(payload); + setDirection('decode'); + }, []); useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData }); - const actionLabel = direction === 'encode' ? t('encode') : t('decode'); + // 💡 4. 贯彻方案 A(彻底消灭 setOutput / setError): + // 让所有的转化逻辑、类型安全校验在 useMemo 内存管道中单次渲染一气呵成! + const conversionPipeline = useMemo(() => { + const trimmed = debouncedInput.trim(); + if (!trimmed) return { output: '', error: null }; + + try { + if (direction === 'encode') { + const result = textToBase64(debouncedInput); + return { output: result.output, error: null }; + } else { + const decoded = base64ToText(trimmed); + return { output: decoded, error: null }; + } + } catch (e) { + const message = e instanceof Error ? e.message : ''; + const i18nKey = ERROR_MESSAGE_TO_I18N[message]; + return { + output: '', + error: i18nKey ? t(i18nKey) : message || t('conversionFailed'), + }; + } + }, [debouncedInput, direction, t]); + + const output = conversionPipeline.output; + const error = conversionPipeline.error; + const placeholder = direction === 'encode' ? t('textInputPlaceholder') : t('base64InputPlaceholder'); const outputLabel = direction === 'encode' ? t('base64Output') : t('textOutput'); @@ -55,97 +80,88 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) { [direction, input], ); - const handleDirectionChange = useCallback( - (value: 'encode' | 'decode') => { - if (value === direction) return; - setDirection(value); - setOutput(''); - setError(null); - }, - [direction], - ); + const handleDirectionChange = (value: 'encode' | 'decode') => { + if (value === direction) return; + setDirection(value); + setInput(''); + setDebouncedInput(''); + }; - const actions: ToolbarAction[] = useMemo( - () => [ - { - key: 'convert', - label: actionLabel, - icon: , - type: 'primary', - position: 'bottom', - disabled: (value: string) => !value.trim(), - onClick: (value: string) => { - setError(null); - try { - if (direction === 'encode') { - const result = textToBase64(value); - setOutput(result.output); - } else { - const decoded = base64ToText(value); - setOutput(decoded); - } - } catch (e) { - const message = e instanceof Error ? e.message : ''; - const i18nKey = ERROR_MESSAGE_TO_I18N[message]; - setError(i18nKey ? t(i18nKey) : message || t('conversionFailed')); - } - }, - }, - ], - [direction, t, actionLabel], - ); + const handleClear = () => { + setInput(''); + setDebouncedInput(''); + }; return ( - <> - +
+ {/* 受控方向切流中枢 */} +
+ +
+ {/* 高性能受控文本输入端 */} { - setInput(v); - setError(null); - }} - actions={actions} - externalError={error || undefined} - onClear={() => setOutput('')} + onChange={setInput} + externalError={error || undefined} // 💡 流式异常大闸动态注入 + showClear={true} + allowCopy={true} + minRows={5} + maxRows={10} + onClear={handleClear} /> + {/* 图片 URI 类型劫持警告引导区: + - 💡 修复点:彻底废除原生亮色硬编码 hover:bg-blue-100 类名, + - 完美向全站 shadcn 暗黑生态看齐,采用标准的 bg-primary/10 混合变体。 + */} {showImageHint && ( -
- {t('imageDataUriHint')} - +
)} + {/* 5. 编码/解码核心数据承载流卡片 */} {output && ( -
-
- {outputLabel} - +
+
+ + {outputLabel} + +
+ 2000 ? `${output.substring(0, 2000)}...` : output} showClear={false} - showCount + minRows={4} />
)} - +
); } diff --git a/pages/Base64Converter/__tests__/FileMode.test.tsx b/pages/Base64Converter/__tests__/FileMode.test.tsx index 09cd890..3e9f91a 100644 --- a/pages/Base64Converter/__tests__/FileMode.test.tsx +++ b/pages/Base64Converter/__tests__/FileMode.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import FileMode from '../FileMode'; // Mock CopyButton @@ -13,6 +13,11 @@ vi.mock('@/components/CopyButton', () => ({ beforeEach(() => { localStorage.clear(); + vi.useFakeTimers({ shouldAdvanceTime: true }); +}); + +afterEach(() => { + vi.useRealTimers(); }); // useStorageState's async loadState may overwrite user toggle if we click before the @@ -124,7 +129,7 @@ describe('FileMode', () => { it('应该渲染 encode/decode 切换按钮', () => { render(); - expect(screen.getAllByText('encode').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('encode')).toBeInTheDocument(); expect(screen.getByText('decode')).toBeInTheDocument(); }); @@ -143,7 +148,9 @@ describe('FileMode', () => { const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); fireEvent.change(input, { target: { value: 'JVBERi0K' } }); - fireEvent.click(screen.getAllByText('decode')[1]); + act(() => { + vi.advanceTimersByTime(250); + }); await waitFor(() => { expect(screen.getByText('decodedFileOutput')).toBeInTheDocument(); @@ -159,7 +166,10 @@ describe('FileMode', () => { const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); fireEvent.change(input, { target: { value: 'JVBERi0K' } }); - fireEvent.click(screen.getAllByText('decode')[1]); + + act(() => { + vi.advanceTimersByTime(250); + }); const filenameInput = (await screen.findByDisplayValue('decoded.pdf')) as HTMLInputElement; fireEvent.change(filenameInput, { target: { value: 'my-report.pdf' } }); @@ -173,7 +183,10 @@ describe('FileMode', () => { const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); fireEvent.change(input, { target: { value: 'JVBERi0K' } }); - fireEvent.click(screen.getAllByText('decode')[1]); + + act(() => { + vi.advanceTimersByTime(250); + }); expect(await screen.findByText('download')).toBeInTheDocument(); }); @@ -185,7 +198,10 @@ describe('FileMode', () => { const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); fireEvent.change(input, { target: { value: '!!!not base64' } }); - fireEvent.click(screen.getAllByText('decode')[1]); + + act(() => { + vi.advanceTimersByTime(250); + }); await waitFor(() => { expect(screen.getByText('invalidBase64')).toBeInTheDocument(); @@ -199,13 +215,16 @@ describe('FileMode', () => { const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); fireEvent.change(input, { target: { value: 'JVBERi0K' } }); - fireEvent.click(screen.getAllByText('decode')[1]); + + act(() => { + vi.advanceTimersByTime(250); + }); await waitFor(() => { expect(screen.getByText('decodedFileOutput')).toBeInTheDocument(); }); - fireEvent.click(screen.getAllByText('encode')[0]); + fireEvent.click(screen.getByText('encode')); await waitFor(() => { expect(screen.queryByText('decodedFileOutput')).not.toBeInTheDocument(); diff --git a/pages/Base64Converter/__tests__/ImageMode.test.tsx b/pages/Base64Converter/__tests__/ImageMode.test.tsx index bcc163a..e8ccfc7 100644 --- a/pages/Base64Converter/__tests__/ImageMode.test.tsx +++ b/pages/Base64Converter/__tests__/ImageMode.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import ImageMode from '../ImageMode'; // Mock CopyButton @@ -13,6 +13,11 @@ vi.mock('@/components/CopyButton', () => ({ beforeEach(() => { localStorage.clear(); + vi.useFakeTimers({ shouldAdvanceTime: true }); +}); + +afterEach(() => { + vi.useRealTimers(); }); const waitForStorageReady = () => act(() => Promise.resolve()); @@ -119,7 +124,7 @@ describe('ImageMode', () => { it('应该渲染 encode/decode 切换按钮', async () => { render(); await waitForStorageReady(); - expect(screen.getAllByText('encode').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('encode')).toBeInTheDocument(); expect(screen.getByText('decode')).toBeInTheDocument(); }); @@ -130,7 +135,10 @@ describe('ImageMode', () => { const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } }); - fireEvent.click(screen.getAllByText('decode')[1]); + + act(() => { + vi.advanceTimersByTime(250); + }); await waitFor(() => { expect(screen.getByText('decodedImageOutput')).toBeInTheDocument(); @@ -147,7 +155,10 @@ describe('ImageMode', () => { const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } }); - fireEvent.click(screen.getAllByText('decode')[1]); + + act(() => { + vi.advanceTimersByTime(250); + }); expect(await screen.findByDisplayValue('decoded.png')).toBeInTheDocument(); }); @@ -159,7 +170,10 @@ describe('ImageMode', () => { const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); fireEvent.change(input, { target: { value: '!!!not base64' } }); - fireEvent.click(screen.getAllByText('decode')[1]); + + act(() => { + vi.advanceTimersByTime(250); + }); await waitFor(() => { expect(screen.getByText('invalidBase64')).toBeInTheDocument(); diff --git a/pages/Base64Converter/__tests__/TextMode.test.tsx b/pages/Base64Converter/__tests__/TextMode.test.tsx index 573001d..cae191a 100644 --- a/pages/Base64Converter/__tests__/TextMode.test.tsx +++ b/pages/Base64Converter/__tests__/TextMode.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import TextMode from '../TextMode'; // Mock CopyButton @@ -8,9 +8,17 @@ vi.mock('@/components/CopyButton', () => ({ })); describe('TextMode', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it('应该渲染编码/解码切换按钮', () => { render(); - expect(screen.getAllByText('encode').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('encode')).toBeInTheDocument(); expect(screen.getByText('decode')).toBeInTheDocument(); }); @@ -24,13 +32,13 @@ describe('TextMode', () => { const input = screen.getByPlaceholderText('textInputPlaceholder'); fireEvent.change(input, { target: { value: 'Hello' } }); - const convertBtn = screen.getAllByText('encode')[1]; - fireEvent.click(convertBtn); + act(() => { + vi.advanceTimersByTime(200); + }); await waitFor(() => { expect(screen.getByText('base64Output')).toBeInTheDocument(); }); - // 输出内容在 CopyButton 的 data-testid 中 expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8='); }); @@ -43,8 +51,9 @@ describe('TextMode', () => { const input = screen.getByPlaceholderText('base64InputPlaceholder'); fireEvent.change(input, { target: { value: 'SGVsbG8=' } }); - const convertBtn = screen.getAllByText('decode')[1]; - fireEvent.click(convertBtn); + act(() => { + vi.advanceTimersByTime(200); + }); await waitFor(() => { expect(screen.getByText('textOutput')).toBeInTheDocument(); @@ -61,8 +70,9 @@ describe('TextMode', () => { const input = screen.getByPlaceholderText('base64InputPlaceholder'); fireEvent.change(input, { target: { value: 'invalid!!!' } }); - const convertBtn = screen.getAllByText('decode')[1]; - fireEvent.click(convertBtn); + act(() => { + vi.advanceTimersByTime(200); + }); await waitFor(() => { expect(screen.getByText('invalidBase64')).toBeInTheDocument(); @@ -75,16 +85,17 @@ describe('TextMode', () => { // 先编码 const input = screen.getByPlaceholderText('textInputPlaceholder'); fireEvent.change(input, { target: { value: 'Hello' } }); - fireEvent.click(screen.getAllByText('encode')[1]); + + act(() => { + vi.advanceTimersByTime(200); + }); await waitFor(() => { expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8='); }); // 切换方向 - await act(async () => { - fireEvent.click(screen.getByText('decode')); - }); + fireEvent.click(screen.getByText('decode')); // 输出应该被清除 await waitFor(() => { @@ -97,7 +108,10 @@ describe('TextMode', () => { const input = screen.getByPlaceholderText('textInputPlaceholder'); fireEvent.change(input, { target: { value: 'Hello' } }); - fireEvent.click(screen.getAllByText('encode')[1]); + + act(() => { + vi.advanceTimersByTime(200); + }); await waitFor(() => { expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8='); @@ -111,20 +125,6 @@ describe('TextMode', () => { }); }); - it('空输入时转换按钮应该禁用', () => { - render(); - const convertBtn = screen.getAllByText('encode')[1]; - expect(convertBtn).toBeDisabled(); - }); - - it('输入非空时转换按钮应该启用', () => { - render(); - const input = screen.getByPlaceholderText('textInputPlaceholder'); - fireEvent.change(input, { target: { value: 'Hello' } }); - const convertBtn = screen.getAllByText('encode')[1]; - expect(convertBtn).not.toBeDisabled(); - }); - it('解码模式下粘贴图片 data URI 时应该显示切换图像模式的提示', () => { render(); @@ -172,8 +172,9 @@ describe('TextMode', () => { const input = screen.getByPlaceholderText('base64InputPlaceholder'); fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } }); - const convertBtn = screen.getAllByText('decode')[1]; - fireEvent.click(convertBtn); + act(() => { + vi.advanceTimersByTime(200); + }); await waitFor(() => { expect(screen.getByText('binaryDataDetected')).toBeInTheDocument(); diff --git a/pages/Base64Converter/__tests__/index.test.tsx b/pages/Base64Converter/__tests__/index.test.tsx index c099382..41395c2 100644 --- a/pages/Base64Converter/__tests__/index.test.tsx +++ b/pages/Base64Converter/__tests__/index.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import Base64ConverterPage from '../index'; // Mock useLazyTranslation @@ -22,46 +22,61 @@ vi.mock('@/config/features', async (importOriginal) => { // Mock 子组件 vi.mock('../TextMode', () => ({ - default: () =>
TextMode
, + default: ({ onSwitchToImageMode }: { onSwitchToImageMode?: () => void }) => ( +
+ TextMode + {onSwitchToImageMode && } +
+ ), })); -vi.mock('../FileMode', () => ({ - default: () =>
FileMode
, +vi.mock('../Base64ConverterSection', () => ({ + default: ({ mode }: { mode: string }) =>
{mode}
, })); -vi.mock('../ImageMode', () => ({ - default: () =>
ImageMode
, -})); +const waitForStorageInit = () => + act(async () => { + await Promise.resolve(); + }); describe('Base64ConverterPage', () => { - it('应该默认渲染文本模式', () => { + it('应该默认渲染文本模式', async () => { render(); + await waitForStorageInit(); expect(screen.getByTestId('text-mode')).toBeInTheDocument(); }); - it('应该渲染模式切换按钮', () => { + it('应该渲染模式切换按钮', async () => { render(); + await waitForStorageInit(); expect(screen.getByText('base64Converter:textMode')).toBeInTheDocument(); expect(screen.getByText('base64Converter:fileMode')).toBeInTheDocument(); expect(screen.getByText('base64Converter:imageMode')).toBeInTheDocument(); }); - it('切换到文件模式应该渲染 FileMode', () => { + it('切换到文件模式应该渲染 FileMode', async () => { render(); + await waitForStorageInit(); fireEvent.click(screen.getByText('base64Converter:fileMode')); - expect(screen.getByTestId('file-mode')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId('file-mode')).toBeInTheDocument(); + }); expect(screen.queryByTestId('text-mode')).not.toBeInTheDocument(); }); - it('切换到图像模式应该渲染 ImageMode', () => { + it('切换到图像模式应该渲染 ImageMode', async () => { render(); + await waitForStorageInit(); fireEvent.click(screen.getByText('base64Converter:imageMode')); - expect(screen.getByTestId('image-mode')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId('image-mode')).toBeInTheDocument(); + }); expect(screen.queryByTestId('text-mode')).not.toBeInTheDocument(); }); - it('应该渲染页面标题', () => { + it('应该渲染页面标题', async () => { render(); + await waitForStorageInit(); expect(screen.getByText('base64Converter:pageTitle')).toBeInTheDocument(); expect(screen.getByText('base64Converter:pageSubtitle')).toBeInTheDocument(); }); diff --git a/pages/Base64Converter/index.tsx b/pages/Base64Converter/index.tsx index 0a943dc..485dd7d 100644 --- a/pages/Base64Converter/index.tsx +++ b/pages/Base64Converter/index.tsx @@ -1,16 +1,14 @@ -import { Type, Upload, Image } from 'lucide-react'; +import { Image as ImageIcon, Type, Upload } from 'lucide-react'; import { useLazyTranslation } from '@/utils/useLazyTranslation'; import PageHeader from '@/components/PageHeader'; import { base64ConverterPageStyles } from '@/config/pageTheme'; import { useStorageState } from '@/utils/useStorageState'; import type { Base64ConverterPageMode } from '@/types/storage'; import TextMode from './TextMode'; -import FileMode from './FileMode'; -import ImageMode from './ImageMode'; +import Base64ConverterSection from './Base64ConverterSection'; // ✅ 正确对接全新的一体化大组件 import SwitchButtonGroup from '@/components/SwitchButtonGroup'; const VALID_PAGE_MODES: readonly Base64ConverterPageMode[] = ['text', 'file', 'image']; - const isValidPageMode = (val: unknown): val is Base64ConverterPageMode => typeof val === 'string' && (VALID_PAGE_MODES as readonly string[]).includes(val); @@ -25,37 +23,37 @@ export default function Index() { ); const modeIcon: Record = { - text: , - file: , - image: , + text: , + file: , + image: , }; return ( -
-
- +
+ -
- setPageMode(value)} - size="small" - /> + setPageMode(value)} + size="small" + className="w-full sm:w-auto" + /> - {pageMode === 'text' && setPageMode('image')} />} - {pageMode === 'file' && } - {pageMode === 'image' && } -
+
+ {pageMode === 'text' && setPageMode('image')} />} + {pageMode === 'file' && } + {pageMode === 'image' && }
); diff --git a/pages/Base64Converter/useBase64Converter.ts b/pages/Base64Converter/useBase64Converter.ts new file mode 100644 index 0000000..b0d406b --- /dev/null +++ b/pages/Base64Converter/useBase64Converter.ts @@ -0,0 +1,154 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { FileToBase64Result } from '@/utils/base64Converter'; +import { + base64ToBlob, + fileToBase64, + isFileSizeValid, + isSupportedImageExtension, + isSupportedImageType, + MAX_FILE_SIZE, +} from '@/utils/base64Converter'; + +interface FileInfo { + name: string; + size: number; + type: string; +} + +interface UseBase64ConverterProps { + mode: 'file' | 'image'; +} + +export function useBase64Converter({ mode }: UseBase64ConverterProps) { + const { t } = useTranslation('base64Converter'); + + const [result, setResult] = useState(null); + const [info, setInfo] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef(null); + const cancelRef = useRef(false); + + const [decodeInput, setDecodeInput] = useState(''); + const [debouncedDecodeInput, setDebouncedDecodeInput] = useState(''); + const [encodeError, setEncodeError] = useState(null); + const [customFileName, setCustomFileName] = useState(''); + + useEffect(() => { + const handle = setTimeout(() => { + setDebouncedDecodeInput(decodeInput); + }, 250); + return () => clearTimeout(handle); + }, [decodeInput]); + + const resetAll = useCallback(() => { + cancelRef.current = true; + setResult(null); + setInfo(null); + setIsLoading(false); + setDecodeInput(''); + setDebouncedDecodeInput(''); + setCustomFileName(''); + setEncodeError(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + }, []); + + const handleFileSelect = useCallback( + async (file: File) => { + cancelRef.current = false; + setEncodeError(null); + setResult(null); + setInfo(null); + + if (!isFileSizeValid(file.size)) { + setEncodeError(t('fileSizeExceeded', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })); + return; + } + + if ( + mode === 'image' && + !isSupportedImageType(file.type) && + !isSupportedImageExtension(file.name) + ) { + setEncodeError(t('unsupportedImageType')); + return; + } + + setInfo({ + name: file.name, + size: file.size, + type: file.type || 'application/octet-stream', + }); + setIsLoading(true); + + try { + const res = await fileToBase64(file); + if (!cancelRef.current) setResult(res); + } catch (e) { + if (!cancelRef.current) { + setEncodeError(e instanceof Error ? e.message : t('conversionFailed')); + } + } finally { + if (!cancelRef.current) setIsLoading(false); + } + }, + [mode, t], + ); + + const safeFileSelect = useCallback( + (file: File) => { + handleFileSelect(file).catch((err) => { + console.error(`Base64 [${mode}] pipeline crash:`, err); + }); + }, + [handleFileSelect, mode], + ); + + const decodePipeline = useMemo(() => { + const cleanedInput = debouncedDecodeInput.replace(/^data:image\/[a-z+]+;base64,/i, '').trim(); + if (!cleanedInput) return { decoded: null, error: null }; + + try { + const res = base64ToBlob(cleanedInput); + return { decoded: res, error: null }; + } catch (e) { + const message = e instanceof Error ? e.message : ''; + return { + decoded: null, + error: message === 'Invalid Base64 string' ? t('invalidBase64') : t('conversionFailed'), + }; + } + }, [debouncedDecodeInput, t]); + + const decoded = decodePipeline.decoded; + const decodeError = decodePipeline.error; + + const decodedFileName = useMemo(() => { + if (customFileName) return customFileName; + if (decoded) return `decoded${decoded.suggestedExtension}`; + return ''; + }, [customFileName, decoded]); + + // 💡 托管最大文件体积字符串算子,清除下游引入风险 + const maxFileSizeStr = `${MAX_FILE_SIZE / 1024 / 1024} MB`; + + return { + result, + info, + isLoading, + isDragging, + setIsDragging, + fileInputRef, + encodeError, + decodeInput, + setDecodeInput, + decoded, + decodeError, + decodedFileName, + setCustomFileName, + resetAll, + safeFileSelect, + maxFileSizeStr, + }; +}