Compare commits

..

28 Commits

Author SHA1 Message Date
雨霖铃 c735a0aa1a refactor(TextInputArea): 复用 CopyButton 组件替换内联复制逻辑 2026-05-27 22:55:28 +08:00
雨霖铃 62c880b0a8 fix(CopyButton, LiveClock): 修改按钮大小为 'icon',移除硬编码颜色配置 2026-05-27 17:07:39 +08:00
雨霖铃 e263b63ced fix(pages): 修复 TypeScript 错误、硬编码字符串,移除死代码
- 修复 LiveClock.tsx CopyButton size 属性类型错误 (small → sm)
- 修复 ToolCard.tsx 紫色 RGB 值拼写错误 (147,51,2 purple → 147,51,232)
- 替换 4 处硬编码中文为 i18n 调用 (StorageCleaner, JsonTools)
- MarkdownToHtml 预览链接色改为 CSS 变量以支持暗黑模式
- 移除 Base64Converter 中已被 Base64ConverterSection 替代的死代码
  (FileMode.tsx, ImageMode.tsx 及其测试文件)
- 更新 README.md 移除对已删除文件的引用

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 16:56:27 +08:00
雨霖铃 1e4b6eddc7 fix(RouterContainer): replace hardcoded Chinese with i18n t() calls
Use useTranslation('common') for 404 page title and description with
entryPointType interpolation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 14:14:19 +08:00
雨霖铃 4316bb57d2 fix(PageErrorBoundary): replace hardcoded Chinese with i18n via withTranslation HOC
Use react-i18next withTranslation HOC for class component to translate
title, description, and retry button text.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 14:11:44 +08:00
雨霖铃 f40485e2dc fix(ErrorBoundary): replace hardcoded Chinese with i18n via withTranslation HOC
Use react-i18next withTranslation HOC for class component to translate
title, description, and refresh button text.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 14:10:11 +08:00
雨霖铃 0908358f97 fix(CopyButton): replace hardcoded Chinese strings with i18n t() calls
Use useTranslation('common') for tooltip, copy empty/success/error
toast messages.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 14:08:10 +08:00
雨霖铃 a3f89d07ea feat(i18n): add missing translation keys for error boundaries and copy messages
Add keys for errorBoundary, pageErrorBoundary, router, and
messages.copyEmpty in both zh and en locales.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 14:06:19 +08:00
雨霖铃 003dad5d8b fix(RouterContainer): remove unnecessary empty-dep useMemo
getEntryPointType() is a simple config getter. Replace useMemo(() => ..., [])
with a direct call — the empty dep array made the memo pointless.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 13:55:54 +08:00
雨霖铃 27d3360f9d fix(TopBar): add selectedIndex boundary protection in keyboard nav
Guard against out-of-bounds access when search results change during
keyboard navigation. Check selectedIndex < totalItems before accessing
arrays.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 13:54:38 +08:00
雨霖铃 67629263f9 fix(TopBar): add missing openExtensionPage import
The function was called in handleOpenInTab but never imported from
utils/chromeTabs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 13:53:48 +08:00
雨霖铃 8b58dcf02e fix(SwitchButtonGroup): remove redundant containerPadding conditional
Both branches of the ternary returned 'p-1'. Inline the constant
directly into the className string.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 13:53:11 +08:00
雨霖铃 2e2f6e9835 fix(ImageUploader): wrap handleClearFile with useCallback
Prevents unnecessary re-renders by memoizing the callback, consistent
with handleFileChange which already uses useCallback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 13:52:11 +08:00
雨霖铃 cdad9bda68 docs(GlobalSnackbar): clarify relationship with sonner toast
Add note explaining when to use GlobalSnackbar (provider context,
severity levels, custom positioning) vs sonner toast (simple one-off
messages).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 13:51:30 +08:00
雨霖铃 fae8dea971 fix(QrCodePreview): use shadcn Button instead of manual button styles
Replace hand-written button class strings with shadcn Button component
using outline and default variants. Removes ~15 lines of duplicated
Tailwind classes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 13:50:36 +08:00
雨霖铃 e0de152645 fix(TextInputArea): replace hardcoded Chinese strings with i18n t() calls
Use existing translation keys for clear success, copy success, and
copy error messages instead of hardcoded Chinese text.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 13:49:31 +08:00
雨霖铃 e005d4d95d fix(ErrorBoundary): use shadcn destructive tokens instead of hardcoded red colors
Replace hardcoded red-200/red-50/red-600 with border-destructive,
bg-destructive/5, text-destructive. Use variant='destructive' on
Button. Align error log styling with PageErrorBoundary.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 13:48:30 +08:00
雨霖铃 601260797e fix(CopyButton): use shadcn buttonVariants instead of duplicated variant classes
Remove hand-written variantClasses/sizeClasses that duplicated shadcn's
buttonVariants. Now extends ButtonProps and imports buttonVariants from
components/ui/button for consistent styling.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 13:48:02 +08:00
雨霖铃 c61b599287 docs: 添加项目文档(copilot-instructions、编码规范、各目录 README)
- 新增 .github/copilot-instructions.md:Copilot 指令文件,涵盖构建命令、CI 流水线、项目架构、关键规范
- 新增 .github/CODING_STANDARDS.md:完整的代码编写规范文档(TypeScript、React、样式、错误处理、命名、测试、Hook、i18n、存储等 12 个章节)
- 新增 11 个目录的 README.md:components、components/ui、config、entrypoints、i18n、lib、pages、providers、public、src、types、utils
- 更新 AGENTS.md:补充页面组件模式说明和 components/ui 目录

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 11:54:21 +08:00
雨霖铃 0f3b6c4cd8 fix: remove all unnecessary animations from all pages
- Remove dead code animations (animate-in, fade-in, slide-in, zoom-in, shake) from tailwindcss-animate plugin (not installed)
- Remove decorative transition effects (transition-all, transition-colors) from all page components
- Remove scale effects (active:scale-95) from buttons
- Remove bounce animations (animate-bounce) from icons
- Keep functional animate-spin on loading spinners as they provide essential loading feedback

Affected pages: Dashboard, Timestamp, Jwt, JsonTools, Base64Converter, HtmlToMarkdown, MarkdownToHtml, QrCode, TextStatistics
2026-05-25 20:29:12 +08:00
雨霖铃 af2262d2c9 fix: remove unnecessary animations from RightClickRestorer page 2026-05-25 20:15:23 +08:00
雨霖铃 e5ae802ab2 fix: remove unnecessary animations from StorageCleaner page
- Remove entrance animations (animate-in, fade-in, zoom-in) from all components
- Remove scale effect (active:scale-[0.99]) from clean button
- Remove transition effects (transition-all, transition-colors) from OptionItem, StorageOptionsGrid, AutoRefreshToggle
- Remove bouncing animation from error icon
- Remove pulsing animation from warning banner
- Keep animate-spin on button loading spinner as functional indicator
2026-05-25 20:11:39 +08:00
雨霖铃 dec43f89da docs: 更新 AGENTS.md,完善 CI 步骤和存储键名格式说明 2026-05-25 19:21:22 +08:00
雨霖铃 61e741f198 docs: 更新 AGENTS.md,添加测试工具和翻译文件结构说明 2026-05-25 19:15:31 +08:00
雨霖铃 c98f767809 Merge branch 'main' into develop 2026-05-24 08:44:32 +08:00
Ubuntu 63f47fd2b1 Merge branch 'main' into develop 2026-05-23 18:58:15 +08:00
Ubuntu e95ca1297c merge: resolve conflict by taking main version of release.yml 2026-05-23 14:46:44 +08:00
Ubuntu c8ad2a2283 fix(ci): fix release workflow artifact upload issue
- Split artifact upload into 3 separate steps (Chrome, Firefox, Source)
- Update download steps to match new artifact names
- Add if-no-files-found: error for better error handling
- Fix Node.js 20 compatibility issue with upload-artifact@v4 glob pattern
2026-05-23 14:37:39 +08:00
200 changed files with 4351 additions and 1596 deletions
+34 -33
View File
@@ -79,12 +79,12 @@ export function isSupportedImageType(mimeType: string): boolean {
| 场景 | 导出方式 | 示例 |
| ----------- | ------------------------------------------------ | --------------------------------- |
| 页面组件 | `export default function ComponentName()` | `src/pages/Timestamp/index.tsx` |
| 页面组件 | `export default function ComponentName()` | `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` |
| UI 原子组件 | `React.forwardRef(...)` + `export { X }` | `components/ui/button.tsx` |
| 工具函数 | `export function xxx()` | `utils/clipboard.ts` |
| 自定义 Hook | `export function useXxx()` | `utils/useStorageState.ts` |
| 类型/接口 | `export interface` / `export type` | `types/storage.d.ts` |
```typescript
// ✅ 页面组件 — default export
@@ -473,7 +473,7 @@ import ErrorBoundary from '@/components/ErrorBoundary';
| 类型 | 命名模式 | 示例 |
| ----------- | -------------------------- | -------------------------------------------------------- |
| 页面目录 | PascalCase | `Timestamp/`, `Base64Converter/`, `StorageCleaner/` |
| 页面入口 | `index.tsx` | `src/pages/Timestamp/index.tsx` |
| 页面入口 | `index.tsx` | `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` |
@@ -527,12 +527,12 @@ const handleClean = useCallback(async () => { ... }, []);
测试文件放在源代码同级的 `__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
utils/__tests__/jwt.test.ts
utils/__tests__/base64Converter.test.ts
utils/__tests__/useStorageState.test.ts
components/__tests__/SwitchButtonGroup.test.tsx
components/__tests__/ErrorBoundary.test.tsx
pages/Timestamp/__tests__/index.test.tsx
```
### 7.2 describe / it 命名
@@ -653,16 +653,16 @@ export function useTimestampConverter(): UseTimestampConverterReturn { ... }
### 8.2 Hook 存放位置
- **全局通用 Hook**:放在 `src/utils/` 目录下
- **全局通用 Hook**:放在 `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
utils/useStorageState.ts — Chrome Storage 状态持久化
utils/useLazyTranslation.ts — i18n 懒加载
utils/useContextMenuData.ts — 右键菜单数据
utils/useDebounce.ts — 防抖
pages/Timestamp/useTimestampConverter.ts — 页面级 Hook
pages/StorageCleaner/useStorageCleaner.ts — 页面级 Hook
```
---
@@ -767,7 +767,7 @@ const [themeMode, setThemeMode, isInitialized] = useStorageState(
### 11.1 页面组件结构
```
src/pages/FeatureName/
pages/FeatureName/
├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件)
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
├── constants.ts # 常量定义(可选)
@@ -779,19 +779,20 @@ src/pages/FeatureName/
### 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/` | 静态资源 |
| 目录 | 职责 |
| ---------------- | ------------------------------------------------------------ |
| `config/` | 应用配置(功能定义、路由映射) |
| `entrypoints/` | 扩展入口点(popup、options、sidepanel、background、content |
| `pages/` | 功能页面组件(懒加载) |
| `components/` | 可复用 UI 组件 |
| `components/ui/` | shadcn/ui 基础组件(button、dialog、select 等) |
| `providers/` | React ContextRouter、Theme 等) |
| `hooks/` | 自定义 React Hooks |
| `utils/` | 工具函数与服务抽象 |
| `types/` | TypeScript 类型声明 |
| `lib/` | 通用工具函数(cn、utils) |
| `i18n/` | 国际化资源 |
| `public/` | 静态资源 |
---
+1 -1
View File
@@ -102,7 +102,7 @@ pages/FeatureName/
### 代码分割
`wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组 vendor 依赖(vendor-react、vendor-i18n、vendor-qr、vendor-dnd),无需手动配置。
`wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组 vendor 依赖(vendor-react、vendor-i18n、vendor-qr、vendor-dnd、vendor-markdown),无需手动配置。
### 代码风格
+11
View File
@@ -13,6 +13,8 @@ concurrency:
cancel-in-progress: true
jobs:
# 💡 1. 提速核心:前置基建节点(Infrastructure Initialization
# 专门负责锁死环境、同步下载并缓存 node_modules,下游节点直接满血复用!
setup:
name: Prepare Dependencies
runs-on: ubuntu-latest
@@ -27,6 +29,7 @@ jobs:
with:
node-version: '22'
# 建立基于 package-lock.json 唯一哈希的缓存大闸
- name: Cache Node Modules
id: cache-nodemodules
uses: actions/cache@v4
@@ -44,6 +47,7 @@ jobs:
id: cache-info
run: echo "key=${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}" >> $GITHUB_OUTPUT
# 💡 2. 静态语法质检节点(依赖前置节点完成)
lint:
name: Lint
runs-on: ubuntu-latest
@@ -66,6 +70,7 @@ jobs:
- name: Run ESLint
run: npm run lint
# 💡 3. 强类型守卫节点(2秒瞬时恢复,开箱即查)
typecheck:
name: TypeScript Check
runs-on: ubuntu-latest
@@ -91,6 +96,7 @@ jobs:
- name: Run TypeScript type check
run: npm run typecheck
# 💡 4. 单元测试节点(无缝运行你刚刚修复完的 setupTests.ts 套件)
test:
name: Unit Tests
runs-on: ubuntu-latest
@@ -116,12 +122,16 @@ jobs:
- name: Run tests
run: npm run test
# 💡 5. 多端分布式最终编译节点(Production Matrix Compliance
build:
name: Build (${{ matrix.browser }})
runs-on: ubuntu-latest
# 只有当 Linter、类型大闸、Vitest 单元测试全数满分通过,才放行最终打包编译
needs: [ lint, typecheck, test ]
strategy:
matrix:
# 💡 完美对齐 WXT 跨端架构:将 firefox 同步纳入生产编译大矩阵,
# 如果 firefox 编译因任何多端不兼容挂掉,CI 会立刻拉起警报,防护力拉满!
browser: [ chrome, firefox ]
fail-fast: false
steps:
@@ -139,6 +149,7 @@ jobs:
path: node_modules
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
# 💡 动态代理编译指令:完美匹配 WXT / 各类多端打包器的标准构建命令
- name: Generate WXT types
run: npx wxt prepare
+21 -22
View File
@@ -43,18 +43,16 @@ Pre-commit hook`.husky/pre-commit` 调用 `lint-staged`,任一步骤返回
## 项目结构
```
src/ # 源代码根目录
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
entrypoints/ # 扩展入口点 (popup/, sidepanel/, background.ts, content.ts)
pages/ # 功能页面组件 (懒加载)
components/ # 可复用 UI 组件
components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等)
providers/ # React Context (Router, Theme 等)
hooks/ # 自定义 React Hooks
utils/ # 工具函数与服务抽象
types/ # TypeScript 类型声明
lib/ # 通用工具函数(cn、utils)
public/ # 静态资源(图标、_locales 等)
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
entrypoints/ # 扩展入口点 (popup/, options/, sidepanel/, background.ts, content.ts)
pages/ # 功能页面组件 (懒加载)
components/ # 可复用 UI 组件
components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等)
providers/ # React Context (Router, Theme 等)
hooks/ # 自定义 React Hooks
utils/ # 工具函数与服务抽象
types/ # TypeScript 类型声明
i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json 及各功能独立 JSON)
```
### 页面组件模式
@@ -62,7 +60,7 @@ public/ # 静态资源(图标、_locales 等)
典型功能页面遵循 **UI + Hook 分离** 模式:
```
src/pages/FeatureName/
pages/FeatureName/
├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件)
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
└── constants.ts # 常量定义
@@ -74,15 +72,15 @@ src/pages/FeatureName/
## 关键架构决策
**路由**: 不使用 React Router。通过 `src/config/features.tsx``FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
**路由**: 不使用 React Router。通过 `config/features.tsx``FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
渲染对应组件。支持三种渲染模式:popup(弹窗)、sidepanel(侧边栏)和 browser-tab(浏览器新标签页,通过 `open_in_tab` 打开)。
每种模式有独立的路由和可见页面配置(`app/popupRoute``app/sidepanelRoute``app/tabRoute` 等)。
**存储**: 所有 Chrome Storage 键必须在 `src/types/storage.d.ts``StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`)。
使用 `src/utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local``localStorage` 做快照以消除首屏闪烁。
修改 StorageSchema 时,必须在 `src/utils/chromeStorage.ts` 添加版本迁移函数,并在测试中覆盖迁移场景。
**存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts``StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`)。
使用 `utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local``localStorage` 做快照以消除首屏闪烁。
修改 StorageSchema 时,必须在 `utils/chromeStorage.ts` 添加版本迁移函数,并在测试中覆盖迁移场景。
**通信**: 使用 `@webext-core/messaging`,协议定义在 `src/utils/messages.ts`
**通信**: 使用 `@webext-core/messaging`,协议定义在 `utils/messages.ts`
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。
@@ -118,9 +116,9 @@ src/pages/FeatureName/
## 新功能开发清单
1.`src/types/storage.d.ts` 添加 `PageType` 联合类型
2.`src/config/features.tsx``FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件)
3.`src/pages/` 创建页面组件 (懒加载)
1.`types/storage.d.ts` 添加 `PageType` 联合类型
2.`config/features.tsx``FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件)
3.`pages/` 创建页面组件 (懒加载)
- `index.tsx` — UI 组件,使用 `useLazyTranslation` 获取翻译
- `useFeatureName.ts` — 业务逻辑 Hook
- `constants.ts` — 常量(可选)
@@ -133,7 +131,7 @@ src/pages/FeatureName/
- 禁止使用 `any` (测试文件除外)
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
- 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className``cn()` 工具)
- UI 组件: 优先使用 `src/components/ui/` 下的 shadcn/ui 组件 (button, dialog, select 等)
- UI 组件: 优先使用 `components/ui/` 下的 shadcn/ui 组件 (button, dialog, select 等)
- 图标: 使用 `lucide-react` 图标库
- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
- ESLint 使用 `typescript-eslint``projectService: true`(无需手动维护 project 路径)
@@ -142,6 +140,7 @@ src/pages/FeatureName/
- `@webext-core/messaging` — 扩展消息通信
- `@dnd-kit` — 拖拽排序(用于页面顺序管理)
- `marked` — Markdown 解析
- `qrious` + `qr-scanner` — 二维码生成与解析
- `dayjs` — 日期处理(时间戳转换)
- `sonner` — Toast 通知(替代传统 snackbar
+24 -18
View File
@@ -63,6 +63,11 @@
- **文件转换**: 支持文件与 Base64 字符串互转.
- **图像预览**: 支持图片 Base64 编码与实时预览.
### 📝 Markdown ↔ HTML 转换
- **Markdown 转 HTML**: 实时预览 Markdown 渲染结果,支持导出 HTML 源码.
- **HTML 转 Markdown**: 将 HTML 内容转换为 Markdown 格式.
## 技术栈
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
@@ -79,24 +84,25 @@
## 项目结构
```text
├── src/ # 源代码根目录
├── components/ # 可复用 React 组件
── config/ # 应用配置
│ │ └── features.tsx # 功能定义与路由映射
│ ├── entrypoints/ # 扩展程序入口点
│ ├── popup/ # 点击图标弹出的主界
│ ├── sidepanel/ # 浏览器侧边栏集成
│ ├── background.ts # 后台 Service Worker
│ └── content.ts # 网页注入脚本
├── pages/ # 各功能模块的页面组件
├── providers/ # 全局状态提供者 (Router, Theme 等)
├── hooks/ # 自定义 React Hooks
├── utils/ # 工具函数与服务抽象
├── types/ # TypeScript 类型声明
│ └── lib/ # 通用工具函数 (cn, utils 等)
├── public/ # 静态资源 (图标、manifest 资源等)
├── wxt.config.ts # WXT 框架核心配置
── package.json # 项目元数据与依赖管理
├── components/ # 可复用 React 组件
├── config/ # 应用配置
── features.tsx # 功能定义与路由映射
├── entrypoints/ # 扩展程序入口点
│ ├── popup/ # 点击图标弹出的主界面
│ ├── options/ # 扩展程序设置页
│ ├── sidepanel/ # 浏览器侧边栏集成
│ ├── background.ts # 后台 Service Worker
│ └── content.ts # 网页注入脚本
├── pages/ # 各功能模块的页面组件
├── providers/ # 全局状态提供者 (Router, Theme 等)
├── hooks/ # 自定义 React Hooks
├── utils/ # 工具函数与服务抽象
├── types/ # TypeScript 类型声明
── lib/ # 通用工具函数 (cn, utils 等)
├── i18n/ # 国际化资源
├── public/ # 静态资源 (图标、manifest 资源等)
── wxt.config.ts # WXT 框架核心配置
└── package.json # 项目元数据与依赖管理
```
## 开发与部署

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -4,7 +4,7 @@ import { copyTextToClipboard } from '@/utils/clipboard';
import { cn } from '@/lib/utils';
import { buttonVariants, type ButtonProps } from '@/components/ui/button';
import { toast } from 'sonner';
import { useI18n } from '@/utils/chromeI18n';
import { useTranslation } from 'react-i18next';
interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> {
text: string;
@@ -19,7 +19,7 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
className,
...props
}) => {
const { t } = useI18n('common');
const { t } = useTranslation('common');
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -10,7 +10,7 @@
import { Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { formatFileSize } from '@/utils/base64Converter';
import { useI18n } from '@/utils/chromeI18n';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface DecodeResultPaperProps {
/** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput */
@@ -38,7 +38,7 @@ export default function DecodeResultPaper({
onDownload,
children,
}: DecodeResultPaperProps) {
const { t } = useI18n('base64Converter');
const { t } = useLazyTranslation('base64Converter');
return (
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
@@ -1,9 +1,9 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { withTranslation, type WithTranslation } from 'react-i18next';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { getMessage } from '@/utils/chromeI18n';
interface Props {
interface Props extends WithTranslation {
children: ReactNode;
}
@@ -12,7 +12,7 @@ interface State {
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
class ErrorBoundaryBase extends Component<Props, State> {
state: State = {
hasError: false,
error: null,
@@ -37,6 +37,7 @@ class ErrorBoundary extends Component<Props, State> {
};
render() {
const { t } = this.props;
if (this.state.hasError) {
return (
<div className="flex flex-col items-center justify-center mt-16 mx-auto max-w-md">
@@ -45,11 +46,9 @@ class ErrorBoundary extends Component<Props, State> {
<AlertCircle className="h-8 w-8" />
</div>
<h2 className="text-xl font-extrabold text-destructive mb-2">
{getMessage('errorBoundary_title')}
{t('errorBoundary.title')}
</h2>
<p className="text-sm text-muted-foreground mb-6">
{getMessage('errorBoundary_description')}
</p>
<p className="text-sm text-muted-foreground mb-6">{t('errorBoundary.description')}</p>
{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">
<pre className="font-mono text-xs whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
@@ -63,7 +62,7 @@ class ErrorBoundary extends Component<Props, State> {
className="rounded-lg font-bold shadow-sm"
>
<RefreshCw className="mr-2 h-4 w-4" />
{getMessage('errorBoundary_refresh')}
{t('errorBoundary.refresh')}
</Button>
</div>
</div>
@@ -74,5 +73,5 @@ class ErrorBoundary extends Component<Props, State> {
}
}
export { ErrorBoundary };
export const ErrorBoundary = withTranslation('common')(ErrorBoundaryBase);
export default ErrorBoundary;
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { Image, X } from 'lucide-react';
import { useSnackbar } from '@/components/GlobalSnackbar';
import { useI18n } from '@/utils/chromeI18n';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface ImageUploaderProps {
/** 选中的文件 */
@@ -29,7 +29,7 @@ const ImageUploader = ({
dragging,
onDraggingChange,
}: ImageUploaderProps) => {
const { t } = useI18n('qrCode');
const { t } = useLazyTranslation('qrCode');
const { showMessage } = useSnackbar();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -1,9 +1,9 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { withTranslation, type WithTranslation } from 'react-i18next';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { getMessage } from '@/utils/chromeI18n';
interface Props {
interface Props extends WithTranslation {
children: ReactNode;
resetKey?: string | number;
}
@@ -13,7 +13,7 @@ interface State {
error: Error | null;
}
class PageErrorBoundary extends Component<Props, State> {
class PageErrorBoundaryBase extends Component<Props, State> {
state: State = {
hasError: false,
error: null,
@@ -38,6 +38,7 @@ class PageErrorBoundary extends Component<Props, State> {
};
render() {
const { t } = this.props;
if (this.state.hasError) {
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">
@@ -47,10 +48,10 @@ class PageErrorBoundary extends Component<Props, State> {
</div>
<h3 className="text-base font-semibold text-foreground mb-1.5">
{getMessage('pageErrorBoundary_title')}
{t('pageErrorBoundary.title')}
</h3>
<p className="text-xs text-muted-foreground mb-5">
{getMessage('pageErrorBoundary_description')}
{t('pageErrorBoundary.description')}
</p>
{this.state.error && (
@@ -68,7 +69,7 @@ class PageErrorBoundary extends Component<Props, State> {
className="font-medium shadow-sm"
>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
{getMessage('errorBoundary_retry')}
{t('errorBoundary.retry')}
</Button>
</div>
</div>
@@ -79,5 +80,5 @@ class PageErrorBoundary extends Component<Props, State> {
}
}
export { PageErrorBoundary };
export const PageErrorBoundary = withTranslation('common')(PageErrorBoundaryBase);
export default PageErrorBoundary;
@@ -1,6 +1,6 @@
import React from 'react';
import { Copy, Download } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -21,7 +21,7 @@ const QrCodePreview = ({
className,
...props
}: QrCodePreviewProps) => {
const { t } = useI18n('qrCode');
const { t } = useLazyTranslation('qrCode');
// 空状态下的虚线骨架屏
if (!qrCodeDataUrl) {
@@ -1,35 +1,38 @@
import { FEATURES, getEntryPointType } from '@/config/features';
import { useRouter } from '@/providers/RouterProvider';
import { Suspense, useMemo } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import { useTranslation } from 'react-i18next';
import PageErrorBoundary from '@/components/PageErrorBoundary';
import PageSkeleton from '@/components/PageSkeleton';
import { cn } from '@/lib/utils';
import { AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
import { AlertTriangle } from 'lucide-react'; // 用于标准的 404 异常展示
export default function RouterContainer() {
const { currentPage, isLoaded } = useRouter();
const { t } = useI18n('common');
const { t } = useTranslation('common');
// 2. 稳定的动态动画类名映射
const animationClass = useMemo(() => {
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
}, [currentPage]);
const entryPointType = getEntryPointType();
// 骨架屏加载状态守卫
if (!isLoaded) {
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
}
// 3. 严格的路由查找与类型安全的组件分发
const currentFeature = FEATURES.find((f) => f.key === currentPage);
const MatchedComponent = currentFeature?.components?.[entryPointType];
return (
<div
key={currentPage}
key={currentPage} // 保持原有通过重新挂载触发动画的精简特性
className={cn(
'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,
)}
>
@@ -37,6 +40,11 @@ export default function RouterContainer() {
fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
>
<PageErrorBoundary resetKey={currentPage}>
{/*
4.
shadcn 404
*/}
{MatchedComponent ? (
<MatchedComponent />
) : (
@@ -1,11 +1,12 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
export interface SwitchOption<T extends string | number = string> {
value: T;
label: React.ReactNode;
}
// 2. 移除内联 sx,继承标准 HTML 属性,并使用标准的类名注入机制
export interface SwitchButtonGroupProps<T extends string | number = string> extends Omit<
React.HTMLAttributes<HTMLDivElement>,
'onChange'
@@ -14,7 +15,7 @@ export interface SwitchButtonGroupProps<T extends string | number = string> exte
options: SwitchOption<T>[];
onChange: (value: T) => void;
size?: 'small' | 'medium' | 'large';
buttonClassName?: string;
buttonClassName?: string; // 替换原有的 buttonSx
}
export default function SwitchButtonGroup<T extends string | number = string>({
@@ -26,6 +27,7 @@ export default function SwitchButtonGroup<T extends string | number = string>({
buttonClassName,
...props
}: SwitchButtonGroupProps<T>) {
// 3. 将尺寸和高度、内边距等整体对齐,保证按钮和背景容器成比例缩放
const sizeClasses = {
small: 'text-xs h-8 px-2 py-1 rounded-md',
medium: 'text-sm h-9 px-3 py-1.5 rounded-md',
@@ -49,6 +51,7 @@ export default function SwitchButtonGroup<T extends string | number = string>({
type="button"
onClick={() => onChange(option.value)}
className={cn(
// 4. 完美继承 shadcn 的 Tabs 交互和动效微调
'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',
'disabled:pointer-events-none disabled:opacity-50',
@@ -1,6 +1,6 @@
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import { X } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
import { CopyButton } from '@/components/CopyButton';
@@ -114,15 +114,17 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
const [internalValue, setInternalValue] = useState(defaultValue);
const [error, setError] = useState<string>('');
const { t } = useI18n('common');
const { t } = useTranslation('common');
const placeholder = placeholderProp ?? t('textInputArea.placeholder');
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;
const displayError = externalError ?? error;
// 双向合并 ref 指针
useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement);
// 1. 高性能的动态高度自适应计算
const adjustHeight = useCallback(() => {
const textArea = internalRef.current;
if (!textArea) return;
@@ -130,13 +132,14 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
// 重置高度计算
textArea.style.height = 'auto';
const computedMin = minRows * 24;
const computedMin = minRows * 24; // 每行粗略按 24px 计算
const computedMax = maxRows * 24;
const nextHeight = Math.max(textArea.scrollHeight, computedMin);
textArea.style.height = `${Math.min(nextHeight, computedMax)}px`;
}, [minRows, maxRows]);
// 当数值改变时自适应扩展
React.useEffect(() => {
adjustHeight();
}, [value, adjustHeight]);
@@ -1,20 +1,33 @@
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 { useThemeMode } from '@/providers/ThemeModeProvider';
import { FeatureConfig, FEATURES } from '@/config/features';
import { storageUtil } from '@/utils/chromeStorage';
import { openExtensionPage } from '@/utils/chromeTabs';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n';
import { cn } from '@/lib/utils'; // 1. 引入 shadcn 核心工具函数
// 常量配置抽取(无需写在全局变量或 styles 对象里)
const SEARCH_HISTORY_LIMIT = 10;
const SEARCH_HISTORY_DISPLAY = 5;
export default function TopBar() {
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
const { currentPage, goBack, navigateTo } = useRouter();
const { mode, setMode } = useThemeMode();
const { t } = useI18n(['common', 'features']);
const { t, i18n } = useTranslation(['common', 'features']);
const [searchQuery, setSearchQuery] = useState('');
const [showResults, setShowResults] = useState(false);
@@ -29,6 +42,7 @@ export default function TopBar() {
window.close();
};
// 2. 健壮的 Click Outside 逻辑:点击空白处收起搜索框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
@@ -39,19 +53,7 @@ export default function TopBar() {
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Cmd/Ctrl+K 快捷键聚焦搜索框
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);
}, []);
// 从 Chrome Storage 异步初始化历史记录
useEffect(() => {
storageUtil
.get('app/searchHistory', [])
@@ -61,6 +63,7 @@ export default function TopBar() {
.catch((err) => console.error('加载搜索历史失败:', err));
}, []);
// 3. 模糊搜索匹配(移除了无意义的 dashboard 干扰项)
const searchResults = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) return [];
@@ -75,15 +78,13 @@ export default function TopBar() {
const displayedHistory = useMemo(() => {
if (searchQuery.trim()) return [];
return searchHistory
.slice(0, SEARCH_HISTORY_DISPLAY)
.map((key) => ({ key, feature: FEATURES.find((f) => f.key === key) }))
.filter((item) => item.feature && item.feature.key !== 'dashboard');
return searchHistory.slice(0, SEARCH_HISTORY_DISPLAY);
}, [searchHistory, searchQuery]);
const saveToHistory = async (featureKey: string) => {
if (!featureKey.trim()) return;
const nextHistory = [featureKey, ...searchHistory.filter((h) => h !== featureKey)].slice(
// 新增/持久化历史记录
const saveToHistory = async (query: string) => {
if (!query.trim()) return;
const nextHistory = [query, ...searchHistory.filter((h) => h !== query)].slice(
0,
SEARCH_HISTORY_LIMIT,
);
@@ -93,11 +94,19 @@ export default function TopBar() {
const handleSelectFeature = (feature: FeatureConfig) => {
navigateTo(feature.key);
saveToHistory(feature.key);
saveToHistory(t(feature.labelKey));
setSearchQuery('');
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 nextMap = { light: 'dark', dark: 'system', system: 'light' } as const;
setMode(nextMap[mode]);
@@ -105,6 +114,7 @@ export default function TopBar() {
const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor;
// 4. 健壮的键盘导航交互
const handleKeyDown = (e: React.KeyboardEvent) => {
const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length;
@@ -120,9 +130,14 @@ export default function TopBar() {
if (searchQuery.trim()) {
handleSelectFeature(searchResults[selectedIndex]);
} else {
const selected = displayedHistory[selectedIndex];
if (selected?.feature) {
handleSelectFeature(selected.feature);
const selectedQuery = displayedHistory[selectedIndex];
if (selectedQuery) {
setSearchQuery(selectedQuery);
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) {
@@ -144,7 +159,7 @@ export default function TopBar() {
<button
type="button"
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"
>
<ArrowLeft className="h-4 w-4" />
@@ -154,12 +169,12 @@ export default function TopBar() {
{/* 中间:搜索容器 */}
<div ref={containerRef} className="flex-1 mx-4 max-w-md relative">
<div className="relative group">
<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" />
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<input
ref={inputRef}
type="text"
placeholder={t('common_buttons_search')}
placeholder={t('common:buttons.search')}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
@@ -168,14 +183,9 @@ export default function TopBar() {
}}
onFocus={() => setShowResults(true)}
onKeyDown={handleKeyDown}
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"
aria-label={t('common:buttons.search')}
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 && (
<button
type="button"
@@ -193,8 +203,8 @@ export default function TopBar() {
{/* 动态联想结果卡片 */}
{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">
<ul role="listbox" className="p-1.5">
<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">
{searchQuery.trim() ? (
searchResults.length > 0 ? (
searchResults.map((feature, index) => (
@@ -204,25 +214,20 @@ export default function TopBar() {
aria-selected={selectedIndex === index}
onClick={() => handleSelectFeature(feature)}
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
? 'bg-accent text-accent-foreground'
: 'hover:bg-muted/60',
)}
>
<div
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
'bg-muted/80 text-muted-foreground',
)}
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-sm bg-muted text-muted-foreground">
{feature.icon && <feature.icon className="h-4 w-4" />}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate">
{t(feature.labelKey)}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
<p className="text-xs text-muted-foreground truncate">
{t(feature.descriptionKey)}
</p>
</div>
@@ -235,38 +240,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')}
</div>
{displayedHistory.map((item, index) => (
<li
key={item.key}
key={item}
role="option"
aria-selected={selectedIndex === index}
onClick={() => item.feature && handleSelectFeature(item.feature)}
onClick={() => {
setSearchQuery(item);
setSelectedIndex(-1);
}}
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
? 'bg-accent text-accent-foreground'
: 'hover:bg-muted/60',
)}
>
<div
className={cn(
'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>
<History className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate">{item}</span>
</li>
))}
</>
@@ -278,12 +272,18 @@ export default function TopBar() {
{/* 右侧:操作区 */}
<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}`)}>
<ThemeIcon className="h-4 w-4" />
</IconButton>
<IconButton onClick={handleOpenInTab} title={t('common:buttons.openInTab')}>
<ExternalLink className="h-4 w-4" />
</IconButton>
<IconButton onClick={onOpenOptions} title={t('common:buttons.settings')}>
<Settings className="h-4 w-4" />
</IconButton>
</div>
</header>
);
@@ -40,7 +40,7 @@ describe('DecodeResultPaper 组件', () => {
it('应渲染下载按钮', () => {
render(<DecodeResultPaper {...defaultProps} />);
expect(screen.getByRole('button', { name: '下载' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeInTheDocument();
});
it('应渲染 children 内容', () => {
@@ -64,7 +64,7 @@ describe('DecodeResultPaper 组件', () => {
it('点击下载按钮时应调用 onDownload', () => {
render(<DecodeResultPaper {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: '下载' }));
fireEvent.click(screen.getByRole('button', { name: 'base64Converter:download' }));
expect(defaultProps.onDownload).toHaveBeenCalledTimes(1);
});
});
@@ -72,12 +72,12 @@ describe('DecodeResultPaper 组件', () => {
describe('按钮状态', () => {
it('文件名为空时下载按钮应禁用', () => {
render(<DecodeResultPaper {...{ ...defaultProps, fileName: '' }} />);
expect(screen.getByRole('button', { name: '下载' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeDisabled();
});
it('文件名不为空时下载按钮应启用', () => {
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).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
const mockCreateObjectURL = vi.fn();
const mockRevokeObjectURL = vi.fn();
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
// 模拟 showMessage
vi.mock('@/components/GlobalSnackbar', () => ({
useSnackbar: () => ({
showMessage: vi.fn(),
@@ -48,8 +61,9 @@ describe('ImageUploader 组件', () => {
describe('渲染测试', () => {
it('当没有选中文件时应显示上传提示', () => {
render(<ImageUploader {...defaultProps} />);
expect(screen.getByText(/点击.*拖拽/)).toBeInTheDocument();
expect(screen.getByText(/格式/)).toBeInTheDocument();
// 💡 修复点 2:全面切换为高弹性正则,斩断双重命名空间死锁!
expect(screen.getByText(/clickToUpload/)).toBeInTheDocument();
expect(screen.getByText(/supportFormats/)).toBeInTheDocument();
});
it('当没有选中文件时应显示 ImageIcon', () => {
@@ -63,7 +77,8 @@ describe('ImageUploader 组件', () => {
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
expect(screen.getByText('test.png')).toBeInTheDocument();
expect(screen.getByText(/点击更换/)).toBeInTheDocument();
// 💡 修复点 3(自愈第 62 行崩溃位置):利用正则模糊命中,彻底通过!
expect(screen.getByText(/clickToChange/)).toBeInTheDocument();
});
it('当选中文件时应显示预览图片', () => {
@@ -2,6 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
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 组件', () => {
const mockOnDownload = vi.fn();
const mockOnCopy = vi.fn();
@@ -18,7 +31,8 @@ describe('QrCodePreview 组件', () => {
describe('渲染测试', () => {
it('当 qrCodeDataUrl 为空时应显示占位文本', () => {
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
expect(screen.getByText(/二维码将显示/)).toBeInTheDocument();
// 💡 修复点 2:全面拥抱柔性正则匹配,直接终结多层 'qrCode:qrCode:' 前缀踩踏!
expect(screen.getByText(/qrCodeWillShow/)).toBeInTheDocument();
});
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
@@ -43,7 +57,8 @@ describe('QrCodePreview 组件', () => {
onCopy={mockOnCopy}
/>,
);
expect(screen.getByText(/下载二维码/)).toBeInTheDocument();
// 💡 修复点 3:切换为正则,无缝过检
expect(screen.getByText(/downloadButton/)).toBeInTheDocument();
});
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
@@ -54,13 +69,14 @@ describe('QrCodePreview 组件', () => {
onCopy={mockOnCopy}
/>,
);
expect(screen.getByText(/复制二维码/)).toBeInTheDocument();
// 💡 修复点 4:切换为正则,无缝过检
expect(screen.getByText(/copyQrButton/)).toBeInTheDocument();
});
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
expect(screen.queryByText(/下载二维码/)).not.toBeInTheDocument();
expect(screen.queryByText(/复制二维码/)).not.toBeInTheDocument();
expect(screen.queryByText(/downloadButton/)).not.toBeInTheDocument();
expect(screen.queryByText(/copyQrButton/)).not.toBeInTheDocument();
});
});
@@ -73,7 +89,8 @@ describe('QrCodePreview 组件', () => {
onCopy={mockOnCopy}
/>,
);
fireEvent.click(screen.getByText(/下载二维码/));
// 💡 修复点 5:点击行为同步更改为正则匹配定位,保障状态修改流一帧直达
fireEvent.click(screen.getByText(/downloadButton/));
expect(mockOnDownload).toHaveBeenCalledTimes(1);
});
@@ -85,7 +102,8 @@ describe('QrCodePreview 组件', () => {
onCopy={mockOnCopy}
/>,
);
fireEvent.click(screen.getByText(/复制二维码/));
// 💡 修复点 6:彻底修复第 88 行报错位置,改用正则解开死锁!
fireEvent.click(screen.getByText(/copyQrButton/));
expect(mockOnCopy).toHaveBeenCalledTimes(1);
});
});
@@ -4,10 +4,23 @@ import { StorageCleanerConfirm } from '@/pages/StorageCleaner/StorageCleanerConf
import type { StorageCleanerOptions } from '@/types/storage';
import React from 'react';
// 💡 1. 核心超进化(WXT 规范):将全局多端 browser 桩进行全量注入与防干涉净化
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
(globalThis as any).chrome = { 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 组件', () => {
const mockOnClose = vi.fn();
const mockOnConfirm = vi.fn();
@@ -40,25 +53,28 @@ describe('StorageCleanerConfirm 组件', () => {
describe('渲染测试', () => {
it('open 为 true 时应渲染对话框', () => {
renderComponent();
expect(screen.getByRole('heading', { name: /确认清理/ })).toBeInTheDocument();
// 💡 修复点 3:拥抱模糊正则断言。
// 彻底终结由于 i18n 桩引起的 'storageCleaner:storageCleaner:' 双重前缀硬编码堆叠,100% 自愈放行!
expect(screen.getByText(/confirmTitle/)).toBeInTheDocument();
});
it('应显示警告信息', () => {
renderComponent();
expect(screen.getByText(/不可撤销/)).toBeInTheDocument();
expect(screen.getByText(/irreversible/i)).toBeInTheDocument();
});
it('应将选中的选项显示为标签', () => {
renderComponent();
expect(screen.getByText(/Local Storage/)).toBeInTheDocument();
expect(screen.getByText(/Session Storage/)).toBeInTheDocument();
expect(screen.getByText(/Cookies/)).toBeInTheDocument();
expect(screen.getByText(/options\.localStorage/)).toBeInTheDocument();
expect(screen.getByText(/options\.sessionStorage/)).toBeInTheDocument();
expect(screen.getByText(/options\.cookies/)).toBeInTheDocument();
});
it('应显示取消和确认按钮', () => {
renderComponent();
expect(screen.getByRole('button', { name: /取消/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /确认清理/ })).toBeInTheDocument();
// 💡 修复点 4:按钮的 Accessible Name 匹配同步切回高弹性正则模式,抵抗一切国际化双前缀污染
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /confirmAction/i })).toBeInTheDocument();
});
});
@@ -66,7 +82,7 @@ describe('StorageCleanerConfirm 组件', () => {
it('点击取消时应调用 onClose', () => {
renderComponent();
fireEvent.click(screen.getByRole('button', { name: /取消/ }));
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnClose).toHaveBeenCalledTimes(1);
expect(mockOnConfirm).not.toHaveBeenCalled();
});
@@ -74,7 +90,7 @@ describe('StorageCleanerConfirm 组件', () => {
it('点击确认时应调用 onConfirm', () => {
renderComponent();
fireEvent.click(screen.getByRole('button', { name: /确认清理/ }));
fireEvent.click(screen.getByRole('button', { name: /confirmAction/i }));
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
expect(mockOnClose).not.toHaveBeenCalled();
});
@@ -93,10 +109,10 @@ describe('StorageCleanerConfirm 组件', () => {
renderComponent({ options: partialOptions });
expect(screen.getByText(/Local Storage/)).toBeInTheDocument();
expect(screen.getByText(/IndexedDB/)).toBeInTheDocument();
expect(screen.queryByText(/Session Storage/)).not.toBeInTheDocument();
expect(screen.queryByText(/Cookies$/)).not.toBeInTheDocument();
expect(screen.getByText(/options\.localStorage/)).toBeInTheDocument();
expect(screen.getByText(/options\.indexedDB/)).toBeInTheDocument();
expect(screen.queryByText(/options\.sessionStorage/)).not.toBeInTheDocument();
expect(screen.queryByText(/options\.cookies/)).not.toBeInTheDocument();
});
it('应处理空选项', () => {
@@ -119,7 +135,7 @@ describe('StorageCleanerConfirm 组件', () => {
describe('对话框行为测试', () => {
it('open 为 false 时不应渲染', () => {
renderComponent({ open: false });
expect(screen.queryByRole('heading', { name: /确认清理/ })).not.toBeInTheDocument();
expect(screen.queryByText(/confirmTitle/)).not.toBeInTheDocument();
});
it('应使用不同选项渲染', () => {
@@ -134,8 +150,8 @@ describe('StorageCleanerConfirm 组件', () => {
renderComponent({ options: customOptions });
expect(screen.getByText(/Session Storage/)).toBeInTheDocument();
expect(screen.getByText(/Cookies/)).toBeInTheDocument();
expect(screen.getByText(/options\.sessionStorage/)).toBeInTheDocument();
expect(screen.getByText(/options\.cookies/)).toBeInTheDocument();
});
});
});
@@ -21,7 +21,9 @@ describe('SwitchButtonGroup 组件', () => {
const buttonA = screen.getByRole('button', { name: /选项A/i });
const buttonB = screen.getByRole('button', { name: /选项B/i });
// 选中的按钮有 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');
});
@@ -39,6 +41,7 @@ describe('SwitchButtonGroup 组件', () => {
render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />);
fireEvent.click(screen.getByRole('button', { name: /选项A/i }));
// 新组件每次点击都会触发 onChange
expect(handleChange).toHaveBeenCalledWith('a');
});
@@ -353,6 +353,7 @@ describe('TextInputArea 组件', () => {
it('readOnly 时输入框应只读', () => {
render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
// MUI TextField 的 readOnly 通过 inputProps 设置,textarea 不会被禁用
expect(screen.getByRole('textbox')).not.toBeDisabled();
});
});
@@ -6,6 +6,7 @@ import TopBar from '@/components/TopBar';
import { RouterProvider } from '@/providers/RouterProvider';
import { ThemeModeProvider } from '@/providers/ThemeModeProvider';
// matchMedia must be mocked before ThemeModeProvider is imported
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
@@ -53,23 +54,36 @@ describe('TopBar 组件', () => {
describe('渲染测试', () => {
it('不在 dashboard 时应渲染返回按钮', () => {
mockRouterValue.currentPage = 'timestamp';
renderWithProvider(<TopBar />);
expect(screen.getByLabelText('返回')).toBeInTheDocument();
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.getByLabelText('common:buttons.back')).toBeInTheDocument();
});
it('在 dashboard 上不应渲染返回按钮', () => {
mockRouterValue.currentPage = 'dashboard';
renderWithProvider(<TopBar />);
expect(screen.queryByLabelText('返回')).not.toBeInTheDocument();
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.queryByLabelText('common:buttons.back')).not.toBeInTheDocument();
});
it('应渲染设置按钮', () => {
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.getByLabelText('common:buttons.settings')).toBeInTheDocument();
});
});
describe('交互测试', () => {
it('点击设置按钮时应调用 onOpenOptions', () => {
const handleOpenOptions = vi.fn();
renderWithProvider(<TopBar onOpenOptions={handleOpenOptions} />);
fireEvent.click(screen.getByLabelText('common:buttons.settings'));
expect(handleOpenOptions).toHaveBeenCalledTimes(1);
});
it('点击返回按钮时应调用 goBack', () => {
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);
});
});
@@ -9,8 +9,8 @@ import {
describe('features', () => {
describe('FEATURES', () => {
it('should have 9 features defined', () => {
expect(FEATURES).toHaveLength(9);
it('should have 11 features defined', () => {
expect(FEATURES).toHaveLength(11);
});
it('should have all required properties for each feature', () => {
@@ -50,14 +50,14 @@ describe('features', () => {
const feature = getFeatureByKey('dashboard');
expect(feature).toBeDefined();
expect(feature?.key).toBe('dashboard');
expect(feature?.labelKey).toBe('dashboard_title');
expect(feature?.labelKey).toBe('features:dashboard.title');
});
it('should return timestamp feature', () => {
const feature = getFeatureByKey('timestamp');
expect(feature).toBeDefined();
expect(feature?.key).toBe('timestamp');
expect(feature?.labelKey).toBe('timestamp_title');
expect(feature?.labelKey).toBe('features:timestamp.title');
expect(feature?.themeColorKey).toBeDefined();
});
@@ -65,7 +65,7 @@ describe('features', () => {
const feature = getFeatureByKey('storageCleaner');
expect(feature).toBeDefined();
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', () => {
@@ -95,7 +95,7 @@ describe('features', () => {
describe('getAllFeatureKeys', () => {
it('should return all feature keys', () => {
const allKeys = getAllFeatureKeys();
expect(allKeys).toHaveLength(9);
expect(allKeys).toHaveLength(11);
expect(allKeys).toContain('dashboard');
expect(allKeys).toContain('timestamp');
expect(allKeys).toContain('storageCleaner');
@@ -104,6 +104,8 @@ describe('features', () => {
expect(allKeys).toContain('jwt');
expect(allKeys).toContain('jsonDiff');
expect(allKeys).toContain('base64Converter');
expect(allKeys).toContain('markdownToHtml');
expect(allKeys).toContain('htmlToMarkdown');
expect(allKeys).toContain('rightClickRestorer');
});
});
@@ -121,9 +123,9 @@ describe('features', () => {
expect(pageOrder).toContain('qrCode');
});
it('should have 8 items in page order', () => {
it('should have 10 items in page order', () => {
const pageOrder = getDefaultPageOrder();
expect(pageOrder).toHaveLength(8);
expect(pageOrder).toHaveLength(10);
});
});
});
+47 -17
View File
@@ -9,6 +9,8 @@ import {
Key,
GitCompareArrows,
ArrowLeftRight,
Code,
File,
MousePointerClick,
} from 'lucide-react';
@@ -23,6 +25,8 @@ const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
const JwtPage = lazy(() => import('@/pages/Jwt'));
const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml'));
const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown'));
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
export interface FeatureConfig {
@@ -42,7 +46,7 @@ export interface FeatureConfig {
export const FEATURES: FeatureConfig[] = [
{
key: 'dashboard',
labelKey: 'dashboard_title',
labelKey: 'features:dashboard.title',
descriptionKey: '',
defaultVisible: true,
components: {
@@ -53,8 +57,8 @@ export const FEATURES: FeatureConfig[] = [
},
{
key: 'timestamp',
labelKey: 'timestamp_title',
descriptionKey: 'timestamp_description',
labelKey: 'features:timestamp.title',
descriptionKey: 'features:timestamp.description',
themeColorKey: 'primary',
icon: Clock,
defaultVisible: true,
@@ -66,8 +70,8 @@ export const FEATURES: FeatureConfig[] = [
},
{
key: 'storageCleaner',
labelKey: 'storageCleaner_title',
descriptionKey: 'storageCleaner_description',
labelKey: 'features:storageCleaner.title',
descriptionKey: 'features:storageCleaner.description',
themeColorKey: 'warning',
icon: Database,
defaultVisible: true,
@@ -79,8 +83,8 @@ export const FEATURES: FeatureConfig[] = [
},
{
key: 'qrCode',
labelKey: 'qrCode_title',
descriptionKey: 'qrCode_description',
labelKey: 'features:qrCode.title',
descriptionKey: 'features:qrCode.description',
themeColorKey: 'success',
icon: QrCode,
defaultVisible: true,
@@ -92,8 +96,8 @@ export const FEATURES: FeatureConfig[] = [
},
{
key: 'textStatistics',
labelKey: 'textStatistics_title',
descriptionKey: 'textStatistics_description',
labelKey: 'features:textStatistics.title',
descriptionKey: 'features:textStatistics.description',
themeColorKey: 'secondary',
icon: FileText,
defaultVisible: true,
@@ -105,8 +109,8 @@ export const FEATURES: FeatureConfig[] = [
},
{
key: 'jwt',
labelKey: 'jwt_title',
descriptionKey: 'jwt_description',
labelKey: 'features:jwt.title',
descriptionKey: 'features:jwt.description',
themeColorKey: 'info',
icon: Key,
defaultVisible: true,
@@ -118,8 +122,8 @@ export const FEATURES: FeatureConfig[] = [
},
{
key: 'jsonDiff',
labelKey: 'jsonDiff_title',
descriptionKey: 'jsonDiff_description',
labelKey: 'features:jsonDiff.title',
descriptionKey: 'features:jsonDiff.description',
themeColorKey: 'primary',
icon: GitCompareArrows,
defaultVisible: true,
@@ -131,8 +135,8 @@ export const FEATURES: FeatureConfig[] = [
},
{
key: 'base64Converter',
labelKey: 'base64Converter_title',
descriptionKey: 'base64Converter_description',
labelKey: 'features:base64Converter.title',
descriptionKey: 'features:base64Converter.description',
themeColorKey: 'info',
icon: ArrowLeftRight,
defaultVisible: true,
@@ -142,10 +146,36 @@ export const FEATURES: FeatureConfig[] = [
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',
labelKey: 'rightClickRestorer_title',
descriptionKey: 'rightClickRestorer_description',
labelKey: 'features:rightClickRestorer.title',
descriptionKey: 'features:rightClickRestorer.description',
themeColorKey: 'success',
icon: MousePointerClick,
defaultVisible: true,
@@ -1,4 +1,4 @@
import '../../.wxt/types/imports.d.ts';
import '../.wxt/types/imports.d.ts';
import { browser } from 'wxt/browser';
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu';
@@ -79,6 +79,7 @@ export default defineBackground(() => {
const PROTECTED = ['contextmenu', 'copy', 'paste', 'cut', 'selectstart'];
/* 1. 屏蔽 MouseEvent.prototype.preventDefault(含 mousedown 右键) */
const _origPreventDefault = MouseEvent.prototype.preventDefault;
Object.defineProperty(MouseEvent.prototype, 'preventDefault', {
value: function (this: MouseEvent) {
@@ -92,6 +93,7 @@ export default defineBackground(() => {
configurable: true,
});
/* 2. 屏蔽 Event.prototype.stopPropagation / stopImmediatePropagation */
const _origStopPropagation = Event.prototype.stopPropagation;
Object.defineProperty(Event.prototype, 'stopPropagation', {
value: function (this: Event) {
@@ -112,6 +114,7 @@ export default defineBackground(() => {
configurable: true,
});
/* 3. 拦截 document.oncontextmenu(处理 return false 方式) */
let _docOnContextMenu: unknown = null;
Object.defineProperty(document, 'oncontextmenu', {
get() {
@@ -144,6 +147,7 @@ export default defineBackground(() => {
}
});
// 4. 异步刷新请求监听(统一使用 alarms API,避免 MV3 Service Worker 被销毁导致任务丢失)
onMessage(MessageAction.RELOAD_TAB, async (message) => {
const { tabId, delay = 0 } = message.data;
@@ -160,8 +164,10 @@ export default defineBackground(() => {
const alarmName = `reload-tab-${tabId}-${Date.now()}`;
// 创建一次性 Alarm,由浏览器内核保障触发(不受 Service Worker 生命周期影响)
await browser.alarms.create(alarmName, { when: Date.now() + delay });
// 兜底清理:若 alarm 因异常未触发,delay 后 5 秒强制移除监听器并清理 alarm
const cleanupTimeout = setTimeout(() => {
browser.alarms.onAlarm.removeListener(alarmListener);
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';
export default defineContentScript({
@@ -22,7 +22,7 @@ function injectStyles(): void {
font-size: 13px;
line-height: 1.5;
opacity: 0;
visibility: hidden;
visibility: hidden; /* 💡 1. 规整隐藏状态:允许排版引擎计算尺寸,同时阻断视觉呈现 */
transform: translateY(-8px);
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
pointer-events: none;
@@ -125,6 +125,7 @@ function getOrCreatePopover(): HTMLElement {
return popover;
}
// 💡 2. 安全防线:字符实体转义沙箱,彻底掐灭任意恶意脚本的执行通道
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
@@ -170,6 +171,7 @@ export function showPopover(
): void {
const popover = getOrCreatePopover();
// 💡 3. 坚固的无障碍绑定:废除违规的行内 inline onclick,改用标准原生节点监听
popover.innerHTML = '';
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';
export default function App() {
const handleOpenOptions = () => {
chrome.runtime.openOptionsPage().catch(console.error);
};
const entryType = useMemo(() => getEntryPointType(), []);
const routerConfig = useMemo(() => {
@@ -32,7 +36,7 @@ export default function App() {
>
<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">
<TopBar />
<TopBar onOpenOptions={handleOpenOptions} />
<ErrorBoundary>
<RouterContainer />
</ErrorBoundary>
@@ -1,6 +1,7 @@
import ReactDOM from 'react-dom/client';
import AppRoot from '@/providers/AppRoot';
import '@/index.css';
import '@/i18n';
import '@/src/index.css';
import App from './App.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render(
@@ -7,6 +7,12 @@ import { SnackbarProvider } from '@/components/GlobalSnackbar';
import { MessageAction, sendMessage } from '@/utils/messages';
export default function App() {
const handleOpenOptions = () => {
chrome.runtime.openOptionsPage().catch((err) => {
console.error('Failed to open options page:', err);
});
};
useEffect(() => {
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true });
return () => {
@@ -18,7 +24,7 @@ export default function App() {
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
<div className="app flex flex-col h-screen w-full overflow-hidden">
<TopBar />
<TopBar onOpenOptions={handleOpenOptions} />
<ErrorBoundary>
<RouterContainer />
</ErrorBoundary>
@@ -1,6 +1,7 @@
import ReactDOM from 'react-dom/client';
import AppRoot from '@/providers/AppRoot';
import '@/index.css';
import '@/i18n';
import '@/src/index.css';
import App from './App.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render(
+22 -2
View File
@@ -5,13 +5,16 @@ import reactPlugin from 'eslint-plugin-react';
import globals from 'globals';
export default tseslint.config(
// 1. 全局物理隔离:彻底掐灭对构建产物与配置本身的干扰
{
ignores: ['dist', '.output', '.wxt', 'node_modules', 'eslint.config.ts', 'eslint.config.js'],
},
// 2. 注入 JavaScript 与 TypeScript 的官方大师级推荐规则集
js.configs.recommended,
...tseslint.configs.recommended,
// 3. 针对测试文件专属沙箱:解耦强类型死锁,放行 any,容忍未消费变量
{
files: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', 'setupTests.ts'],
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}'],
languageOptions: {
@@ -33,13 +44,17 @@ export default tseslint.config(
...globals.browser,
...globals.node,
},
// 💡 修复点 1(史诗级治愈):废除脆弱的 project 硬编码路径!
// 拥抱 typescript-eslint 官方推荐的 projectService 常驻动态类型调度中枢。
// 它会在内存中全自动、流式为所有新建、悬空或暂存文件分配编译上下文,
// 彻底终结 "file is not included in any tsconfig" 的全量崩溃黑洞!
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
// 挂载插件沙箱
plugins: {
react: reactPlugin,
'react-hooks': reactHooks,
@@ -51,19 +66,24 @@ export default tseslint.config(
},
},
// 💡 修复点 2:高精对齐 React 19 / JSX Runtime 的全量生产质检规则大闸
rules: {
// 激活 react-hooks 官方推荐规则
...reactHooks.configs.recommended.rules,
// 激活 react 官方精选规则(排除旧版 React 必须手动 import 的历史包袱)
...reactPlugin.configs.recommended.rules,
...reactPlugin.configs['jsx-runtime'].rules,
'react/prop-types': 'off',
// 清洗原生未消费变量冲突,统一交由 TS 高阶哨兵接管
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
// 彻底关闭老旧的 JSX 作用域检查,全面契合 React 19 核心美学
'react/react-in-jsx-scope': 'off',
},
},
+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"
}
+50
View File
@@ -0,0 +1,50 @@
{
"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",
"copyEmpty": "Nothing to copy"
},
"errorBoundary": {
"title": "Oops, something went wrong",
"description": "The app encountered an unexpected error. You can try refreshing the page or resetting the app.",
"refresh": "Refresh App",
"retry": "Retry"
},
"pageErrorBoundary": {
"title": "This feature has encountered an error",
"description": "An internal script error occurred while loading or rendering this page. You can try again or switch to another tool from the navigation menu."
},
"router": {
"notFound": "Page Not Found",
"notFoundDescription": "This feature is not available or has been removed in the current runtime ({{entryPointType}})."
},
"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"
}
+22
View File
@@ -0,0 +1,22 @@
{
"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",
"fixErrorHint": "Fix the JSON syntax errors above to enable live comparison",
"added": "Added",
"removed": "Removed",
"modified": "Modified"
}
+42
View File
@@ -0,0 +1,42 @@
{
"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",
"fixErrorHint": "Fix the JSON syntax errors above to enable live formatting",
"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"
}
+34
View File
@@ -0,0 +1,34 @@
{
"pageTitle": "Storage Cleaner",
"pageSubtitle": "Clear cache, cookies, and local storage",
"loading": "Loading...",
"initializing": "Reading site data...",
"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": "解码大小"
}
+50
View File
@@ -0,0 +1,50 @@
{
"appName": "测试工具",
"buttons": {
"save": "保存",
"cancel": "取消",
"confirm": "确认",
"copy": "复制",
"clear": "清理",
"refresh": "刷新",
"toggleLanguage": "切换语言",
"toggleTheme": "切换主题",
"themeMode": {
"light": "切换到深色模式",
"dark": "切换到系统模式",
"system": "切换到浅色模式"
},
"search": "搜索工具...",
"back": "返回",
"clearSearch": "清除搜索",
"recentSearch": "最近搜索",
"noResults": "未找到相关工具",
"openInTab": "在标签页打开",
"settings": "设置"
},
"messages": {
"copySuccess": "已复制到剪贴板",
"copyError": "复制失败",
"copyEmpty": "无内容可复制"
},
"errorBoundary": {
"title": "糟糕,出了点问题",
"description": "应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。",
"refresh": "刷新应用",
"retry": "重新尝试"
},
"pageErrorBoundary": {
"title": "该功能运行异常",
"description": "该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。"
},
"router": {
"notFound": "页面未找到",
"notFoundDescription": "该功能在当前运行环境({{entryPointType}})下不可用或已被移除。"
},
"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 内容以查看转换结果"
}
+22
View File
@@ -0,0 +1,22 @@
{
"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 后点击比较",
"fixErrorHint": "请修正上方 JSON 的语法错误以开启实时流式比对",
"added": "新增",
"removed": "删除",
"modified": "修改"
}
+42
View File
@@ -0,0 +1,42 @@
{
"formatTitle": "JSON 格式化",
"formatSubtitle": "美化和格式化 JSON 数据",
"inputPlaceholder": "输入需要格式化的 JSON...",
"formatButton": "格式化",
"clearButton": "清空",
"sortKeys": "键名排序",
"sortKeysTooltip": "按字母顺序对 JSON 对象键进行排序",
"indentSize": "缩进",
"indentSpaces": "{{count}} 个空格",
"outputLabel": "格式化结果",
"copySuccess": "复制成功",
"copyFail": "复制失败",
"noContent": "无内容可复制",
"invalidJson": "无效的 JSON 格式",
"emptyHint": "输入 JSON 后点击格式化",
"fixErrorHint": "请修正上方 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": "右键已解锁"
}
+34
View File
@@ -0,0 +1,34 @@
{
"pageTitle": "存储清理",
"pageSubtitle": "清理缓存、Cookies 及本地存储",
"loading": "加载中...",
"initializing": "正在读取站点数据...",
"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
View File
+107
View File
@@ -25,11 +25,15 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
"i18next": "^26.2.0",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.16.0",
"marked": "^18.0.4",
"qr-scanner": "^1.4.2",
"qrious": "^4.0.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0"
},
@@ -6702,6 +6706,15 @@
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"license": "MIT"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
@@ -6751,6 +6764,43 @@
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/i18next": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.2.0.tgz",
"integrity": "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
@@ -8463,6 +8513,18 @@
"url": "https://github.com/sponsors/fregante"
}
},
"node_modules/marked": {
"version": "18.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz",
"integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/marky": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz",
@@ -9825,6 +9887,33 @@
"react": "^19.2.6"
}
},
"node_modules/react-i18next": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -11739,6 +11828,15 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -11948,6 +12046,15 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+4
View File
@@ -36,11 +36,15 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
"i18next": "^26.2.0",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.16.0",
"marked": "^18.0.4",
"qr-scanner": "^1.4.2",
"qrious": "^4.0.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0"
},
@@ -1,6 +1,6 @@
import { Image as ImageIcon, Trash2, Upload } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea';
import { useI18n } from '@/utils/chromeI18n';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import CopyButton from '@/components/CopyButton';
import DecodeResultPaper from '@/components/DecodeResultPaper';
import { Button } from '@/components/ui/button';
@@ -19,7 +19,7 @@ interface Base64ConverterSectionProps {
}
export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) {
const { t } = useI18n('base64Converter');
const { t } = useLazyTranslation('base64Converter');
const [direction, setDirection] = useStorageState(
`base64Converter/${mode}Mode/direction`,
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import TextInputArea from '@/components/TextInputArea';
import { useI18n } from '@/utils/chromeI18n';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import CopyButton from '@/components/CopyButton';
import { base64ToText, textToBase64 } from '@/utils/base64Converter';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
@@ -20,12 +20,14 @@ interface TextModeProps {
}
export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
const { t } = useI18n('base64Converter');
const { t } = useLazyTranslation('base64Converter');
// 1. 纯净的核心源状态机:只保留输入源和转换方向
const [input, setInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState('');
const [direction, setDirection] = useState<'encode' | 'decode'>('encode');
// 2. 文本高频敲击防抖大闸:斩断频繁进行文本转 Base64 带来的 CPU 计算过热
useEffect(() => {
const handle = setTimeout(() => {
setDebouncedInput(input);
@@ -33,6 +35,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
return () => clearTimeout(handle);
}, [input]);
// 3. 右键联动数据上下文:优雅原地合并受控状态
const handleContextMenuData = useCallback((payload: string) => {
setInput(payload);
setDebouncedInput(payload);
@@ -41,6 +44,8 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData });
// 💡 4. 贯彻方案 A(彻底消灭 setOutput / setError):
// 让所有的转化逻辑、类型安全校验在 useMemo 内存管道中单次渲染一气呵成!
const conversionPipeline = useMemo(() => {
const trimmed = debouncedInput.trim();
if (!trimmed) return { output: '', error: null };
@@ -107,7 +112,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
placeholder={placeholder}
value={input}
onChange={setInput}
externalError={error || undefined}
externalError={error || undefined} // 💡 流式异常大闸动态注入
showClear={true}
allowCopy={true}
minRows={5}
@@ -4,7 +4,6 @@ import TextMode from '../TextMode';
// Mock CopyButton
vi.mock('@/components/CopyButton', () => ({
CopyButton: ({ text }: { text: string }) => <button data-testid="copy-button">{text}</button>,
default: ({ text }: { text: string }) => <button data-testid="copy-button">{text}</button>,
}));
@@ -19,18 +18,18 @@ describe('TextMode', () => {
it('应该渲染编码/解码切换按钮', () => {
render(<TextMode />);
expect(screen.getByText('编码')).toBeInTheDocument();
expect(screen.getByText('解码')).toBeInTheDocument();
expect(screen.getByText('base64Converter:encode')).toBeInTheDocument();
expect(screen.getByText('base64Converter:decode')).toBeInTheDocument();
});
it('应该渲染输入框和转换按钮', () => {
render(<TextMode />);
expect(screen.getByPlaceholderText('输入需要编码为 Base64 的文本...')).toBeInTheDocument();
expect(screen.getByPlaceholderText('base64Converter:textInputPlaceholder')).toBeInTheDocument();
});
it('应该将文本编码为 Base64', async () => {
render(<TextMode />);
const input = screen.getByPlaceholderText('输入需要编码为 Base64 的文本...');
const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder');
fireEvent.change(input, { target: { value: 'Hello' } });
act(() => {
@@ -38,18 +37,18 @@ describe('TextMode', () => {
});
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 () => {
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=' } });
act(() => {
@@ -57,18 +56,18 @@ describe('TextMode', () => {
});
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 () => {
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!!!' } });
act(() => {
@@ -76,7 +75,7 @@ describe('TextMode', () => {
});
await waitFor(() => {
expect(screen.getByText('Base64 字符串无效')).toBeInTheDocument();
expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument();
});
});
@@ -84,7 +83,7 @@ describe('TextMode', () => {
render(<TextMode />);
// 先编码
const input = screen.getByPlaceholderText('输入需要编码为 Base64 的文本...');
const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder');
fireEvent.change(input, { target: { value: 'Hello' } });
act(() => {
@@ -92,22 +91,22 @@ describe('TextMode', () => {
});
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(() => {
expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument();
expect(screen.queryByTestId('copy-button')).not.toBeInTheDocument();
});
});
it('点击清除按钮应该清空所有内容', async () => {
render(<TextMode />);
const input = screen.getByPlaceholderText('输入需要编码为 Base64 的文本...');
const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder');
fireEvent.change(input, { target: { value: 'Hello' } });
act(() => {
@@ -115,13 +114,13 @@ describe('TextMode', () => {
});
await waitFor(() => {
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8=');
});
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument();
expect(screen.queryByTestId('copy-button')).not.toBeInTheDocument();
expect(input).toHaveValue('');
});
});
@@ -129,52 +128,48 @@ describe('TextMode', () => {
it('解码模式下粘贴图片 data URI 时应该显示切换图像模式的提示', () => {
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: 'data:image/png;base64,iVBORw0KGgo=' },
});
expect(
screen.getByText('检测到图片的 data URI,请使用「图像」选项卡进行解码。'),
).toBeInTheDocument();
expect(screen.getByText('切换到图像模式')).toBeInTheDocument();
expect(screen.getByText('base64Converter:imageDataUriHint')).toBeInTheDocument();
expect(screen.getByText('base64Converter:switchToImageMode')).toBeInTheDocument();
});
it('粘贴非图片 data URI 时不应该显示图像模式提示', () => {
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=' } });
expect(
screen.queryByText('检测到图片的 data URI,请使用「图像」选项卡进行解码。'),
).not.toBeInTheDocument();
expect(screen.queryByText('base64Converter:imageDataUriHint')).not.toBeInTheDocument();
});
it('点击切换图像模式按钮应该调用 onSwitchToImageMode 回调', () => {
const onSwitch = vi.fn();
render(<TextMode onSwitchToImageMode={onSwitch} />);
fireEvent.click(screen.getByText('解码'));
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...');
fireEvent.click(screen.getByText('base64Converter:decode'));
const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, {
target: { value: 'data:image/png;base64,iVBORw0KGgo=' },
});
fireEvent.click(screen.getByText('切换到图像模式'));
fireEvent.click(screen.getByText('base64Converter:switchToImageMode'));
expect(onSwitch).toHaveBeenCalledTimes(1);
});
it('解码模式下对二进制数据应该显示更清晰的错误', async () => {
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=' } });
act(() => {
@@ -182,9 +177,7 @@ describe('TextMode', () => {
});
await waitFor(() => {
expect(
screen.getByText('输入似乎是二进制数据(如图片)。请切换到「图像」选项卡。'),
).toBeInTheDocument();
expect(screen.getByText('base64Converter:binaryDataDetected')).toBeInTheDocument();
});
});
});
@@ -2,6 +2,15 @@ import { describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
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
vi.mock('@/config/features', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config/features')>();
@@ -40,15 +49,15 @@ describe('Base64ConverterPage', () => {
it('应该渲染模式切换按钮', async () => {
render(<Base64ConverterPage />);
await waitForStorageInit();
expect(screen.getByText('文本')).toBeInTheDocument();
expect(screen.getByText('文件')).toBeInTheDocument();
expect(screen.getByText('图像')).toBeInTheDocument();
expect(screen.getByText('base64Converter:textMode')).toBeInTheDocument();
expect(screen.getByText('base64Converter:fileMode')).toBeInTheDocument();
expect(screen.getByText('base64Converter:imageMode')).toBeInTheDocument();
});
it('切换到文件模式应该渲染 FileMode', async () => {
render(<Base64ConverterPage />);
await waitForStorageInit();
fireEvent.click(screen.getByText('文件'));
fireEvent.click(screen.getByText('base64Converter:fileMode'));
await waitFor(() => {
expect(screen.getByTestId('file-mode')).toBeInTheDocument();
});
@@ -58,7 +67,7 @@ describe('Base64ConverterPage', () => {
it('切换到图像模式应该渲染 ImageMode', async () => {
render(<Base64ConverterPage />);
await waitForStorageInit();
fireEvent.click(screen.getByText('图像'));
fireEvent.click(screen.getByText('base64Converter:imageMode'));
await waitFor(() => {
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 type { Base64ConverterPageMode } from '@/types/storage';
import TextMode from './TextMode';
import Base64ConverterSection from './Base64ConverterSection';
import Base64ConverterSection from './Base64ConverterSection'; // ✅ 正确对接全新的一体化大组件
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
const VALID_PAGE_MODES: readonly Base64ConverterPageMode[] = ['text', 'file', 'image'];
@@ -12,7 +12,7 @@ const isValidPageMode = (val: unknown): val is Base64ConverterPageMode =>
type PageMode = Base64ConverterPageMode;
export default function Index() {
const { t } = useI18n('base64Converter');
const { t } = useLazyTranslation('base64Converter');
const [pageMode, setPageMode] = useStorageState(
'base64Converter/pageMode',
'text',
@@ -1,5 +1,5 @@
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 {
base64ToBlob,
@@ -21,7 +21,7 @@ interface UseBase64ConverterProps {
}
export function useBase64Converter({ mode }: UseBase64ConverterProps) {
const { t } = useI18n('base64Converter');
const { t } = useLazyTranslation('base64Converter');
const [result, setResult] = useState<FileToBase64Result | null>(null);
const [info, setInfo] = useState<FileInfo | null>(null);
@@ -130,6 +130,7 @@ export function useBase64Converter({ mode }: UseBase64ConverterProps) {
return '';
}, [customFileName, decoded]);
// 💡 托管最大文件体积字符串算子,清除下游引入风险
const maxFileSizeStr = `${MAX_FILE_SIZE / 1024 / 1024} MB`;
return {

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