refactor: 抽离 useBase64Converter hook 并新增 Base64ConverterSection 组件,统一 Base64 转换页面的 shadcn 样式
This commit is contained in:
@@ -287,6 +287,7 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={t('textInputArea.copyContent')}
|
||||
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-background/80 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
@@ -296,6 +297,7 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
aria-label={t('textInputArea.clear')}
|
||||
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { Image as ImageIcon, Trash2, Upload } from 'lucide-react';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
||||
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';
|
||||
import { useBase64Converter } from './useBase64Converter';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const isValidDirection = (val: unknown): val is Base64ConvertDirection =>
|
||||
val === 'encode' || val === 'decode';
|
||||
|
||||
interface Base64ConverterSectionProps {
|
||||
mode: 'file' | 'image';
|
||||
}
|
||||
|
||||
export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) {
|
||||
const { t } = useTranslation('base64Converter');
|
||||
|
||||
const [direction, setDirection] = useStorageState(
|
||||
`base64Converter/${mode}Mode/direction`,
|
||||
'encode',
|
||||
isValidDirection,
|
||||
);
|
||||
|
||||
const {
|
||||
result,
|
||||
info,
|
||||
isLoading,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
fileInputRef,
|
||||
encodeError,
|
||||
decodeInput,
|
||||
setDecodeInput,
|
||||
decoded,
|
||||
decodeError,
|
||||
decodedFileName,
|
||||
setCustomFileName,
|
||||
resetAll,
|
||||
safeFileSelect,
|
||||
maxFileSizeStr,
|
||||
} = useBase64Converter({ mode });
|
||||
|
||||
const handleDirectionChange = (next: Base64ConvertDirection) => {
|
||||
if (!next || next === direction) return;
|
||||
resetAll();
|
||||
setDirection(next);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (decoded) downloadBlob(decoded.blob, decodedFileName);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col space-y-4 animate-in fade-in duration-300">
|
||||
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
||||
<SwitchButtonGroup
|
||||
value={direction}
|
||||
options={[
|
||||
{ value: 'encode', label: t('encode') },
|
||||
{ value: 'decode', label: t('decode') },
|
||||
]}
|
||||
onChange={handleDirectionChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{direction === 'encode' ? (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div
|
||||
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/60 bg-primary/5'
|
||||
: 'border-border bg-muted/40 hover:border-primary/80 hover:bg-muted/70',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={mode === 'image' ? 'image/*' : undefined}
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) safeFileSelect(file);
|
||||
}}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<div className="w-9 h-9 border-3 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
) : info ? (
|
||||
<div className="flex flex-col items-center gap-1.5 text-center w-full animate-in fade-in duration-200">
|
||||
{mode === 'image' && result && (
|
||||
<div className="relative p-1 border border-border bg-background rounded-lg shadow-sm mb-1 max-w-[180px] overflow-hidden">
|
||||
<img
|
||||
src={result.output}
|
||||
alt="preview"
|
||||
className="max-h-32 w-full object-contain rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Upload
|
||||
className={cn('w-8 h-8 text-primary', mode === 'file' && 'animate-bounce')}
|
||||
/>
|
||||
<span className="text-sm font-bold text-foreground/90 max-w-[280px] truncate">
|
||||
{info.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/80 font-mono tabular-nums">
|
||||
{formatFileSize(info.size)} · {info.type}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-primary/80 mt-1">
|
||||
{t('clickOrDropToReplace')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
{mode === 'image' ? (
|
||||
<ImageIcon className="w-8 h-8 text-muted-foreground/60" />
|
||||
) : (
|
||||
<Upload className="w-8 h-8 text-muted-foreground/60" />
|
||||
)}
|
||||
<span className="text-xs font-bold text-foreground/80">
|
||||
{mode === 'image' ? t('clickOrDropToImage') : t('clickOrDropToFile')}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground/60">
|
||||
{t('maxFileSize', { max: maxFileSizeStr })}
|
||||
</span>
|
||||
{mode === 'image' && (
|
||||
<span className="text-[10px] font-medium text-muted-foreground/50">
|
||||
{t('supportedFormats')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{encodeError && (
|
||||
<div className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-xs font-semibold text-destructive">
|
||||
{encodeError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
|
||||
<div className="flex justify-between items-center select-none">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
|
||||
{t('base64Output')}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<CopyButton
|
||||
text={result.rawBase64}
|
||||
tooltip={t('copyRaw')}
|
||||
className="h-6 px-2 rounded-md border text-[10px] font-bold"
|
||||
/>
|
||||
<CopyButton
|
||||
text={result.output}
|
||||
tooltip={t('copyDataUri')}
|
||||
className="h-6 px-2 rounded-md border text-[10px] font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextInputArea
|
||||
readOnly
|
||||
value={
|
||||
result.output.length > 2000
|
||||
? `${result.output.substring(0, 2000)}...`
|
||||
: result.output
|
||||
}
|
||||
showClear={false}
|
||||
minRows={4}
|
||||
/>
|
||||
<div className="flex items-center justify-between font-mono text-[10px] text-muted-foreground/70 select-none pt-1">
|
||||
<div className="flex gap-4 items-center tabular-nums">
|
||||
<span>
|
||||
{t('originalSize')}:{' '}
|
||||
<span className="font-semibold text-foreground/80">
|
||||
{formatFileSize(result.originalBytes)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-border/60">|</span>
|
||||
<span>
|
||||
{t('encodedSize')}:{' '}
|
||||
<span className="font-semibold text-foreground/80">
|
||||
{formatFileSize(result.outputBytes)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={resetAll}
|
||||
className="h-7 rounded-md text-muted-foreground hover:text-destructive text-[11px] gap-1 px-2"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{t('clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<TextInputArea
|
||||
placeholder={t('decodeBase64Placeholder')}
|
||||
value={decodeInput}
|
||||
onChange={setDecodeInput}
|
||||
externalError={decodeError || undefined}
|
||||
showClear={true}
|
||||
allowCopy={true}
|
||||
minRows={6}
|
||||
onClear={resetAll}
|
||||
/>
|
||||
{decoded && (
|
||||
<DecodeResultPaper
|
||||
title={mode === 'image' ? t('decodedImageOutput') : t('decodedFileOutput')}
|
||||
mimeType={decoded.mimeType}
|
||||
blobSize={decoded.blob.size}
|
||||
fileName={decodedFileName}
|
||||
onFileNameChange={setCustomFileName}
|
||||
onDownload={handleDownload}
|
||||
>
|
||||
{mode === 'image' && (
|
||||
<div className="relative p-1.5 border border-border bg-background dark:bg-muted/10 rounded-xl max-w-[220px] mb-3 overflow-hidden shadow-sm">
|
||||
<img
|
||||
src={`data:${decoded.mimeType};base64,${decoded.rawBase64}`}
|
||||
alt="decoded preview"
|
||||
className="max-h-40 w-full rounded-lg object-contain bg-[linear-gradient(45deg,#ccc_25%,transparent_25%),linear-gradient(-45deg,#ccc_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#ccc_75%),linear-gradient(-45deg,transparent_75%,#ccc_75%)] bg-[size:10px_10px] bg-[position:0_0,0_5px,5px_-5px,-5px_0] dark:bg-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DecodeResultPaper>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+128
-221
@@ -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<string, string> = {
|
||||
'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<FileToBase64Result | null>(null);
|
||||
const [info, setInfo] = useState<FileInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
// decode state
|
||||
const [decodeInput, setDecodeInput] = useState('');
|
||||
const [decoded, setDecoded] = useState<Base64ToBlobResult | null>(null);
|
||||
const [decodedFileName, setDecodedFileName] = useState('');
|
||||
|
||||
// shared
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<>
|
||||
<SwitchButtonGroup
|
||||
value={direction}
|
||||
options={[
|
||||
{ value: 'encode', label: t('encode') },
|
||||
{ value: 'decode', label: t('decode') },
|
||||
]}
|
||||
onChange={handleDirectionChange}
|
||||
size="small"
|
||||
/>
|
||||
<div className="w-full flex flex-col space-y-4 animate-in fade-in duration-300">
|
||||
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
||||
<SwitchButtonGroup
|
||||
value={direction}
|
||||
options={[
|
||||
{ value: 'encode', label: t('encode') },
|
||||
{ value: 'decode', label: t('decode') },
|
||||
]}
|
||||
onChange={(next) => {
|
||||
if (next && next !== direction) {
|
||||
resetAll();
|
||||
setDirection(next);
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{direction === 'encode' && (
|
||||
<>
|
||||
{direction === 'encode' ? (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
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={`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',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -191,49 +93,63 @@ export default function FileMode() {
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileSelect(file);
|
||||
if (file) safeFileSelect(file);
|
||||
}}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<div className="w-10 h-10 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
<div className="w-9 h-9 border-3 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
) : info ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Upload className="w-10 h-10 text-primary" />
|
||||
<span className="text-sm font-bold">{info.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<Upload className="w-8 h-8 text-primary animate-bounce" />
|
||||
<span className="text-sm font-bold text-foreground/90 max-w-[280px] truncate">
|
||||
{info.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/80 font-mono tabular-nums">
|
||||
{formatFileSize(info.size)} · {info.type}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{t('clickOrDropToReplace')}</span>
|
||||
<span className="text-[11px] font-medium text-primary/80 mt-1">
|
||||
{t('clickOrDropToReplace')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Upload className="w-10 h-10 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground font-semibold">
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<Upload className="w-8 h-8 text-muted-foreground/60" />
|
||||
<span className="text-xs font-bold text-foreground/80">
|
||||
{t('clickOrDropToFile')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-[10px] font-medium text-muted-foreground/60">
|
||||
{t('maxFileSize', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{encodeError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700"
|
||||
className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-xs font-semibold text-destructive"
|
||||
>
|
||||
{error}
|
||||
{encodeError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs font-bold text-muted-foreground">{t('base64Output')}</span>
|
||||
<div className="flex gap-1">
|
||||
<CopyButton text={result.rawBase64} tooltip={t('copyRaw')} />
|
||||
<CopyButton text={result.output} tooltip={t('copyDataUri')} color="info" />
|
||||
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
|
||||
<div className="flex justify-between items-center select-none">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
|
||||
{t('base64Output')}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<CopyButton
|
||||
text={result.rawBase64}
|
||||
tooltip={t('copyRaw')}
|
||||
className="h-6 px-2 rounded-md border text-[10px] font-bold"
|
||||
/>
|
||||
<CopyButton
|
||||
text={result.output}
|
||||
tooltip={t('copyDataUri')}
|
||||
className="h-6 px-2 rounded-md border text-[10px] font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextInputArea
|
||||
@@ -244,70 +160,61 @@ export default function FileMode() {
|
||||
: result.output
|
||||
}
|
||||
showClear={false}
|
||||
showCount
|
||||
minRows={4}
|
||||
/>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('originalSize')}: {formatFileSize(result.originalBytes)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('encodedSize')}: {formatFileSize(result.outputBytes)}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent rounded-md transition-colors"
|
||||
<div className="flex items-center justify-between font-mono text-[10px] text-muted-foreground/70 select-none pt-1">
|
||||
<div className="flex gap-4 items-center tabular-nums">
|
||||
<span>
|
||||
{t('originalSize')}:{' '}
|
||||
<span className="font-semibold text-foreground/80">
|
||||
{formatFileSize(result.originalBytes)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-border/60">|</span>
|
||||
<span>
|
||||
{t('encodedSize')}:{' '}
|
||||
<span className="font-semibold text-foreground/80">
|
||||
{formatFileSize(result.outputBytes)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={resetAll}
|
||||
className="h-7 rounded-md text-muted-foreground hover:text-destructive text-[11px] gap-1 px-2"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{t('clear')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info && !result && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="flex items-center gap-1 px-3 py-2 text-sm text-muted-foreground hover:bg-accent rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('clear')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{direction === 'decode' && (
|
||||
<>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<TextInputArea
|
||||
placeholder={t('decodeBase64Placeholder')}
|
||||
value={decodeInput}
|
||||
onChange={(v) => {
|
||||
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 && (
|
||||
<DecodeResultPaper
|
||||
title={t('decodedFileOutput')}
|
||||
mimeType={decoded.mimeType}
|
||||
blobSize={decoded.blob.size}
|
||||
fileName={decodedFileName}
|
||||
onFileNameChange={setDecodedFileName}
|
||||
onFileNameChange={setCustomFileName}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+179
-236
@@ -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<string, string> = {
|
||||
'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<FileToBase64Result | null>(null);
|
||||
const [info, setInfo] = useState<FileInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
// decode state
|
||||
const [decodeInput, setDecodeInput] = useState('');
|
||||
const [decoded, setDecoded] = useState<Base64ToBlobResult | null>(null);
|
||||
const [decodedFileName, setDecodedFileName] = useState('');
|
||||
|
||||
// shared
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<>
|
||||
<SwitchButtonGroup
|
||||
value={direction}
|
||||
options={[
|
||||
{ value: 'encode', label: t('encode') },
|
||||
{ value: 'decode', label: t('decode') },
|
||||
]}
|
||||
onChange={handleDirectionChange}
|
||||
size="small"
|
||||
/>
|
||||
<div className="w-full flex flex-col space-y-4 animate-in fade-in duration-300">
|
||||
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
||||
<SwitchButtonGroup
|
||||
value={direction}
|
||||
options={[
|
||||
{ value: 'encode', label: t('encode') },
|
||||
{ value: 'decode', label: t('decode') },
|
||||
]}
|
||||
onChange={handleDirectionChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{direction === 'encode' && (
|
||||
<>
|
||||
{direction === 'encode' ? (
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* 图片拖拽投递箱终端 */}
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => 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',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileSelect(file);
|
||||
if (file) safeFileSelect(file);
|
||||
}}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<div className="w-10 h-10 border-4 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
<div className="w-9 h-9 border-3 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
) : info ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex flex-col items-center gap-1.5 text-center animate-in fade-in duration-200 w-full">
|
||||
{result && (
|
||||
<img
|
||||
src={result.output}
|
||||
alt="preview"
|
||||
className="max-w-full max-h-40 rounded-lg object-contain mb-2"
|
||||
/>
|
||||
<div className="relative p-1 border border-border bg-background rounded-lg shadow-sm mb-1 max-w-[180px] overflow-hidden">
|
||||
<img
|
||||
src={result.output}
|
||||
alt="preview"
|
||||
className="max-h-32 w-full object-contain rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-bold">{info.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-sm font-bold text-foreground/90 max-w-[280px] truncate">
|
||||
{info.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/80 font-mono tabular-nums">
|
||||
{formatFileSize(info.size)} · {info.type}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{t('clickOrDropToReplace')}</span>
|
||||
<span className="text-[11px] font-medium text-primary/80 mt-1">
|
||||
{t('clickOrDropToReplace')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Image className="w-10 h-10 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground font-semibold">
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<ImageIcon className="w-8 h-8 text-muted-foreground/60" />
|
||||
<span className="text-xs font-bold text-foreground/80">
|
||||
{t('clickOrDropToImage')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{t('supportedFormats')}</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground/60">
|
||||
{t('supportedFormats')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{encodeError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700"
|
||||
className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-xs font-semibold text-destructive tracking-wide"
|
||||
>
|
||||
{error}
|
||||
{encodeError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs font-bold text-muted-foreground">{t('base64Output')}</span>
|
||||
<div className="flex gap-1">
|
||||
<CopyButton text={result.rawBase64} tooltip={t('copyRaw')} />
|
||||
<CopyButton text={result.output} tooltip={t('copyDataUri')} color="info" />
|
||||
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
|
||||
<div className="flex justify-between items-center select-none">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
|
||||
{t('base64Output')}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<CopyButton
|
||||
text={result.rawBase64}
|
||||
tooltip={t('copyRaw')}
|
||||
className="h-6 px-2 rounded-md border text-[10px] font-bold"
|
||||
/>
|
||||
<CopyButton
|
||||
text={result.output}
|
||||
tooltip={t('copyDataUri')}
|
||||
className="h-6 px-2 rounded-md border text-[10px] font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-mono text-xs break-all max-h-[200px] overflow-y-auto leading-relaxed text-primary font-semibold">
|
||||
{result.output.length > 2000
|
||||
? `${result.output.substring(0, 2000)}...`
|
||||
: result.output}
|
||||
</div>
|
||||
<div className="flex gap-4 mt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('originalSize')}: {formatFileSize(result.originalBytes)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('encodedSize')}: {formatFileSize(result.outputBytes)}
|
||||
</span>
|
||||
|
||||
<TextInputArea
|
||||
readOnly
|
||||
value={
|
||||
result.output.length > 2000
|
||||
? `${result.output.substring(0, 2000)}...`
|
||||
: result.output
|
||||
}
|
||||
showClear={false}
|
||||
minRows={4}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between font-mono text-[10px] text-muted-foreground/70 select-none pt-1">
|
||||
<div className="flex gap-4 items-center tabular-nums">
|
||||
<span>
|
||||
{t('originalSize')}:{' '}
|
||||
<span className="font-semibold text-foreground/80">
|
||||
{formatFileSize(result.originalBytes)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-border/60">|</span>
|
||||
<span>
|
||||
{t('encodedSize')}:{' '}
|
||||
<span className="font-semibold text-foreground/80">
|
||||
{formatFileSize(result.outputBytes)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={resetAll}
|
||||
className="h-7 rounded-md text-muted-foreground hover:text-destructive text-[11px] gap-1 px-2"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{t('clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="flex items-center gap-1 px-3 py-2 text-sm text-muted-foreground hover:bg-accent rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('clear')}
|
||||
</button>
|
||||
{info && !result && (
|
||||
<div className="flex justify-end select-none">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetAll}
|
||||
className="h-8 rounded-md text-xs gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/5 dark:hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{t('clear')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{direction === 'decode' && (
|
||||
<>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<TextInputArea
|
||||
placeholder={t('decodeBase64Placeholder')}
|
||||
value={decodeInput}
|
||||
onChange={(v) => {
|
||||
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 && (
|
||||
<DecodeResultPaper
|
||||
title={t('decodedImageOutput')}
|
||||
mimeType={decoded.mimeType}
|
||||
blobSize={decoded.blob.size}
|
||||
fileName={decodedFileName}
|
||||
onFileNameChange={setDecodedFileName}
|
||||
onDownload={handleDownload}
|
||||
>
|
||||
<img
|
||||
src={`data:${decoded.mimeType};base64,${decoded.rawBase64}`}
|
||||
alt="decoded preview"
|
||||
className="max-w-full max-h-40 rounded-lg object-contain mb-3"
|
||||
/>
|
||||
</DecodeResultPaper>
|
||||
<div className="animate-in slide-in-from-bottom-2 duration-300">
|
||||
<DecodeResultPaper
|
||||
title={t('decodedImageOutput')}
|
||||
mimeType={decoded.mimeType}
|
||||
blobSize={decoded.blob.size}
|
||||
fileName={decodedFileName}
|
||||
onFileNameChange={setCustomFileName}
|
||||
onDownload={handleDownload}
|
||||
>
|
||||
<div className="relative p-1.5 border border-border bg-background dark:bg-muted/10 rounded-xl max-w-[220px] mb-3 overflow-hidden shadow-sm">
|
||||
<img
|
||||
src={`data:${decoded.mimeType};base64,${decoded.rawBase64}`}
|
||||
alt="decoded preview"
|
||||
className="max-h-40 w-full rounded-lg object-contain bg-[linear-gradient(45deg,#ccc_25%,transparent_25%),linear-gradient(-45deg,#ccc_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#ccc_75%),linear-gradient(-45deg,transparent_75%,#ccc_75%)] bg-[size:10px_10px] bg-[position:0_0,0_5px,5px_-5px,-5px_0] dark:bg-none"
|
||||
/>
|
||||
</div>
|
||||
</DecodeResultPaper>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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: <ArrowLeftRight />,
|
||||
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 (
|
||||
<>
|
||||
<SwitchButtonGroup
|
||||
value={direction}
|
||||
options={[
|
||||
{ value: 'encode', label: t('encode') },
|
||||
{ value: 'decode', label: t('decode') },
|
||||
]}
|
||||
onChange={handleDirectionChange}
|
||||
size="small"
|
||||
/>
|
||||
<div className="w-full flex flex-col space-y-4 animate-in fade-in duration-300">
|
||||
{/* 受控方向切流中枢 */}
|
||||
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
||||
<SwitchButtonGroup
|
||||
value={direction}
|
||||
options={[
|
||||
{ value: 'encode', label: t('encode') },
|
||||
{ value: 'decode', label: t('decode') },
|
||||
]}
|
||||
onChange={handleDirectionChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 高性能受控文本输入端 */}
|
||||
<TextInputArea
|
||||
placeholder={placeholder}
|
||||
value={input}
|
||||
onChange={(v) => {
|
||||
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 && (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-primary/10 border border-primary/30">
|
||||
<span className="text-sm text-primary">{t('imageDataUriHint')}</span>
|
||||
<button
|
||||
<div className="flex items-center justify-between p-3.5 rounded-xl bg-primary/10 border border-primary/20 animate-in slide-in-from-top-1 duration-200">
|
||||
<span className="text-xs font-semibold text-primary tracking-tight">
|
||||
{t('imageDataUriHint')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSwitchToImageMode}
|
||||
className="px-3 py-1 text-sm font-medium text-primary hover:bg-blue-100 rounded-md transition-colors"
|
||||
className="h-7 rounded-md text-xs font-bold text-primary hover:text-primary hover:bg-primary/20 dark:hover:bg-primary/10 transition-colors px-2.5"
|
||||
>
|
||||
{t('switchToImageMode')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5. 编码/解码核心数据承载流卡片 */}
|
||||
{output && (
|
||||
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-xs font-bold text-muted-foreground">{outputLabel}</span>
|
||||
<CopyButton text={output} />
|
||||
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3 animate-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="flex justify-between items-center select-none">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
|
||||
{outputLabel}
|
||||
</span>
|
||||
<CopyButton
|
||||
text={output}
|
||||
className="h-6 px-2 rounded-md border text-[10px] font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextInputArea
|
||||
readOnly
|
||||
value={output.length > 2000 ? `${output.substring(0, 2000)}...` : output}
|
||||
showClear={false}
|
||||
showCount
|
||||
minRows={4}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(<FileMode />);
|
||||
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();
|
||||
|
||||
@@ -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(<ImageMode />);
|
||||
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();
|
||||
|
||||
@@ -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(<TextMode />);
|
||||
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(<TextMode />);
|
||||
const convertBtn = screen.getAllByText('encode')[1];
|
||||
expect(convertBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('输入非空时转换按钮应该启用', () => {
|
||||
render(<TextMode />);
|
||||
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(<TextMode />);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: () => <div data-testid="text-mode">TextMode</div>,
|
||||
default: ({ onSwitchToImageMode }: { onSwitchToImageMode?: () => void }) => (
|
||||
<div data-testid="text-mode">
|
||||
TextMode
|
||||
{onSwitchToImageMode && <button onClick={onSwitchToImageMode}>switchToImage</button>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../FileMode', () => ({
|
||||
default: () => <div data-testid="file-mode">FileMode</div>,
|
||||
vi.mock('../Base64ConverterSection', () => ({
|
||||
default: ({ mode }: { mode: string }) => <div data-testid={`${mode}-mode`}>{mode}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../ImageMode', () => ({
|
||||
default: () => <div data-testid="image-mode">ImageMode</div>,
|
||||
}));
|
||||
const waitForStorageInit = () =>
|
||||
act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
describe('Base64ConverterPage', () => {
|
||||
it('应该默认渲染文本模式', () => {
|
||||
it('应该默认渲染文本模式', async () => {
|
||||
render(<Base64ConverterPage />);
|
||||
await waitForStorageInit();
|
||||
expect(screen.getByTestId('text-mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该渲染模式切换按钮', () => {
|
||||
it('应该渲染模式切换按钮', async () => {
|
||||
render(<Base64ConverterPage />);
|
||||
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(<Base64ConverterPage />);
|
||||
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(<Base64ConverterPage />);
|
||||
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(<Base64ConverterPage />);
|
||||
await waitForStorageInit();
|
||||
expect(screen.getByText('base64Converter:pageTitle')).toBeInTheDocument();
|
||||
expect(screen.getByText('base64Converter:pageSubtitle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -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<PageMode, React.ReactNode> = {
|
||||
text: <Type />,
|
||||
file: <Upload />,
|
||||
image: <Image />,
|
||||
text: <Type className="h-4 w-4" />,
|
||||
file: <Upload className="h-4 w-4" />,
|
||||
image: <ImageIcon className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="p-2">
|
||||
<PageHeader
|
||||
title={t('base64Converter:pageTitle')}
|
||||
subtitle={t('base64Converter:pageSubtitle')}
|
||||
icon={modeIcon[pageMode]}
|
||||
iconColor={base64ConverterPageStyles.primaryColor}
|
||||
/>
|
||||
<div className="p-4 w-full flex flex-col space-y-4 min-h-[520px] select-none animate-in fade-in duration-300">
|
||||
<PageHeader
|
||||
title={t('base64Converter:pageTitle')}
|
||||
subtitle={t('base64Converter:pageSubtitle')}
|
||||
icon={modeIcon[pageMode]}
|
||||
iconColor={base64ConverterPageStyles.primaryColor}
|
||||
className="pb-1"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<SwitchButtonGroup
|
||||
value={pageMode}
|
||||
options={[
|
||||
{ value: 'text', label: t('base64Converter:textMode') },
|
||||
{ value: 'file', label: t('base64Converter:fileMode') },
|
||||
{ value: 'image', label: t('base64Converter:imageMode') },
|
||||
]}
|
||||
onChange={(value: PageMode) => setPageMode(value)}
|
||||
size="small"
|
||||
/>
|
||||
<SwitchButtonGroup
|
||||
value={pageMode}
|
||||
options={[
|
||||
{ value: 'text', label: t('base64Converter:textMode') },
|
||||
{ value: 'file', label: t('base64Converter:fileMode') },
|
||||
{ value: 'image', label: t('base64Converter:imageMode') },
|
||||
]}
|
||||
onChange={(value: PageMode) => setPageMode(value)}
|
||||
size="small"
|
||||
className="w-full sm:w-auto"
|
||||
/>
|
||||
|
||||
{pageMode === 'text' && <TextMode onSwitchToImageMode={() => setPageMode('image')} />}
|
||||
{pageMode === 'file' && <FileMode />}
|
||||
{pageMode === 'image' && <ImageMode />}
|
||||
</div>
|
||||
<div className="w-full pt-1">
|
||||
{pageMode === 'text' && <TextMode onSwitchToImageMode={() => setPageMode('image')} />}
|
||||
{pageMode === 'file' && <Base64ConverterSection mode="file" />}
|
||||
{pageMode === 'image' && <Base64ConverterSection mode="image" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<FileToBase64Result | null>(null);
|
||||
const [info, setInfo] = useState<FileInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
const [decodeInput, setDecodeInput] = useState('');
|
||||
const [debouncedDecodeInput, setDebouncedDecodeInput] = useState('');
|
||||
const [encodeError, setEncodeError] = useState<string | null>(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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user