* fix(ci): fix release workflow artifact upload issue

- Split artifact upload into 3 separate steps (Chrome, Firefox, Source)
- Update download steps to match new artifact names
- Add if-no-files-found: error for better error handling
- Fix Node.js 20 compatibility issue with upload-artifact@v4 glob pattern

* docs: 更新 AGENTS.md,添加测试工具和翻译文件结构说明

* docs: 更新 AGENTS.md,完善 CI 步骤和存储键名格式说明

* fix: remove unnecessary animations from StorageCleaner page

- Remove entrance animations (animate-in, fade-in, zoom-in) from all components
- Remove scale effect (active:scale-[0.99]) from clean button
- Remove transition effects (transition-all, transition-colors) from OptionItem, StorageOptionsGrid, AutoRefreshToggle
- Remove bouncing animation from error icon
- Remove pulsing animation from warning banner
- Keep animate-spin on button loading spinner as functional indicator

* fix: remove unnecessary animations from RightClickRestorer page

* fix: remove all unnecessary animations from all pages

- Remove dead code animations (animate-in, fade-in, slide-in, zoom-in, shake) from tailwindcss-animate plugin (not installed)
- Remove decorative transition effects (transition-all, transition-colors) from all page components
- Remove scale effects (active:scale-95) from buttons
- Remove bounce animations (animate-bounce) from icons
- Keep functional animate-spin on loading spinners as they provide essential loading feedback

Affected pages: Dashboard, Timestamp, Jwt, JsonTools, Base64Converter, HtmlToMarkdown, MarkdownToHtml, QrCode, TextStatistics

* docs: 添加项目文档(copilot-instructions、编码规范、各目录 README)

- 新增 .github/copilot-instructions.md:Copilot 指令文件,涵盖构建命令、CI 流水线、项目架构、关键规范
- 新增 .github/CODING_STANDARDS.md:完整的代码编写规范文档(TypeScript、React、样式、错误处理、命名、测试、Hook、i18n、存储等 12 个章节)
- 新增 11 个目录的 README.md:components、components/ui、config、entrypoints、i18n、lib、pages、providers、public、src、types、utils
- 更新 AGENTS.md:补充页面组件模式说明和 components/ui 目录

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(CopyButton): use shadcn buttonVariants instead of duplicated variant classes

Remove hand-written variantClasses/sizeClasses that duplicated shadcn's
buttonVariants. Now extends ButtonProps and imports buttonVariants from
components/ui/button for consistent styling.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(ErrorBoundary): use shadcn destructive tokens instead of hardcoded red colors

Replace hardcoded red-200/red-50/red-600 with border-destructive,
bg-destructive/5, text-destructive. Use variant='destructive' on
Button. Align error log styling with PageErrorBoundary.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(TextInputArea): replace hardcoded Chinese strings with i18n t() calls

Use existing translation keys for clear success, copy success, and
copy error messages instead of hardcoded Chinese text.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(QrCodePreview): use shadcn Button instead of manual button styles

Replace hand-written button class strings with shadcn Button component
using outline and default variants. Removes ~15 lines of duplicated
Tailwind classes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(GlobalSnackbar): clarify relationship with sonner toast

Add note explaining when to use GlobalSnackbar (provider context,
severity levels, custom positioning) vs sonner toast (simple one-off
messages).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(ImageUploader): wrap handleClearFile with useCallback

Prevents unnecessary re-renders by memoizing the callback, consistent
with handleFileChange which already uses useCallback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(SwitchButtonGroup): remove redundant containerPadding conditional

Both branches of the ternary returned 'p-1'. Inline the constant
directly into the className string.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(TopBar): add missing openExtensionPage import

The function was called in handleOpenInTab but never imported from
utils/chromeTabs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(TopBar): add selectedIndex boundary protection in keyboard nav

Guard against out-of-bounds access when search results change during
keyboard navigation. Check selectedIndex < totalItems before accessing
arrays.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(RouterContainer): remove unnecessary empty-dep useMemo

getEntryPointType() is a simple config getter. Replace useMemo(() => ..., [])
with a direct call — the empty dep array made the memo pointless.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(i18n): add missing translation keys for error boundaries and copy messages

Add keys for errorBoundary, pageErrorBoundary, router, and
messages.copyEmpty in both zh and en locales.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(CopyButton): replace hardcoded Chinese strings with i18n t() calls

Use useTranslation('common') for tooltip, copy empty/success/error
toast messages.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(ErrorBoundary): replace hardcoded Chinese with i18n via withTranslation HOC

Use react-i18next withTranslation HOC for class component to translate
title, description, and refresh button text.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(PageErrorBoundary): replace hardcoded Chinese with i18n via withTranslation HOC

Use react-i18next withTranslation HOC for class component to translate
title, description, and retry button text.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(RouterContainer): replace hardcoded Chinese with i18n t() calls

Use useTranslation('common') for 404 page title and description with
entryPointType interpolation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* 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>

* fix(CopyButton, LiveClock): 修改按钮大小为 'icon',移除硬编码颜色配置

* refactor(TextInputArea): 复用 CopyButton 组件替换内联复制逻辑

* fix(test): 修复 CopyButton mock 未捕获 clipboard writeText 异常

---------

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
LingandRX
2026-05-28 00:40:54 +08:00
committed by GitHub
parent 42d3fedbaa
commit d4c29a1bf4
48 changed files with 1922 additions and 1113 deletions
-220
View File
@@ -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>
);
}
-259
View File
@@ -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();
});
});
});
@@ -4,6 +4,7 @@ import TextMode from '../TextMode';
// Mock CopyButton
vi.mock('@/components/CopyButton', () => ({
CopyButton: ({ text }: { text: string }) => <button data-testid="copy-button">{text}</button>,
default: ({ text }: { text: string }) => <button data-testid="copy-button">{text}</button>,
}));
@@ -39,7 +40,7 @@ describe('TextMode', () => {
await waitFor(() => {
expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument();
});
expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8=');
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
});
it('应该解码 Base64 文本', async () => {
@@ -58,7 +59,7 @@ describe('TextMode', () => {
await waitFor(() => {
expect(screen.getByText('base64Converter:textOutput')).toBeInTheDocument();
});
expect(screen.getByTestId('copy-button')).toHaveTextContent('Hello');
expect(screen.getByRole('button', { name: 'Hello' })).toBeInTheDocument();
});
it('应该对无效 Base64 显示错误', async () => {
@@ -91,7 +92,7 @@ describe('TextMode', () => {
});
await waitFor(() => {
expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8=');
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
});
// 切换方向
@@ -99,7 +100,7 @@ describe('TextMode', () => {
// 输出应该被清除
await waitFor(() => {
expect(screen.queryByTestId('copy-button')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument();
});
});
@@ -114,13 +115,13 @@ describe('TextMode', () => {
});
await waitFor(() => {
expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8=');
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
await waitFor(() => {
expect(screen.queryByTestId('copy-button')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument();
expect(input).toHaveValue('');
});
});
+1 -1
View File
@@ -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
};
+1 -1
View File
@@ -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>
)}
+1 -1
View File
@@ -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>
)}
+1 -3
View File
@@ -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>
)}
+3 -1
View File
@@ -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)与派生样式完全独立硬编码,杜绝任何语法踩踏
+140
View File
@@ -0,0 +1,140 @@
# pages/
功能页面组件目录,每个子目录对应一个工具页面。
## 目录结构约定
典型页面遵循 **UI + Hook 分离** 模式:
```
pages/FeatureName/
├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件)
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
├── constants.ts # 常量定义(可选)
├── SubComponent.tsx # 子组件(可选)
└── __tests__/ # 测试文件
└── index.test.tsx
```
## 页面列表
### Dashboard/
仪表盘首页,以卡片网格展示所有可见工具,支持点击导航。
| 文件 | 用途 |
| -------------- | -------------------------------------------- |
| `index.tsx` | 页面组件,渲染工具卡片网格 |
| `ToolCard.tsx` | 工具卡片组件,展示图标、标题、描述和实时数据 |
### Timestamp/
时间戳转换工具,支持秒/毫秒级互转、多时区选择、实时时钟。
| 文件 | 用途 |
| -------------------------- | ----------------------------------------------------------------- |
| `index.tsx` | 页面 UI |
| `useTimestampConverter.ts` | 业务逻辑 Hook,包含转换模式、输入、单位、时区状态和响应式计算管线 |
| `LiveClock.tsx` | 实时时钟子组件,`React.memo` 优化 |
| `ResultView.tsx` | 转换结果展示子组件 |
| `constants.ts` | 时区列表等常量 |
### StorageCleaner/
浏览器存储清理工具,支持 Cookie/LocalStorage/SessionStorage/IndexedDB/Cache/SW 清理。
| 文件 | 用途 |
| --------------------------- | -------------- |
| `index.tsx` | 页面 UI |
| `useStorageCleaner.ts` | 业务逻辑 Hook |
| `StorageOptionsGrid.tsx` | 清理选项网格 |
| `StorageCleanerConfirm.tsx` | 清理确认对话框 |
| `AutoRefreshToggle.tsx` | 自动刷新开关 |
| `ErrorDisplay.tsx` | 错误展示组件 |
| `CleaningResult.tsx` | 清理结果展示 |
| `OptionItem.tsx` | 单个选项组件 |
### QrCode/
二维码工具,支持生成(URL→QR)和解析(QR→URL)。
| 文件/目录 | 用途 |
| ------------- | ---------------- |
| `index.tsx` | 页面 UI |
| `types.ts` | 类型定义 |
| `contexts/` | Context Provider |
| `hooks/` | 业务逻辑 Hooks |
| `components/` | 子组件 |
### TextStatistics/
文本统计工具,实时计算字符数、单词数、行数、字节大小。
| 文件 | 用途 |
| ----------- | --------------------------------------------- |
| `index.tsx` | 页面组件,集成 `TextInputArea` 和统计结果展示 |
### Jwt/
JWT 解析工具,解码 Header/Payload/Signature。
| 文件/目录 | 用途 |
| ------------- | ---------------- |
| `index.tsx` | 页面 UI |
| `types.ts` | 类型定义 |
| `contexts/` | Context Provider |
| `hooks/` | 业务逻辑 Hooks |
| `components/` | 子组件 |
### JsonTools/
JSON 工具集:差异比较、格式化、YAML/TOML/Minify 转换。
| 文件 | 用途 |
| ------------------------ | ---------------------------- |
| `index.tsx` | 页面入口,子模式切换 |
| `types.ts` | 类型定义 |
| `diffEngine.ts` | 差异比较引擎 |
| `JsonDiffInput.tsx` | JSON 输入组件 |
| `DiffResult.tsx` | 差异结果展示 |
| `DiffNavigator.tsx` | 差异导航器 |
| `JsonFormatSection.tsx` | 格式化区域 |
| `JsonConvertSection.tsx` | 转换区域(YAML/TOML/Minify |
| `JsonTree.tsx` | JSON 树形展示 |
### Base64Converter/
Base64 编解码工具,支持文本/文件/图片三种模式。
| 文件 | 用途 |
| ---------------------------- | --------------------- |
| `index.tsx` | 页面入口,子模式切换 |
| `useBase64Converter.ts` | 业务逻辑 Hook |
| `TextMode.tsx` | 文本模式 |
| `Base64ConverterSection.tsx` | 文件/图片通用转换区域 |
### MarkdownToHtml/
Markdown 转 HTML 工具,支持分栏/预览/源码三种视图模式。
### HtmlToMarkdown/
HTML 转 Markdown 工具,支持分栏/预览/Markdown 三种视图模式。
### RightClickRestorer/
右键菜单恢复工具,解除网站对右键的限制。
| 文件 | 用途 |
| -------------------------- | ------------- |
| `index.tsx` | 页面 UI |
| `useRightClickRestorer.ts` | 业务逻辑 Hook |
## 新增页面
1.`types/storage.d.ts` 添加 `PageType` 联合类型成员
2.`config/features.tsx``FEATURES` 数组添加配置
3.`pages/` 创建页面目录(遵循上述结构)
4.`i18n/locales/{zh,en}/features.json` 添加翻译
5. 如需新权限,更新 `wxt.config.ts`
6. 添加对应的单元测试
+1 -1
View File
@@ -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>
);
+1 -2
View File
@@ -88,8 +88,7 @@ const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClock
<CopyButton
text={currentDisplay.text}
tooltip={t('timestamp:copyTsTooltip')}
size="small"
className="h-7 w-7 rounded-md border" // 移除了硬编码的颜色配置表,完全交由组件的内置 Class 渲染
className="h-7 w-7 rounded-md border"
/>
</div>
);