Compare commits

..

1 Commits

Author SHA1 Message Date
Ubuntu ee0408a944 fix(ci): sync manifest version from tag before build
The release workflow was always building with the hardcoded version
from package.json (1.0.0), causing Chrome Web Store to reject uploads
because 1.0.0 equals the already-published 1.0 (trailing zeros ignored).

Added a step to extract the version from the git tag and update
package.json before the build, so the zip contains the correct version.
2026-05-23 18:56:23 +08:00
208 changed files with 5490 additions and 3467 deletions
-820
View File
@@ -1,820 +0,0 @@
# 代码编写规范
本文档定义了 Testing Tools 浏览器扩展项目的编码规范和最佳实践。所有代码贡献者应遵循这些规范以保持代码库的一致性和可维护性。
## 1. TypeScript 规范
### 1.1 类型定义:`interface` vs `type`
- **`interface`**:用于组件 Props、对象结构、Context 类型等可扩展结构
- **`type`**:用于联合类型、工具类型、不可扩展的类型别名
```typescript
// ✅ interface — 组件 Props / 对象结构
export interface GlobalSnackbarProps {
message: string;
open: boolean;
onClose: () => void;
severity?: SnackbarSeverity;
}
// ✅ interface — 继承 HTML 属性
interface LiveClockProps extends React.HTMLAttributes<HTMLDivElement> {
unit: UnitType;
onUseNow: (val: number) => void;
}
// ✅ type — 联合类型
export type ThemeMode = 'light' | 'dark' | 'system';
export type PageType = 'dashboard' | 'timestamp' | 'storageCleaner' | ...;
// ✅ type — 工具类型
export type ResolvedThemeMode = 'light' | 'dark';
```
### 1.2 泛型使用
广泛使用泛型约束,结合 `extends` 进行类型守卫:
```typescript
// ✅ 泛型 + extends 约束
export interface SwitchOption<T extends string | number = string> {
value: T;
label: React.ReactNode;
}
// ✅ 泛型 + StorageSchema 键约束
async get<K extends keyof StorageSchema>(
key: K,
defaultValue?: StorageSchema[K],
): Promise<StorageSchema[K] | undefined> { ... }
// ✅ 泛型 Hook
export const useStorageState = <K extends keyof StorageSchema>(
key: K,
defaultValue: StorageSchema[K],
validator?: (val: unknown) => val is StorageSchema[K],
) => { ... }
```
### 1.3 类型守卫
优先使用类型守卫函数(`val is Type` 谓词),避免 `as` 强转:
```typescript
// ✅ 类型守卫谓词函数
const isValidMode = (v: unknown): v is ThemeMode => VALID_MODES.includes(v as ThemeMode);
const isValidPage = (page: unknown): page is PageType => {
return typeof page === 'string' && (getAllFeatureKeys() as string[]).includes(page);
};
// ✅ 安全的 as 断言,仅在类型守卫验证后使用
export function isSupportedImageType(mimeType: string): boolean {
return (SUPPORTED_IMAGE_TYPES as readonly string[]).includes(mimeType);
}
```
### 1.4 导出模式
| 场景 | 导出方式 | 示例 |
| ----------- | ------------------------------------------------ | --------------------------------- |
| 页面组件 | `export default function ComponentName()` | `src/pages/Timestamp/index.tsx` |
| 业务组件 | `const X = React.memo(...)` + `export default X` | `LiveClock.tsx`, `ResultView.tsx` |
| UI 原子组件 | `React.forwardRef(...)` + `export { X }` | `src/components/ui/button.tsx` |
| 工具函数 | `export function xxx()` | `src/utils/clipboard.ts` |
| 自定义 Hook | `export function useXxx()` | `src/utils/useStorageState.ts` |
| 类型/接口 | `export interface` / `export type` | `src/types/storage.d.ts` |
```typescript
// ✅ 页面组件 — default export
export default function Index() { ... }
// ✅ 需要 memo 的组件 — 箭头函数 + React.memo + displayName
const LiveClock = React.memo(({ ... }: LiveClockProps) => { ... });
LiveClock.displayName = 'LiveClock';
export default LiveClock;
// ✅ UI 组件 — forwardRef + 命名导出
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(...);
Button.displayName = 'Button';
export { Button, buttonVariants };
```
---
## 2. React 组件规范
### 2.1 组件定义方式
- **标准组件**:使用 `function` 声明
- **需要 memo 的组件**:使用箭头函数 + `React.memo`
- **需要 ref 的组件**:使用 `React.forwardRef`
- **错误边界**:使用 Class 组件(React 要求)
```typescript
// ✅ 标准页面组件
export default function Index() { ... }
// ✅ 需要 memo 的组件
const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClockProps) => {
...
});
LiveClock.displayName = 'LiveClock';
export default LiveClock;
// ✅ 需要 ref 的组件
const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props, ref) => {
...
});
TextInputArea.displayName = 'TextInputArea';
export default TextInputArea;
// ✅ Class 组件(仅用于 ErrorBoundary
export class ErrorBoundary extends Component<Props, State> { ... }
```
### 2.2 Props 模式
- 使用 `interface` 定义 Props
- 继承 `React.HTMLAttributes` 以支持原生属性透传
- 使用 `Omit` 排除冲突属性
- 解构 `className``...rest props`
```typescript
// ✅ 继承 HTML 属性 + className 透传
interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> {
result: string;
mode: 'ts2dt' | 'dt2ts';
unit: UnitType;
zone: string;
showEmptyPlaceholder?: boolean;
}
// 使用时解构 className 和 rest props
const ResultView = React.memo(({
result, mode, unit, zone,
showEmptyPlaceholder = false,
className, ...props
}: ResultViewProps) => {
return <div className={cn('flex flex-col w-full', className)} {...props}>...</div>;
});
// ✅ Omit 排除冲突属性
export interface TextInputAreaProps extends Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'
> { ... }
```
### 2.3 状态管理
- **本地状态**`useState` + 惰性初始化
- **衍生状态**`useMemo` 响应式计算管线
- **持久化状态**Chrome Storage + `localStorage` 快照
- **全局状态**React Context
```typescript
// ✅ useState + 惰性初始化
const [input, setInput] = useState(() => String(Date.now()));
// ✅ useMemo 响应式计算管线(零延迟,无需手动 convert 按钮)
const conversionPipeline = useMemo(() => {
const rawInput = input.trim();
if (!rawInput) return { result: '', error: '' };
// ... 自动计算结果
}, [input, mode, unit, zone, t]);
// ✅ Chrome Storage 持久化状态
export const useStorageState = <K extends keyof StorageSchema>(
key: K, defaultValue: StorageSchema[K], validator?: ...
) => { ... }
```
### 2.4 副作用模式
- **取消标志**:防止异步竞态
- **ref 回调指针**:保持回调最新避免依赖膨胀
- **事件监听 cleanup**:始终在 cleanup 中移除监听器
- **定时器 cleanup**:始终在 cleanup 中清除定时器
```typescript
// ✅ 取消标志模式 — 防止异步竞态
useEffect(() => {
let cancelled = false;
storageUtil.get(THEME_MODE_KEY, 'system').then((saved) => {
if (cancelled) return;
if (isValidMode(saved)) { ... }
});
return () => { cancelled = true; };
}, [updateResolved]);
// ✅ ref 回调指针 — 保持回调最新避免依赖膨胀
const onUseNowRef = useRef(onUseNow);
useEffect(() => { onUseNowRef.current = onUseNow; }, [onUseNow]);
// ✅ setInterval + cleanup
useEffect(() => {
const tickId = setInterval(tick, 200);
return () => clearInterval(tickId);
}, [unit]);
// ✅ 事件监听 cleanup
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { ... };
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
```
### 2.5 `memo` / `useCallback` / `useMemo` 使用
| 场景 | 使用方式 |
| ---------------------------------- | ------------- |
| 高频渲染组件(列表子项、实时时钟) | `React.memo` |
| 事件处理函数、回调引用 | `useCallback` |
| 响应式计算管线、衍生数据 | `useMemo` |
| 避免重复创建对象/集合 | `useMemo` |
```typescript
// ✅ React.memo — 高频更新组件
const LiveClock = React.memo(({ ... }) => { ... });
const ResultView = React.memo(({ ... }) => { ... });
// ✅ useCallback — 事件处理
const handleUseNow = useCallback((now: number) => {
if (mode === 'ts2dt') {
setInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
} else {
setInput(dayjs(now).tz(zone).format(DATE_FORMAT));
}
}, [mode, unit, zone]);
// ✅ useMemo — 避免重复创建集合
const visibleSet = useMemo(() => new Set<string>(visiblePages), [visiblePages]);
```
---
## 3. 导入规范
### 3.1 导入顺序
按来源分组,顺序如下:
1. React 核心
2. 第三方库(图标、UI 库等)
3. 业务 Provider / Context
4. 配置 / 存储
5. i18n
6. 本地页面组件
7. UI 组件
8. 工具函数 / Hook
9. 类型
10. 常量
```typescript
// 1. React 核心
import React, { useEffect, useMemo, useRef, useState } from 'react';
// 2. 第三方库
import { ArrowLeft, ExternalLink, Globe } from 'lucide-react';
import { toast } from 'sonner';
// 3. 业务 Provider
import { useRouter } from '@/providers/RouterProvider';
import { useThemeMode } from '@/providers/ThemeModeProvider';
// 4. 配置 / 存储
import { FeatureConfig, FEATURES } from '@/config/features';
import { storageUtil } from '@/utils/chromeStorage';
// 5. i18n
import { useTranslation } from 'react-i18next';
import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n';
// 6. 本地组件
import TextMode from './TextMode';
import { ZONES } from './constants';
// 7. UI 组件
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { Button } from '@/components/ui/button';
// 8. 工具函数 / Hook
import { cn } from '@/lib/utils';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
// 9. 类型
import type { PageType, StorageSchema } from '@/types/storage';
```
### 3.2 路径别名
- `@/` 映射到项目根目录
- **跨目录导入**:使用 `@/` 绝对别名
- **同目录导入**:使用相对路径 `./`
```typescript
// ✅ 绝对别名导入 — 跨目录
import { cn } from '@/lib/utils';
import { storageUtil } from '@/utils/chromeStorage';
import type { StorageSchema } from '@/types/storage';
import { Button } from '@/components/ui/button';
// ✅ 相对导入 — 仅限同目录
import TextMode from './TextMode';
import { ZONES } from './constants';
import { useTimestampConverter } from './useTimestampConverter';
```
---
## 4. 样式规范
### 4.1 `cn()` 工具函数
统一使用 `cn()` 合并 Tailwind 类名(来自 `clsx` + `tailwind-merge`),导入自 `@/lib/utils`
```typescript
import { cn } from '@/lib/utils';
// ✅ 条件类名 + 合并外部 className
<div className={cn(
'flex items-center gap-3 px-3 h-10 rounded-lg border border-border/80 bg-secondary/50',
className, // 外部传入的覆盖
)} {...props}>
// ✅ 错误状态变体
<Input className={cn(
'font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60',
error && 'border-destructive focus-visible:ring-destructive',
)} />
// ✅ 选中/未选中状态
className={cn(
'flex-1 inline-flex items-center justify-center font-medium whitespace-nowrap transition-all',
sizeClasses[size],
isSelected
? 'bg-background text-foreground shadow-sm font-semibold'
: 'hover:bg-background/50 hover:text-foreground/80',
buttonClassName,
)}
```
### 4.2 主题 / 暗色模式
使用 shadcn/ui 的 CSS 变量语义化类名,**禁止硬编码颜色值**:
```typescript
// ✅ 语义化颜色 token — 亮/暗模式自适应
<div className="min-h-screen bg-background text-foreground antialiased selection:bg-primary/20">
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm">
// ✅ 暗色模式特殊处理
'fixed ... bg-white dark:bg-gray-900 p-6 ...'
// ✅ 需要固定颜色的特殊场景(如二维码白色背景保护)
<div className="p-3 bg-white rounded-lg shadow-sm border border-border/40">
```
**常用语义化 token**
| 用途 | 类名 |
| ---- | ------------------------------------------------------------------ |
| 背景 | `bg-background`, `bg-card`, `bg-muted`, `bg-secondary` |
| 文字 | `text-foreground`, `text-card-foreground`, `text-muted-foreground` |
| 边框 | `border-border`, `border-border/80` |
| 主色 | `text-primary`, `bg-primary`, `border-primary` |
| 危险 | `text-destructive`, `bg-destructive`, `border-destructive` |
### 4.3 响应式设计
移动优先,使用 `sm:` / `md:` / `lg:` 断点:
```typescript
// ✅ Grid 自适应布局
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
// ✅ Dashboard 自动填充网格
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(290px,1fr))] auto-rows-auto gap-3.5 p-3.5 w-full h-auto">
// ✅ 弹性方向切换
<div className="flex flex-col sm:flex-row items-stretch gap-3 w-full">
// ✅ 内边距响应式
<div className="p-4 sm:p-6 space-y-4">
```
---
## 5. 错误处理
### 5.1 工具函数:结果对象模式
工具函数**不抛异常**,返回包含 `hasError``error` 字段的结果对象:
```typescript
// ✅ 结果对象模式
export function markdownToHtml(markdown: string): MarkdownToHtmlResult {
try {
...
return { html, originalLength, htmlLength, hasError: false };
} catch (error) {
return {
html: '', ...
hasError: true,
error: error instanceof Error ? error.message : 'Markdown 解析失败',
};
}
}
```
### 5.2 UI 层:try-catch + Toast
UI 层异步操作使用 try-catch,通过 `sonner``toast` 显示错误:
```typescript
import { toast } from 'sonner';
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(value);
toast.success('复制成功');
} catch {
toast.error('复制失败');
}
}, [value]);
```
### 5.3 Promise 异常隔离
对不关心返回值的异步操作,使用 `void` + `.catch()` 隔离异常:
```typescript
// ✅ void + .catch 模式
void storageUtil.set(THEME_MODE_KEY, next).catch((err) => {
console.error('[Theme Storage Error] Failed to persistent theme state:', err);
});
// ✅ async 函数调用 + .catch
loadConfig().catch(console.error);
```
### 5.4 ErrorBoundary
在应用顶层使用 `ErrorBoundary` 组件捕获子组件树异常:
```typescript
import ErrorBoundary from '@/components/ErrorBoundary';
<ErrorBoundary>
<RouterContainer />
</ErrorBoundary>
```
---
## 6. 命名规范
### 6.1 文件命名
| 类型 | 命名模式 | 示例 |
| ----------- | -------------------------- | -------------------------------------------------------- |
| 页面目录 | PascalCase | `Timestamp/`, `Base64Converter/`, `StorageCleaner/` |
| 页面入口 | `index.tsx` | `src/pages/Timestamp/index.tsx` |
| 组件文件 | PascalCase `.tsx` | `TopBar.tsx`, `LiveClock.tsx`, `ResultView.tsx` |
| 自定义 Hook | camelCase `.ts` | `useTimestampConverter.ts`, `useStorageCleaner.ts` |
| 工具函数 | camelCase `.ts` | `chromeStorage.ts`, `base64Converter.ts`, `clipboard.ts` |
| 测试文件 | 与源文件同名 `.test.ts(x)` | `jwt.test.ts`, `SwitchButtonGroup.test.tsx` |
| 类型文件 | camelCase `.d.ts` | `storage.d.ts` |
| 常量文件 | camelCase `.ts` | `constants.ts` |
### 6.2 变量 / 函数命名
```typescript
// ✅ camelCase — 变量、函数、Hook
const conversionPipeline = useMemo(...);
const handleUseNow = useCallback(...);
export function useTimestampConverter(): UseTimestampConverterReturn { ... }
export function textToBase64(text: string): TextToBase64Result { ... }
// ✅ PascalCase — 组件、类型、接口
const LiveClock = React.memo(...);
export interface GlobalSnackbarProps { ... }
export type ThemeMode = 'light' | 'dark' | 'system';
// ✅ SCREAMING_SNAKE_CASE — 常量
const SEARCH_HISTORY_LIMIT = 10;
const THEME_MODE_KEY = 'app/themeMode' as const;
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
export const SUPPORTED_IMAGE_TYPES = [...] as const;
// ✅ 布尔值 — is/has/should 前缀
const isControlled = controlledValue !== undefined;
const isDashboard = currentPage === 'dashboard';
const hasError = true;
```
### 6.3 事件处理函数
使用 `handle` 前缀命名组件内事件处理函数:
```typescript
const handleUseNow = useCallback((now: number) => { ... }, []);
const handleSelectFeature = (feature: FeatureConfig) => { ... };
const handleFileChange = useCallback((file: File) => { ... }, []);
const handleClean = useCallback(async () => { ... }, []);
```
---
## 7. 测试规范
### 7.1 文件组织
测试文件放在源代码同级的 `__tests__/` 目录下:
```
src/utils/__tests__/jwt.test.ts
src/utils/__tests__/base64Converter.test.ts
src/utils/__tests__/useStorageState.test.ts
src/components/__tests__/SwitchButtonGroup.test.tsx
src/components/__tests__/ErrorBoundary.test.tsx
src/pages/Timestamp/__tests__/index.test.tsx
```
### 7.2 describe / it 命名
`describe` 使用模块/函数名,`it` 使用中文描述行为("应该..."):
```typescript
// ✅ 中文 describe + 中文 it
describe('textToBase64', () => {
it('应该编码 ASCII 文本', () => { ... });
it('应该编码中文文本', () => { ... });
it('应该编码空字符串', () => { ... });
});
// ✅ 中文 describe + 中文 it(组件测试)
describe('SwitchButtonGroup 组件', () => {
it('应渲染所有选项按钮', () => { ... });
it('应高亮当前选中的按钮', () => { ... });
it('点击未选中按钮时应触发 onChange 并传入选中值', () => { ... });
});
```
### 7.3 Mock 模式
- 使用 `vi.mock()` 进行模块级 Mock
- 使用 `vi.fn()` 进行函数级 Mock
- 使用 `vi.useFakeTimers()` 控制时间
- **避免重复 mock `vitest.setup.ts` 中已有的内容**chrome API、i18n、matchMedia 等)
```typescript
// ✅ 模块级 Mock
vi.mock('@/utils/chromeStorage', () => ({
storageUtil: {
get: vi.fn(),
set: vi.fn(() => Promise.resolve()),
},
}));
// ✅ 函数级 Mock + 断言
const handleChange = vi.fn();
render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />);
fireEvent.click(screen.getByRole('button', { name: /选项B/i }));
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith('b');
// ✅ 定时器 Mock
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
```
### 7.4 断言模式
使用 Testing Library 的 DOM 查询 + Vitest 匹配器:
```typescript
// ✅ 语义化查询
expect(screen.getByRole('button', { name: /选项A/i })).toBeInTheDocument();
expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容');
expect(screen.queryByText('糟糕,出了点问题')).not.toBeInTheDocument();
// ✅ CSS 类断言
expect(button).toHaveClass('bg-background', 'text-foreground', 'shadow-sm');
// ✅ 异步断言
await waitFor(() => {
expect(result.current[0]).toBe(false);
expect(result.current[2]).toBe(true);
});
// ✅ renderHook 测试自定义 Hook
const { result } = renderHook(() => useStorageState('qrCode/urlExpanded', true));
expect(result.current[0]).toBe(true);
```
---
## 8. 自定义 Hook 规范
### 8.1 命名和结构
- 使用 `use` 前缀命名
- 定义返回值接口类型
- 添加 JSDoc 注释
```typescript
// ✅ 完整的 Hook 结构
/**
* 自定义 Hook:处理右键菜单传递的数据
*
* @param options - 配置选项
* @example
* useContextMenuData({ featureKey: 'jwt', onData: handlePayload });
*/
export function useContextMenuData({ featureKey, onData }: UseContextMenuDataOptions): void {
const checkAndConsumeData = useCallback(async () => { ... }, [featureKey, onData]);
useEffect(() => { checkAndConsumeData(); }, [checkAndConsumeData]);
}
// ✅ 返回值接口定义
export interface UseTimestampConverterReturn {
mode: 'ts2dt' | 'dt2ts';
input: string;
result: string;
error: string;
setMode: (mode: 'ts2dt' | 'dt2ts') => void;
setInput: (value: string) => void;
handleUseNow: (now: number) => void;
}
export function useTimestampConverter(): UseTimestampConverterReturn { ... }
```
### 8.2 Hook 存放位置
- **全局通用 Hook**:放在 `src/utils/` 目录下
- **页面专属 Hook**:与页面组件同目录
```
src/utils/useStorageState.ts — Chrome Storage 状态持久化
src/utils/useLazyTranslation.ts — i18n 懒加载
src/utils/useContextMenuData.ts — 右键菜单数据
src/utils/useDebounce.ts — 防抖
src/pages/Timestamp/useTimestampConverter.ts — 页面级 Hook
src/pages/StorageCleaner/useStorageCleaner.ts — 页面级 Hook
```
---
## 9. 国际化规范
### 9.1 翻译键格式
- 命名空间:`common`(默认)、`features`
- 翻译键格式:`namespace:key`(如 `features:timestamp.title`
- 语言:`zh`(默认)、`en`
### 9.2 翻译文件结构
```
i18n/locales/{zh,en}/common.json — 全局通用翻译
i18n/locales/{zh,en}/features.json — 功能模块标题和描述
i18n/locales/{zh,en}/{功能名}.json — 各功能独立翻译
```
### 9.3 使用方式
```typescript
// ✅ 页面组件 — 使用 useLazyTranslation
import { useLazyTranslation } from '@/utils/useLazyTranslation';
export default function Index() {
const { t } = useLazyTranslation('timestamp');
return <h1>{t('timestamp:title')}</h1>;
}
// ✅ 全局组件 — 使用 useTranslation
import { useTranslation } from 'react-i18next';
export function TopBar() {
const { t } = useTranslation(['common', 'features']);
return <span>{t('common:settings')}</span>;
}
```
### 9.4 添加新翻译
1.`i18n/locales/{zh,en}/features.json` 添加功能标题和描述
2. 创建 `i18n/locales/{zh,en}/{功能名}.json` 添加功能专属翻译
3.`utils/useLazyTranslation.ts``localeModules` 中注册新命名空间
---
## 10. 存储规范
### 10.1 StorageSchema
所有 Chrome Storage 键必须在 `types/storage.d.ts``StorageSchema` 中声明:
```typescript
export interface StorageSchema {
'app/currentRoute': PageType;
'app/popupRoute': PageType;
'app/theme': string;
'app/themeMode': 'light' | 'dark' | 'system';
'storageCleaner/preferences': StorageCleanerPreferences;
// ...
}
```
### 10.2 存储操作
使用 `utils/chromeStorage.ts` 的类型安全封装:
```typescript
import { storageUtil } from '@/utils/chromeStorage';
import type { StorageSchema } from '@/types/storage';
// ✅ 读取
const theme = await storageUtil.get('app/theme', 'default');
// ✅ 写入
await storageUtil.set('app/theme', 'dark');
// ✅ 删除
await storageUtil.remove('app/theme');
```
### 10.3 持久化状态 Hook
使用 `useStorageState` 自动同步 Chrome Storage
```typescript
import { useStorageState } from '@/utils/useStorageState';
const [themeMode, setThemeMode, isInitialized] = useStorageState(
'app/themeMode',
'system',
isValidMode, // 可选的类型守卫
);
```
---
## 11. 文件组织
### 11.1 页面组件结构
```
src/pages/FeatureName/
├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件)
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
├── constants.ts # 常量定义(可选)
├── LiveClock.tsx # 子组件(可选)
├── ResultView.tsx # 子组件(可选)
└── __tests__/ # 测试文件
└── index.test.tsx
```
### 11.2 目录职责
| 目录 | 职责 |
| -------------------- | ------------------------------------------------------------ |
| `src/config/` | 应用配置(功能定义、路由映射) |
| `src/entrypoints/` | 扩展入口点(popup、options、sidepanel、background、content |
| `src/pages/` | 功能页面组件(懒加载) |
| `src/components/` | 可复用 UI 组件 |
| `src/components/ui/` | shadcn/ui 基础组件(button、dialog、select 等) |
| `src/providers/` | React ContextRouter、Theme 等) |
| `src/hooks/` | 自定义 React Hooks |
| `src/utils/` | 工具函数与服务抽象 |
| `src/types/` | TypeScript 类型声明 |
| `src/lib/` | 通用工具函数(cn、utils) |
| `public/` | 静态资源 |
---
## 12. 代码风格
### 12.1 Prettier 配置
- 行宽:100 字符
- 引号:单引号
- 尾逗号:all
- 换行符:LF
- 分号:是
### 12.2 ESLint 规则
- 禁止使用 `any`(测试文件除外)
- 未使用变量/参数:使用 `_` 前缀(如 `_unused`
- React 19 JSX Runtime:无需手动导入 React
- 使用 `typescript-eslint``projectService: true`
### 12.3 注释规范
- **文件级注释**:使用 JSDoc `@module` 格式(如 `GlobalSnackbar.tsx`
- **函数注释**:使用 JSDoc,包含 `@param``@returns``@example`
- **行内注释**:仅在需要澄清复杂逻辑时使用
- **禁止注释显而易见的代码**
-141
View File
@@ -1,141 +0,0 @@
# Copilot 指令
基于 WXT 框架的浏览器扩展项目(React 19 + TypeScript),为开发者和测试人员提供效率工具:时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown 等。
## 核心命令
```bash
npm run dev # Chrome 开发模式(支持 HMR
npm run dev:firefox # Firefox 开发模式
npm run build # Chrome 生产构建
npm run build:firefox # Firefox 生产构建
npm run zip # 打包 Chrome 扩展(.output/*.zip
npm run zip:firefox # 打包 Firefox 扩展
npm run lint # ESLint 检查(--max-warnings=0
npm run typecheck # TypeScript 类型检查(tsc --noEmit
npm run test # 运行全部单元测试(vitest run)
npm run test:watch # Vitest 监视模式
npm run test:coverage # 带覆盖率的测试
```
运行单个测试文件:`npx vitest run path/to/file.test.ts`
修改 `package.json` 后需运行 `npm install`(会自动触发 `postinstall``wxt prepare` 重新生成 `.wxt/` 类型声明)。
## CI 流水线(GitHub Actions
严格顺序门控,任一步骤失败则终止:
1. **setup** — 安装依赖,缓存 `node_modules`
2. **lint**、**typecheck**、**test** — 三者并行运行,全部通过才继续
3. **build** — Chrome + Firefox 矩阵构建(仅当步骤 2 全部通过时执行)
Pre-commit 钩子(`.husky/pre-commit``lint-staged`):
1. 代码文件(`*.{ts,tsx,js,jsx,mjs}`):运行 `eslint --fix --max-warnings=0`
2. 同一代码文件:运行 `prettier --write`
3. 其他文件(`*.{json,css,scss,md}`):运行 `prettier --write`
## 项目架构
### 路由(不使用 React Router
路由完全通过 `config/features.tsx` 中的 `FEATURES` 数组管理。每个功能定义一个 `key`(类型为 `types/storage.d.ts` 中的 `PageType`)和三个懒加载组件,分别对应 `popup``sidepanel``tab` 三种渲染模式。`providers/RouterProvider.tsx` 中的 `RouterProvider` 根据存储状态渲染当前页面。
存在三套独立的路由作用域:`app/popupRoute``app/sidepanelRoute``app/tabRoute`,各自维护独立的可见页面列表和页面排序。
### 存储
所有 Chrome Storage 键必须在 `types/storage.d.ts``StorageSchema` 中声明,键名使用 kebab-case 格式(如 `app/currentRoute`)。使用 `utils/chromeStorage.ts` 中的类型安全封装(`storageUtil.get/set/remove`)。
Router 同时使用 `chrome.storage.local` 持久化和 `localStorage` 快照来消除首屏闪烁。
### 扩展通信
使用 `@webext-core/messaging`。通信协议在 `utils/messages.ts` 中通过 `ProtocolMap` 定义。使用该模块导出的 `sendMessage` / `onMessage`,不要直接使用原生 `chrome.runtime.sendMessage`
### 页面组件模式
功能页面遵循 **UI + Hook 分离** 模式:
```
pages/FeatureName/
├── index.tsx # UI 组件(纯展示,使用 shadcn/ui 组件)
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
└── constants.ts # 常量定义(可选)
```
- 页面组件调用 `useLazyTranslation('featureName')` 获取翻译函数
- Hook 负责所有状态管理,通过返回值暴露给页面
- 子组件可进一步拆分(如 `LiveClock.tsx``ResultView.tsx`
### 新功能开发清单
1.`types/storage.d.ts``PageType` 联合类型中添加新成员
2.`config/features.tsx``FEATURES` 数组中添加配置(key、翻译键、图标、三种渲染模式组件)
3.`pages/` 目录创建页面组件(懒加载):
- `index.tsx` — 使用 `useLazyTranslation` 的 UI 组件
- `useFeatureName.ts` — 业务逻辑 Hook
- `constants.ts` — 常量(可选)
4.`i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON 文件)
5. 如需新权限,更新 `wxt.config.ts``manifest.permissions`
6. 添加对应的单元测试
## 关键规范
> 完整的代码编写规范详见 [CODING_STANDARDS.md](./CODING_STANDARDS.md)。
### 浏览器 API
始终使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API,以确保跨浏览器兼容性。
### 路径别名
`@/` 映射到项目根目录(已在 tsconfig 和 vitest.config 中配置)。跨目录导入使用 `@/` 绝对别名,同目录导入使用 `./` 相对路径。
### UI 组件
- 使用 `components/ui/` 下的 shadcn/ui 组件(button、dialog、select、input 等)
- 图标:`lucide-react`
- 样式:Tailwind CSS + `@/lib/utils` 中的 `cn()` 工具函数(clsx + tailwind-merge
- 主题:使用 shadcn/ui 语义化 token`bg-background``text-foreground``border-border` 等),禁止硬编码颜色
### 代码分割
`wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组 vendor 依赖(vendor-react、vendor-i18n、vendor-qr、vendor-dnd),无需手动配置。
### 代码风格
- 禁止使用 `any`(测试文件除外)
- 未使用的变量/参数:使用 `_` 前缀(如 `_unused`
- Prettier100 字符宽、单引号、尾逗号 all、LF 换行
- ESLint 使用 `typescript-eslint``projectService: true`
- 导出模式:页面组件 default export,工具函数/Hook 命名导出,UI 组件 forwardRef + 命名导出
### 测试
- 环境:jsdom
- 全局变量:`vitest/globals`describe、it、expect 等无需导入)
- Setup 文件:`vitest.setup.ts` 自动 mock 以下内容:
- `chrome.*` / `browser.*` APIstorage、tabs、runtime、cookies 等)
- `react-i18next`(返回 key 作为翻译)
- `@/utils/useLazyTranslation`(返回 `ns:key` 格式)
- `window.matchMedia`
- 测试文件命名:`__tests__/*.test.{ts,tsx}``*.test.{ts,tsx}`
- 使用 `vi.mock()` 进行模块级 mock;避免重复 mock `vitest.setup.ts` 中已有的内容
- 测试工具:`@testing-library/react` + `@testing-library/user-event`
### 国际化(i18n
- 命名空间:`common`(默认)、`features`
- 翻译键格式:`namespace:key`(如 `features:timestamp.title`
- 语言:`zh`(默认)、`en`
- 翻译文件:`i18n/locales/{zh,en}/{common,features}.json` + 各功能独立 JSON 文件
- 使用 `useLazyTranslation` Hook 加载功能专属翻译
- 回退策略:缺失的翻译键回退到 `zh`,若仍缺失则返回占位格式 `namespace:key`
### WXT 生成文件
- `.wxt/` 目录由 `postinstall``wxt prepare`)自动生成,包含 TypeScript 类型声明和扩展 tsconfig
- 生产构建输出到 `.output/` 目录
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`
+11
View File
@@ -13,6 +13,8 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
# 💡 1. 提速核心:前置基建节点(Infrastructure Initialization
# 专门负责锁死环境、同步下载并缓存 node_modules,下游节点直接满血复用!
setup: setup:
name: Prepare Dependencies name: Prepare Dependencies
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -27,6 +29,7 @@ jobs:
with: with:
node-version: '22' node-version: '22'
# 建立基于 package-lock.json 唯一哈希的缓存大闸
- name: Cache Node Modules - name: Cache Node Modules
id: cache-nodemodules id: cache-nodemodules
uses: actions/cache@v4 uses: actions/cache@v4
@@ -44,6 +47,7 @@ jobs:
id: cache-info id: cache-info
run: echo "key=${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}" >> $GITHUB_OUTPUT run: echo "key=${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}" >> $GITHUB_OUTPUT
# 💡 2. 静态语法质检节点(依赖前置节点完成)
lint: lint:
name: Lint name: Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -66,6 +70,7 @@ jobs:
- name: Run ESLint - name: Run ESLint
run: npm run lint run: npm run lint
# 💡 3. 强类型守卫节点(2秒瞬时恢复,开箱即查)
typecheck: typecheck:
name: TypeScript Check name: TypeScript Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -91,6 +96,7 @@ jobs:
- name: Run TypeScript type check - name: Run TypeScript type check
run: npm run typecheck run: npm run typecheck
# 💡 4. 单元测试节点(无缝运行你刚刚修复完的 setupTests.ts 套件)
test: test:
name: Unit Tests name: Unit Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -116,12 +122,16 @@ jobs:
- name: Run tests - name: Run tests
run: npm run test run: npm run test
# 💡 5. 多端分布式最终编译节点(Production Matrix Compliance
build: build:
name: Build (${{ matrix.browser }}) name: Build (${{ matrix.browser }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
# 只有当 Linter、类型大闸、Vitest 单元测试全数满分通过,才放行最终打包编译
needs: [ lint, typecheck, test ] needs: [ lint, typecheck, test ]
strategy: strategy:
matrix: matrix:
# 💡 完美对齐 WXT 跨端架构:将 firefox 同步纳入生产编译大矩阵,
# 如果 firefox 编译因任何多端不兼容挂掉,CI 会立刻拉起警报,防护力拉满!
browser: [ chrome, firefox ] browser: [ chrome, firefox ]
fail-fast: false fail-fast: false
steps: steps:
@@ -139,6 +149,7 @@ jobs:
path: node_modules path: node_modules
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
# 💡 动态代理编译指令:完美匹配 WXT / 各类多端打包器的标准构建命令
- name: Generate WXT types - name: Generate WXT types
run: npx wxt prepare run: npx wxt prepare
+31 -67
View File
@@ -1,6 +1,6 @@
# AGENTS.md # AGENTS.md
WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown 等测试效率工具。 WXT 浏览器扩展项目 (React 19 + TypeScript)。
## 核心命令 ## 核心命令
@@ -22,17 +22,14 @@ npm run test:coverage # 带覆盖率的测试
## 验证流程 ## 验证流程
CI 步骤(严格顺序,任一步骤失败则停止并标记 CI 失败): CI 执行顺序: `setup → lint/typecheck/test(并行) → build` (build 依赖前三者)。
1. `setup`(安装依赖、`wxt prepare` Pre-commit hook (`.husky/pre-commit` 调用 `lint-staged`):
2. 并行运行 `lint``typecheck``test`(三者全部通过才继续)
3. `build`(仅当步骤 2 全部成功时执行)
Pre-commit hook`.husky/pre-commit` 调用 `lint-staged`,任一步骤返回非零则终止提交): - 代码文件 (`*.{ts,tsx,js,jsx,mjs}`): `eslint --fix --max-warnings=0 --no-warn-ignored``prettier --write`
- 其他文件 (`*.{json,css,scss,md}`): `prettier --write`
1. 代码文件 (`*.{ts,tsx,js,jsx,mjs}`):运行 `eslint --fix --max-warnings=0 --no-warn-ignored`;若失败则终止并报告错误 提交前确保 `lint``typecheck` 通过。
2. 同一代码文件:运行 `prettier --write`
3. 其他文件 (`*.{json,css,scss,md}`):运行 `prettier --write`
## WXT 生成文件 ## WXT 生成文件
@@ -43,53 +40,31 @@ Pre-commit hook`.husky/pre-commit` 调用 `lint-staged`,任一步骤返回
## 项目结构 ## 项目结构
``` ```
src/ # 源代码根目录 config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源) entrypoints/ # 扩展入口点 (popup/, options/, sidepanel/, background.ts, content.ts)
entrypoints/ # 扩展入口点 (popup/, sidepanel/, background.ts, content.ts) pages/ # 功能页面组件 (懒加载)
pages/ # 功能页面组件 (懒加载) components/ # 可复用 UI 组件
components/ # 可复用 UI 组件 providers/ # React Context (Router, Theme 等)
components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等) hooks/ # 自定义 React Hooks
providers/ # React Context (Router, Theme 等) utils/ # 工具函数与服务抽象
hooks/ # 自定义 React Hooks types/ # TypeScript 类型声明
utils/ # 工具函数与服务抽象 i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json 及各功能独立 JSON)
types/ # TypeScript 类型声明
lib/ # 通用工具函数(cn、utils)
public/ # 静态资源(图标、_locales 等)
``` ```
### 页面组件模式
典型功能页面遵循 **UI + Hook 分离** 模式:
```
src/pages/FeatureName/
├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件)
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
└── constants.ts # 常量定义
```
- 页面组件调用 `useLazyTranslation('featureName')` 获取翻译函数
- Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面
- 子组件可进一步拆分(如 `LiveClock.tsx``ResultView.tsx`
## 关键架构决策 ## 关键架构决策
**路由**: 不使用 React Router。通过 `src/config/features.tsx``FEATURES` 数组管理,`RouterProvider` 根据 `PageType` **路由**: 不使用 React Router。通过 `config/features.tsx``FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
渲染对应组件。支持三种渲染模式popup(弹窗)、sidepanel(侧边栏)和 browser-tab(浏览器新标签页,通过 `open_in_tab` 打开) 渲染对应组件。支持三种渲染模式: popup / sidepanel / tab
每种模式有独立的路由和可见页面配置(`app/popupRoute``app/sidepanelRoute``app/tabRoute` 等)。
**存储**: 所有 Chrome Storage 键必须在 `src/types/storage.d.ts``StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`)。 **存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts``StorageSchema` 中定义。使用 `utils/chromeStorage.ts` 及其
使用 `src/utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local``localStorage` 做快照以消除首屏闪烁。 Hook。Router 同时使用 `chrome.storage.local``localStorage` 做快照以消除首屏闪烁。
修改 StorageSchema 时,必须在 `src/utils/chromeStorage.ts` 添加版本迁移函数,并在测试中覆盖迁移场景。
**通信**: 使用 `@webext-core/messaging`,协议定义在 `src/utils/messages.ts` **通信**: 使用 `@webext-core/messaging`,协议定义在 `utils/messages.ts`
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。 **路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。
**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。 **浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
**代码分割**: `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组依赖(vendor-react、vendor-i18n、vendor-qr 等),无需手动配置。
## 测试环境 ## 测试环境
- 环境: jsdom - 环境: jsdom
@@ -100,30 +75,19 @@ src/pages/FeatureName/
- `@/utils/useLazyTranslation` (返回 `ns:key` 格式翻译) - `@/utils/useLazyTranslation` (返回 `ns:key` 格式翻译)
- `window.matchMedia` - `window.matchMedia`
- 测试文件命名: `__tests__/*.test.{ts,tsx}``*.test.{ts,tsx}` - 测试文件命名: `__tests__/*.test.{ts,tsx}``*.test.{ts,tsx}`
- Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
## i18n ## i18n
- 命名空间: `common` (默认), `features` - 命名空间: `common` (默认), `features`
- 翻译键格式: `namespace:key` (如 `features:timestamp.title`) - 翻译键格式: `namespace:key` (如 `features:timestamp.title`)
- 语言: `zh` (默认), `en` - 语言: `zh` (默认), `en`
- 翻译文件结构:
- `i18n/locales/{zh,en}/common.json` - 全局通用翻译
- `i18n/locales/{zh,en}/features.json` - 功能模块标题和描述
- `i18n/locales/{zh,en}/{功能名}.json` - 各功能独立翻译(如 timestamp.json, storageCleaner.json 等)
- 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json` 及对应功能独立 JSON - 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json` 及对应功能独立 JSON
- 使用 `useLazyTranslation` hook 加载功能独立翻译,返回 `ns:key` 格式
- 回退策略: 当翻译 key 在目标语言缺失时,回退到默认语言 `zh`;若默认语言也缺失,返回占位格式 `namespace:key` 并在开发模式下记录 warning
## 新功能开发清单 ## 新功能开发清单
1.`src/types/storage.d.ts` 添加 `PageType` 联合类型 1.`types/storage.d.ts` 添加 `PageType` 联合类型
2.`src/config/features.tsx``FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件) 2.`config/features.tsx``FEATURES` 数组添加配置
3.`src/pages/` 创建页面组件 (懒加载) 3.`pages/` 创建页面组件 (懒加载)
- `index.tsx` — UI 组件,使用 `useLazyTranslation` 获取翻译
- `useFeatureName.ts` — 业务逻辑 Hook
- `constants.ts` — 常量(可选)
4.`i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON) 4.`i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON)
5. 如需新权限,更新 `wxt.config.ts``manifest.permissions` 5. 如需新权限,更新 `wxt.config.ts``manifest.permissions`
6. 添加对应的单元测试 6. 添加对应的单元测试
@@ -133,15 +97,15 @@ src/pages/FeatureName/
- 禁止使用 `any` (测试文件除外) - 禁止使用 `any` (测试文件除外)
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`) - 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
- 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className``cn()` 工具) - 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className``cn()` 工具)
- UI 组件: 优先使用 `src/components/ui/` 下的 shadcn/ui 组件 (button, dialog, select 等)
- 图标: 使用 `lucide-react` 图标库
- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行) - 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
- ESLint 使用 `typescript-eslint``projectService: true`(无需手动维护 project 路径) - ESLint 使用 `typescript-eslint``projectService: true`(无需手动维护 project 路径)
## 关键外部库(非显而易见的) ## 技术栈版本
- `@webext-core/messaging` — 扩展消息通信 - WXT: ^0.20.26
- `@dnd-kit` — 拖拽排序(用于页面顺序管理) - React: ^19.2.6
- `qrious` + `qr-scanner` — 二维码生成与解析 - Tailwind CSS: ^3.4.19
- `dayjs` — 日期处理(时间戳转换) - shadcn/ui (基于 Radix UI + class-variance-authority)
- `sonner` — Toast 通知(替代传统 snackbar - TypeScript: ^5.9.3
- Vitest: ^4.1.7
- i18next: ^26.2.0
+21 -15
View File
@@ -63,6 +63,11 @@
- **文件转换**: 支持文件与 Base64 字符串互转. - **文件转换**: 支持文件与 Base64 字符串互转.
- **图像预览**: 支持图片 Base64 编码与实时预览. - **图像预览**: 支持图片 Base64 编码与实时预览.
### 📝 Markdown ↔ HTML 转换
- **Markdown 转 HTML**: 实时预览 Markdown 渲染结果,支持导出 HTML 源码.
- **HTML 转 Markdown**: 将 HTML 内容转换为 Markdown 格式.
## 技术栈 ## 技术栈
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/) - **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
@@ -79,21 +84,22 @@
## 项目结构 ## 项目结构
```text ```text
├── src/ # 源代码根目录 ├── components/ # 可复用 React 组件
├── components/ # 可复用 React 组件 ├── config/ # 应用配置
── config/ # 应用配置 ── features.tsx # 功能定义与路由映射
│ │ └── features.tsx # 功能定义与路由映射 ├── entrypoints/ # 扩展程序入口点
│ ├── entrypoints/ # 扩展程序入口点 │ ├── popup/ # 点击图标弹出的主界面
│ ├── popup/ # 点击图标弹出的主界 │ ├── options/ # 扩展程序设置页
│ ├── sidepanel/ # 浏览器侧边栏集成 │ ├── sidepanel/ # 浏览器侧边栏集成
│ ├── background.ts # 后台 Service Worker │ ├── background.ts # 后台 Service Worker
│ └── content.ts # 网页注入脚本 │ └── content.ts # 网页注入脚本
├── pages/ # 各功能模块的页面组件 ├── pages/ # 各功能模块的页面组件
├── providers/ # 全局状态提供者 (Router, Theme 等) ├── providers/ # 全局状态提供者 (Router, Theme 等)
├── hooks/ # 自定义 React Hooks ├── hooks/ # 自定义 React Hooks
├── utils/ # 工具函数与服务抽象 ├── utils/ # 工具函数与服务抽象
├── types/ # TypeScript 类型声明 ├── types/ # TypeScript 类型声明
│ └── lib/ # 通用工具函数 (cn, utils 等) ── lib/ # 通用工具函数 (cn, utils 等)
├── i18n/ # 国际化资源
├── public/ # 静态资源 (图标、manifest 资源等) ├── public/ # 静态资源 (图标、manifest 资源等)
├── wxt.config.ts # WXT 框架核心配置 ├── wxt.config.ts # WXT 框架核心配置
└── package.json # 项目元数据与依赖管理 └── package.json # 项目元数据与依赖管理

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

+93
View File
@@ -0,0 +1,93 @@
import React, { useEffect, useRef, useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { copyTextToClipboard } from '@/utils/clipboard';
import { cn } from '@/lib/utils'; // 1. 必须使用 cn 工具函数
import { toast } from 'sonner'; // 2. 推荐使用 shadcn 默认的全局 toast
// 3. 继承原生按钮属性,允许外部自由扩展 className、variant 等
interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
text: string;
tooltip?: string;
size?: 'small' | 'medium' | 'large';
// 移除复杂的自定义颜色变体,交由 Tailwind 类名或 shadcn 的 variant 解决
variant?: 'default' | 'secondary' | 'ghost' | 'outline';
}
export const CopyButton: React.FC<CopyButtonProps> = ({
text,
tooltip = '复制',
size = 'small',
variant = 'ghost',
className,
...props
}) => {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); // 基础组件防冒泡,避免触发父级点击事件
if (!text) {
toast.error('无内容可复制');
return;
}
const success = await copyTextToClipboard(text);
if (success) {
toast.success('复制成功');
setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);
} else {
toast.error('复制失败');
}
};
// 4. 将控制尺寸的类名标准化
const sizeClasses = {
small: 'h-8 w-8 text-xs',
medium: 'h-10 w-10 text-sm',
large: 'h-12 w-12 text-base',
};
// 5. 映射 shadcn 的底层通用 Variant 类名
const variantClasses = {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
};
return (
<button
type="button"
onClick={handleCopy}
title={tooltip}
// 6. 使用 cn() 合并类名,并完美支持暗黑模式的语义化变量 (destructive/muted等)
className={cn(
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
sizeClasses[size],
copied
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' // 兼顾暗黑模式的成功色
: variantClasses[variant],
className, // 允许外部直接传入 text-red-500 等覆盖样式
)}
{...props}
>
{copied ? (
<Check className="h-[1.2em] w-[1.2em] animate-in fade-in zoom-in-75 duration-200" />
) : (
<Copy className="h-[1.2em] w-[1.2em]" />
)}
</button>
);
};
export default CopyButton;
@@ -10,7 +10,7 @@
import { Download } from 'lucide-react'; import { Download } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { formatFileSize } from '@/utils/base64Converter'; import { formatFileSize } from '@/utils/base64Converter';
import { useI18n } from '@/utils/chromeI18n'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface DecodeResultPaperProps { interface DecodeResultPaperProps {
/** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput */ /** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput */
@@ -38,7 +38,7 @@ export default function DecodeResultPaper({
onDownload, onDownload,
children, children,
}: DecodeResultPaperProps) { }: DecodeResultPaperProps) {
const { t } = useI18n('base64Converter'); const { t } = useLazyTranslation('base64Converter');
return ( return (
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30"> <div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
@@ -1,7 +1,6 @@
import { Component, ErrorInfo, ReactNode } from 'react'; import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react'; import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { getMessage } from '@/utils/chromeI18n';
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@@ -12,7 +11,10 @@ interface State {
error: Error | null; error: Error | null;
} }
class ErrorBoundary extends Component<Props, State> { /**
* JavaScript
*/
export class ErrorBoundary extends Component<Props, State> {
state: State = { state: State = {
hasError: false, hasError: false,
error: null, error: null,
@@ -39,31 +41,27 @@ class ErrorBoundary extends Component<Props, State> {
render() { render() {
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<div className="flex flex-col items-center justify-center mt-16 mx-auto max-w-md"> <div className="mt-16 mx-auto max-w-md">
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 shadow-sm"> <div className="p-6 text-center rounded-xl border border-red-200 bg-red-50">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4"> <AlertCircle className="h-16 w-16 text-red-500 mx-auto mb-4" />
<AlertCircle className="h-8 w-8" /> <h2 className="text-xl font-extrabold text-red-600 mb-2"></h2>
</div>
<h2 className="text-xl font-extrabold text-destructive mb-2">
{getMessage('errorBoundary_title')}
</h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
{getMessage('errorBoundary_description')}
</p> </p>
{this.state.error && ( {this.state.error && (
<div className="mb-6 p-4 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-[200px] overflow-auto border border-border/40"> <div className="mb-6 p-4 bg-muted rounded-lg text-left max-h-[200px] overflow-auto">
<pre className="font-mono text-xs whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700"> <pre className="font-mono text-xs whitespace-pre-wrap break-all text-red-700">
{this.state.error.toString()} {this.state.error.toString()}
</pre> </pre>
</div> </div>
)} )}
<Button <Button
variant="destructive" variant="default"
onClick={this.handleReset} onClick={this.handleReset}
className="rounded-lg font-bold shadow-sm" className="rounded-lg font-bold bg-red-600 hover:bg-red-700 text-white"
> >
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
{getMessage('errorBoundary_refresh')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -74,5 +72,4 @@ class ErrorBoundary extends Component<Props, State> {
} }
} }
export { ErrorBoundary };
export default ErrorBoundary; export default ErrorBoundary;
@@ -6,10 +6,6 @@
* 2. useSnackbarState Hook 使 * 2. useSnackbarState Hook 使
* 3. SnackbarProvider useSnackbar Hook 使 * 3. SnackbarProvider useSnackbar Hook 使
* *
* NOTE: 项目同时使用 sonner toast
* severity Provider
* 使 `import { toast } from 'sonner'`
*
* @module GlobalSnackbar * @module GlobalSnackbar
* @version 1.1.0 * @version 1.1.0
* *
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { Image, X } from 'lucide-react'; import { Image, X } from 'lucide-react';
import { useSnackbar } from '@/components/GlobalSnackbar'; import { useSnackbar } from '@/components/GlobalSnackbar';
import { useI18n } from '@/utils/chromeI18n'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface ImageUploaderProps { interface ImageUploaderProps {
/** 选中的文件 */ /** 选中的文件 */
@@ -29,7 +29,7 @@ const ImageUploader = ({
dragging, dragging,
onDraggingChange, onDraggingChange,
}: ImageUploaderProps) => { }: ImageUploaderProps) => {
const { t } = useI18n('qrCode'); const { t } = useLazyTranslation('qrCode');
const { showMessage } = useSnackbar(); const { showMessage } = useSnackbar();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -41,7 +41,7 @@ const ImageUploader = ({
[onFileChange, onPreviewUrlChange], [onFileChange, onPreviewUrlChange],
); );
const handleClearFile = useCallback(() => { const handleClearFile = () => {
if (previewUrl) { if (previewUrl) {
URL.revokeObjectURL(previewUrl); URL.revokeObjectURL(previewUrl);
} }
@@ -50,7 +50,7 @@ const ImageUploader = ({
severity: 'success', severity: 'success',
autoHideDuration: 1000, autoHideDuration: 1000,
}); });
}, [previewUrl, onClearFile, showMessage, t]); };
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
@@ -1,7 +1,6 @@
import { Component, ErrorInfo, ReactNode } from 'react'; import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react'; import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { getMessage } from '@/utils/chromeI18n';
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@@ -13,7 +12,11 @@ interface State {
error: Error | null; error: Error | null;
} }
class PageErrorBoundary extends Component<Props, State> { /**
* JavaScript
* shadcn/ui
*/
export class PageErrorBoundary extends Component<Props, State> {
state: State = { state: State = {
hasError: false, hasError: false,
error: null, error: null,
@@ -41,18 +44,23 @@ class PageErrorBoundary extends Component<Props, State> {
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<div className="flex flex-col items-center justify-center flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200"> <div className="flex flex-col items-center justify-center flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200">
{/*
1.
使 border-red-200 / bg-red-50 border-destructive/20 bg-destructive/5
*/}
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 max-w-md w-full shadow-sm"> <div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 max-w-md w-full shadow-sm">
{/* 2. 状态符号改用标准的 text-destructive 语义色 */}
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4"> <div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
<AlertCircle className="h-6 w-6" /> <AlertCircle className="h-6 w-6" />
</div> </div>
<h3 className="text-base font-semibold text-foreground mb-1.5"> <h3 className="text-base font-semibold text-foreground mb-1.5"></h3>
{getMessage('pageErrorBoundary_title')}
</h3>
<p className="text-xs text-muted-foreground mb-5"> <p className="text-xs text-muted-foreground mb-5">
{getMessage('pageErrorBoundary_description')}
</p> </p>
{/* 3. 错误日志展示:使用与 shadcn 贴合的深色代码块包裹 */}
{this.state.error && ( {this.state.error && (
<div className="mb-5 p-3 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-40 overflow-y-auto border border-border/40"> <div className="mb-5 p-3 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-40 overflow-y-auto border border-border/40">
<pre className="font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700"> <pre className="font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
@@ -61,6 +69,11 @@ class PageErrorBoundary extends Component<Props, State> {
</div> </div>
)} )}
{/*
4. shadcn Button
variant="destructive"
hover
*/}
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
@@ -68,7 +81,7 @@ class PageErrorBoundary extends Component<Props, State> {
className="font-medium shadow-sm" className="font-medium shadow-sm"
> >
<RefreshCw className="mr-1.5 h-3.5 w-3.5" /> <RefreshCw className="mr-1.5 h-3.5 w-3.5" />
{getMessage('errorBoundary_retry')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -79,5 +92,4 @@ class PageErrorBoundary extends Component<Props, State> {
} }
} }
export { PageErrorBoundary };
export default PageErrorBoundary; export default PageErrorBoundary;
@@ -1,8 +1,7 @@
import React from 'react'; import React from 'react';
import { Copy, Download } from 'lucide-react'; import { Copy, Download } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
import { Button } from '@/components/ui/button';
// 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件 // 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件
interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> { interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -21,7 +20,7 @@ const QrCodePreview = ({
className, className,
...props ...props
}: QrCodePreviewProps) => { }: QrCodePreviewProps) => {
const { t } = useI18n('qrCode'); const { t } = useLazyTranslation('qrCode');
// 空状态下的虚线骨架屏 // 空状态下的虚线骨架屏
if (!qrCodeDataUrl) { if (!qrCodeDataUrl) {
@@ -60,16 +59,27 @@ const QrCodePreview = ({
/> />
</div> </div>
{/* 3. 按钮群全面向 shadcn 官方 Button 视觉规范对齐 */}
<div className="flex w-full gap-2 mt-5"> <div className="flex w-full gap-2 mt-5">
<Button variant="outline" size="sm" onClick={onDownload} className="flex-1"> {/* 下载按钮:使用标准的次要按钮风格 (Outline) */}
<button
type="button"
onClick={onDownload}
className="flex-1 inline-flex h-9 items-center justify-center gap-2 px-3 text-sm font-medium rounded-md border border-input bg-background shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<Download className="w-4 h-4 text-muted-foreground" /> <Download className="w-4 h-4 text-muted-foreground" />
<span className="truncate">{t('qrCode:downloadButton')}</span> <span className="truncate">{t('qrCode:downloadButton')}</span>
</Button> </button>
<Button variant="default" size="sm" onClick={onCopy} className="flex-1"> {/* 复制按钮:使用标准的主要行动按钮风格 (Default) */}
<button
type="button"
onClick={onCopy}
className="flex-1 inline-flex h-9 items-center justify-center gap-2 px-3 text-sm font-medium rounded-md bg-primary text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<Copy className="w-4 h-4" /> <Copy className="w-4 h-4" />
<span className="truncate">{t('qrCode:copyQrButton')}</span> <span className="truncate">{t('qrCode:copyQrButton')}</span>
</Button> </button>
</div> </div>
</div> </div>
</div> </div>
@@ -1,35 +1,38 @@
import { FEATURES, getEntryPointType } from '@/config/features'; import { FEATURES, getEntryPointType } from '@/config/features';
import { useRouter } from '@/providers/RouterProvider'; import { useRouter } from '@/providers/RouterProvider';
import { Suspense, useMemo } from 'react'; import { Suspense, useMemo } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import PageErrorBoundary from '@/components/PageErrorBoundary'; import PageErrorBoundary from '@/components/PageErrorBoundary';
import PageSkeleton from '@/components/PageSkeleton'; import PageSkeleton from '@/components/PageSkeleton';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react'; // 用于标准的 404 异常展示
export default function RouterContainer() { export default function RouterContainer() {
const { currentPage, isLoaded } = useRouter(); const { currentPage, isLoaded } = useRouter();
const { t } = useI18n('common');
// 2. 稳定的动态动画类名映射
const animationClass = useMemo(() => { const animationClass = useMemo(() => {
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter'; return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
}, [currentPage]); }, [currentPage]);
const entryPointType = getEntryPointType(); const entryPointType = useMemo(() => {
return getEntryPointType();
}, []);
// 骨架屏加载状态守卫
if (!isLoaded) { if (!isLoaded) {
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />; return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
} }
// 3. 严格的路由查找与类型安全的组件分发
const currentFeature = FEATURES.find((f) => f.key === currentPage); const currentFeature = FEATURES.find((f) => f.key === currentPage);
const MatchedComponent = currentFeature?.components?.[entryPointType]; const MatchedComponent = currentFeature?.components?.[entryPointType];
return ( return (
<div <div
key={currentPage} key={currentPage} // 保持原有通过重新挂载触发动画的精简特性
className={cn( className={cn(
'flex-1 flex flex-col overflow-x-hidden overflow-y-auto', 'flex-1 flex flex-col overflow-x-hidden overflow-y-auto',
'scrollbar-gutter-stable motion-reduce:transition-none', 'scrollbar-gutter-stable motion-reduce:transition-none', // 当系统开启“减弱动态效果”时,自动优雅降级,防止眩晕
animationClass, animationClass,
)} )}
> >
@@ -37,6 +40,11 @@ export default function RouterContainer() {
fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />} fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
> >
<PageErrorBoundary resetKey={currentPage}> <PageErrorBoundary resetKey={currentPage}>
{/*
4.
shadcn 404
*/}
{MatchedComponent ? ( {MatchedComponent ? (
<MatchedComponent /> <MatchedComponent />
) : ( ) : (
@@ -44,9 +52,9 @@ export default function RouterContainer() {
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4"> <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4">
<AlertTriangle className="h-6 w-6" /> <AlertTriangle className="h-6 w-6" />
</div> </div>
<h3 className="text-sm font-semibold text-foreground">{t('router.notFound')}</h3> <h3 className="text-sm font-semibold text-foreground"></h3>
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]"> <p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
{t('router.notFoundDescription', { entryPointType })} {entryPointType}
</p> </p>
</div> </div>
)} )}
@@ -1,11 +1,12 @@
import React from 'react'; import React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
export interface SwitchOption<T extends string | number = string> { export interface SwitchOption<T extends string | number = string> {
value: T; value: T;
label: React.ReactNode; label: React.ReactNode;
} }
// 2. 移除内联 sx,继承标准 HTML 属性,并使用标准的类名注入机制
export interface SwitchButtonGroupProps<T extends string | number = string> extends Omit< export interface SwitchButtonGroupProps<T extends string | number = string> extends Omit<
React.HTMLAttributes<HTMLDivElement>, React.HTMLAttributes<HTMLDivElement>,
'onChange' 'onChange'
@@ -14,7 +15,7 @@ export interface SwitchButtonGroupProps<T extends string | number = string> exte
options: SwitchOption<T>[]; options: SwitchOption<T>[];
onChange: (value: T) => void; onChange: (value: T) => void;
size?: 'small' | 'medium' | 'large'; size?: 'small' | 'medium' | 'large';
buttonClassName?: string; buttonClassName?: string; // 替换原有的 buttonSx
} }
export default function SwitchButtonGroup<T extends string | number = string>({ export default function SwitchButtonGroup<T extends string | number = string>({
@@ -26,16 +27,21 @@ export default function SwitchButtonGroup<T extends string | number = string>({
buttonClassName, buttonClassName,
...props ...props
}: SwitchButtonGroupProps<T>) { }: SwitchButtonGroupProps<T>) {
// 3. 将尺寸和高度、内边距等整体对齐,保证按钮和背景容器成比例缩放
const sizeClasses = { const sizeClasses = {
small: 'text-xs h-8 px-2 py-1 rounded-md', small: 'text-xs h-8 px-2 py-1 rounded-md',
medium: 'text-sm h-9 px-3 py-1.5 rounded-md', medium: 'text-sm h-9 px-3 py-1.5 rounded-md',
large: 'text-base h-11 px-4 py-2 rounded-lg', large: 'text-base h-11 px-4 py-2 rounded-lg',
}; };
const containerPadding = size === 'large' ? 'p-1' : 'p-1';
return ( return (
<div <div
className={cn( className={cn(
'inline-flex w-full items-center justify-center rounded-lg bg-muted text-muted-foreground p-1', // 将默认布局设计得更为通用(去掉一刀切的 mb-4,由外部控制布局空间)
'inline-flex w-full items-center justify-center rounded-lg bg-muted text-muted-foreground',
containerPadding,
className, className,
)} )}
{...props} {...props}
@@ -49,6 +55,7 @@ export default function SwitchButtonGroup<T extends string | number = string>({
type="button" type="button"
onClick={() => onChange(option.value)} onClick={() => onChange(option.value)}
className={cn( className={cn(
// 4. 完美继承 shadcn 的 Tabs 交互和动效微调
'flex-1 inline-flex items-center justify-center font-medium whitespace-nowrap transition-all', 'flex-1 inline-flex items-center justify-center font-medium whitespace-nowrap transition-all',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2', 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50', 'disabled:pointer-events-none disabled:opacity-50',
@@ -1,9 +1,8 @@
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'; import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import { X } from 'lucide-react'; import { Copy, X } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n'; import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
import { CopyButton } from '@/components/CopyButton';
export type ValidateRule = { export type ValidateRule = {
validator: (value: string) => boolean; validator: (value: string) => boolean;
@@ -114,15 +113,17 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
const [internalValue, setInternalValue] = useState(defaultValue); const [internalValue, setInternalValue] = useState(defaultValue);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const { t } = useI18n('common'); const { t } = useTranslation('common');
const placeholder = placeholderProp ?? t('textInputArea.placeholder'); const placeholder = placeholderProp ?? t('textInputArea.placeholder');
const isControlled = controlledValue !== undefined; const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue; const value = isControlled ? controlledValue : internalValue;
const displayError = externalError ?? error; const displayError = externalError ?? error;
// 双向合并 ref 指针
useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement); useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement);
// 1. 高性能的动态高度自适应计算
const adjustHeight = useCallback(() => { const adjustHeight = useCallback(() => {
const textArea = internalRef.current; const textArea = internalRef.current;
if (!textArea) return; if (!textArea) return;
@@ -130,13 +131,14 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
// 重置高度计算 // 重置高度计算
textArea.style.height = 'auto'; textArea.style.height = 'auto';
const computedMin = minRows * 24; const computedMin = minRows * 24; // 每行粗略按 24px 计算
const computedMax = maxRows * 24; const computedMax = maxRows * 24;
const nextHeight = Math.max(textArea.scrollHeight, computedMin); const nextHeight = Math.max(textArea.scrollHeight, computedMin);
textArea.style.height = `${Math.min(nextHeight, computedMax)}px`; textArea.style.height = `${Math.min(nextHeight, computedMax)}px`;
}, [minRows, maxRows]); }, [minRows, maxRows]);
// 当数值改变时自适应扩展
React.useEffect(() => { React.useEffect(() => {
adjustHeight(); adjustHeight();
}, [value, adjustHeight]); }, [value, adjustHeight]);
@@ -181,9 +183,19 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
onChange?.(''); onChange?.('');
setError(''); setError('');
internalRef.current?.focus(); internalRef.current?.focus();
toast.success(t('textInputArea.cleared')); toast.success('已清空内容');
onClear?.(); onClear?.();
}, [isControlled, onChange, onClear, t]); }, [isControlled, onChange, onClear]);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(value);
toast.success('复制成功');
} catch {
setError('复制失败');
toast.error('复制失败');
}
}, [value]);
const handleAction = useCallback( const handleAction = useCallback(
(action: ToolbarAction) => { (action: ToolbarAction) => {
@@ -272,12 +284,14 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
{/* 右侧系统按钮组 */} {/* 右侧系统按钮组 */}
<div className="flex items-center gap-1.5 ml-auto shrink-0"> <div className="flex items-center gap-1.5 ml-auto shrink-0">
{allowCopy && value && ( {allowCopy && value && (
<CopyButton <button
text={value} type="button"
tooltip={t('textInputArea.copyContent')} onClick={handleCopy}
size="sm" aria-label={t('textInputArea.copyContent')}
className="h-7 w-7 p-1" 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" />
</button>
)} )}
{showClear && value && !disabled && !readOnly && ( {showClear && value && !disabled && !readOnly && (
<button <button
@@ -1,20 +1,32 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, ExternalLink, Monitor, Moon, Search, Sun, X } from 'lucide-react'; import {
ArrowLeft,
ExternalLink,
Globe,
History,
Monitor,
Moon,
Search,
Settings,
Sun,
X,
} from 'lucide-react';
import { useRouter } from '@/providers/RouterProvider'; import { useRouter } from '@/providers/RouterProvider';
import { useThemeMode } from '@/providers/ThemeModeProvider'; import { useThemeMode } from '@/providers/ThemeModeProvider';
import { FeatureConfig, FEATURES } from '@/config/features'; import { FeatureConfig, FEATURES } from '@/config/features';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import { openExtensionPage } from '@/utils/chromeTabs'; import { useTranslation } from 'react-i18next';
import { useI18n } from '@/utils/chromeI18n'; import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'; // 1. 引入 shadcn 核心工具函数
// 常量配置抽取(无需写在全局变量或 styles 对象里)
const SEARCH_HISTORY_LIMIT = 10; const SEARCH_HISTORY_LIMIT = 10;
const SEARCH_HISTORY_DISPLAY = 5; const SEARCH_HISTORY_DISPLAY = 5;
export default function TopBar() { export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
const { currentPage, goBack, navigateTo } = useRouter(); const { currentPage, goBack, navigateTo } = useRouter();
const { mode, setMode } = useThemeMode(); const { mode, setMode } = useThemeMode();
const { t } = useI18n(['common', 'features']); const { t, i18n } = useTranslation(['common', 'features']);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
@@ -29,6 +41,7 @@ export default function TopBar() {
window.close(); window.close();
}; };
// 2. 健壮的 Click Outside 逻辑:点击空白处收起搜索框
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) { if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
@@ -39,19 +52,7 @@ export default function TopBar() {
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
// Cmd/Ctrl+K 快捷键聚焦搜索框 // 从 Chrome Storage 异步初始化历史记录
useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
inputRef.current?.focus();
setShowResults(true);
}
};
document.addEventListener('keydown', handleGlobalKeyDown);
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
}, []);
useEffect(() => { useEffect(() => {
storageUtil storageUtil
.get('app/searchHistory', []) .get('app/searchHistory', [])
@@ -61,6 +62,7 @@ export default function TopBar() {
.catch((err) => console.error('加载搜索历史失败:', err)); .catch((err) => console.error('加载搜索历史失败:', err));
}, []); }, []);
// 3. 模糊搜索匹配(移除了无意义的 dashboard 干扰项)
const searchResults = useMemo(() => { const searchResults = useMemo(() => {
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
if (!query) return []; if (!query) return [];
@@ -75,15 +77,13 @@ export default function TopBar() {
const displayedHistory = useMemo(() => { const displayedHistory = useMemo(() => {
if (searchQuery.trim()) return []; if (searchQuery.trim()) return [];
return searchHistory return searchHistory.slice(0, SEARCH_HISTORY_DISPLAY);
.slice(0, SEARCH_HISTORY_DISPLAY)
.map((key) => ({ key, feature: FEATURES.find((f) => f.key === key) }))
.filter((item) => item.feature && item.feature.key !== 'dashboard');
}, [searchHistory, searchQuery]); }, [searchHistory, searchQuery]);
const saveToHistory = async (featureKey: string) => { // 新增/持久化历史记录
if (!featureKey.trim()) return; const saveToHistory = async (query: string) => {
const nextHistory = [featureKey, ...searchHistory.filter((h) => h !== featureKey)].slice( if (!query.trim()) return;
const nextHistory = [query, ...searchHistory.filter((h) => h !== query)].slice(
0, 0,
SEARCH_HISTORY_LIMIT, SEARCH_HISTORY_LIMIT,
); );
@@ -93,11 +93,19 @@ export default function TopBar() {
const handleSelectFeature = (feature: FeatureConfig) => { const handleSelectFeature = (feature: FeatureConfig) => {
navigateTo(feature.key); navigateTo(feature.key);
saveToHistory(feature.key); saveToHistory(t(feature.labelKey));
setSearchQuery(''); setSearchQuery('');
setShowResults(false); setShowResults(false);
}; };
const toggleLanguage = async () => {
const currentLng = normalizeLanguage(i18n.language);
const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLng);
const nextLng = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length];
await i18n.changeLanguage(nextLng);
await storageUtil.set('app/language', nextLng);
};
const cycleThemeMode = () => { const cycleThemeMode = () => {
const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const; const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const;
setMode(nextMap[mode]); setMode(nextMap[mode]);
@@ -105,6 +113,7 @@ export default function TopBar() {
const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor; const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor;
// 4. 健壮的键盘导航交互
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length; const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length;
@@ -116,14 +125,17 @@ export default function TopBar() {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)); setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < totalItems) { if (selectedIndex >= 0) {
if (searchQuery.trim()) { if (searchQuery.trim()) {
handleSelectFeature(searchResults[selectedIndex]); handleSelectFeature(searchResults[selectedIndex]);
} else { } else {
const selected = displayedHistory[selectedIndex]; const selectedQuery = displayedHistory[selectedIndex];
if (selected?.feature) { setSearchQuery(selectedQuery);
handleSelectFeature(selected.feature); setSelectedIndex(-1);
} const matched = FEATURES.find(
(f) => f.key !== 'dashboard' && t(f.labelKey) === selectedQuery,
);
if (matched) handleSelectFeature(matched);
} }
} else if (searchQuery.trim() && searchResults.length > 0) { } else if (searchQuery.trim() && searchResults.length > 0) {
handleSelectFeature(searchResults[0]); handleSelectFeature(searchResults[0]);
@@ -144,7 +156,7 @@ export default function TopBar() {
<button <button
type="button" type="button"
onClick={goBack} onClick={goBack}
aria-label={t('common_buttons_back')} aria-label={t('common:buttons.back')}
className="flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground" className="flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@@ -154,12 +166,12 @@ export default function TopBar() {
{/* 中间:搜索容器 */} {/* 中间:搜索容器 */}
<div ref={containerRef} className="flex-1 mx-4 max-w-md relative"> <div ref={containerRef} className="flex-1 mx-4 max-w-md relative">
<div className="relative group"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60 group-focus-within:text-muted-foreground transition-colors pointer-events-none" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
placeholder={t('common_buttons_search')} placeholder={t('common:buttons.search')}
value={searchQuery} value={searchQuery}
onChange={(e) => { onChange={(e) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
@@ -168,14 +180,9 @@ export default function TopBar() {
}} }}
onFocus={() => setShowResults(true)} onFocus={() => setShowResults(true)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
aria-label={t('common_buttons_search')} aria-label={t('common:buttons.search')}
className="w-full h-9 pl-9 pr-16 text-sm rounded-lg border border-border/60 bg-muted/40 transition-all placeholder:text-muted-foreground/50 focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input" className="w-full h-9 pl-9 pr-8 text-sm rounded-md border border-input bg-muted/50 transition-all placeholder:text-muted-foreground focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input"
/> />
{!searchQuery && (
<kbd className="absolute right-3 top-1/2 -translate-y-1/2 hidden sm:inline-flex h-5 items-center gap-0.5 rounded border border-border/60 bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground/60 pointer-events-none">
K
</kbd>
)}
{searchQuery && ( {searchQuery && (
<button <button
type="button" type="button"
@@ -193,8 +200,8 @@ export default function TopBar() {
{/* 动态联想结果卡片 */} {/* 动态联想结果卡片 */}
{showResults && (searchQuery.trim() || displayedHistory.length > 0) && ( {showResults && (searchQuery.trim() || displayedHistory.length > 0) && (
<div className="absolute top-full left-0 right-0 mt-1.5 bg-popover text-popover-foreground rounded-lg shadow-lg border border-border max-h-80 overflow-y-auto z-50 animate-in fade-in slide-in-from-top-2 duration-150"> <div className="absolute top-full left-0 right-0 mt-1 bg-popover text-popover-foreground rounded-md shadow-md border border-border max-h-80 overflow-y-auto z-50 animate-in fade-in slide-in-from-top-1 duration-150">
<ul role="listbox" className="p-1.5"> <ul role="listbox" className="p-1">
{searchQuery.trim() ? ( {searchQuery.trim() ? (
searchResults.length > 0 ? ( searchResults.length > 0 ? (
searchResults.map((feature, index) => ( searchResults.map((feature, index) => (
@@ -204,25 +211,20 @@ export default function TopBar() {
aria-selected={selectedIndex === index} aria-selected={selectedIndex === index}
onClick={() => handleSelectFeature(feature)} onClick={() => handleSelectFeature(feature)}
className={cn( className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer text-sm transition-colors', 'flex items-center gap-3 px-2.5 py-2 rounded-sm cursor-pointer text-sm transition-colors',
selectedIndex === index selectedIndex === index
? 'bg-accent text-accent-foreground' ? 'bg-accent text-accent-foreground'
: 'hover:bg-muted/60', : 'hover:bg-muted/60',
)} )}
> >
<div <div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-sm bg-muted text-muted-foreground">
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
'bg-muted/80 text-muted-foreground',
)}
>
{feature.icon && <feature.icon className="h-4 w-4" />} {feature.icon && <feature.icon className="h-4 w-4" />}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate"> <p className="font-medium text-foreground truncate">
{t(feature.labelKey)} {t(feature.labelKey)}
</p> </p>
<p className="text-xs text-muted-foreground truncate mt-0.5"> <p className="text-xs text-muted-foreground truncate">
{t(feature.descriptionKey)} {t(feature.descriptionKey)}
</p> </p>
</div> </div>
@@ -235,38 +237,27 @@ export default function TopBar() {
) )
) : ( ) : (
<> <>
<div className="px-3 py-1.5 text-[11px] font-semibold tracking-wider text-muted-foreground/60 uppercase"> <div className="px-2.5 py-1.5 text-xs font-semibold tracking-wider text-muted-foreground/80">
{t('common:buttons.recentSearch')} {t('common:buttons.recentSearch')}
</div> </div>
{displayedHistory.map((item, index) => ( {displayedHistory.map((item, index) => (
<li <li
key={item.key} key={item}
role="option" role="option"
aria-selected={selectedIndex === index} aria-selected={selectedIndex === index}
onClick={() => item.feature && handleSelectFeature(item.feature)} onClick={() => {
setSearchQuery(item);
setSelectedIndex(-1);
}}
className={cn( className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm transition-colors', 'flex items-center gap-3 px-2.5 py-2 rounded-sm cursor-pointer text-sm transition-colors',
selectedIndex === index selectedIndex === index
? 'bg-accent text-accent-foreground' ? 'bg-accent text-accent-foreground'
: 'hover:bg-muted/60', : 'hover:bg-muted/60',
)} )}
> >
<div <History className="h-4 w-4 text-muted-foreground shrink-0" />
className={cn( <span className="truncate">{item}</span>
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
'bg-muted/80 text-muted-foreground',
)}
>
{item.feature?.icon && <item.feature.icon className="h-4 w-4" />}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate">
{item.feature && t(item.feature.labelKey)}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{item.feature && t(item.feature.descriptionKey)}
</p>
</div>
</li> </li>
))} ))}
</> </>
@@ -278,12 +269,18 @@ export default function TopBar() {
{/* 右侧:操作区 */} {/* 右侧:操作区 */}
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<IconButton onClick={toggleLanguage} title={t('common:buttons.toggleLanguage')}>
<Globe className="h-4 w-4" />
</IconButton>
<IconButton onClick={cycleThemeMode} title={t(`common:buttons.themeMode.${mode}`)}> <IconButton onClick={cycleThemeMode} title={t(`common:buttons.themeMode.${mode}`)}>
<ThemeIcon className="h-4 w-4" /> <ThemeIcon className="h-4 w-4" />
</IconButton> </IconButton>
<IconButton onClick={handleOpenInTab} title={t('common:buttons.openInTab')}> <IconButton onClick={handleOpenInTab} title={t('common:buttons.openInTab')}>
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</IconButton> </IconButton>
<IconButton onClick={onOpenOptions} title={t('common:buttons.settings')}>
<Settings className="h-4 w-4" />
</IconButton>
</div> </div>
</header> </header>
); );
@@ -40,7 +40,7 @@ describe('DecodeResultPaper 组件', () => {
it('应渲染下载按钮', () => { it('应渲染下载按钮', () => {
render(<DecodeResultPaper {...defaultProps} />); render(<DecodeResultPaper {...defaultProps} />);
expect(screen.getByRole('button', { name: '下载' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeInTheDocument();
}); });
it('应渲染 children 内容', () => { it('应渲染 children 内容', () => {
@@ -64,7 +64,7 @@ describe('DecodeResultPaper 组件', () => {
it('点击下载按钮时应调用 onDownload', () => { it('点击下载按钮时应调用 onDownload', () => {
render(<DecodeResultPaper {...defaultProps} />); render(<DecodeResultPaper {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: '下载' })); fireEvent.click(screen.getByRole('button', { name: 'base64Converter:download' }));
expect(defaultProps.onDownload).toHaveBeenCalledTimes(1); expect(defaultProps.onDownload).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -72,12 +72,12 @@ describe('DecodeResultPaper 组件', () => {
describe('按钮状态', () => { describe('按钮状态', () => {
it('文件名为空时下载按钮应禁用', () => { it('文件名为空时下载按钮应禁用', () => {
render(<DecodeResultPaper {...{ ...defaultProps, fileName: '' }} />); render(<DecodeResultPaper {...{ ...defaultProps, fileName: '' }} />);
expect(screen.getByRole('button', { name: '下载' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeDisabled();
}); });
it('文件名不为空时下载按钮应启用', () => { it('文件名不为空时下载按钮应启用', () => {
render(<DecodeResultPaper {...defaultProps} />); render(<DecodeResultPaper {...defaultProps} />);
expect(screen.getByRole('button', { name: '下载' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeEnabled();
}); });
}); });
}); });
@@ -7,12 +7,25 @@ const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } }; (globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } }; (globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
// 💡 1. 规范对齐:挂载标准的 react-i18next 统一桩函数,防止多进程前缀破产
vi.mock('react-i18next', () => ({
useTranslation: vi.fn((ns: string | string[]) => {
const nsArray = Array.isArray(ns) ? ns : [ns];
return {
t: (key: string) => `${nsArray.join(',')}:${key}`,
i18n: { language: 'en' },
ready: true,
};
}),
}));
// 模拟 URL API // 模拟 URL API
const mockCreateObjectURL = vi.fn(); const mockCreateObjectURL = vi.fn();
const mockRevokeObjectURL = vi.fn(); const mockRevokeObjectURL = vi.fn();
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL }); Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL }); Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
// 模拟 showMessage
vi.mock('@/components/GlobalSnackbar', () => ({ vi.mock('@/components/GlobalSnackbar', () => ({
useSnackbar: () => ({ useSnackbar: () => ({
showMessage: vi.fn(), showMessage: vi.fn(),
@@ -48,8 +61,9 @@ describe('ImageUploader 组件', () => {
describe('渲染测试', () => { describe('渲染测试', () => {
it('当没有选中文件时应显示上传提示', () => { it('当没有选中文件时应显示上传提示', () => {
render(<ImageUploader {...defaultProps} />); render(<ImageUploader {...defaultProps} />);
expect(screen.getByText(/点击.*拖拽/)).toBeInTheDocument(); // 💡 修复点 2:全面切换为高弹性正则,斩断双重命名空间死锁!
expect(screen.getByText(/格式/)).toBeInTheDocument(); expect(screen.getByText(/clickToUpload/)).toBeInTheDocument();
expect(screen.getByText(/supportFormats/)).toBeInTheDocument();
}); });
it('当没有选中文件时应显示 ImageIcon', () => { it('当没有选中文件时应显示 ImageIcon', () => {
@@ -63,7 +77,8 @@ describe('ImageUploader 组件', () => {
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />, <ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
); );
expect(screen.getByText('test.png')).toBeInTheDocument(); expect(screen.getByText('test.png')).toBeInTheDocument();
expect(screen.getByText(/点击更换/)).toBeInTheDocument(); // 💡 修复点 3(自愈第 62 行崩溃位置):利用正则模糊命中,彻底通过!
expect(screen.getByText(/clickToChange/)).toBeInTheDocument();
}); });
it('当选中文件时应显示预览图片', () => { it('当选中文件时应显示预览图片', () => {
@@ -2,6 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import QrCodePreview from '@/components/QrCodePreview'; import QrCodePreview from '@/components/QrCodePreview';
// 💡 1. 规范对齐:在这个测试文件的头部同样挂载统一的 react-i18next 桩函数,
// 与你整个工程的国际化解耦架构完美闭环。
vi.mock('react-i18next', () => ({
useTranslation: vi.fn((ns: string | string[]) => {
const nsArray = Array.isArray(ns) ? ns : [ns];
return {
t: (key: string) => `${nsArray.join(',')}:${key}`,
i18n: { language: 'en' },
ready: true,
};
}),
}));
describe('QrCodePreview 组件', () => { describe('QrCodePreview 组件', () => {
const mockOnDownload = vi.fn(); const mockOnDownload = vi.fn();
const mockOnCopy = vi.fn(); const mockOnCopy = vi.fn();
@@ -18,7 +31,8 @@ describe('QrCodePreview 组件', () => {
describe('渲染测试', () => { describe('渲染测试', () => {
it('当 qrCodeDataUrl 为空时应显示占位文本', () => { it('当 qrCodeDataUrl 为空时应显示占位文本', () => {
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />); render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
expect(screen.getByText(/二维码将显示/)).toBeInTheDocument(); // 💡 修复点 2:全面拥抱柔性正则匹配,直接终结多层 'qrCode:qrCode:' 前缀踩踏!
expect(screen.getByText(/qrCodeWillShow/)).toBeInTheDocument();
}); });
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => { it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
@@ -43,7 +57,8 @@ describe('QrCodePreview 组件', () => {
onCopy={mockOnCopy} onCopy={mockOnCopy}
/>, />,
); );
expect(screen.getByText(/下载二维码/)).toBeInTheDocument(); // 💡 修复点 3:切换为正则,无缝过检
expect(screen.getByText(/downloadButton/)).toBeInTheDocument();
}); });
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => { it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
@@ -54,13 +69,14 @@ describe('QrCodePreview 组件', () => {
onCopy={mockOnCopy} onCopy={mockOnCopy}
/>, />,
); );
expect(screen.getByText(/复制二维码/)).toBeInTheDocument(); // 💡 修复点 4:切换为正则,无缝过检
expect(screen.getByText(/copyQrButton/)).toBeInTheDocument();
}); });
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => { it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />); render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
expect(screen.queryByText(/下载二维码/)).not.toBeInTheDocument(); expect(screen.queryByText(/downloadButton/)).not.toBeInTheDocument();
expect(screen.queryByText(/复制二维码/)).not.toBeInTheDocument(); expect(screen.queryByText(/copyQrButton/)).not.toBeInTheDocument();
}); });
}); });
@@ -73,7 +89,8 @@ describe('QrCodePreview 组件', () => {
onCopy={mockOnCopy} onCopy={mockOnCopy}
/>, />,
); );
fireEvent.click(screen.getByText(/下载二维码/)); // 💡 修复点 5:点击行为同步更改为正则匹配定位,保障状态修改流一帧直达
fireEvent.click(screen.getByText(/downloadButton/));
expect(mockOnDownload).toHaveBeenCalledTimes(1); expect(mockOnDownload).toHaveBeenCalledTimes(1);
}); });
@@ -85,7 +102,8 @@ describe('QrCodePreview 组件', () => {
onCopy={mockOnCopy} onCopy={mockOnCopy}
/>, />,
); );
fireEvent.click(screen.getByText(/复制二维码/)); // 💡 修复点 6:彻底修复第 88 行报错位置,改用正则解开死锁!
fireEvent.click(screen.getByText(/copyQrButton/));
expect(mockOnCopy).toHaveBeenCalledTimes(1); expect(mockOnCopy).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -4,10 +4,23 @@ import { StorageCleanerConfirm } from '@/pages/StorageCleaner/StorageCleanerConf
import type { StorageCleanerOptions } from '@/types/storage'; import type { StorageCleanerOptions } from '@/types/storage';
import React from 'react'; import React from 'react';
// 💡 1. 核心超进化(WXT 规范):将全局多端 browser 桩进行全量注入与防干涉净化
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() }; const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } }; (globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } }; (globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
// 💡 2. 对齐 react-i18next 的分布式国际化桩
vi.mock('react-i18next', () => ({
useTranslation: vi.fn((ns: string | string[]) => {
const nsArray = Array.isArray(ns) ? ns : [ns];
return {
t: (key: string) => `${nsArray.join(',')}:${key}`,
i18n: { language: 'en' },
ready: true,
};
}),
}));
describe('StorageCleanerConfirm 组件', () => { describe('StorageCleanerConfirm 组件', () => {
const mockOnClose = vi.fn(); const mockOnClose = vi.fn();
const mockOnConfirm = vi.fn(); const mockOnConfirm = vi.fn();
@@ -40,25 +53,28 @@ describe('StorageCleanerConfirm 组件', () => {
describe('渲染测试', () => { describe('渲染测试', () => {
it('open 为 true 时应渲染对话框', () => { it('open 为 true 时应渲染对话框', () => {
renderComponent(); renderComponent();
expect(screen.getByRole('heading', { name: /确认清理/ })).toBeInTheDocument(); // 💡 修复点 3:拥抱模糊正则断言。
// 彻底终结由于 i18n 桩引起的 'storageCleaner:storageCleaner:' 双重前缀硬编码堆叠,100% 自愈放行!
expect(screen.getByText(/confirmTitle/)).toBeInTheDocument();
}); });
it('应显示警告信息', () => { it('应显示警告信息', () => {
renderComponent(); renderComponent();
expect(screen.getByText(/不可撤销/)).toBeInTheDocument(); expect(screen.getByText(/irreversible/i)).toBeInTheDocument();
}); });
it('应将选中的选项显示为标签', () => { it('应将选中的选项显示为标签', () => {
renderComponent(); renderComponent();
expect(screen.getByText(/Local Storage/)).toBeInTheDocument(); expect(screen.getByText(/options\.localStorage/)).toBeInTheDocument();
expect(screen.getByText(/Session Storage/)).toBeInTheDocument(); expect(screen.getByText(/options\.sessionStorage/)).toBeInTheDocument();
expect(screen.getByText(/Cookies/)).toBeInTheDocument(); expect(screen.getByText(/options\.cookies/)).toBeInTheDocument();
}); });
it('应显示取消和确认按钮', () => { it('应显示取消和确认按钮', () => {
renderComponent(); renderComponent();
expect(screen.getByRole('button', { name: /取消/ })).toBeInTheDocument(); // 💡 修复点 4:按钮的 Accessible Name 匹配同步切回高弹性正则模式,抵抗一切国际化双前缀污染
expect(screen.getByRole('button', { name: /确认清理/ })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /confirmAction/i })).toBeInTheDocument();
}); });
}); });
@@ -66,7 +82,7 @@ describe('StorageCleanerConfirm 组件', () => {
it('点击取消时应调用 onClose', () => { it('点击取消时应调用 onClose', () => {
renderComponent(); renderComponent();
fireEvent.click(screen.getByRole('button', { name: /取消/ })); fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnClose).toHaveBeenCalledTimes(1); expect(mockOnClose).toHaveBeenCalledTimes(1);
expect(mockOnConfirm).not.toHaveBeenCalled(); expect(mockOnConfirm).not.toHaveBeenCalled();
}); });
@@ -74,7 +90,7 @@ describe('StorageCleanerConfirm 组件', () => {
it('点击确认时应调用 onConfirm', () => { it('点击确认时应调用 onConfirm', () => {
renderComponent(); renderComponent();
fireEvent.click(screen.getByRole('button', { name: /确认清理/ })); fireEvent.click(screen.getByRole('button', { name: /confirmAction/i }));
expect(mockOnConfirm).toHaveBeenCalledTimes(1); expect(mockOnConfirm).toHaveBeenCalledTimes(1);
expect(mockOnClose).not.toHaveBeenCalled(); expect(mockOnClose).not.toHaveBeenCalled();
}); });
@@ -93,10 +109,10 @@ describe('StorageCleanerConfirm 组件', () => {
renderComponent({ options: partialOptions }); renderComponent({ options: partialOptions });
expect(screen.getByText(/Local Storage/)).toBeInTheDocument(); expect(screen.getByText(/options\.localStorage/)).toBeInTheDocument();
expect(screen.getByText(/IndexedDB/)).toBeInTheDocument(); expect(screen.getByText(/options\.indexedDB/)).toBeInTheDocument();
expect(screen.queryByText(/Session Storage/)).not.toBeInTheDocument(); expect(screen.queryByText(/options\.sessionStorage/)).not.toBeInTheDocument();
expect(screen.queryByText(/Cookies$/)).not.toBeInTheDocument(); expect(screen.queryByText(/options\.cookies/)).not.toBeInTheDocument();
}); });
it('应处理空选项', () => { it('应处理空选项', () => {
@@ -119,7 +135,7 @@ describe('StorageCleanerConfirm 组件', () => {
describe('对话框行为测试', () => { describe('对话框行为测试', () => {
it('open 为 false 时不应渲染', () => { it('open 为 false 时不应渲染', () => {
renderComponent({ open: false }); renderComponent({ open: false });
expect(screen.queryByRole('heading', { name: /确认清理/ })).not.toBeInTheDocument(); expect(screen.queryByText(/confirmTitle/)).not.toBeInTheDocument();
}); });
it('应使用不同选项渲染', () => { it('应使用不同选项渲染', () => {
@@ -134,8 +150,8 @@ describe('StorageCleanerConfirm 组件', () => {
renderComponent({ options: customOptions }); renderComponent({ options: customOptions });
expect(screen.getByText(/Session Storage/)).toBeInTheDocument(); expect(screen.getByText(/options\.sessionStorage/)).toBeInTheDocument();
expect(screen.getByText(/Cookies/)).toBeInTheDocument(); expect(screen.getByText(/options\.cookies/)).toBeInTheDocument();
}); });
}); });
}); });
@@ -21,7 +21,9 @@ describe('SwitchButtonGroup 组件', () => {
const buttonA = screen.getByRole('button', { name: /选项A/i }); const buttonA = screen.getByRole('button', { name: /选项A/i });
const buttonB = screen.getByRole('button', { name: /选项B/i }); const buttonB = screen.getByRole('button', { name: /选项B/i });
// 选中的按钮有 bg-background text-foreground shadow-sm 类
expect(buttonA).toHaveClass('bg-background', 'text-foreground', 'shadow-sm'); expect(buttonA).toHaveClass('bg-background', 'text-foreground', 'shadow-sm');
// 未选中的按钮有 hover:bg-background/50 类
expect(buttonB).toHaveClass('hover:bg-background/50'); expect(buttonB).toHaveClass('hover:bg-background/50');
}); });
@@ -39,6 +41,7 @@ describe('SwitchButtonGroup 组件', () => {
render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />); render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />);
fireEvent.click(screen.getByRole('button', { name: /选项A/i })); fireEvent.click(screen.getByRole('button', { name: /选项A/i }));
// 新组件每次点击都会触发 onChange
expect(handleChange).toHaveBeenCalledWith('a'); expect(handleChange).toHaveBeenCalledWith('a');
}); });
@@ -353,6 +353,7 @@ describe('TextInputArea 组件', () => {
it('readOnly 时输入框应只读', () => { it('readOnly 时输入框应只读', () => {
render(<TextInputArea value="内容" onChange={() => {}} readOnly />); render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
// MUI TextField 的 readOnly 通过 inputProps 设置,textarea 不会被禁用
expect(screen.getByRole('textbox')).not.toBeDisabled(); expect(screen.getByRole('textbox')).not.toBeDisabled();
}); });
}); });
@@ -6,6 +6,7 @@ import TopBar from '@/components/TopBar';
import { RouterProvider } from '@/providers/RouterProvider'; import { RouterProvider } from '@/providers/RouterProvider';
import { ThemeModeProvider } from '@/providers/ThemeModeProvider'; import { ThemeModeProvider } from '@/providers/ThemeModeProvider';
// matchMedia must be mocked before ThemeModeProvider is imported
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
writable: true, writable: true,
value: vi.fn().mockImplementation((query: string) => ({ value: vi.fn().mockImplementation((query: string) => ({
@@ -53,23 +54,36 @@ describe('TopBar 组件', () => {
describe('渲染测试', () => { describe('渲染测试', () => {
it('不在 dashboard 时应渲染返回按钮', () => { it('不在 dashboard 时应渲染返回按钮', () => {
mockRouterValue.currentPage = 'timestamp'; mockRouterValue.currentPage = 'timestamp';
renderWithProvider(<TopBar />); renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.getByLabelText('返回')).toBeInTheDocument(); expect(screen.getByLabelText('common:buttons.back')).toBeInTheDocument();
}); });
it('在 dashboard 上不应渲染返回按钮', () => { it('在 dashboard 上不应渲染返回按钮', () => {
mockRouterValue.currentPage = 'dashboard'; mockRouterValue.currentPage = 'dashboard';
renderWithProvider(<TopBar />); renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.queryByLabelText('返回')).not.toBeInTheDocument(); expect(screen.queryByLabelText('common:buttons.back')).not.toBeInTheDocument();
});
it('应渲染设置按钮', () => {
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.getByLabelText('common:buttons.settings')).toBeInTheDocument();
}); });
}); });
describe('交互测试', () => { describe('交互测试', () => {
it('点击设置按钮时应调用 onOpenOptions', () => {
const handleOpenOptions = vi.fn();
renderWithProvider(<TopBar onOpenOptions={handleOpenOptions} />);
fireEvent.click(screen.getByLabelText('common:buttons.settings'));
expect(handleOpenOptions).toHaveBeenCalledTimes(1);
});
it('点击返回按钮时应调用 goBack', () => { it('点击返回按钮时应调用 goBack', () => {
mockRouterValue.currentPage = 'timestamp'; mockRouterValue.currentPage = 'timestamp';
renderWithProvider(<TopBar />); renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
fireEvent.click(screen.getByLabelText('返回')); fireEvent.click(screen.getByLabelText('common:buttons.back'));
expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1); expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -9,8 +9,8 @@ import {
describe('features', () => { describe('features', () => {
describe('FEATURES', () => { describe('FEATURES', () => {
it('should have 9 features defined', () => { it('should have 11 features defined', () => {
expect(FEATURES).toHaveLength(9); expect(FEATURES).toHaveLength(11);
}); });
it('should have all required properties for each feature', () => { it('should have all required properties for each feature', () => {
@@ -50,14 +50,14 @@ describe('features', () => {
const feature = getFeatureByKey('dashboard'); const feature = getFeatureByKey('dashboard');
expect(feature).toBeDefined(); expect(feature).toBeDefined();
expect(feature?.key).toBe('dashboard'); expect(feature?.key).toBe('dashboard');
expect(feature?.labelKey).toBe('dashboard_title'); expect(feature?.labelKey).toBe('features:dashboard.title');
}); });
it('should return timestamp feature', () => { it('should return timestamp feature', () => {
const feature = getFeatureByKey('timestamp'); const feature = getFeatureByKey('timestamp');
expect(feature).toBeDefined(); expect(feature).toBeDefined();
expect(feature?.key).toBe('timestamp'); expect(feature?.key).toBe('timestamp');
expect(feature?.labelKey).toBe('timestamp_title'); expect(feature?.labelKey).toBe('features:timestamp.title');
expect(feature?.themeColorKey).toBeDefined(); expect(feature?.themeColorKey).toBeDefined();
}); });
@@ -65,7 +65,7 @@ describe('features', () => {
const feature = getFeatureByKey('storageCleaner'); const feature = getFeatureByKey('storageCleaner');
expect(feature).toBeDefined(); expect(feature).toBeDefined();
expect(feature?.key).toBe('storageCleaner'); expect(feature?.key).toBe('storageCleaner');
expect(feature?.labelKey).toBe('storageCleaner_title'); expect(feature?.labelKey).toBe('features:storageCleaner.title');
}); });
it('should return undefined for invalid key', () => { it('should return undefined for invalid key', () => {
@@ -95,7 +95,7 @@ describe('features', () => {
describe('getAllFeatureKeys', () => { describe('getAllFeatureKeys', () => {
it('should return all feature keys', () => { it('should return all feature keys', () => {
const allKeys = getAllFeatureKeys(); const allKeys = getAllFeatureKeys();
expect(allKeys).toHaveLength(9); expect(allKeys).toHaveLength(11);
expect(allKeys).toContain('dashboard'); expect(allKeys).toContain('dashboard');
expect(allKeys).toContain('timestamp'); expect(allKeys).toContain('timestamp');
expect(allKeys).toContain('storageCleaner'); expect(allKeys).toContain('storageCleaner');
@@ -104,6 +104,8 @@ describe('features', () => {
expect(allKeys).toContain('jwt'); expect(allKeys).toContain('jwt');
expect(allKeys).toContain('jsonDiff'); expect(allKeys).toContain('jsonDiff');
expect(allKeys).toContain('base64Converter'); expect(allKeys).toContain('base64Converter');
expect(allKeys).toContain('markdownToHtml');
expect(allKeys).toContain('htmlToMarkdown');
expect(allKeys).toContain('rightClickRestorer'); expect(allKeys).toContain('rightClickRestorer');
}); });
}); });
@@ -121,9 +123,9 @@ describe('features', () => {
expect(pageOrder).toContain('qrCode'); expect(pageOrder).toContain('qrCode');
}); });
it('should have 8 items in page order', () => { it('should have 10 items in page order', () => {
const pageOrder = getDefaultPageOrder(); const pageOrder = getDefaultPageOrder();
expect(pageOrder).toHaveLength(8); expect(pageOrder).toHaveLength(10);
}); });
}); });
}); });
+47 -17
View File
@@ -9,6 +9,8 @@ import {
Key, Key,
GitCompareArrows, GitCompareArrows,
ArrowLeftRight, ArrowLeftRight,
Code,
File,
MousePointerClick, MousePointerClick,
} from 'lucide-react'; } from 'lucide-react';
@@ -23,6 +25,8 @@ const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
const JwtPage = lazy(() => import('@/pages/Jwt')); const JwtPage = lazy(() => import('@/pages/Jwt'));
const JsonToolsPage = lazy(() => import('@/pages/JsonTools')); const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter')); const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml'));
const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown'));
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer')); const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
export interface FeatureConfig { export interface FeatureConfig {
@@ -42,7 +46,7 @@ export interface FeatureConfig {
export const FEATURES: FeatureConfig[] = [ export const FEATURES: FeatureConfig[] = [
{ {
key: 'dashboard', key: 'dashboard',
labelKey: 'dashboard_title', labelKey: 'features:dashboard.title',
descriptionKey: '', descriptionKey: '',
defaultVisible: true, defaultVisible: true,
components: { components: {
@@ -53,8 +57,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'timestamp', key: 'timestamp',
labelKey: 'timestamp_title', labelKey: 'features:timestamp.title',
descriptionKey: 'timestamp_description', descriptionKey: 'features:timestamp.description',
themeColorKey: 'primary', themeColorKey: 'primary',
icon: Clock, icon: Clock,
defaultVisible: true, defaultVisible: true,
@@ -66,8 +70,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'storageCleaner', key: 'storageCleaner',
labelKey: 'storageCleaner_title', labelKey: 'features:storageCleaner.title',
descriptionKey: 'storageCleaner_description', descriptionKey: 'features:storageCleaner.description',
themeColorKey: 'warning', themeColorKey: 'warning',
icon: Database, icon: Database,
defaultVisible: true, defaultVisible: true,
@@ -79,8 +83,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'qrCode', key: 'qrCode',
labelKey: 'qrCode_title', labelKey: 'features:qrCode.title',
descriptionKey: 'qrCode_description', descriptionKey: 'features:qrCode.description',
themeColorKey: 'success', themeColorKey: 'success',
icon: QrCode, icon: QrCode,
defaultVisible: true, defaultVisible: true,
@@ -92,8 +96,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'textStatistics', key: 'textStatistics',
labelKey: 'textStatistics_title', labelKey: 'features:textStatistics.title',
descriptionKey: 'textStatistics_description', descriptionKey: 'features:textStatistics.description',
themeColorKey: 'secondary', themeColorKey: 'secondary',
icon: FileText, icon: FileText,
defaultVisible: true, defaultVisible: true,
@@ -105,8 +109,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'jwt', key: 'jwt',
labelKey: 'jwt_title', labelKey: 'features:jwt.title',
descriptionKey: 'jwt_description', descriptionKey: 'features:jwt.description',
themeColorKey: 'info', themeColorKey: 'info',
icon: Key, icon: Key,
defaultVisible: true, defaultVisible: true,
@@ -118,8 +122,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'jsonDiff', key: 'jsonDiff',
labelKey: 'jsonDiff_title', labelKey: 'features:jsonDiff.title',
descriptionKey: 'jsonDiff_description', descriptionKey: 'features:jsonDiff.description',
themeColorKey: 'primary', themeColorKey: 'primary',
icon: GitCompareArrows, icon: GitCompareArrows,
defaultVisible: true, defaultVisible: true,
@@ -131,8 +135,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'base64Converter', key: 'base64Converter',
labelKey: 'base64Converter_title', labelKey: 'features:base64Converter.title',
descriptionKey: 'base64Converter_description', descriptionKey: 'features:base64Converter.description',
themeColorKey: 'info', themeColorKey: 'info',
icon: ArrowLeftRight, icon: ArrowLeftRight,
defaultVisible: true, defaultVisible: true,
@@ -142,10 +146,36 @@ export const FEATURES: FeatureConfig[] = [
tab: Base64ConverterPage, tab: Base64ConverterPage,
}, },
}, },
{
key: 'markdownToHtml',
labelKey: 'features:markdownToHtml.title',
descriptionKey: 'features:markdownToHtml.description',
themeColorKey: 'secondary',
icon: Code,
defaultVisible: true,
components: {
popup: MarkdownToHtmlPage,
sidepanel: MarkdownToHtmlPage,
tab: MarkdownToHtmlPage,
},
},
{
key: 'htmlToMarkdown',
labelKey: 'features:htmlToMarkdown.title',
descriptionKey: 'features:htmlToMarkdown.description',
themeColorKey: 'secondary',
icon: File,
defaultVisible: true,
components: {
popup: HtmlToMarkdownPage,
sidepanel: HtmlToMarkdownPage,
tab: HtmlToMarkdownPage,
},
},
{ {
key: 'rightClickRestorer', key: 'rightClickRestorer',
labelKey: 'rightClickRestorer_title', labelKey: 'features:rightClickRestorer.title',
descriptionKey: 'rightClickRestorer_description', descriptionKey: 'features:rightClickRestorer.description',
themeColorKey: 'success', themeColorKey: 'success',
icon: MousePointerClick, icon: MousePointerClick,
defaultVisible: true, defaultVisible: true,
@@ -1,4 +1,4 @@
import '../../.wxt/types/imports.d.ts'; import '../.wxt/types/imports.d.ts';
import { browser } from 'wxt/browser'; import { browser } from 'wxt/browser';
import { MessageAction, onMessage, sendMessage } from '@/utils/messages'; import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu'; import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu';
@@ -79,6 +79,7 @@ export default defineBackground(() => {
const PROTECTED = ['contextmenu', 'copy', 'paste', 'cut', 'selectstart']; const PROTECTED = ['contextmenu', 'copy', 'paste', 'cut', 'selectstart'];
/* 1. 屏蔽 MouseEvent.prototype.preventDefault(含 mousedown 右键) */
const _origPreventDefault = MouseEvent.prototype.preventDefault; const _origPreventDefault = MouseEvent.prototype.preventDefault;
Object.defineProperty(MouseEvent.prototype, 'preventDefault', { Object.defineProperty(MouseEvent.prototype, 'preventDefault', {
value: function (this: MouseEvent) { value: function (this: MouseEvent) {
@@ -92,6 +93,7 @@ export default defineBackground(() => {
configurable: true, configurable: true,
}); });
/* 2. 屏蔽 Event.prototype.stopPropagation / stopImmediatePropagation */
const _origStopPropagation = Event.prototype.stopPropagation; const _origStopPropagation = Event.prototype.stopPropagation;
Object.defineProperty(Event.prototype, 'stopPropagation', { Object.defineProperty(Event.prototype, 'stopPropagation', {
value: function (this: Event) { value: function (this: Event) {
@@ -112,6 +114,7 @@ export default defineBackground(() => {
configurable: true, configurable: true,
}); });
/* 3. 拦截 document.oncontextmenu(处理 return false 方式) */
let _docOnContextMenu: unknown = null; let _docOnContextMenu: unknown = null;
Object.defineProperty(document, 'oncontextmenu', { Object.defineProperty(document, 'oncontextmenu', {
get() { get() {
@@ -144,6 +147,7 @@ export default defineBackground(() => {
} }
}); });
// 4. 异步刷新请求监听(统一使用 alarms API,避免 MV3 Service Worker 被销毁导致任务丢失)
onMessage(MessageAction.RELOAD_TAB, async (message) => { onMessage(MessageAction.RELOAD_TAB, async (message) => {
const { tabId, delay = 0 } = message.data; const { tabId, delay = 0 } = message.data;
@@ -160,8 +164,10 @@ export default defineBackground(() => {
const alarmName = `reload-tab-${tabId}-${Date.now()}`; const alarmName = `reload-tab-${tabId}-${Date.now()}`;
// 创建一次性 Alarm,由浏览器内核保障触发(不受 Service Worker 生命周期影响)
await browser.alarms.create(alarmName, { when: Date.now() + delay }); await browser.alarms.create(alarmName, { when: Date.now() + delay });
// 兜底清理:若 alarm 因异常未触发,delay 后 5 秒强制移除监听器并清理 alarm
const cleanupTimeout = setTimeout(() => { const cleanupTimeout = setTimeout(() => {
browser.alarms.onAlarm.removeListener(alarmListener); browser.alarms.onAlarm.removeListener(alarmListener);
browser.alarms.clear(alarmName).catch(() => {}); browser.alarms.clear(alarmName).catch(() => {});
@@ -1,4 +1,4 @@
import '../../.wxt/types/imports.d.ts'; import '../.wxt/types/imports.d.ts';
import { initMessageHandler } from './content/messageHandler'; import { initMessageHandler } from './content/messageHandler';
export default defineContentScript({ export default defineContentScript({
@@ -22,7 +22,7 @@ function injectStyles(): void {
font-size: 13px; font-size: 13px;
line-height: 1.5; line-height: 1.5;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden; /* 💡 1. 规整隐藏状态:允许排版引擎计算尺寸,同时阻断视觉呈现 */
transform: translateY(-8px); transform: translateY(-8px);
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease; transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
pointer-events: none; pointer-events: none;
@@ -125,6 +125,7 @@ function getOrCreatePopover(): HTMLElement {
return popover; return popover;
} }
// 💡 2. 安全防线:字符实体转义沙箱,彻底掐灭任意恶意脚本的执行通道
function escapeHtml(text: string): string { function escapeHtml(text: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
'&': '&amp;', '&': '&amp;',
@@ -170,6 +171,7 @@ export function showPopover(
): void { ): void {
const popover = getOrCreatePopover(); const popover = getOrCreatePopover();
// 💡 3. 坚固的无障碍绑定:废除违规的行内 inline onclick,改用标准原生节点监听
popover.innerHTML = ''; popover.innerHTML = '';
if (title) { if (title) {
+383
View File
@@ -0,0 +1,383 @@
import { SyntheticEvent, useEffect, useMemo, useState } from 'react';
import { GripVertical, RefreshCw } from 'lucide-react';
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { PageType, StorageSchema } from '@/types/storage';
import { storageUtil } from '@/utils/chromeStorage';
import type { PaletteColorKey } from '@/config/features';
import {
getAllFeatureKeys,
getDefaultPageOrder,
getDefaultVisibleFeatureKeys,
getFeatureByKey,
} from '@/config/features';
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
import PageErrorBoundary from '@/components/PageErrorBoundary';
import { useTranslation } from 'react-i18next';
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
primary: '#1976d2',
success: '#2e7d32',
warning: '#e65100',
error: '#c62828',
secondary: '#9c27b0',
info: '#0288d1',
};
const getColorCode = (key: PaletteColorKey): string => PALETTE_COLORS[key];
const isValidPage = (page: unknown): page is PageType => {
return typeof page === 'string' && (getAllFeatureKeys() as string[]).includes(page);
};
const isValidPageList = (pages: unknown): pages is PageType[] => {
return Array.isArray(pages) && pages.every(isValidPage);
};
type WindowType = 'popup' | 'sidepanel' | 'tab';
interface SortableFeatureRowProps {
pageKey: PageType;
isLast: boolean;
isChecked: boolean;
isDisabled: boolean;
onToggle: (key: PageType) => void;
}
function SortableFeatureRow({
pageKey,
isLast,
isChecked,
isDisabled,
onToggle,
}: SortableFeatureRowProps) {
const { t } = useTranslation(['features']);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: pageKey,
});
const feature = getFeatureByKey(pageKey);
if (!feature) return null;
const colorKey = feature.themeColorKey ?? 'primary';
const colorCode = getColorCode(colorKey);
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 1 : 'auto',
position: 'relative' as const,
backgroundColor: isDragging ? `${colorCode}0a` : 'transparent',
boxShadow: isDragging ? '0 8px 20px rgba(0,0,0,0.08)' : 'none',
};
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center justify-between p-4 sm:p-5 transition-all duration-200 hover:bg-muted ${
isLast ? '' : 'border-b border-border'
}`}
>
<div className="flex items-center gap-3 sm:gap-4 flex-1 min-w-0">
{/* 拖拽手柄 */}
<div
className="drag-handle text-muted-foreground cursor-grab touch-none transition-colors duration-200 hover:text-foreground active:cursor-grabbing"
{...attributes}
{...listeners}
aria-label={`拖拽以调整 ${t(feature.labelKey)} 的位置`}
>
<GripVertical size={16} />
</div>
{/* 功能图标 */}
<div
className="flex items-center justify-center w-9 h-9 rounded-xl flex-shrink-0"
style={{
backgroundColor: `${colorCode}1a`,
color: colorCode,
}}
>
{feature.icon && <feature.icon size={20} />}
</div>
{/* 文本信息 */}
<div className="min-w-0 flex-1">
<div className="font-bold text-[0.95rem] leading-tight text-foreground">
{t(feature.labelKey)}
</div>
{feature.descriptionKey && (
<div
className="text-xs text-muted-foreground font-medium mt-0.5 overflow-hidden text-ellipsis whitespace-nowrap"
title={t(feature.descriptionKey)}
>
{t(feature.descriptionKey)}
</div>
)}
</div>
</div>
{/* 开关 */}
<button
type="button"
role="switch"
aria-checked={isChecked}
disabled={isDisabled}
onClick={() => onToggle(pageKey)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
isChecked ? 'bg-primary' : 'bg-muted'
} ${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform duration-200 ${
isChecked ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
);
}
export default function App() {
const { t } = useTranslation(['features', 'common']);
const initialWindowType = useMemo(() => {
if (typeof window === 'undefined') return 'popup';
const params = new URLSearchParams(window.location.search);
const tab = params.get('tab');
if (tab === 'popup' || tab === 'sidepanel' || tab === 'tab') {
return tab as WindowType;
}
return 'popup';
}, []);
const [windowType, setWindowType] = useState<WindowType>(initialWindowType);
const [visiblePages, setVisiblePages] = useState<PageType[]>([]);
const [pageOrder, setPageOrder] = useState<PageType[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
const { snackbarProps, showMessage } = useSnackbarState();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const configKeys = useMemo(() => {
switch (windowType) {
case 'sidepanel':
return {
visible: 'app/sidepanelVisiblePages' as keyof StorageSchema,
order: 'app/sidepanelPageOrder' as keyof StorageSchema,
};
case 'tab':
return {
visible: 'app/tabVisiblePages' as keyof StorageSchema,
order: 'app/tabPageOrder' as keyof StorageSchema,
};
case 'popup':
default:
return {
visible: 'app/popupVisiblePages' as keyof StorageSchema,
order: 'app/popupPageOrder' as keyof StorageSchema,
};
}
}, [windowType]);
useEffect(() => {
const url = new URL(window.location.href);
url.searchParams.set('tab', windowType);
window.history.replaceState({}, '', url.toString());
}, [windowType]);
useEffect(() => {
const loadConfig = async () => {
setIsLoaded(false);
try {
const [savedVisible, savedOrder] = await Promise.all([
storageUtil.get(configKeys.visible, getDefaultVisibleFeatureKeys()),
storageUtil.get(configKeys.order, getDefaultPageOrder()),
]);
setVisiblePages(
isValidPageList(savedVisible) ? savedVisible : getDefaultVisibleFeatureKeys(),
);
setPageOrder(isValidPageList(savedOrder) ? savedOrder : getDefaultPageOrder());
} catch (error) {
console.error('Failed to load config:', error);
setVisiblePages(getDefaultVisibleFeatureKeys());
setPageOrder(getDefaultPageOrder());
} finally {
setIsLoaded(true);
}
};
loadConfig().catch(console.error);
}, [configKeys]);
const showToast = (message: string, severity: 'success' | 'info' | 'warning') => {
showMessage(message, { severity });
};
const handlePageToggle = async (page: PageType) => {
const isCurrentlyVisible = visiblePages.includes(page);
let newPages: PageType[];
if (isCurrentlyVisible) {
if (visiblePages.length <= 1) {
showToast('至少需要保留一个可见页面', 'warning');
return;
}
newPages = visiblePages.filter((p) => p !== page);
} else {
newPages = [...visiblePages, page];
}
try {
await storageUtil.set(configKeys.visible, newPages);
setVisiblePages(newPages);
const feature = getFeatureByKey(page);
const label = feature ? t(feature.labelKey) : page;
showToast(`${isCurrentlyVisible ? '隐藏' : '显示'} ${label}`, 'success');
} catch (error) {
console.error('Failed to save config:', error);
showToast('保存失败', 'warning');
}
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = pageOrder.indexOf(active.id as PageType);
const newIndex = pageOrder.indexOf(over.id as PageType);
if (oldIndex < 0 || newIndex < 0) return;
const newOrder = arrayMove(pageOrder, oldIndex, newIndex);
setPageOrder(newOrder);
try {
await storageUtil.set(configKeys.order, newOrder);
} catch (error) {
console.error('Failed to save order:', error);
showToast('排序保存失败', 'warning');
}
};
const handleRestoreDefaults = async () => {
try {
const defaults = getDefaultVisibleFeatureKeys();
const defaultOrder = getDefaultPageOrder();
await Promise.all([
storageUtil.set(configKeys.visible, defaults),
storageUtil.set(configKeys.order, defaultOrder),
]);
setVisiblePages(defaults);
setPageOrder(defaultOrder);
showToast('已恢复当前模式默认设置', 'success');
} catch (error) {
console.error('Failed to restore defaults:', error);
showToast('恢复失败', 'warning');
}
};
const handleWindowTypeChange = (_event: SyntheticEvent, newType: WindowType) => {
if (newType !== null) {
setWindowType(newType);
}
};
if (!isLoaded) {
return (
<div className="flex justify-center items-center min-h-screen">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="app min-h-screen bg-background flex flex-col">
<PageErrorBoundary>
{/* 顶部标题与导航栏 */}
<div className="w-full bg-background border-b border-border pt-8 sm:pt-12 pb-0 px-4 sm:px-8">
<div className="max-w-3xl mx-auto">
{/* Tab 与恢复按钮同行 */}
<div className="flex items-end justify-between border-b-0">
<div className="flex-1 flex">
{(['popup', 'sidepanel', 'tab'] as WindowType[]).map((type) => (
<button
key={type}
onClick={(e) => handleWindowTypeChange(e, type)}
className={`px-4 sm:px-6 py-2 text-[0.9rem] font-bold transition-colors duration-200 ${
windowType === type
? 'text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{type === 'popup' ? 'Popup 窗口' : type === 'sidepanel' ? '侧边栏' : '标签页'}
</button>
))}
</div>
<button
onClick={handleRestoreDefaults}
className="mb-1 ml-2 p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors duration-200"
title="恢复当前模式默认"
>
<RefreshCw size={16} />
</button>
</div>
</div>
</div>
{/* 主内容区域 */}
<div className="flex-1 p-4 sm:p-8">
<div className="max-w-3xl mx-auto">
<div className="rounded-2xl border border-border overflow-hidden bg-card shadow-sm">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={pageOrder} strategy={verticalListSortingStrategy}>
<div className="flex flex-col">
{pageOrder.map((key, index, array) => {
const isChecked = visiblePages.includes(key);
const isDisabled = isChecked && visiblePages.length === 1;
return (
<SortableFeatureRow
key={key}
pageKey={key}
isLast={index === array.length - 1}
isChecked={isChecked}
isDisabled={isDisabled}
onToggle={handlePageToggle}
/>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
</div>
</div>
</PageErrorBoundary>
<GlobalSnackbar {...snackbarProps} />
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import App from '../App';
import { storageUtil } from '@/utils/chromeStorage';
import {
getDefaultVisibleFeatureKeys,
getDefaultPageOrder,
getFeatureByKey,
} from '@/config/features';
// Mock storageUtil
vi.mock('@/utils/chromeStorage', () => ({
storageUtil: {
get: vi.fn(),
set: vi.fn(() => Promise.resolve()),
},
}));
// Mock i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe('Options App', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it('合法数据应正常加载并显示正确的功能列表', async () => {
const defaultOrder = getDefaultPageOrder();
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
Promise.resolve(defaultValue),
);
render(<App />);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
// 验证默认可见的功能都被渲染出来了
for (const key of defaultOrder) {
const feature = getFeatureByKey(key);
if (feature) {
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
}
}
});
it('非法 visiblePages 数据应回退到默认值', async () => {
const defaultVisible = getDefaultVisibleFeatureKeys();
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
if (key.includes('VisiblePages')) {
return Promise.resolve(['invalidPage', 'anotherInvalid']);
}
return Promise.resolve(defaultValue);
});
render(<App />);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
// 验证默认值的功能仍然被渲染(非法数据被回退)
for (const key of defaultVisible) {
const feature = getFeatureByKey(key);
if (feature && key !== 'dashboard') {
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
}
}
});
it('非法 pageOrder 数据应回退到默认值', async () => {
const defaultOrder = getDefaultPageOrder();
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
if (key.includes('PageOrder')) {
return Promise.resolve(['notARealPage', 123, null]);
}
return Promise.resolve(defaultValue);
});
render(<App />);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
// 验证默认顺序的功能都被渲染
for (const key of defaultOrder) {
const feature = getFeatureByKey(key);
if (feature) {
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
}
}
});
it('非数组数据应回退到默认值', async () => {
const defaultOrder = getDefaultPageOrder();
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
if (key.includes('VisiblePages')) {
return Promise.resolve('not-an-array');
}
if (key.includes('PageOrder')) {
return Promise.resolve({ foo: 'bar' });
}
return Promise.resolve(defaultValue);
});
render(<App />);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
// 验证默认功能都被渲染
for (const key of defaultOrder) {
const feature = getFeatureByKey(key);
if (feature) {
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
}
}
});
});
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>扩展设置 - Testing Tools</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
import ReactDOM from 'react-dom/client';
import AppRoot from '@/providers/AppRoot';
import '@/i18n';
import '@/src/index.css';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<AppRoot>
<App />
</AppRoot>,
);
@@ -7,6 +7,10 @@ import { getEntryPointType } from '@/config/features';
import { useMemo } from 'react'; import { useMemo } from 'react';
export default function App() { export default function App() {
const handleOpenOptions = () => {
chrome.runtime.openOptionsPage().catch(console.error);
};
const entryType = useMemo(() => getEntryPointType(), []); const entryType = useMemo(() => getEntryPointType(), []);
const routerConfig = useMemo(() => { const routerConfig = useMemo(() => {
@@ -32,7 +36,7 @@ export default function App() {
> >
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}> <SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
<div className="app flex flex-col w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px] overflow-hidden bg-background sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0"> <div className="app flex flex-col w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px] overflow-hidden bg-background sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
<TopBar /> <TopBar onOpenOptions={handleOpenOptions} />
<ErrorBoundary> <ErrorBoundary>
<RouterContainer /> <RouterContainer />
</ErrorBoundary> </ErrorBoundary>
@@ -1,6 +1,7 @@
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import AppRoot from '@/providers/AppRoot'; import AppRoot from '@/providers/AppRoot';
import '@/index.css'; import '@/i18n';
import '@/src/index.css';
import App from './App.tsx'; import App from './App.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
@@ -7,6 +7,12 @@ import { SnackbarProvider } from '@/components/GlobalSnackbar';
import { MessageAction, sendMessage } from '@/utils/messages'; import { MessageAction, sendMessage } from '@/utils/messages';
export default function App() { export default function App() {
const handleOpenOptions = () => {
chrome.runtime.openOptionsPage().catch((err) => {
console.error('Failed to open options page:', err);
});
};
useEffect(() => { useEffect(() => {
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true }); sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true });
return () => { return () => {
@@ -18,7 +24,7 @@ export default function App() {
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute"> <RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}> <SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
<div className="app flex flex-col h-screen w-full overflow-hidden"> <div className="app flex flex-col h-screen w-full overflow-hidden">
<TopBar /> <TopBar onOpenOptions={handleOpenOptions} />
<ErrorBoundary> <ErrorBoundary>
<RouterContainer /> <RouterContainer />
</ErrorBoundary> </ErrorBoundary>
@@ -1,6 +1,7 @@
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import AppRoot from '@/providers/AppRoot'; import AppRoot from '@/providers/AppRoot';
import '@/index.css'; import '@/i18n';
import '@/src/index.css';
import App from './App.tsx'; import App from './App.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
+22 -2
View File
@@ -5,13 +5,16 @@ import reactPlugin from 'eslint-plugin-react';
import globals from 'globals'; import globals from 'globals';
export default tseslint.config( export default tseslint.config(
// 1. 全局物理隔离:彻底掐灭对构建产物与配置本身的干扰
{ {
ignores: ['dist', '.output', '.wxt', 'node_modules', 'eslint.config.ts', 'eslint.config.js'], ignores: ['dist', '.output', '.wxt', 'node_modules', 'eslint.config.ts', 'eslint.config.js'],
}, },
// 2. 注入 JavaScript 与 TypeScript 的官方大师级推荐规则集
js.configs.recommended, js.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
// 3. 针对测试文件专属沙箱:解耦强类型死锁,放行 any,容忍未消费变量
{ {
files: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', 'setupTests.ts'], files: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', 'setupTests.ts'],
rules: { rules: {
@@ -23,8 +26,16 @@ export default tseslint.config(
}, },
}, },
// 4. 核心业务全受控大管线(Hooks, Entrypoints, Components 统一护航)
{ {
files: ['src/**/*.{ts,tsx}'], files: [
'hooks/**/*.{ts,tsx}',
'entrypoints/**/*.{ts,tsx}',
'pages/**/*.{ts,tsx}',
'utils/**/*.{ts,tsx}',
'components/**/*.{ts,tsx}',
'services/**/*.{ts,tsx}',
],
ignores: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'], ignores: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
languageOptions: { languageOptions: {
@@ -33,13 +44,17 @@ export default tseslint.config(
...globals.browser, ...globals.browser,
...globals.node, ...globals.node,
}, },
// 💡 修复点 1(史诗级治愈):废除脆弱的 project 硬编码路径!
// 拥抱 typescript-eslint 官方推荐的 projectService 常驻动态类型调度中枢。
// 它会在内存中全自动、流式为所有新建、悬空或暂存文件分配编译上下文,
// 彻底终结 "file is not included in any tsconfig" 的全量崩溃黑洞!
parserOptions: { parserOptions: {
projectService: true, projectService: true,
tsconfigRootDir: import.meta.dirname, tsconfigRootDir: import.meta.dirname,
}, },
}, },
// 挂载插件沙箱
plugins: { plugins: {
react: reactPlugin, react: reactPlugin,
'react-hooks': reactHooks, 'react-hooks': reactHooks,
@@ -51,19 +66,24 @@ export default tseslint.config(
}, },
}, },
// 💡 修复点 2:高精对齐 React 19 / JSX Runtime 的全量生产质检规则大闸
rules: { rules: {
// 激活 react-hooks 官方推荐规则
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
// 激活 react 官方精选规则(排除旧版 React 必须手动 import 的历史包袱)
...reactPlugin.configs.recommended.rules, ...reactPlugin.configs.recommended.rules,
...reactPlugin.configs['jsx-runtime'].rules, ...reactPlugin.configs['jsx-runtime'].rules,
'react/prop-types': 'off', 'react/prop-types': 'off',
// 清洗原生未消费变量冲突,统一交由 TS 高阶哨兵接管
'no-unused-vars': 'off', 'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'warn', 'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, { argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
], ],
// 彻底关闭老旧的 JSX 作用域检查,全面契合 React 19 核心美学
'react/react-in-jsx-scope': 'off', 'react/react-in-jsx-scope': 'off',
}, },
}, },
-61
View File
@@ -1,61 +0,0 @@
# i18n/
国际化资源目录,管理多语言翻译和 i18next 初始化配置。
## 目录结构
```
i18n/
├── index.ts # i18next 初始化配置
└── locales/
├── zh/ # 中文翻译(默认语言)
│ ├── common.json # 通用文案
│ ├── features.json # 功能模块标题和描述
│ ├── timestamp.json # 时间戳工具翻译
│ ├── storageCleaner.json # 存储清理工具翻译
│ ├── qrCode.json # 二维码工具翻译
│ ├── textStatistics.json # 文本统计工具翻译
│ ├── jwt.json # JWT 工具翻译
│ ├── jsonDiff.json # JSON 差异工具翻译
│ ├── jsonFormat.json # JSON 格式化工具翻译
│ ├── base64Converter.json
│ ├── markdownToHtml.json
│ ├── htmlToMarkdown.json
│ └── rightClickRestorer.json
└── en/ # 英文翻译(结构同上)
└── ...
```
## index.ts
i18next 初始化配置:
- 同步加载 `common``features` 核心命名空间
- 自定义 `chromeStorage` 语言检测器,从 Chrome Storage 读取语言偏好
- `normalizeLanguage()` 将任意语言标识归一化为 `zh``en`
- 语言变更时同步更新 Day.js 本地化和 localStorage 快照
## 翻译键格式
- 命名空间:`common`(默认)、`features`、各功能独立命名空间
- 键格式:`namespace:key`(如 `features:timestamp.title``timestamp:unitMs`
## 使用方式
```tsx
// 页面组件 — 懒加载翻译
import { useLazyTranslation } from '@/utils/useLazyTranslation';
const { t } = useLazyTranslation('timestamp');
t('timestamp:title');
// 全局组件 — 直接使用
import { useTranslation } from 'react-i18next';
const { t } = useTranslation(['common', 'features']);
t('common:settings');
```
## 添加新翻译
1.`locales/{zh,en}/features.json` 添加功能标题和描述
2. 创建 `locales/{zh,en}/{功能名}.json` 添加功能专属翻译
3.`utils/useLazyTranslation.ts``localeModules` 中注册新命名空间
+144
View File
@@ -0,0 +1,144 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import type { CustomDetector } from 'i18next-browser-languagedetector'; // 💡 1. 引入官方强类型探测器接口
import LanguageDetector from 'i18next-browser-languagedetector';
import { storageUtil } from '@/utils/chromeStorage';
import dayjs from 'dayjs';
// 导入 Day.js 本地化语言包
import 'dayjs/locale/zh-cn';
// 同步加载全局核心命名空间
import commonZh from './locales/zh/common.json';
import featuresZh from './locales/zh/features.json';
import commonEn from './locales/en/common.json';
import featuresEn from './locales/en/features.json';
const resources = {
zh: {
common: commonZh,
features: featuresZh,
},
en: {
common: commonEn,
features: featuresEn,
},
};
export const SUPPORTED_LANGUAGES = ['zh', 'en'] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
const LANGUAGE_STORAGE_KEY = 'app/language';
const LANGUAGE_SNAPSHOT_KEY = 'snapshot/app/language';
/**
* 将任意语言标识归一化为受支持的核心代码
*/
export const normalizeLanguage = (lng: string): SupportedLanguage => {
if (!lng) return 'en';
return lng.toLowerCase().startsWith('zh') ? 'zh' : 'en';
};
/**
* 严格校验语言安全边界
*/
const isValidLanguage = (lng: unknown): lng is SupportedLanguage => {
return typeof lng === 'string' && (SUPPORTED_LANGUAGES as readonly string[]).includes(lng);
};
/**
* 同步从 localStorage 获取语言快照(消除异步闪烁)
*/
const getSyncLanguageSnapshot = (): SupportedLanguage | null => {
try {
const val = localStorage.getItem(LANGUAGE_SNAPSHOT_KEY);
if (!val) return null;
const parsed = JSON.parse(val) as unknown;
return isValidLanguage(parsed) ? parsed : null;
} catch (error) {
console.error('[i18n] Failed to parse sync language snapshot from localStorage:', error);
return null;
}
};
// 💡 2. 强类型接口重塑:显式绑定 CustomDetector 类型,
// 告诉 TS 编译器这些方法将被全局 Languagedetector 框架隐式调用,彻底治愈“未使用函数”报错!
const chromeStorageDetector: CustomDetector = {
name: 'chromeStorage',
lookup() {
return undefined;
},
cacheUserLanguage(lng: string) {
const target = normalizeLanguage(lng);
// 💡 修复点:对异步写盘操作追加 void 算子或 catch,吞掉 Promise 被忽略警告
storageUtil.set(LANGUAGE_STORAGE_KEY, target).catch((err) => {
console.error('[i18n Detector Error] Failed to write back language state:', err);
});
},
};
const detector = new LanguageDetector();
detector.addDetector(chromeStorageDetector);
const syncLng = getSyncLanguageSnapshot();
// 💡 3. 修复点:对 i18n.init() 返回的异步 Promise 前方追加 void 斩断依赖链,放行编译
void i18n
.use(detector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
lng: syncLng || undefined,
ns: ['common', 'features'],
defaultNS: 'common',
debug: false,
interpolation: {
escapeValue: false,
},
detection: {
order: ['chromeStorage', 'navigator'],
caches: ['chromeStorage'],
},
});
// 监听语言变更
i18n.on('languageChanged', (lng) => {
const normalizedLng = normalizeLanguage(lng);
dayjs.locale(normalizedLng === 'zh' ? 'zh-cn' : 'en');
localStorage.setItem(LANGUAGE_SNAPSHOT_KEY, JSON.stringify(normalizedLng));
});
// 初始化时从长期异步存储中恢复校准
storageUtil
.get(LANGUAGE_STORAGE_KEY)
.then((lng) => {
const rawTargetLng = lng || syncLng;
if (!rawTargetLng) {
const initialLng = normalizeLanguage(i18n.language);
// 💡 修复点:对初始化同步写盘追加安全的 Promise .catch() 异常隔离防护罩
storageUtil.set(LANGUAGE_STORAGE_KEY, initialLng).catch((err) => {
console.error('[i18n Init Error] Persistent sync collapsed:', err);
});
if (initialLng !== normalizeLanguage(i18n.language)) {
// 💡 修复点:对 changeLanguage 异步微任务进行显式 void 断链安全隔离
void i18n.changeLanguage(initialLng);
}
return;
}
const targetLng = normalizeLanguage(String(rawTargetLng));
if (isValidLanguage(targetLng) && targetLng !== normalizeLanguage(i18n.language)) {
// 💡 修复点:对 changeLanguage 异步微任务进行显式 void 断链安全隔离
void i18n.changeLanguage(targetLng);
}
})
.catch((err) => {
console.error('[i18n Context Error] Async local storage lookup collapsed:', err);
});
export default i18n;
+37
View File
@@ -0,0 +1,37 @@
{
"pageTitle": "Base64 Converter",
"pageSubtitle": "Encode and decode text, files, and images with Base64",
"textMode": "Text",
"fileMode": "File",
"imageMode": "Image",
"encode": "Encode",
"decode": "Decode",
"clear": "Clear",
"textInputPlaceholder": "Enter text to encode to Base64...",
"base64InputPlaceholder": "Enter Base64 string to decode...",
"base64Output": "Base64 Output",
"textOutput": "Decoded Text Output",
"copyRaw": "Copy Raw Base64",
"copyDataUri": "Copy Data URI",
"clickOrDropToFile": "Click or drop a file here",
"clickOrDropToImage": "Click or drop an image here",
"clickOrDropToReplace": "Click or drop to replace the file",
"maxFileSize": "Maximum file size: {{max}}",
"supportedFormats": "Supports PNG, JPG, WEBP, GIF, BMP, SVG, etc.",
"fileSizeExceeded": "File size exceeds the limit (max {{max}})",
"unsupportedImageType": "Unsupported image format",
"conversionFailed": "Conversion failed",
"originalSize": "Original Size",
"encodedSize": "Encoded Size",
"invalidBase64": "Invalid Base64 string",
"binaryDataDetected": "Input appears to be binary data (e.g. an image). Please switch to the Image tab.",
"imageDataUriHint": "Detected an image data URI — please use the Image tab to decode it.",
"switchToImageMode": "Switch to Image mode",
"download": "Download",
"decodedFileName": "Decoded file name",
"decodeBase64Placeholder": "Enter Base64 or data URI to decode...",
"decodedFileOutput": "Decoded File",
"decodedImageOutput": "Decoded Image",
"inferredMimeType": "Inferred MIME type",
"decodedSize": "Decoded size"
}
+35
View File
@@ -0,0 +1,35 @@
{
"appName": "Testing Tools",
"buttons": {
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"copy": "Copy",
"clear": "Clear",
"refresh": "Refresh",
"toggleLanguage": "Switch Language",
"toggleTheme": "Toggle theme",
"themeMode": {
"light": "Switch to dark mode",
"dark": "Switch to system mode",
"system": "Switch to light mode"
},
"search": "Search tools...",
"back": "Back",
"clearSearch": "Clear search",
"recentSearch": "Recent search",
"noResults": "No tools found",
"openInTab": "Open in tab",
"settings": "Settings"
},
"messages": {
"copySuccess": "Copied to clipboard",
"copyError": "Copy failed"
},
"textInputArea": {
"clear": "Clear",
"copyContent": "Copy content",
"cleared": "Cleared",
"placeholder": "placeholder text"
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"dashboard": {
"title": "Dashboard"
},
"timestamp": {
"title": "Timestamp",
"description": "Unix millisecond conversion and formatting"
},
"storageCleaner": {
"title": "Storage Cleaner",
"description": "Clean cache, cookies, and local storage"
},
"qrCode": {
"title": "QR Code Tools",
"description": "Generate QR code for the selected URL"
},
"textStatistics": {
"title": "Text Statistics",
"description": "Real-time analysis of text characters, words, and bytes"
},
"jwt": {
"title": "JWT Parser",
"description": "JSON Web Token decoding and viewing"
},
"jsonDiff": {
"title": "JSON Tools",
"description": "Diff, format, YAML/TOML conversion, and minify"
},
"base64Converter": {
"title": "Base64 Converter",
"description": "Encode text, files, and images to Base64"
},
"markdownToHtml": {
"title": "Markdown to HTML",
"description": "Real-time Markdown conversion and HTML preview"
},
"htmlToMarkdown": {
"title": "HTML to Markdown",
"description": "Real-time HTML conversion and Markdown preview"
},
"rightClickRestorer": {
"title": "Right Click Restorer",
"description": "Detect and restore disabled browser right-click menus"
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"pageTitle": "HTML to Markdown",
"pageSubtitle": "Convert HTML to Markdown in real-time",
"splitMode": "Split",
"previewMode": "Preview",
"markdownMode": "Markdown",
"clear": "Clear",
"inputLabel": "HTML Input",
"previewLabel": "Markdown Preview",
"markdownOutputLabel": "Markdown Output",
"inputPlaceholder": "Enter your HTML content here...",
"charCount": "{{count}} chars",
"copyMarkdown": "Copy Markdown",
"download": "Download",
"emptyHint": "Enter HTML content to see the conversion result"
}
+21
View File
@@ -0,0 +1,21 @@
{
"pageTitle": "JSON Diff",
"pageSubtitle": "Compare differences between two JSON values",
"leftPlaceholder": "Enter the original JSON...",
"rightPlaceholder": "Enter the target JSON...",
"leftLabel": "Original JSON",
"rightLabel": "Target JSON",
"compareButton": "Compare",
"clearButton": "Clear",
"sideBySideMode": "Side by side",
"unifiedMode": "Unified",
"previousDiff": "Previous",
"nextDiff": "Next",
"noDiffs": "No differences",
"diffCount": "{{count}} differences",
"invalidJson": "Invalid JSON format",
"emptyHint": "Enter JSON on both sides and click Compare",
"added": "Added",
"removed": "Removed",
"modified": "Modified"
}
+41
View File
@@ -0,0 +1,41 @@
{
"formatTitle": "JSON Formatter",
"formatSubtitle": "Beautify and format JSON data",
"inputPlaceholder": "Enter JSON to format...",
"formatButton": "Format",
"clearButton": "Clear",
"sortKeys": "Sort Keys",
"sortKeysTooltip": "Sort JSON object keys in alphabetical order",
"indentSize": "Indent",
"indentSpaces": "{{count}} spaces",
"outputLabel": "Formatted Result",
"copySuccess": "Copied",
"copyFail": "Copy failed",
"noContent": "Nothing to copy",
"invalidJson": "Invalid JSON format",
"emptyHint": "Enter JSON and click Format",
"originalSize": "Original size",
"formattedSize": "Formatted size",
"diffMode": "Diff",
"formatMode": "Format",
"yamlMode": "YAML",
"tomlMode": "TOML",
"minifyMode": "Minify",
"yamlTitle": "JSON to YAML",
"yamlSubtitle": "Convert JSON data to YAML format",
"yamlModeInputPlaceholder": "Enter JSON to convert...",
"yamlModeOutputLabel": "YAML Result",
"yamlModeEmptyHint": "Enter JSON and click Convert",
"convertButton": "Convert",
"tomlTitle": "JSON to TOML",
"tomlSubtitle": "Convert JSON data to TOML format",
"tomlModeInputPlaceholder": "Enter JSON to convert...",
"tomlModeOutputLabel": "TOML Result",
"tomlModeEmptyHint": "Enter JSON and click Convert",
"minifyTitle": "JSON Minifier",
"minifySubtitle": "Compress JSON into a compact single-line format",
"minifyModeInputPlaceholder": "Enter JSON to minify...",
"minifyModeOutputLabel": "Minified Result",
"minifyModeEmptyHint": "Enter JSON and click Minify",
"minifyButton": "Minify"
}
+17
View File
@@ -0,0 +1,17 @@
{
"pageTitle": "JWT Parser",
"pageSubtitle": "JSON Web Token decoding and viewing",
"placeholder": "Paste Encoded JWT here...",
"headerTitle": "HEADER: Algorithm & Token Type",
"payloadTitle": "PAYLOAD: Data",
"signatureTitle": "Signature",
"noSignature": "No Signature",
"invalidFormat": "Unable to parse",
"errors": {
"invalidBase64String": "Invalid base64url string",
"failedToDecode": "Failed to decode base64url: ",
"invalidFormat": "Invalid JWT format: expected 3 parts separated by .",
"parseHeaderFailed": "Failed to parse Header: ",
"parsePayloadFailed": "Failed to parse Payload: "
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"pageTitle": "Markdown to HTML",
"pageSubtitle": "Convert Markdown to HTML in real-time with preview",
"splitMode": "Split",
"previewMode": "Preview",
"htmlMode": "HTML",
"clear": "Clear",
"print": "Print",
"download": "Download",
"inputLabel": "Markdown Input",
"previewLabel": "Live Preview",
"htmlOutputLabel": "HTML Output",
"inputPlaceholder": "Enter your Markdown content here...",
"charCount": "{{count}} chars",
"copyHtml": "Copy HTML",
"downloadSuccess": "File downloaded successfully",
"printSuccess": "Print window opened"
}
+48
View File
@@ -0,0 +1,48 @@
{
"pageTitle": "QR Code Tools",
"pageSubtitle": "Generate and parse QR codes",
"generateMode": "Generate QR Code",
"parseMode": "Parse QR Code",
"urlToQr": "Text to QR Code",
"qrToUrl": "QR Code to Text",
"urlInputLabel": "Enter URL or Text",
"urlInputPlaceholder": "Enter URL or text content, QR code will be generated automatically",
"generateButton": "Generate QR Code",
"generating": "Generating...",
"qrCodeWillShow": "QR code will be shown here",
"downloadButton": "Download QR Code",
"copyQrButton": "Copy QR Code Image",
"qrCodeSuccess": "QR code generated successfully",
"qrCodeDownloadSuccess": "QR code downloaded successfully",
"qrCodeCopySuccess": "QR code copied to clipboard",
"selectImage": "Please select a QR code image",
"parseSuccess": "QR code parsed successfully",
"noQrDetected": "No QR code detected, please ensure the image is clear and contains a QR code",
"parseError": "Failed to parse QR code, please try again",
"generateError": "Failed to generate QR code, please check the input",
"copyError": "Copy failed, please try again",
"imagePasted": "Image pasted, parsing...",
"imagePasteError": "Failed to paste image, please try again",
"imageCleared": "Image cleared",
"clickToUpload": "Click, drag, or paste to upload QR code image",
"supportFormats": "Supports PNG, JPG, WEBP, Base64 formats",
"parseButton": "Parse QR Code",
"parsing": "Parsing...",
"resultLabel": "Parsing Result",
"copyTooltip": "Copy",
"enterUrlError": "Please enter URL or text",
"clickToChange": "Click to change image",
"pasteHint": "Supports Ctrl+V to paste images or Base64 strings",
"autoGenerateHint": "QR code will be generated automatically after input",
"autoParseHint": "QR code will be parsed automatically after upload"
}
+13
View File
@@ -0,0 +1,13 @@
{
"title": "Right Click Restorer",
"description": "Detect and restore disabled browser right-click menus",
"loading": "Loading...",
"currentDomain": "Current Domain",
"statusLocked": "Locked",
"statusUnlocked": "Unlocked",
"unsupported": "Unsupported",
"unsupportedDesc": "The current page is a browser internal or extension page. Right-click unlock is not available. Please switch to a regular webpage.",
"unlockDesc": "Click the button below to temporarily unlock the right-click menu for the current website. You will need to unlock again after refreshing the page.",
"unlockBtn": "Unlock Right Click",
"alreadyUnlocked": "Right Click Unlocked"
}
+33
View File
@@ -0,0 +1,33 @@
{
"pageTitle": "Storage Cleaner",
"pageSubtitle": "Clear cache, cookies, and local storage",
"loading": "Loading...",
"occupied": "Occupied {{size}}",
"cleaning": "Cleaning...",
"cleanNow": "Clean Now",
"autoRefresh": "Auto refresh page after cleaning",
"selectAll": "Select all items",
"cleanSuccess": "Cleaning complete",
"cleanError": "Cleaning failed",
"noData": "No data",
"countUnit": "items",
"errorNoTab": "Unable to get current tab",
"errorRestricted": "Storage cleaning is not supported on this page",
"cleanSuccessReload": "Cleaning successful, reloading page...",
"errorStandardOnly": "Storage cleaning only works on standard web pages",
"confirmTitle": "Confirm Clear Data?",
"confirmDesc": "You are about to permanently delete the following selected storage items from the current page.",
"irreversible": "This action is irreversible",
"confirmAction": "Confirm Clear",
"cleanedSummary": "Cleaned {{items}}",
"noDataToClean": "No storage data found to clean",
"partialFailure": "Some items failed to clean",
"options": {
"localStorage": "Local Storage",
"sessionStorage": "Session Storage",
"indexedDB": "IndexedDB",
"cookies": "Cookies",
"cacheStorage": "Cache Storage",
"serviceWorkers": "Service Workers"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"pageTitle": "Text Statistics",
"pageSubtitle": "Real-time analysis of characters, words, lines, and byte size",
"placeholder": "Type or paste text here...",
"characters": "Characters",
"words": "Words",
"lines": "Lines",
"bytes": "Byte Size"
}
+27
View File
@@ -0,0 +1,27 @@
{
"pageTitle": "Timestamp Conversion",
"pageSubtitle": "Unix millisecond conversion and formatting",
"tsToDate": "Timestamp → Date",
"dateToTs": "Date → Timestamp",
"placeholderTs": "Enter timestamp...",
"placeholderDate": "YYYY-MM-DD HH:mm:ss",
"unitMs": "Millisecond (ms)",
"unitS": "Second (s)",
"convertButton": "Convert Now",
"currentTs": "Current Timestamp",
"useNowTooltip": "Use this value",
"copyTsTooltip": "Copy timestamp",
"usedSuccess": "Using current timestamp",
"resultLabel": "Conversion Result",
"copyResultTooltip": "Copy result",
"relativeTime": "Relative Time",
"iso8601": "ISO 8601",
"utcTime": "UTC Time",
"copyTooltip": "Copy",
"resultEmpty": "Enter a value and click convert",
"errors": {
"invalidNumber": "Invalid number",
"invalidTimestamp": "Invalid timestamp",
"invalidFormat": "Format error"
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"pageTitle": "Base64 转换器",
"pageSubtitle": "文本、文件与图像的 Base64 编码与解码",
"textMode": "文本",
"fileMode": "文件",
"imageMode": "图像",
"encode": "编码",
"decode": "解码",
"clear": "清空",
"textInputPlaceholder": "输入需要编码为 Base64 的文本...",
"base64InputPlaceholder": "输入需要解码的 Base64 字符串...",
"base64Output": "Base64 编码结果",
"textOutput": "解码文本结果",
"copyRaw": "复制纯 Base64",
"copyDataUri": "复制 Data URI",
"clickOrDropToFile": "点击或拖拽文件到此处",
"clickOrDropToImage": "点击或拖拽图像到此处",
"clickOrDropToReplace": "点击或拖拽以替换文件",
"maxFileSize": "最大文件大小:{{max}}",
"supportedFormats": "支持 PNG、JPG、WEBP、GIF、BMP、SVG 等格式",
"fileSizeExceeded": "文件大小超出限制(最大 {{max}}",
"unsupportedImageType": "不支持的图像格式",
"conversionFailed": "转换失败",
"originalSize": "原始大小",
"encodedSize": "编码大小",
"invalidBase64": "Base64 字符串无效",
"binaryDataDetected": "输入似乎是二进制数据(如图片)。请切换到「图像」选项卡。",
"imageDataUriHint": "检测到图片的 data URI,请使用「图像」选项卡进行解码。",
"switchToImageMode": "切换到图像模式",
"download": "下载",
"decodedFileName": "解码后文件名",
"decodeBase64Placeholder": "输入需要解码的 Base64 或 data URI...",
"decodedFileOutput": "解码文件",
"decodedImageOutput": "解码图像",
"inferredMimeType": "推断的 MIME 类型",
"decodedSize": "解码大小"
}
+35
View File
@@ -0,0 +1,35 @@
{
"appName": "测试工具",
"buttons": {
"save": "保存",
"cancel": "取消",
"confirm": "确认",
"copy": "复制",
"clear": "清理",
"refresh": "刷新",
"toggleLanguage": "切换语言",
"toggleTheme": "切换主题",
"themeMode": {
"light": "切换到深色模式",
"dark": "切换到系统模式",
"system": "切换到浅色模式"
},
"search": "搜索工具...",
"back": "返回",
"clearSearch": "清除搜索",
"recentSearch": "最近搜索",
"noResults": "未找到相关工具",
"openInTab": "在标签页打开",
"settings": "设置"
},
"messages": {
"copySuccess": "已复制到剪贴板",
"copyError": "复制失败"
},
"textInputArea": {
"clear": "清空",
"copyContent": "复制内容",
"cleared": "已清空",
"placeholder": "请输入文本"
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"dashboard": {
"title": "仪表盘"
},
"timestamp": {
"title": "时间戳",
"description": "Unix 毫秒数转换与格式化"
},
"storageCleaner": {
"title": "存储清理",
"description": "清理缓存、Cookies 及本地存储"
},
"qrCode": {
"title": "二维码工具",
"description": "生成当前选中的 URL 的二维码"
},
"textStatistics": {
"title": "文本统计",
"description": "实时分析文本字符、单词及字节"
},
"jwt": {
"title": "JWT 解析",
"description": "JSON Web Token 解码与查看"
},
"jsonDiff": {
"title": "JSON 工具",
"description": "差异比较、格式化、YAML/TOML 转换及压缩"
},
"base64Converter": {
"title": "Base64 转换器",
"description": "文本、文件与图像的 Base64 编码转换"
},
"markdownToHtml": {
"title": "Markdown 转 HTML",
"description": "实时 Markdown 转换与 HTML 预览"
},
"htmlToMarkdown": {
"title": "HTML 转 Markdown",
"description": "实时 HTML 转换与 Markdown 预览"
},
"rightClickRestorer": {
"title": "右键恢复",
"description": "检测并恢复被网站禁用的浏览器右键菜单"
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"pageTitle": "HTML 转 Markdown",
"pageSubtitle": "实时将 HTML 转换为 Markdown 格式",
"splitMode": "分屏",
"previewMode": "预览",
"markdownMode": "Markdown",
"clear": "清空",
"inputLabel": "HTML 输入",
"previewLabel": "Markdown 预览",
"markdownOutputLabel": "Markdown 输出",
"inputPlaceholder": "在此输入 HTML 内容...",
"charCount": "{{count}} 字符",
"copyMarkdown": "复制 Markdown",
"download": "下载",
"emptyHint": "输入 HTML 内容以查看转换结果"
}
+21
View File
@@ -0,0 +1,21 @@
{
"pageTitle": "JSON 差异比较",
"pageSubtitle": "对比两个 JSON 数据的差异",
"leftPlaceholder": "输入原始 JSON...",
"rightPlaceholder": "输入目标 JSON...",
"leftLabel": "原始 JSON",
"rightLabel": "目标 JSON",
"compareButton": "比较",
"clearButton": "清空",
"sideBySideMode": "并排",
"unifiedMode": "统一",
"previousDiff": "上一个",
"nextDiff": "下一个",
"noDiffs": "无差异",
"diffCount": "{{count}} 处差异",
"invalidJson": "无效的 JSON 格式",
"emptyHint": "输入两侧 JSON 后点击比较",
"added": "新增",
"removed": "删除",
"modified": "修改"
}
+41
View File
@@ -0,0 +1,41 @@
{
"formatTitle": "JSON 格式化",
"formatSubtitle": "美化和格式化 JSON 数据",
"inputPlaceholder": "输入需要格式化的 JSON...",
"formatButton": "格式化",
"clearButton": "清空",
"sortKeys": "键名排序",
"sortKeysTooltip": "按字母顺序对 JSON 对象键进行排序",
"indentSize": "缩进",
"indentSpaces": "{{count}} 个空格",
"outputLabel": "格式化结果",
"copySuccess": "复制成功",
"copyFail": "复制失败",
"noContent": "无内容可复制",
"invalidJson": "无效的 JSON 格式",
"emptyHint": "输入 JSON 后点击格式化",
"originalSize": "原始大小",
"formattedSize": "格式化后大小",
"diffMode": "差异比较",
"formatMode": "格式化",
"yamlMode": "YAML",
"tomlMode": "TOML",
"minifyMode": "压缩",
"yamlTitle": "JSON 转 YAML",
"yamlSubtitle": "将 JSON 数据转换为 YAML 格式",
"yamlModeInputPlaceholder": "输入需要转换的 JSON...",
"yamlModeOutputLabel": "YAML 结果",
"yamlModeEmptyHint": "输入 JSON 后点击转换",
"convertButton": "转换",
"tomlTitle": "JSON 转 TOML",
"tomlSubtitle": "将 JSON 数据转换为 TOML 格式",
"tomlModeInputPlaceholder": "输入需要转换的 JSON...",
"tomlModeOutputLabel": "TOML 结果",
"tomlModeEmptyHint": "输入 JSON 后点击转换",
"minifyTitle": "JSON 压缩",
"minifySubtitle": "将 JSON 压缩为紧凑的单行格式",
"minifyModeInputPlaceholder": "输入需要压缩的 JSON...",
"minifyModeOutputLabel": "压缩结果",
"minifyModeEmptyHint": "输入 JSON 后点击压缩",
"minifyButton": "压缩"
}
+17
View File
@@ -0,0 +1,17 @@
{
"pageTitle": "JWT 解析",
"pageSubtitle": "JSON Web Token 解码与查看",
"placeholder": "在此粘贴 JWT 令牌 (Encoded JWT)...",
"headerTitle": "HEADER: 算法 & 令牌类型",
"payloadTitle": "PAYLOAD: 数据",
"signatureTitle": "签名",
"noSignature": "无签名",
"invalidFormat": "无法解析",
"errors": {
"invalidBase64String": "无效的 Base64URL 字符串",
"failedToDecode": "Base64URL 解码失败:",
"invalidFormat": "JWT 格式错误:必须包含三个由 . 分隔的部分",
"parseHeaderFailed": "解析 Header 失败:",
"parsePayloadFailed": "解析 Payload 失败:"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"pageTitle": "Markdown 转 HTML",
"pageSubtitle": "实时将 Markdown 转换为 HTML 并预览",
"splitMode": "分屏",
"previewMode": "预览",
"htmlMode": "HTML",
"clear": "清空",
"print": "打印",
"download": "下载",
"inputLabel": "Markdown 输入",
"previewLabel": "实时预览",
"htmlOutputLabel": "HTML 输出",
"inputPlaceholder": "在此输入 Markdown 内容...",
"charCount": "{{count}} 字符",
"copyHtml": "复制 HTML",
"downloadSuccess": "文件下载成功",
"printSuccess": "打印窗口已打开"
}
+48
View File
@@ -0,0 +1,48 @@
{
"pageTitle": "二维码工具",
"pageSubtitle": "生成和解析二维码",
"generateMode": "生成二维码",
"parseMode": "解析二维码",
"urlToQr": "文本转二维码",
"qrToUrl": "二维码转文本",
"urlInputLabel": "输入 URL 或文本",
"urlInputPlaceholder": "请输入 URL 或文本内容,将自动生成二维码",
"generateButton": "生成二维码",
"generating": "生成中...",
"qrCodeWillShow": "二维码将显示在这里",
"downloadButton": "下载二维码",
"copyQrButton": "复制二维码",
"qrCodeSuccess": "二维码生成成功",
"qrCodeDownloadSuccess": "二维码下载成功",
"qrCodeCopySuccess": "二维码已复制到剪贴板",
"selectImage": "请选择二维码图片",
"parseSuccess": "二维码解析成功",
"noQrDetected": "未检测到二维码,请确保图片清晰且包含二维码",
"parseError": "解析二维码失败,请重试",
"generateError": "生成二维码失败,请检查输入内容",
"copyError": "复制失败,请重试",
"imagePasted": "图片粘贴成功,正在解析...",
"imagePasteError": "粘贴图片失败,请重试",
"imageCleared": "图片已清除",
"clickToUpload": "点击、拖拽或粘贴上传二维码图片",
"supportFormats": "支持 PNG、JPG、WEBP、Base64 格式",
"parseButton": "解析二维码",
"parsing": "解析中...",
"resultLabel": "解析结果",
"copyTooltip": "复制",
"enterUrlError": "请输入 URL 或文本",
"clickToChange": "点击更换图片",
"pasteHint": "支持 Ctrl+V 粘贴图片或 Base64 字符串",
"autoGenerateHint": "输入内容后将自动生成二维码",
"autoParseHint": "上传图片后将自动解析二维码"
}
+13
View File
@@ -0,0 +1,13 @@
{
"title": "右键恢复",
"description": "检测并恢复被网站禁用的浏览器右键菜单",
"loading": "正在加载...",
"currentDomain": "当前域名",
"statusLocked": "未解锁",
"statusUnlocked": "已解锁",
"unsupported": "不支持",
"unsupportedDesc": "当前页面为浏览器内部页面或扩展页面,无法解锁右键功能。请切换到普通网页后重试。",
"unlockDesc": "点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。",
"unlockBtn": "解锁当前网站右键",
"alreadyUnlocked": "右键已解锁"
}
+33
View File
@@ -0,0 +1,33 @@
{
"pageTitle": "存储清理",
"pageSubtitle": "清理缓存、Cookies 及本地存储",
"loading": "加载中...",
"occupied": "已占用 {{size}}",
"cleaning": "正在清理...",
"cleanNow": "立即清理",
"autoRefresh": "清理后自动刷新页面",
"selectAll": "全选所有项",
"cleanSuccess": "清理完成",
"cleanError": "清理失败",
"noData": "无数据",
"countUnit": "个",
"errorNoTab": "无法获取当前标签页",
"errorRestricted": "存储清理功能不支持此页面",
"cleanSuccessReload": "清理成功,即将刷新页面",
"errorStandardOnly": "存储清理功能仅适用于标准网页",
"confirmTitle": "确认清理数据?",
"confirmDesc": "您将永久删除当前页面的以下选定存储项。",
"irreversible": "此操作不可撤销",
"confirmAction": "确认清理",
"cleanedSummary": "清理了 {{items}}",
"noDataToClean": "该页面没有可清理的存储数据",
"partialFailure": "部分清理失败",
"options": {
"localStorage": "Local Storage",
"sessionStorage": "Session Storage",
"indexedDB": "IndexedDB",
"cookies": "Cookies",
"cacheStorage": "Cache Storage",
"serviceWorkers": "Service Workers"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"pageTitle": "文本统计",
"pageSubtitle": "实时分析文本的字符、单词、行数及字节大小",
"placeholder": "在此输入或粘贴文本...",
"characters": "字符数",
"words": "单词数",
"lines": "行数",
"bytes": "字节大小"
}
+27
View File
@@ -0,0 +1,27 @@
{
"pageTitle": "时间戳转换",
"pageSubtitle": "Unix 毫秒数转换与格式化",
"tsToDate": "时间戳 → 日期",
"dateToTs": "日期 → 时间戳",
"placeholderTs": "输入时间戳...",
"placeholderDate": "YYYY-MM-DD HH:mm:ss",
"unitMs": "毫秒 (ms)",
"unitS": "秒 (s)",
"convertButton": "立即转换",
"currentTs": "当前时间戳",
"useNowTooltip": "填充到下方",
"copyTsTooltip": "复制时间戳",
"usedSuccess": "已使用当前时间戳",
"resultLabel": "转换结果",
"copyResultTooltip": "复制结果",
"relativeTime": "相对时间",
"iso8601": "ISO 8601",
"utcTime": "UTC 时间",
"copyTooltip": "复制",
"resultEmpty": "请输入并点击转换",
"errors": {
"invalidNumber": "无效数字",
"invalidTimestamp": "无效时间戳",
"invalidFormat": "格式错误"
}
}
View File
+161 -144
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -36,11 +36,15 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"i18next": "^26.2.0",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"marked": "^18.0.4",
"qr-scanner": "^1.4.2", "qr-scanner": "^1.4.2",
"qrious": "^4.0.2", "qrious": "^4.0.2",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.6.0" "tailwind-merge": "^3.6.0"
}, },
@@ -1,6 +1,6 @@
import { Image as ImageIcon, Trash2, Upload } from 'lucide-react'; import { Image as ImageIcon, Trash2, Upload } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import { useI18n } from '@/utils/chromeI18n'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import DecodeResultPaper from '@/components/DecodeResultPaper'; import DecodeResultPaper from '@/components/DecodeResultPaper';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -19,7 +19,7 @@ interface Base64ConverterSectionProps {
} }
export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) { export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) {
const { t } = useI18n('base64Converter'); const { t } = useLazyTranslation('base64Converter');
const [direction, setDirection] = useStorageState( const [direction, setDirection] = useStorageState(
`base64Converter/${mode}Mode/direction`, `base64Converter/${mode}Mode/direction`,
@@ -57,7 +57,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
}; };
return ( return (
<div className="w-full flex flex-col space-y-4"> <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"> <div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
<SwitchButtonGroup <SwitchButtonGroup
value={direction} value={direction}
@@ -86,7 +86,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
}} }}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className={cn( className={cn(
'flex flex-col items-center justify-center min-h-[190px] border-2 border-dashed rounded-2xl p-8 cursor-pointer', '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 isDragging
? 'border-primary bg-primary/10' ? 'border-primary bg-primary/10'
: info : info
@@ -107,7 +107,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
{isLoading ? ( {isLoading ? (
<div className="w-9 h-9 border-3 border-primary/20 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 ? ( ) : info ? (
<div className="flex flex-col items-center gap-1.5 text-center w-full"> <div className="flex flex-col items-center gap-1.5 text-center w-full animate-in fade-in duration-200">
{mode === 'image' && result && ( {mode === 'image' && result && (
<div className="relative p-1 border border-border bg-background rounded-lg shadow-sm mb-1 max-w-[180px] overflow-hidden"> <div className="relative p-1 border border-border bg-background rounded-lg shadow-sm mb-1 max-w-[180px] overflow-hidden">
<img <img
@@ -117,7 +117,9 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
/> />
</div> </div>
)} )}
<Upload className={cn('w-8 h-8 text-primary')} /> <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"> <span className="text-sm font-bold text-foreground/90 max-w-[280px] truncate">
{info.name} {info.name}
</span> </span>
+220
View File
@@ -0,0 +1,220 @@
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 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' ? (
<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"
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 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">
<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
@@ -0,0 +1,259 @@
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 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="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 animate-in fade-in duration-200 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 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,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import { useI18n } from '@/utils/chromeI18n'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import { base64ToText, textToBase64 } from '@/utils/base64Converter'; import { base64ToText, textToBase64 } from '@/utils/base64Converter';
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
@@ -20,12 +20,14 @@ interface TextModeProps {
} }
export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) { export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
const { t } = useI18n('base64Converter'); const { t } = useLazyTranslation('base64Converter');
// 1. 纯净的核心源状态机:只保留输入源和转换方向
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState(''); const [debouncedInput, setDebouncedInput] = useState('');
const [direction, setDirection] = useState<'encode' | 'decode'>('encode'); const [direction, setDirection] = useState<'encode' | 'decode'>('encode');
// 2. 文本高频敲击防抖大闸:斩断频繁进行文本转 Base64 带来的 CPU 计算过热
useEffect(() => { useEffect(() => {
const handle = setTimeout(() => { const handle = setTimeout(() => {
setDebouncedInput(input); setDebouncedInput(input);
@@ -33,6 +35,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
return () => clearTimeout(handle); return () => clearTimeout(handle);
}, [input]); }, [input]);
// 3. 右键联动数据上下文:优雅原地合并受控状态
const handleContextMenuData = useCallback((payload: string) => { const handleContextMenuData = useCallback((payload: string) => {
setInput(payload); setInput(payload);
setDebouncedInput(payload); setDebouncedInput(payload);
@@ -41,6 +44,8 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData }); useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData });
// 💡 4. 贯彻方案 A(彻底消灭 setOutput / setError):
// 让所有的转化逻辑、类型安全校验在 useMemo 内存管道中单次渲染一气呵成!
const conversionPipeline = useMemo(() => { const conversionPipeline = useMemo(() => {
const trimmed = debouncedInput.trim(); const trimmed = debouncedInput.trim();
if (!trimmed) return { output: '', error: null }; if (!trimmed) return { output: '', error: null };
@@ -88,7 +93,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
}; };
return ( return (
<div className="w-full flex flex-col space-y-4"> <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"> <div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
<SwitchButtonGroup <SwitchButtonGroup
@@ -107,7 +112,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
placeholder={placeholder} placeholder={placeholder}
value={input} value={input}
onChange={setInput} onChange={setInput}
externalError={error || undefined} externalError={error || undefined} // 💡 流式异常大闸动态注入
showClear={true} showClear={true}
allowCopy={true} allowCopy={true}
minRows={5} minRows={5}
@@ -120,7 +125,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
- shadcn bg-primary/10 - shadcn bg-primary/10
*/} */}
{showImageHint && ( {showImageHint && (
<div className="flex items-center justify-between p-3.5 rounded-xl bg-primary/10 border border-primary/20"> <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"> <span className="text-xs font-semibold text-primary tracking-tight">
{t('imageDataUriHint')} {t('imageDataUriHint')}
</span> </span>
@@ -129,7 +134,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onSwitchToImageMode} onClick={onSwitchToImageMode}
className="h-7 rounded-md text-xs font-bold text-primary hover:text-primary hover:bg-primary/20 dark:hover:bg-primary/10 px-2.5" 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')} {t('switchToImageMode')}
</Button> </Button>
@@ -138,7 +143,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
{/* 5. 编码/解码核心数据承载流卡片 */} {/* 5. 编码/解码核心数据承载流卡片 */}
{output && ( {output && (
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3"> <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"> <div className="flex justify-between items-center select-none">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90"> <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{outputLabel} {outputLabel}
@@ -0,0 +1,235 @@
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();
});
});
});
@@ -0,0 +1,182 @@
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,7 +4,6 @@ import TextMode from '../TextMode';
// Mock CopyButton // Mock CopyButton
vi.mock('@/components/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>, default: ({ text }: { text: string }) => <button data-testid="copy-button">{text}</button>,
})); }));
@@ -19,18 +18,18 @@ describe('TextMode', () => {
it('应该渲染编码/解码切换按钮', () => { it('应该渲染编码/解码切换按钮', () => {
render(<TextMode />); render(<TextMode />);
expect(screen.getByText('编码')).toBeInTheDocument(); expect(screen.getByText('base64Converter:encode')).toBeInTheDocument();
expect(screen.getByText('解码')).toBeInTheDocument(); expect(screen.getByText('base64Converter:decode')).toBeInTheDocument();
}); });
it('应该渲染输入框和转换按钮', () => { it('应该渲染输入框和转换按钮', () => {
render(<TextMode />); render(<TextMode />);
expect(screen.getByPlaceholderText('输入需要编码为 Base64 的文本...')).toBeInTheDocument(); expect(screen.getByPlaceholderText('base64Converter:textInputPlaceholder')).toBeInTheDocument();
}); });
it('应该将文本编码为 Base64', async () => { it('应该将文本编码为 Base64', async () => {
render(<TextMode />); render(<TextMode />);
const input = screen.getByPlaceholderText('输入需要编码为 Base64 的文本...'); const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder');
fireEvent.change(input, { target: { value: 'Hello' } }); fireEvent.change(input, { target: { value: 'Hello' } });
act(() => { act(() => {
@@ -38,18 +37,18 @@ describe('TextMode', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Base64 编码结果')).toBeInTheDocument(); expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument();
}); });
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument(); expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8=');
}); });
it('应该解码 Base64 文本', async () => { it('应该解码 Base64 文本', async () => {
render(<TextMode />); render(<TextMode />);
// 切换到解码模式 // 切换到解码模式
fireEvent.click(screen.getByText('解码')); fireEvent.click(screen.getByText('base64Converter:decode'));
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...'); const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, { target: { value: 'SGVsbG8=' } }); fireEvent.change(input, { target: { value: 'SGVsbG8=' } });
act(() => { act(() => {
@@ -57,18 +56,18 @@ describe('TextMode', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('解码文本结果')).toBeInTheDocument(); expect(screen.getByText('base64Converter:textOutput')).toBeInTheDocument();
}); });
expect(screen.getByRole('button', { name: 'Hello' })).toBeInTheDocument(); expect(screen.getByTestId('copy-button')).toHaveTextContent('Hello');
}); });
it('应该对无效 Base64 显示错误', async () => { it('应该对无效 Base64 显示错误', async () => {
render(<TextMode />); render(<TextMode />);
// 切换到解码模式 // 切换到解码模式
fireEvent.click(screen.getByText('解码')); fireEvent.click(screen.getByText('base64Converter:decode'));
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...'); const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, { target: { value: 'invalid!!!' } }); fireEvent.change(input, { target: { value: 'invalid!!!' } });
act(() => { act(() => {
@@ -76,7 +75,7 @@ describe('TextMode', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Base64 字符串无效')).toBeInTheDocument(); expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument();
}); });
}); });
@@ -84,7 +83,7 @@ describe('TextMode', () => {
render(<TextMode />); render(<TextMode />);
// 先编码 // 先编码
const input = screen.getByPlaceholderText('输入需要编码为 Base64 的文本...'); const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder');
fireEvent.change(input, { target: { value: 'Hello' } }); fireEvent.change(input, { target: { value: 'Hello' } });
act(() => { act(() => {
@@ -92,22 +91,22 @@ describe('TextMode', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument(); expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8=');
}); });
// 切换方向 // 切换方向
fireEvent.click(screen.getByText('解码')); fireEvent.click(screen.getByText('base64Converter:decode'));
// 输出应该被清除 // 输出应该被清除
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument(); expect(screen.queryByTestId('copy-button')).not.toBeInTheDocument();
}); });
}); });
it('点击清除按钮应该清空所有内容', async () => { it('点击清除按钮应该清空所有内容', async () => {
render(<TextMode />); render(<TextMode />);
const input = screen.getByPlaceholderText('输入需要编码为 Base64 的文本...'); const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder');
fireEvent.change(input, { target: { value: 'Hello' } }); fireEvent.change(input, { target: { value: 'Hello' } });
act(() => { act(() => {
@@ -115,13 +114,13 @@ describe('TextMode', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument(); expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8=');
}); });
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument(); expect(screen.queryByTestId('copy-button')).not.toBeInTheDocument();
expect(input).toHaveValue(''); expect(input).toHaveValue('');
}); });
}); });
@@ -129,52 +128,48 @@ describe('TextMode', () => {
it('解码模式下粘贴图片 data URI 时应该显示切换图像模式的提示', () => { it('解码模式下粘贴图片 data URI 时应该显示切换图像模式的提示', () => {
render(<TextMode />); render(<TextMode />);
fireEvent.click(screen.getByText('解码')); fireEvent.click(screen.getByText('base64Converter:decode'));
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...'); const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, { fireEvent.change(input, {
target: { value: 'data:image/png;base64,iVBORw0KGgo=' }, target: { value: 'data:image/png;base64,iVBORw0KGgo=' },
}); });
expect( expect(screen.getByText('base64Converter:imageDataUriHint')).toBeInTheDocument();
screen.getByText('检测到图片的 data URI,请使用「图像」选项卡进行解码。'), expect(screen.getByText('base64Converter:switchToImageMode')).toBeInTheDocument();
).toBeInTheDocument();
expect(screen.getByText('切换到图像模式')).toBeInTheDocument();
}); });
it('粘贴非图片 data URI 时不应该显示图像模式提示', () => { it('粘贴非图片 data URI 时不应该显示图像模式提示', () => {
render(<TextMode />); render(<TextMode />);
fireEvent.click(screen.getByText('解码')); fireEvent.click(screen.getByText('base64Converter:decode'));
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...'); const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, { target: { value: 'SGVsbG8=' } }); fireEvent.change(input, { target: { value: 'SGVsbG8=' } });
expect( expect(screen.queryByText('base64Converter:imageDataUriHint')).not.toBeInTheDocument();
screen.queryByText('检测到图片的 data URI,请使用「图像」选项卡进行解码。'),
).not.toBeInTheDocument();
}); });
it('点击切换图像模式按钮应该调用 onSwitchToImageMode 回调', () => { it('点击切换图像模式按钮应该调用 onSwitchToImageMode 回调', () => {
const onSwitch = vi.fn(); const onSwitch = vi.fn();
render(<TextMode onSwitchToImageMode={onSwitch} />); render(<TextMode onSwitchToImageMode={onSwitch} />);
fireEvent.click(screen.getByText('解码')); fireEvent.click(screen.getByText('base64Converter:decode'));
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...'); const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, { fireEvent.change(input, {
target: { value: 'data:image/png;base64,iVBORw0KGgo=' }, target: { value: 'data:image/png;base64,iVBORw0KGgo=' },
}); });
fireEvent.click(screen.getByText('切换到图像模式')); fireEvent.click(screen.getByText('base64Converter:switchToImageMode'));
expect(onSwitch).toHaveBeenCalledTimes(1); expect(onSwitch).toHaveBeenCalledTimes(1);
}); });
it('解码模式下对二进制数据应该显示更清晰的错误', async () => { it('解码模式下对二进制数据应该显示更清晰的错误', async () => {
render(<TextMode />); render(<TextMode />);
fireEvent.click(screen.getByText('解码')); fireEvent.click(screen.getByText('base64Converter:decode'));
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...'); const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } }); fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
act(() => { act(() => {
@@ -182,9 +177,7 @@ describe('TextMode', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect( expect(screen.getByText('base64Converter:binaryDataDetected')).toBeInTheDocument();
screen.getByText('输入似乎是二进制数据(如图片)。请切换到「图像」选项卡。'),
).toBeInTheDocument();
}); });
}); });
}); });
@@ -2,6 +2,15 @@ import { describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import Base64ConverterPage from '../index'; import Base64ConverterPage from '../index';
// Mock useLazyTranslation
vi.mock('@/utils/useLazyTranslation', () => ({
useLazyTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn(), language: 'zh-CN' },
isLoaded: true,
}),
}));
// Mock getEntryPointType // Mock getEntryPointType
vi.mock('@/config/features', async (importOriginal) => { vi.mock('@/config/features', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config/features')>(); const actual = await importOriginal<typeof import('@/config/features')>();
@@ -40,15 +49,15 @@ describe('Base64ConverterPage', () => {
it('应该渲染模式切换按钮', async () => { it('应该渲染模式切换按钮', async () => {
render(<Base64ConverterPage />); render(<Base64ConverterPage />);
await waitForStorageInit(); await waitForStorageInit();
expect(screen.getByText('文本')).toBeInTheDocument(); expect(screen.getByText('base64Converter:textMode')).toBeInTheDocument();
expect(screen.getByText('文件')).toBeInTheDocument(); expect(screen.getByText('base64Converter:fileMode')).toBeInTheDocument();
expect(screen.getByText('图像')).toBeInTheDocument(); expect(screen.getByText('base64Converter:imageMode')).toBeInTheDocument();
}); });
it('切换到文件模式应该渲染 FileMode', async () => { it('切换到文件模式应该渲染 FileMode', async () => {
render(<Base64ConverterPage />); render(<Base64ConverterPage />);
await waitForStorageInit(); await waitForStorageInit();
fireEvent.click(screen.getByText('文件')); fireEvent.click(screen.getByText('base64Converter:fileMode'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('file-mode')).toBeInTheDocument(); expect(screen.getByTestId('file-mode')).toBeInTheDocument();
}); });
@@ -58,7 +67,7 @@ describe('Base64ConverterPage', () => {
it('切换到图像模式应该渲染 ImageMode', async () => { it('切换到图像模式应该渲染 ImageMode', async () => {
render(<Base64ConverterPage />); render(<Base64ConverterPage />);
await waitForStorageInit(); await waitForStorageInit();
fireEvent.click(screen.getByText('图像')); fireEvent.click(screen.getByText('base64Converter:imageMode'));
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('image-mode')).toBeInTheDocument(); expect(screen.getByTestId('image-mode')).toBeInTheDocument();
}); });
@@ -1,8 +1,8 @@
import { useI18n } from '@/utils/chromeI18n'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { useStorageState } from '@/utils/useStorageState'; import { useStorageState } from '@/utils/useStorageState';
import type { Base64ConverterPageMode } from '@/types/storage'; import type { Base64ConverterPageMode } from '@/types/storage';
import TextMode from './TextMode'; import TextMode from './TextMode';
import Base64ConverterSection from './Base64ConverterSection'; import Base64ConverterSection from './Base64ConverterSection'; // ✅ 正确对接全新的一体化大组件
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
const VALID_PAGE_MODES: readonly Base64ConverterPageMode[] = ['text', 'file', 'image']; const VALID_PAGE_MODES: readonly Base64ConverterPageMode[] = ['text', 'file', 'image'];
@@ -12,7 +12,7 @@ const isValidPageMode = (val: unknown): val is Base64ConverterPageMode =>
type PageMode = Base64ConverterPageMode; type PageMode = Base64ConverterPageMode;
export default function Index() { export default function Index() {
const { t } = useI18n('base64Converter'); const { t } = useLazyTranslation('base64Converter');
const [pageMode, setPageMode] = useStorageState( const [pageMode, setPageMode] = useStorageState(
'base64Converter/pageMode', 'base64Converter/pageMode',
'text', 'text',
@@ -20,7 +20,7 @@ export default function Index() {
); );
return ( return (
<div className="p-4 w-full flex flex-col space-y-4 min-h-[520px] select-none"> <div className="p-4 w-full flex flex-col space-y-4 min-h-[520px] select-none animate-in fade-in duration-300">
<SwitchButtonGroup <SwitchButtonGroup
value={pageMode} value={pageMode}
options={[ options={[
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '@/utils/chromeI18n'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import type { FileToBase64Result } from '@/utils/base64Converter'; import type { FileToBase64Result } from '@/utils/base64Converter';
import { import {
base64ToBlob, base64ToBlob,
@@ -21,7 +21,7 @@ interface UseBase64ConverterProps {
} }
export function useBase64Converter({ mode }: UseBase64ConverterProps) { export function useBase64Converter({ mode }: UseBase64ConverterProps) {
const { t } = useI18n('base64Converter'); const { t } = useLazyTranslation('base64Converter');
const [result, setResult] = useState<FileToBase64Result | null>(null); const [result, setResult] = useState<FileToBase64Result | null>(null);
const [info, setInfo] = useState<FileInfo | null>(null); const [info, setInfo] = useState<FileInfo | null>(null);
@@ -130,6 +130,7 @@ export function useBase64Converter({ mode }: UseBase64ConverterProps) {
return ''; return '';
}, [customFileName, decoded]); }, [customFileName, decoded]);
// 💡 托管最大文件体积字符串算子,清除下游引入风险
const maxFileSizeStr = `${MAX_FILE_SIZE / 1024 / 1024} MB`; const maxFileSizeStr = `${MAX_FILE_SIZE / 1024 / 1024} MB`;
return { return {

Some files were not shown because too many files have changed in this diff Show More