fix(pages): 修复 TypeScript 错误、硬编码字符串,移除死代码
- 修复 LiveClock.tsx CopyButton size 属性类型错误 (small → sm) - 修复 ToolCard.tsx 紫色 RGB 值拼写错误 (147,51,2 purple → 147,51,232) - 替换 4 处硬编码中文为 i18n 调用 (StorageCleaner, JsonTools) - MarkdownToHtml 预览链接色改为 CSS 变量以支持暗黑模式 - 移除 Base64Converter 中已被 Base64ConverterSection 替代的死代码 (FileMode.tsx, ImageMode.tsx 及其测试文件) - 更新 README.md 移除对已删除文件的引用 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
"diffCount": "{{count}} differences",
|
||||
"invalidJson": "Invalid JSON format",
|
||||
"emptyHint": "Enter JSON on both sides and click Compare",
|
||||
"fixErrorHint": "Fix the JSON syntax errors above to enable live comparison",
|
||||
"added": "Added",
|
||||
"removed": "Removed",
|
||||
"modified": "Modified"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"noContent": "Nothing to copy",
|
||||
"invalidJson": "Invalid JSON format",
|
||||
"emptyHint": "Enter JSON and click Format",
|
||||
"fixErrorHint": "Fix the JSON syntax errors above to enable live formatting",
|
||||
"originalSize": "Original size",
|
||||
"formattedSize": "Formatted size",
|
||||
"diffMode": "Diff",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"pageTitle": "Storage Cleaner",
|
||||
"pageSubtitle": "Clear cache, cookies, and local storage",
|
||||
"loading": "Loading...",
|
||||
"initializing": "Reading site data...",
|
||||
"occupied": "Occupied {{size}}",
|
||||
"cleaning": "Cleaning...",
|
||||
"cleanNow": "Clean Now",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"diffCount": "{{count}} 处差异",
|
||||
"invalidJson": "无效的 JSON 格式",
|
||||
"emptyHint": "输入两侧 JSON 后点击比较",
|
||||
"fixErrorHint": "请修正上方 JSON 的语法错误以开启实时流式比对",
|
||||
"added": "新增",
|
||||
"removed": "删除",
|
||||
"modified": "修改"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"noContent": "无内容可复制",
|
||||
"invalidJson": "无效的 JSON 格式",
|
||||
"emptyHint": "输入 JSON 后点击格式化",
|
||||
"fixErrorHint": "请修正上方 JSON 的语法错误以开启实时流式格式化",
|
||||
"originalSize": "原始大小",
|
||||
"formattedSize": "格式化后大小",
|
||||
"diffMode": "差异比较",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"pageTitle": "存储清理",
|
||||
"pageSubtitle": "清理缓存、Cookies 及本地存储",
|
||||
"loading": "加载中...",
|
||||
"initializing": "正在读取站点数据...",
|
||||
"occupied": "已占用 {{size}}",
|
||||
"cleaning": "正在清理...",
|
||||
"cleanNow": "立即清理",
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import { Trash2, Upload } from 'lucide-react';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
||||
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';
|
||||
import { useBase64Converter } from './useBase64Converter'; // 💡 斩断重复代码
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const isValidDirection = (val: unknown): val is Base64ConvertDirection =>
|
||||
val === 'encode' || val === 'decode';
|
||||
|
||||
export default function FileMode() {
|
||||
const { t } = useLazyTranslation('base64Converter');
|
||||
const [direction, setDirection] = useStorageState(
|
||||
'base64Converter/fileMode/direction',
|
||||
'encode',
|
||||
isValidDirection,
|
||||
);
|
||||
|
||||
const {
|
||||
result,
|
||||
info,
|
||||
isLoading,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
fileInputRef,
|
||||
encodeError,
|
||||
decodeInput,
|
||||
setDecodeInput,
|
||||
decoded,
|
||||
decodeError,
|
||||
decodedFileName,
|
||||
setCustomFileName,
|
||||
resetAll,
|
||||
safeFileSelect,
|
||||
} = useBase64Converter({ mode: 'file' });
|
||||
|
||||
const handleDownload = () => {
|
||||
if (decoded) downloadBlob(decoded.blob, decodedFileName);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col space-y-4">
|
||||
<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' ? (
|
||||
<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',
|
||||
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"
|
||||
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">
|
||||
<Upload className="w-8 h-8 text-primary" />
|
||||
<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">
|
||||
<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-[10px] font-medium text-muted-foreground/60">
|
||||
{t('maxFileSize', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{encodeError && (
|
||||
<div
|
||||
role="alert"
|
||||
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={t('decodedFileOutput')}
|
||||
mimeType={decoded.mimeType}
|
||||
blobSize={decoded.blob.size}
|
||||
fileName={decodedFileName}
|
||||
onFileNameChange={setCustomFileName}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import { Image as ImageIcon, Trash2 } from 'lucide-react';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||
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';
|
||||
|
||||
export default function ImageMode() {
|
||||
const { t } = useLazyTranslation('base64Converter');
|
||||
const [direction, setDirection] = useStorageState(
|
||||
'base64Converter/imageMode/direction',
|
||||
'encode',
|
||||
isValidDirection,
|
||||
);
|
||||
|
||||
// 消费完全托管的核心 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;
|
||||
resetAll();
|
||||
setDirection(next);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (decoded) downloadBlob(decoded.blob, decodedFileName);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col space-y-4">
|
||||
<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',
|
||||
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="image/*"
|
||||
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">
|
||||
{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>
|
||||
)}
|
||||
<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">
|
||||
<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-[10px] font-medium text-muted-foreground/60">
|
||||
{t('supportedFormats')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{encodeError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-xs font-semibold text-destructive tracking-wide"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</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 && (
|
||||
<div>
|
||||
<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,235 +0,0 @@
|
||||
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
|
||||
vi.mock('@/components/CopyButton', () => ({
|
||||
default: ({ text, tooltip }: { text: string; tooltip?: string }) => (
|
||||
<button data-testid="copy-button" data-tooltip={tooltip}>
|
||||
{text.slice(0, 20)}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// useStorageState's async loadState may overwrite user toggle if we click before the
|
||||
// initial chrome.storage read settles. Flush pending microtasks first.
|
||||
const waitForStorageReady = () => act(() => Promise.resolve());
|
||||
|
||||
describe('FileMode', () => {
|
||||
it('应该渲染文件上传区域', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
expect(screen.getByText('base64Converter:clickOrDropToFile')).toBeInTheDocument();
|
||||
expect(screen.getByText('base64Converter:maxFileSize')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该处理有效的文件选择', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
|
||||
const file = new File(['test content'], 'test.txt', { type: 'text/plain' });
|
||||
|
||||
// 文件输入是隐藏的,直接触发 change 事件
|
||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test.txt')).toBeInTheDocument();
|
||||
expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('应该拒绝超出大小限制的文件', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
|
||||
// 创建一个超过 10MB 的文件
|
||||
const largeContent = new Uint8Array(11 * 1024 * 1024);
|
||||
const file = new File([largeContent], 'large.bin', { type: 'application/octet-stream' });
|
||||
|
||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:fileSizeExceeded');
|
||||
});
|
||||
});
|
||||
|
||||
it('点击清除按钮应该清空文件状态', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
|
||||
const file = new File(['test'], 'test.txt', { type: 'text/plain' });
|
||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test.txt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('base64Converter:clear'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('test.txt')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('base64Converter:clickOrDropToFile')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('应该显示文件大小和类型信息', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
|
||||
const file = new File(['test content'], 'test.txt', { type: 'text/plain' });
|
||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test.txt')).toBeInTheDocument();
|
||||
});
|
||||
// 文件类型显示在 caption 中,格式为 "size · type"
|
||||
expect(screen.getByText(/test\.txt/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该显示原始大小和编码大小', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
|
||||
const file = new File(['test content'], 'test.txt', { type: 'text/plain' });
|
||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/base64Converter:originalSize/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/base64Converter:encodedSize/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('应该提供复制按钮', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
|
||||
const file = new File(['test'], 'test.txt', { type: 'text/plain' });
|
||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
const copyButtons = screen.getAllByTestId('copy-button');
|
||||
expect(copyButtons.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('应该渲染 encode/decode 切换按钮', () => {
|
||||
render(<FileMode />);
|
||||
expect(screen.getByText('base64Converter:encode')).toBeInTheDocument();
|
||||
expect(screen.getByText('base64Converter:decode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('切到 decode 应该显示 Base64 输入框', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
expect(
|
||||
await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('解码 PDF Base64 后应该显示 application/pdf 与默认文件名 decoded.pdf', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
|
||||
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('base64Converter:decodedFileOutput')).toBeInTheDocument();
|
||||
expect(screen.getByText(/application\/pdf/)).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('decoded.pdf')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('解码后的文件名应该可编辑', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
|
||||
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
const filenameInput = (await screen.findByDisplayValue('decoded.pdf')) as HTMLInputElement;
|
||||
fireEvent.change(filenameInput, { target: { value: 'my-report.pdf' } });
|
||||
expect(filenameInput.value).toBe('my-report.pdf');
|
||||
});
|
||||
|
||||
it('解码后应该显示下载按钮', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
|
||||
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
expect(await screen.findByText('base64Converter:download')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('解码非法 Base64 应该显示 invalidBase64 错误', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
|
||||
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||
fireEvent.change(input, { target: { value: '!!!not base64' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('切换方向时应该清空解码状态', async () => {
|
||||
render(<FileMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
|
||||
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('base64Converter:decodedFileOutput')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('base64Converter:encode'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('decodedFileOutput')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
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
|
||||
vi.mock('@/components/CopyButton', () => ({
|
||||
default: ({ text, tooltip }: { text: string; tooltip?: string }) => (
|
||||
<button data-testid="copy-button" data-tooltip={tooltip}>
|
||||
{text.slice(0, 20)}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const waitForStorageReady = () => act(() => Promise.resolve());
|
||||
|
||||
describe('ImageMode', () => {
|
||||
it('应该渲染图像上传区域', async () => {
|
||||
render(<ImageMode />);
|
||||
await waitForStorageReady();
|
||||
expect(screen.getByText('base64Converter:clickOrDropToImage')).toBeInTheDocument();
|
||||
expect(screen.getByText('base64Converter:supportedFormats')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该接受有效的图像文件', async () => {
|
||||
render(<ImageMode />);
|
||||
await waitForStorageReady();
|
||||
|
||||
const file = new File(['fake-image-data'], 'test.png', { type: 'image/png' });
|
||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test.png')).toBeInTheDocument();
|
||||
expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('应该拒绝非图像文件', async () => {
|
||||
render(<ImageMode />);
|
||||
await waitForStorageReady();
|
||||
|
||||
const file = new File(['not an image'], 'test.txt', { type: 'text/plain' });
|
||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:unsupportedImageType');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该拒绝超出大小限制的图像', async () => {
|
||||
render(<ImageMode />);
|
||||
await waitForStorageReady();
|
||||
|
||||
const largeContent = new Uint8Array(11 * 1024 * 1024);
|
||||
const file = new File([largeContent], 'large.png', { type: 'image/png' });
|
||||
|
||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:fileSizeExceeded');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该通过扩展名识别图像', async () => {
|
||||
render(<ImageMode />);
|
||||
await waitForStorageReady();
|
||||
|
||||
// 没有 MIME 类型但有正确扩展名
|
||||
const file = new File(['fake'], 'test.jpg');
|
||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test.jpg')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('点击清除按钮应该清空图像状态', async () => {
|
||||
render(<ImageMode />);
|
||||
await waitForStorageReady();
|
||||
|
||||
const file = new File(['fake'], 'test.png', { type: 'image/png' });
|
||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test.png')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('base64Converter:clear'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('test.png')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('base64Converter:clickOrDropToImage')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('应该显示图像预览', async () => {
|
||||
render(<ImageMode />);
|
||||
await waitForStorageReady();
|
||||
|
||||
const file = new File(['fake-image'], 'test.png', { type: 'image/png' });
|
||||
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
const img = screen.getByAltText('preview');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img.tagName.toLowerCase()).toBe('img');
|
||||
});
|
||||
});
|
||||
|
||||
it('应该渲染 encode/decode 切换按钮', async () => {
|
||||
render(<ImageMode />);
|
||||
await waitForStorageReady();
|
||||
expect(screen.getByText('base64Converter:encode')).toBeInTheDocument();
|
||||
expect(screen.getByText('base64Converter:decode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('解码 PNG Base64 后应该显示图像预览', async () => {
|
||||
render(<ImageMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
|
||||
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('base64Converter:decodedImageOutput')).toBeInTheDocument();
|
||||
const img = screen.getByAltText('decoded preview');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img.tagName.toLowerCase()).toBe('img');
|
||||
});
|
||||
});
|
||||
|
||||
it('解码后默认文件名应该为 decoded.png', async () => {
|
||||
render(<ImageMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
|
||||
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
expect(await screen.findByDisplayValue('decoded.png')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('解码非法 Base64 应该显示 invalidBase64 错误', async () => {
|
||||
render(<ImageMode />);
|
||||
await waitForStorageReady();
|
||||
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||
|
||||
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||
fireEvent.change(input, { target: { value: '!!!not base64' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ const PALETTE_COLORS: Record<PaletteColorKey, string> = {
|
||||
success: '22, 163, 74', // green
|
||||
warning: '217, 119, 6', // amber (存储清理的橙色轴)
|
||||
error: '220, 38, 38', // red
|
||||
secondary: '147, 51, 2 purple',
|
||||
secondary: '147, 51, 232',
|
||||
info: '37, 99, 235', // blue
|
||||
};
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ export default function JsonConvertSection({
|
||||
/* 5. 空状态提示容器:完美的中性虚线引导,不喧宾夺主 */
|
||||
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none">
|
||||
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed">
|
||||
{error ? '请修正上方 JSON 的语法错误以激活流式转换' : t(`jsonFormat:${pk}EmptyHint`)}
|
||||
{error ? t('jsonFormat:fixErrorHint') : t(`jsonFormat:${pk}EmptyHint`)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -160,7 +160,7 @@ export default function JsonFormatSection() {
|
||||
/* 空状态指示引导区 */
|
||||
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none">
|
||||
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed">
|
||||
{error ? '请修正上方 JSON 语法错误以开启实时流式格式化' : t('jsonFormat:emptyHint')}
|
||||
{error ? t('jsonFormat:fixErrorHint') : t('jsonFormat:emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -182,9 +182,7 @@ export default function Index() {
|
||||
) : (
|
||||
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[140px]">
|
||||
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[260px] leading-relaxed">
|
||||
{leftError || rightError
|
||||
? '请修正上方 JSON 的语法错误以开启实时流式比对'
|
||||
: t('jsonDiff:emptyHint')}
|
||||
{leftError || rightError ? t('jsonDiff:fixErrorHint') : t('jsonDiff:emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -37,7 +37,7 @@ const PREVIEW_STYLES = `
|
||||
.markdown-body h1 { border-bottom: 1px solid var(--md-border); padding-bottom: 0.3em; font-size: 1.6em; }
|
||||
.markdown-body h2 { border-bottom: 1px solid var(--md-border); padding-bottom: 0.3em; font-size: 1.35em; }
|
||||
.markdown-body p { margin-top: 0; margin-bottom: 16px; }
|
||||
.markdown-body a { color: #3b82f6; text-decoration: none; }
|
||||
.markdown-body a { color: var(--md-link-color); text-decoration: none; }
|
||||
.markdown-body a:hover { text-decoration: underline; }
|
||||
.markdown-body code {
|
||||
background-color: var(--md-code-bg);
|
||||
@@ -110,6 +110,7 @@ export default function MarkdownToHtmlPage() {
|
||||
--md-pre-bg: rgba(255,255,255,0.04);
|
||||
--md-muted: #8b949e;
|
||||
--md-quote-line: rgba(255,255,255,0.25);
|
||||
--md-link-color: #58a6ff;
|
||||
}`
|
||||
: `:root {
|
||||
--md-bg: #ffffff;
|
||||
@@ -119,6 +120,7 @@ export default function MarkdownToHtmlPage() {
|
||||
--md-pre-bg: rgba(128,128,128,0.03);
|
||||
--md-muted: #4b5563;
|
||||
--md-quote-line: rgba(128,128,128,0.3);
|
||||
--md-link-color: #3b82f6;
|
||||
}`;
|
||||
|
||||
// 💡 3. 核心大清洗:将全局基础树(html, body)与派生样式完全独立硬编码,杜绝任何语法踩踏
|
||||
|
||||
+2
-4
@@ -107,13 +107,11 @@ JSON 工具集:差异比较、格式化、YAML/TOML/Minify 转换。
|
||||
Base64 编解码工具,支持文本/文件/图片三种模式。
|
||||
|
||||
| 文件 | 用途 |
|
||||
| ---------------------------- | -------------------- |
|
||||
| ---------------------------- | --------------------- |
|
||||
| `index.tsx` | 页面入口,子模式切换 |
|
||||
| `useBase64Converter.ts` | 业务逻辑 Hook |
|
||||
| `TextMode.tsx` | 文本模式 |
|
||||
| `FileMode.tsx` | 文件模式 |
|
||||
| `ImageMode.tsx` | 图片模式 |
|
||||
| `Base64ConverterSection.tsx` | 通用转换区域 |
|
||||
| `Base64ConverterSection.tsx` | 文件/图片通用转换区域 |
|
||||
|
||||
### MarkdownToHtml/
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function Index() {
|
||||
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full">
|
||||
<Loader2 className="h-6 w-6 text-muted-foreground/80" />
|
||||
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
|
||||
正在读取站点数据...
|
||||
{t('storageCleaner:initializing')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -88,7 +88,7 @@ const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClock
|
||||
<CopyButton
|
||||
text={currentDisplay.text}
|
||||
tooltip={t('timestamp:copyTsTooltip')}
|
||||
size="small"
|
||||
size="sm"
|
||||
className="h-7 w-7 rounded-md border" // 移除了硬编码的颜色配置表,完全交由组件的内置 Class 渲染
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user