Compare commits

...

2 Commits

Author SHA1 Message Date
Cursor Agent 8ac7a92016 fix: 修复规则导入失败、Worker 过期响应与 popup 标签页解析错误
- ruleStorage.importRules:导出备份含 id 时按新规则导入,避免 save 误判为更新
- 移植 generationId 机制与 Worker 事件循环让出,修复取消后展示错误数据
- 将 getCurrentTab 提取至 chromeTabs,RightClickRestorer 与 messages 复用 lastFocusedWindow 查询
2026-06-26 11:08:23 +00:00
rsgltzyd 1041b3ece1 docs: 更新AGENTS.md文档并清理废弃文件
1. 重构AGENTS.md内容,补充功能列表、项目结构说明
2. 删除CLAUDE.md、src/entrypoints/README.md、src/pages/README.md、.github/copilot-instructions.md等废弃文档
2026-06-26 13:54:14 +08:00
19 changed files with 436 additions and 429 deletions
-141
View File
@@ -1,141 +0,0 @@
# Copilot 指令
基于 WXT 框架的浏览器扩展项目(React 19 + TypeScript),为开发者和测试人员提供效率工具:时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown 等。
## 核心命令
```bash
npm run dev # Chrome 开发模式(支持 HMR
npm run dev:firefox # Firefox 开发模式
npm run build # Chrome 生产构建
npm run build:firefox # Firefox 生产构建
npm run zip # 打包 Chrome 扩展(.output/*.zip
npm run zip:firefox # 打包 Firefox 扩展
npm run lint # ESLint 检查(--max-warnings=0
npm run typecheck # TypeScript 类型检查(tsc --noEmit
npm run test # 运行全部单元测试(vitest run)
npm run test:watch # Vitest 监视模式
npm run test:coverage # 带覆盖率的测试
```
运行单个测试文件:`npx vitest run path/to/file.test.ts`
修改 `package.json` 后需运行 `npm install`(会自动触发 `postinstall``wxt prepare` 重新生成 `.wxt/` 类型声明)。
## CI 流水线(GitHub Actions
严格顺序门控,任一步骤失败则终止:
1. **setup** — 安装依赖,缓存 `node_modules`
2. **lint**、**typecheck**、**test** — 三者并行运行,全部通过才继续
3. **build** — Chrome + Firefox 矩阵构建(仅当步骤 2 全部通过时执行)
Pre-commit 钩子(`.husky/pre-commit``lint-staged`):
1. 代码文件(`*.{ts,tsx,js,jsx,mjs}`):运行 `eslint --fix --max-warnings=0`
2. 同一代码文件:运行 `prettier --write`
3. 其他文件(`*.{json,css,scss,md}`):运行 `prettier --write`
## 项目架构
### 路由(不使用 React Router
路由完全通过 `config/features.tsx` 中的 `FEATURES` 数组管理。每个功能定义一个 `key`(类型为 `types/storage.d.ts` 中的 `PageType`)和三个懒加载组件,分别对应 `popup``sidepanel``tab` 三种渲染模式。`providers/RouterProvider.tsx` 中的 `RouterProvider` 根据存储状态渲染当前页面。
存在三套独立的路由作用域:`app/popupRoute``app/sidepanelRoute``app/tabRoute`,各自维护独立的可见页面列表和页面排序。
### 存储
所有 Chrome Storage 键必须在 `types/storage.d.ts``StorageSchema` 中声明,键名使用 kebab-case 格式(如 `app/currentRoute`)。使用 `utils/chromeStorage.ts` 中的类型安全封装(`storageUtil.get/set/remove`)。
Router 同时使用 `chrome.storage.local` 持久化和 `localStorage` 快照来消除首屏闪烁。
### 扩展通信
使用 `@webext-core/messaging`。通信协议在 `utils/messages.ts` 中通过 `ProtocolMap` 定义。使用该模块导出的 `sendMessage` / `onMessage`,不要直接使用原生 `chrome.runtime.sendMessage`
### 页面组件模式
功能页面遵循 **UI + Hook 分离** 模式,详见 [CODING_STANDARDS.md § 11](./CODING_STANDARDS.md#11-页面开发规范)
```
pages/FeatureName/
├── index.tsx # UI 组件(纯展示,使用 shadcn/ui 组件)
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
└── constants.ts # 常量定义(可选)
```
- 页面组件调用 `useI18n('featureName')` 获取翻译函数
- Hook 负责所有状态管理,通过返回值暴露给页面
- 子组件可进一步拆分(如 `LiveClock.tsx``ResultView.tsx`
### 新功能开发清单
1.`types/storage.d.ts``PageType` 联合类型中添加新成员
2.`config/features.tsx``FEATURES` 数组中添加配置(key、翻译键、图标、三种渲染模式组件)
3.`pages/` 目录创建页面组件(懒加载):
- `index.tsx` — 使用 `useLazyTranslation` 的 UI 组件
- `useFeatureName.ts` — 业务逻辑 Hook
- `constants.ts` — 常量(可选)
4.`i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON 文件)
5. 如需新权限,更新 `wxt.config.ts``manifest.permissions`
6. 添加对应的单元测试
## 关键规范
> 完整的代码编写规范详见 [CODING_STANDARDS.md](./CODING_STANDARDS.md)。
### 浏览器 API
始终使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API,以确保跨浏览器兼容性。
### 路径别名
`@/` 映射到项目根目录(已在 tsconfig 和 vitest.config 中配置)。跨目录导入使用 `@/` 绝对别名,同目录导入使用 `./` 相对路径。
### UI 组件
- 使用 `components/ui/` 下的 shadcn/ui 组件(button、dialog、select、input 等)
- 图标:`lucide-react`
- 样式:Tailwind CSS + `@/lib/utils` 中的 `cn()` 工具函数(clsx + tailwind-merge
- 主题:使用 shadcn/ui 语义化 token`bg-background``text-foreground``border-border` 等),禁止硬编码颜色
### 代码分割
`wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组 vendor 依赖(vendor-react、vendor-i18n、vendor-qr、vendor-dnd),无需手动配置。
### 代码风格
- 禁止使用 `any`(测试文件除外)
- 未使用的变量/参数:使用 `_` 前缀(如 `_unused`
- Prettier100 字符宽、单引号、尾逗号 all、LF 换行
- ESLint 使用 `typescript-eslint``projectService: true`
- 导出模式:页面组件 default export,工具函数/Hook 命名导出,UI 组件 forwardRef + 命名导出
### 测试
- 环境:jsdom
- 全局变量:`vitest/globals`describe、it、expect 等无需导入)
- Setup 文件:`vitest.setup.ts` 自动 mock 以下内容:
- `chrome.*` / `browser.*` APIstorage、tabs、runtime、cookies 等)
- `react-i18next`(返回 key 作为翻译)
- `@/utils/useLazyTranslation`(返回 `ns:key` 格式)
- `window.matchMedia`
- 测试文件命名:`__tests__/*.test.{ts,tsx}``*.test.{ts,tsx}`
- 使用 `vi.mock()` 进行模块级 mock;避免重复 mock `vitest.setup.ts` 中已有的内容
- 测试工具:`@testing-library/react` + `@testing-library/user-event`
### 国际化(i18n
- 命名空间:`common`(默认)、`features`
- 翻译键格式:`namespace:key`(如 `features:timestamp.title`
- 语言:`zh`(默认)、`en`
- 翻译文件:`i18n/locales/{zh,en}/{common,features}.json` + 各功能独立 JSON 文件
- 使用 `useLazyTranslation` Hook 加载功能专属翻译
- 回退策略:缺失的翻译键回退到 `zh`,若仍缺失则返回占位格式 `namespace:key`
### WXT 生成文件
- `.wxt/` 目录由 `postinstall``wxt prepare`)自动生成,包含 TypeScript 类型声明和扩展 tsconfig
- 生产构建输出到 `.output/` 目录
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`
+72 -35
View File
@@ -1,6 +1,23 @@
# AGENTS.md
WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown、测试数据生成器等测试效率工具。
WXT 浏览器扩展项目 (React 19 + TypeScript)。为开发者和测试人员提供浏览器内效率工具
## 功能列表
功能定义见 `src/config/features.tsx``PageType``src/types/storage.d.ts`
| key | 页面目录 | 说明 |
| -------------------- | -------------------------- | ------------------------------------------- |
| `dashboard` | `pages/Dashboard` | 仪表盘首页,工具导航与最近使用 |
| `timestamp` | `pages/Timestamp` | Unix 时间戳转换与格式化 |
| `storageCleaner` | `pages/StorageCleaner` | 清理 Cookies、本地存储、IndexedDB、Cache 等 |
| `qrCode` | `pages/QrCode` | 二维码生成与解析 |
| `textStatistics` | `pages/TextStatistics` | 文本字符、单词、行数、字节统计 |
| `jwt` | `pages/Jwt` | JWT 解码与查看 |
| `jsonTools` | `pages/JsonTools` | JSON 差异比较、格式化、YAML/TOML 转换与压缩 |
| `base64Converter` | `pages/Base64Converter` | 文本、文件、图像 Base64 编解码 |
| `rightClickRestorer` | `pages/RightClickRestorer` | 恢复被网站禁用的右键菜单 |
| `testDataGenerator` | `pages/TestDataGenerator` | 自定义规则批量生成测试数据 |
## 核心命令
@@ -16,30 +33,64 @@ npm run typecheck # tsc --noEmit
npm run test # vitest run (单次执行)
npm run test:watch # vitest 监视模式
npm run test:coverage # 带覆盖率的测试
npx vitest run path/to/file.test.ts # 运行单个测试文件
npx wxt prepare # 重新生成 .wxt/ 类型声明(npm install 时 postinstall 会自动执行)
```
修改 `package.json` 或首次克隆仓库后需执行 `npm install`,会自动触发 `postinstall``wxt prepare`
## 验证流程
详见 [CI 配置](./.github/CI.md)
详见 [CI 配置](./.github/CI.md)。本地与 CI 的检查层次如下。
### Pre-commit`.husky/pre-commit`
1. 后台运行 `tsc --noEmit`(不阻塞提交,结果输出到 stderr)
2. 前台运行 `lint-staged`
- 代码文件 (`*.{ts,tsx,js,jsx,mjs}`)`eslint --fix --max-warnings=0`,再 `prettier --write`
- 其他文件 (`*.{json,css,scss,md}`)`prettier --write`
### Pre-push`.husky/pre-push`
1. 全项目 `tsc --noEmit`(阻塞推送)
2. 对本次推送相对 upstream(或 `origin/main`)变更的 `*.{ts,tsx,js,jsx,mjs}` 文件运行 `eslint --max-warnings=0`
### CIGitHub Actions
1. `setup` — 安装依赖
2. 并行 `lint``typecheck`(含 `wxt prepare`)、`test`
3. `build` — Chrome + Firefox 矩阵构建(仅当步骤 2 全部通过)
## 项目结构
```
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
workers/ # Web Worker(数据生成等耗时任务)
public/ # 静态资源(图标、_locales 等
src/ # 源代码根目录
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
entrypoints/ # 扩展入口点 (popup/, sidepanel/, background.ts, content.ts)
layout/ # 应用壳层布局(TopBar 导航、搜索、主题切换)
pages/ # 功能页面组件 (懒加载)
components/ # 可复用 UI 组件
components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等)
providers/ # React Context (Router, Theme 等)
hooks/ # 自定义 React Hooks
utils/ # 工具函数与服务抽象
types/ # TypeScript 类型声明
lib/ # 通用工具函数(cn、utils)及数据生成器定义
workers/ # Web Worker(数据生成等耗时任务
spec/ # 功能规格、修复方案与验收标准(见 spec/README.md
public/ # 静态资源(图标、_locales 等)
.wxt/ # wxt prepare 自动生成,含类型声明与扩展 tsconfig(勿手动编辑)
.output/ # 生产构建输出目录
```
### layout/
应用壳层,与 popup / sidepanel / tab 入口绑定。当前含 `TopBar/`(搜索、主题切换、返回导航、「在标签页打开」),遵循与 `pages/` 相同的 UI + Hook 模式。详见 [layout/README.md](./src/layout/README.md)。
### spec/
功能规格与验收标准文档,重大改动前优先查阅。索引见 [spec/README.md](./spec/README.md)。
### 页面组件模式
典型功能页面遵循 **UI + Hook 分离** 模式。详见 [CODING_STANDARDS.md § 11](./.github/CODING_STANDARDS.md#11-页面开发规范)。
@@ -62,7 +113,7 @@ src/pages/FeatureName/
## 关键架构决策
**路由**: 不使用 React Router。通过 `src/config/features.tsx``FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
渲染对应组件。支持三种渲染模式:popup(弹窗)、sidepanel(侧边栏)和 browser-tab(浏览器新标签页,通过 `open_in_tab` 打开)。
渲染对应组件。支持三种渲染模式:popup(弹窗)、sidepanel(侧边栏)和 tab(浏览器新标签页,TopBar「在标签页打开」调用 `openExtensionPage('popup.html', { mode: 'tab' })`,由 `getEntryPointType()` 根据 URL 参数 `mode=tab` 识别)。
每种模式有独立的路由和可见页面配置(`app/popupRoute``app/sidepanelRoute``app/tabRoute` 等)。
**存储**: 所有 Chrome Storage 键必须在 `src/types/storage.d.ts``StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`)。
@@ -70,12 +121,10 @@ src/pages/FeatureName/
**通信**: 使用 `@webext-core/messaging`,协议定义在 `src/utils/messages.ts`
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)
**路径别名**: `@/` 映射到 `src/` 目录(已在 `.wxt/tsconfig.json``vitest.config.ts` 中配置)。项目根目录使用 `@@/`
**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
**代码分割**: `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组依赖(vendor-react、vendor-qr、vendor-dnd 等),无需手动配置。
## 测试环境
- 环境: jsdom
@@ -87,17 +136,6 @@ src/pages/FeatureName/
- Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
## 新功能开发清单
1.`src/types/storage.d.ts` 添加 `PageType` 联合类型
2.`src/config/features.tsx``FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件)
3.`src/pages/` 创建页面组件 (懒加载)
- `index.tsx` — UI 组件
- `use{FeatureName}.ts` — 业务逻辑 Hook
- `constants.ts` — 常量(可选)
4. 如需新权限,更新 `wxt.config.ts``manifest.permissions`;如有不使用的权限,需移除
5. 添加对应的单元测试
## 代码规范
- 禁止使用 `any` (测试文件除外)
@@ -105,10 +143,9 @@ src/pages/FeatureName/
- 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className``cn()` 工具)
- UI 组件: 优先使用 `src/components/ui/` 下的 shadcn/ui 组件 (button, dialog, select 等)
- 图标: 使用 `lucide-react` 图标库
- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
- ESLint 使用 `typescript-eslint``projectService: true`(无需手动维护 project 路径)
- **Git Commit**: 必须使用中文描述,遵循 Conventional Commits 规范(如 `fix(组件名): 描述``feat(功能名): 描述`
- **测试维护**: 新增/编辑已有功能/组件时,需要针对相关的测试进行更新
- 格式: Prettier [配置](./.prettierrc)
- ESLint 使用 `typescript-eslint``projectService: true`
- Git Commit: 使用中文描述,遵循 [Conventional Commits 规范](https://www.conventionalcommits.org/zh-hans/v1.0.0/)
## 关键外部库(非显而易见的)
@@ -116,4 +153,4 @@ src/pages/FeatureName/
- `@dnd-kit` — 拖拽排序(用于页面顺序管理和字段列表排序)
- `qrious` + `qr-scanner` — 二维码生成与解析
- `dayjs` — 日期处理(时间戳转换)
- `sonner` — Toast 通知(替代传统 snackbar
- `sonner` — Toast 通知
+1 -1
View File
@@ -1 +1 @@
@AGENT.md
@AGENTS.md
-51
View File
@@ -1,51 +0,0 @@
# entrypoints/
WXT 框架要求的扩展生命周期入口点,对应 Chrome Extension 的各个上下文。
## 入口文件
| 文件 | 用途 |
| ------------------------------- | --------------------------------------------------------------------------------------------- |
| `background.ts` | Service Worker 入口:注册右键菜单、监听菜单点击、处理消息通信、管理侧边栏状态、注入主环境脚本 |
| `content.ts` | Content Script 入口:注入所有页面(`<all_urls>`),在 `document_end` 时初始化消息处理器 |
| `rightClickRestorer.content.ts` | 专用 Content Script:处理右键菜单恢复功能,注入浮动状态徽章 |
## 子目录
### popup/
Popup 弹窗页面(点击扩展图标弹出)。
| 文件 | 用途 |
| ------------ | ------------------------------------------------------------------------------------- |
| `index.html` | HTML 入口 |
| `main.tsx` | React 挂载点 |
| `App.tsx` | 根组件,组装 `RouterProvider` + `layout/TopBar` + `ErrorBoundary` + `RouterContainer` |
### sidepanel/
侧边栏页面,结构与 popup 类似,额外通知 background 侧边栏开启/关闭状态。
### options/
设置页面,支持:
- 拖拽排序功能顺序(`@dnd-kit`
- 功能可见性管理(显示/隐藏)
- Popup/Sidepanel/Tab 三种模式独立配置
### content/
Content Script 内部分模块:
| 文件 | 用途 |
| ----------------------- | ------------------------------------------------------------------- |
| `messageHandler.ts` | 消息处理器初始化入口 |
| `contextMenuHandler.ts` | 右键菜单点击事件处理,执行时间戳转换/文本统计并通过 UI Popover 展示 |
| `uiPopover.ts` | 在页面中注入浮层 Popover UI,展示右键菜单操作结果 |
## 架构说明
- `background.ts` 是扩展的核心协调者,处理跨上下文通信
- `content.ts` 注入到所有页面,负责接收和处理来自 background 的消息
- `popup/``sidepanel/``options/` 共享同一套页面组件(来自 `pages/`),通过 `RouterProvider` 的不同配置实现独立路由
-139
View File
@@ -1,139 +0,0 @@
# pages/
功能页面组件目录,每个子目录对应一个工具页面。
## 目录结构约定
典型页面遵循 **UI + Hook 分离** 模式:
```
pages/FeatureName/
├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件)
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
├── constants.ts # 常量定义(可选)
├── SubComponent.tsx # 子组件(可选)
└── __tests__/ # 测试文件
└── index.test.tsx
```
## 页面列表
### Dashboard/
仪表盘首页,以卡片网格展示所有可见工具,支持点击导航。
| 文件 | 用途 |
| ----------- | -------------------------- |
| `index.tsx` | 页面组件,渲染工具卡片网格 |
### Timestamp/
时间戳转换工具,支持秒/毫秒级互转、多时区选择、实时时钟。
| 文件 | 用途 |
| -------------------------- | ----------------------------------------------------------------- |
| `index.tsx` | 页面 UI |
| `useTimestampConverter.ts` | 业务逻辑 Hook,包含转换模式、输入、单位、时区状态和响应式计算管线 |
| `LiveClock.tsx` | 实时时钟子组件,`React.memo` 优化 |
| `ResultView.tsx` | 转换结果展示子组件 |
| `constants.ts` | 时区列表等常量 |
### StorageCleaner/
浏览器存储清理工具,支持 Cookie/LocalStorage/SessionStorage/IndexedDB/Cache/SW 清理。
| 文件 | 用途 |
| --------------------------- | -------------- |
| `index.tsx` | 页面 UI |
| `useStorageCleaner.ts` | 业务逻辑 Hook |
| `StorageOptionsGrid.tsx` | 清理选项网格 |
| `StorageCleanerConfirm.tsx` | 清理确认对话框 |
| `AutoRefreshToggle.tsx` | 自动刷新开关 |
| `ErrorDisplay.tsx` | 错误展示组件 |
| `CleaningResult.tsx` | 清理结果展示 |
| `OptionItem.tsx` | 单个选项组件 |
### QrCode/
二维码工具,支持生成(URL→QR)和解析(QR→URL)。
| 文件/目录 | 用途 |
| ------------- | ---------------- |
| `index.tsx` | 页面 UI |
| `types.ts` | 类型定义 |
| `contexts/` | Context Provider |
| `hooks/` | 业务逻辑 Hooks |
| `components/` | 子组件 |
### TextStatistics/
文本统计工具,实时计算字符数、单词数、行数、字节大小。
| 文件 | 用途 |
| ----------- | --------------------------------------------- |
| `index.tsx` | 页面组件,集成 `TextInputArea` 和统计结果展示 |
### Jwt/
JWT 解析工具,解码 Header/Payload/Signature。
| 文件/目录 | 用途 |
| ------------- | ---------------- |
| `index.tsx` | 页面 UI |
| `types.ts` | 类型定义 |
| `contexts/` | Context Provider |
| `hooks/` | 业务逻辑 Hooks |
| `components/` | 子组件 |
### JsonTools/
JSON 工具集:差异比较、格式化、YAML/TOML/Minify 转换。
| 文件 | 用途 |
| ------------------------ | ---------------------------- |
| `index.tsx` | 页面入口,子模式切换 |
| `types.ts` | 类型定义 |
| `diffEngine.ts` | 差异比较引擎 |
| `JsonDiffInput.tsx` | JSON 输入组件 |
| `DiffResult.tsx` | 差异结果展示 |
| `DiffNavigator.tsx` | 差异导航器 |
| `JsonFormatSection.tsx` | 格式化区域 |
| `JsonConvertSection.tsx` | 转换区域(YAML/TOML/Minify |
| `JsonTree.tsx` | JSON 树形展示 |
### Base64Converter/
Base64 编解码工具,支持文本/文件/图片三种模式。
| 文件 | 用途 |
| ---------------------------- | --------------------- |
| `index.tsx` | 页面入口,子模式切换 |
| `useBase64Converter.ts` | 业务逻辑 Hook |
| `TextMode.tsx` | 文本模式 |
| `Base64ConverterSection.tsx` | 文件/图片通用转换区域 |
### MarkdownToHtml/
Markdown 转 HTML 工具,支持分栏/预览/源码三种视图模式。
### HtmlToMarkdown/
HTML 转 Markdown 工具,支持分栏/预览/Markdown 三种视图模式。
### RightClickRestorer/
右键菜单恢复工具,解除网站对右键的限制。
| 文件 | 用途 |
| -------------------------- | ------------- |
| `index.tsx` | 页面 UI |
| `useRightClickRestorer.ts` | 业务逻辑 Hook |
## 新增页面
1.`types/storage.d.ts` 添加 `PageType` 联合类型成员
2.`config/features.tsx``FEATURES` 数组添加配置
3.`pages/` 创建页面目录(遵循上述结构)
4.`i18n/locales/{zh,en}/features.json` 添加翻译
5. 如需新权限,更新 `wxt.config.ts`
6. 添加对应的单元测试
@@ -1,8 +1,11 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { useRightClickRestorer } from '../useRightClickRestorer';
import { sendMessageToContent } from '@/utils/messages';
import { getCurrentTab } from '@/utils/chromeTabs';
const mockTabsQuery = vi.fn();
vi.mock('@/utils/chromeTabs', () => ({
getCurrentTab: vi.fn(),
}));
vi.mock('@/utils/messages', () => ({
MessageAction: {
@@ -14,8 +17,9 @@ vi.mock('@/utils/messages', () => ({
beforeEach(() => {
vi.clearAllMocks();
mockTabsQuery.mockResolvedValue([{ url: 'https://example.com/path' }]);
chrome.tabs.query = mockTabsQuery;
vi.mocked(getCurrentTab).mockResolvedValue({
url: 'https://example.com/path',
} as chrome.tabs.Tab);
vi.mocked(sendMessageToContent).mockResolvedValue({ success: true, restored: false });
});
@@ -31,8 +35,7 @@ describe('useRightClickRestorer', () => {
});
it('should mark internal pages as unsupported', async () => {
mockTabsQuery.mockResolvedValue([{ url: 'chrome://newtab/' }]);
chrome.tabs.query = mockTabsQuery;
vi.mocked(getCurrentTab).mockResolvedValue({ url: 'chrome://newtab/' } as chrome.tabs.Tab);
const { result } = renderHook(() => useRightClickRestorer());
@@ -59,8 +62,7 @@ describe('useRightClickRestorer', () => {
});
it('should not unlock unsupported pages', async () => {
mockTabsQuery.mockResolvedValue([{ url: 'chrome://settings/' }]);
chrome.tabs.query = mockTabsQuery;
vi.mocked(getCurrentTab).mockResolvedValue({ url: 'chrome://settings/' } as chrome.tabs.Tab);
const { result } = renderHook(() => useRightClickRestorer());
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { getCurrentTab } from '@/utils/chromeTabs';
import { MessageAction, sendMessageToContent } from '@/utils/messages';
import { isUnsupportedPageUrl } from '@/utils/restrictedUrls';
import type { RestorerStatus } from './constants';
@@ -24,7 +25,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
useEffect(() => {
const load = async () => {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = await getCurrentTab();
const url = tab?.url;
setDomain(url ? new URL(url).hostname : '');
@@ -2,7 +2,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, it, expect, vi } from 'vitest';
import { browser } from 'wxt/browser';
import Index from '../index';
import { clearStorage, getCookieSize, getCurrentTab } from '@/utils/storageCleaner';
import { clearStorage, getCookieSize } from '@/utils/storageCleaner';
import { getCurrentTab } from '@/utils/chromeTabs';
import { toast } from 'sonner';
// Mock the chrome APIs
@@ -13,8 +14,11 @@ vi.mock('@/utils/chromeStorage', () => ({
},
}));
vi.mock('@/utils/storageCleaner', () => ({
vi.mock('@/utils/chromeTabs', () => ({
getCurrentTab: vi.fn().mockResolvedValue({ id: 1, url: 'https://example.com' }),
}));
vi.mock('@/utils/storageCleaner', () => ({
getCookieSize: vi.fn().mockResolvedValue(0),
getLocalStorageSize: vi.fn().mockResolvedValue(0),
getSessionStorageSize: vi.fn().mockResolvedValue(0),
@@ -10,12 +10,12 @@ import {
clearStorage,
getCacheStorageSize,
getCookieSize,
getCurrentTab,
getLocalStorageSize,
getOriginStorageEstimate,
getServiceWorkerCount,
getSessionStorageSize,
} from '@/utils/storageCleaner';
import { getCurrentTab } from '@/utils/chromeTabs';
import { isRestrictedUrl } from '@/utils/restrictedUrls';
import { toast } from 'sonner';
@@ -0,0 +1,117 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useGenerator } from '@/pages/TestDataGenerator/hooks/useGenerator';
import type { FieldConfig, WorkerResponseMessage } from '@/types/testDataGenerator';
const mockField: FieldConfig = {
id: 'field-1',
name: 'username',
generatorId: 'string',
params: {},
required: true,
nullRate: 0,
unique: false,
};
type WorkerListener = (event: MessageEvent<WorkerResponseMessage>) => void;
class MockWorker {
static instances: MockWorker[] = [];
onmessage: WorkerListener | null = null;
onerror: ((event: ErrorEvent) => void) | null = null;
postedMessages: unknown[] = [];
constructor(_url: URL, _options?: WorkerOptions) {
MockWorker.instances.push(this);
}
postMessage(message: unknown) {
this.postedMessages.push(message);
}
terminate() {
const index = MockWorker.instances.indexOf(this);
if (index >= 0) {
MockWorker.instances.splice(index, 1);
}
}
emit(message: WorkerResponseMessage) {
this.onmessage?.({ data: message } as MessageEvent<WorkerResponseMessage>);
}
}
describe('useGenerator', () => {
beforeEach(() => {
MockWorker.instances = [];
vi.stubGlobal('Worker', MockWorker);
});
it('应忽略过期 generationId 的 complete 消息', async () => {
const { result } = renderHook(() => useGenerator());
act(() => {
result.current.generate([mockField], 10);
});
const worker = MockWorker.instances[0];
expect(worker).toBeDefined();
act(() => {
result.current.cancel();
});
act(() => {
result.current.generate([mockField], 5);
});
act(() => {
worker.emit({
type: 'complete',
generationId: 1,
payload: {
success: true,
data: [{ username: 'stale' }],
stats: { total: 10, success: 10, failed: 0, duration: 1 },
},
});
});
expect(result.current.result).toBeNull();
expect(result.current.isGenerating).toBe(true);
act(() => {
worker.emit({
type: 'complete',
generationId: 3,
payload: {
success: true,
data: [{ username: 'fresh' }],
stats: { total: 5, success: 5, failed: 0, duration: 1 },
},
});
});
await waitFor(() => {
expect(result.current.isGenerating).toBe(false);
});
expect(result.current.result?.data?.[0]).toEqual({ username: 'fresh' });
});
it('cancel 后应发送 cancel 消息并使 generationId 失效', () => {
const { result } = renderHook(() => useGenerator());
act(() => {
result.current.generate([mockField], 100);
});
const worker = MockWorker.instances[0];
act(() => {
result.current.cancel();
});
expect(result.current.isGenerating).toBe(false);
expect(worker.postedMessages).toEqual(expect.arrayContaining([{ type: 'cancel' }]));
});
});
@@ -8,7 +8,8 @@ import type {
FieldConfig,
GenerateResult,
GenerateProgress,
WorkerMessage,
WorkerRequestMessage,
WorkerResponseMessage,
} from '@/types/testDataGenerator';
export interface UseGeneratorReturn {
@@ -35,6 +36,7 @@ export function useGenerator(): UseGeneratorReturn {
const [error, setError] = useState<string | null>(null);
const workerRef = useRef<Worker | null>(null);
const generationIdRef = useRef(0);
// 清理 Worker
useEffect(() => {
@@ -58,22 +60,30 @@ export function useGenerator(): UseGeneratorReturn {
type: 'module',
});
worker.onmessage = (e: MessageEvent<WorkerMessage>) => {
worker.onmessage = (e: MessageEvent<WorkerResponseMessage>) => {
const data = e.data;
const { type } = data;
if (data.generationId !== generationIdRef.current) {
return;
}
switch (type) {
case 'progress':
setProgress(data.payload as GenerateProgress);
setProgress(data.payload);
break;
case 'complete':
setIsGenerating(false);
setResult(data.payload as GenerateResult);
if (data.payload.success) {
setResult(data.payload);
} else if (data.payload.error && data.payload.error !== '生成已取消') {
setError(data.payload.error);
}
setProgress(null);
break;
case 'error':
setIsGenerating(false);
setError((data.payload as { error: string }).error);
setError(data.payload.error);
setProgress(null);
break;
}
@@ -100,15 +110,17 @@ export function useGenerator(): UseGeneratorReturn {
(fields: FieldConfig[], count: number, csvMode = false) => {
if (isGenerating) return;
const generationId = ++generationIdRef.current;
setIsGenerating(true);
setProgress(null);
setResult(null);
setError(null);
const worker = getWorker();
const message: WorkerMessage = {
const message: WorkerRequestMessage = {
type: 'start',
payload: { fields, count, csvMode },
payload: { generationId, fields, count, csvMode },
};
worker.postMessage(message);
},
@@ -120,7 +132,8 @@ export function useGenerator(): UseGeneratorReturn {
*/
const cancel = useCallback(() => {
if (workerRef.current && isGenerating) {
const message: WorkerMessage = { type: 'cancel' };
++generationIdRef.current;
const message: WorkerRequestMessage = { type: 'cancel' };
workerRef.current.postMessage(message);
setIsGenerating(false);
setProgress(null);
+15 -5
View File
@@ -156,19 +156,29 @@ export interface GenerateProgress {
}
/**
* Worker 消息类型
* Worker 请求消息类型
*/
export type WorkerMessage =
export type WorkerRequestMessage =
| { type: 'start'; payload: WorkerStartPayload }
| { type: 'progress'; payload: GenerateProgress }
| { type: 'complete'; payload: GenerateResult }
| { type: 'error'; payload: { error: string } }
| { type: 'cancel' };
/**
* Worker 响应消息类型
*/
export type WorkerResponseMessage =
| { type: 'progress'; generationId: number; payload: GenerateProgress }
| { type: 'complete'; generationId: number; payload: GenerateResult }
| { type: 'error'; generationId: number; payload: { error: string } };
/** @deprecated 使用 WorkerRequestMessage | WorkerResponseMessage */
export type WorkerMessage = WorkerRequestMessage | WorkerResponseMessage;
/**
* Worker 启动参数
*/
export interface WorkerStartPayload {
/** 生成任务 ID,用于忽略过期 Worker 响应 */
generationId: number;
/** 字段配置列表 */
fields: FieldConfig[];
/** 生成数量 */
+31 -1
View File
@@ -1,7 +1,37 @@
import { describe, expect, it, vi } from 'vitest';
import { openExtensionPage } from '@/utils/chromeTabs';
import { getCurrentTab, openExtensionPage } from '@/utils/chromeTabs';
describe('chromeTabs', () => {
describe('getCurrentTab', () => {
it('应优先使用 lastFocusedWindow 查询当前标签页', async () => {
const mockTab = { id: 1, url: 'https://example.com' };
(browser.tabs.query as ReturnType<typeof vi.fn>).mockResolvedValueOnce([mockTab]);
const tab = await getCurrentTab();
expect(tab).toEqual(mockTab);
expect(browser.tabs.query).toHaveBeenCalledWith({
active: true,
lastFocusedWindow: true,
});
});
it('当 lastFocusedWindow 无结果时应回退到 currentWindow', async () => {
const fallbackTab = { id: 2, url: 'https://fallback.com' };
(browser.tabs.query as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([fallbackTab]);
const tab = await getCurrentTab();
expect(tab).toEqual(fallbackTab);
expect(browser.tabs.query).toHaveBeenLastCalledWith({
active: true,
currentWindow: true,
});
});
});
describe('openExtensionPage', () => {
it('应该在新标签页中打开扩展页面', async () => {
await openExtensionPage('popup.html');
+83
View File
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as ruleStorage from '../ruleStorage';
const STORAGE_KEY = 'testDataGenerator_rules';
const mockField = {
id: 'field-1',
name: 'username',
generatorId: 'string',
params: {},
required: true,
nullRate: 0,
unique: false,
};
describe('ruleStorage', () => {
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
it('save 在 localStorage 写入失败时应返回 null', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('QuotaExceededError');
});
const result = ruleStorage.save({
name: 'Test Rule',
fields: [mockField],
});
expect(result).toBeNull();
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('update 在 localStorage 写入失败时应返回 null', () => {
const saved = ruleStorage.save({
name: 'Test Rule',
fields: [mockField],
});
expect(saved).not.toBeNull();
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('QuotaExceededError');
});
const updated = ruleStorage.update(saved!.id, { name: 'Updated Rule' });
expect(updated).toBeNull();
});
it('deleteRule 在 localStorage 写入失败时应返回 false', () => {
const saved = ruleStorage.save({
name: 'Test Rule',
fields: [mockField],
});
expect(saved).not.toBeNull();
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('QuotaExceededError');
});
expect(ruleStorage.deleteRule(saved!.id)).toBe(false);
expect(ruleStorage.getById(saved!.id)).toBeDefined();
});
it('importRules 应能恢复导出的规则备份', () => {
const saved = ruleStorage.save({
name: 'Backup Rule',
fields: [mockField],
});
expect(saved).not.toBeNull();
const exported = ruleStorage.exportRules();
ruleStorage.clear();
const result = ruleStorage.importRules(exported);
expect(result.success).toBe(1);
expect(result.failed).toBe(0);
expect(ruleStorage.getAll()).toHaveLength(1);
expect(ruleStorage.getAll()[0].name).toBe('Backup Rule');
});
});
+24
View File
@@ -1,7 +1,31 @@
import { browser } from 'wxt/browser';
/**
* Chrome 标签页相关工具函数
*/
/**
* 获取用户当前正在浏览的标签页。
* popup 中 `currentWindow` 指向弹窗自身,需优先使用 `lastFocusedWindow`。
*/
export async function getCurrentTab() {
const [tab] = await browser.tabs.query({
active: true,
lastFocusedWindow: true,
});
if (tab) {
return tab;
}
const [fallbackTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
return fallbackTab;
}
/**
* 在新标签页中打开扩展页面
* @param page - 扩展页面路径(如 'popup.html'
+2 -1
View File
@@ -1,4 +1,5 @@
import { defineExtensionMessaging } from '@webext-core/messaging';
import { getCurrentTab } from '@/utils/chromeTabs';
export enum MessageAction {
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
@@ -38,7 +39,7 @@ export async function sendMessageToContent<K extends keyof ProtocolMap>(
: [data: Parameters<ProtocolMap[K]>[0]]
): Promise<ReturnType<ProtocolMap[K]>> {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = await getCurrentTab();
if (!tab?.id) {
console.warn(`[Messaging] 无法获取当前标签页,无法发送动作: ${action}`);
return { success: false, message: '无法获取当前标签页' } as ReturnType<ProtocolMap[K]>;
+24 -8
View File
@@ -78,7 +78,9 @@ export function save(
useCount: 0,
};
rules.unshift(newRule);
setAll(rules);
if (!setAll(rules)) {
return null;
}
return newRule;
}
@@ -95,7 +97,9 @@ export function save(
updatedAt: now,
};
rules[index] = updatedRule;
setAll(rules);
if (!setAll(rules)) {
return null;
}
return updatedRule;
}
@@ -117,7 +121,9 @@ export function update(id: string, updates: Partial<DataRule>): DataRule | null
updatedAt: Date.now(),
};
rules[index] = updatedRule;
setAll(rules);
if (!setAll(rules)) {
return null;
}
return updatedRule;
}
@@ -132,8 +138,7 @@ export function deleteRule(id: string): boolean {
return false;
}
rules.splice(index, 1);
setAll(rules);
return true;
return setAll(rules);
}
/**
@@ -165,7 +170,9 @@ export function duplicate(id: string, copySuffix = '(副本)'): DataRule | n
const rules = getAll();
rules.unshift(newRule);
setAll(rules);
if (!setAll(rules)) {
return null;
}
return newRule;
}
@@ -241,7 +248,14 @@ export function importRules(jsonString: string): {
continue;
}
const saved = save(item as Omit<DataRule, 'id' | 'createdAt' | 'updatedAt' | 'useCount'>);
const ruleItem = item as DataRule;
const existsLocally = Boolean(ruleItem.id && getById(ruleItem.id));
const saved = save({
name: ruleItem.name,
description: ruleItem.description,
fields: ruleItem.fields,
...(existsLocally ? { id: ruleItem.id } : {}),
});
if (saved) {
result.success++;
} else {
@@ -267,11 +281,13 @@ export function clear(): void {
/**
* 保存所有规则到存储
*/
function setAll(rules: DataRule[]): void {
function setAll(rules: DataRule[]): boolean {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(rules));
return true;
} catch (error) {
console.error('[ruleStorage] 保存规则失败:', error);
return false;
}
}
-23
View File
@@ -11,29 +11,6 @@ import { browser } from 'wxt/browser';
type CleanScriptResult = IndexedDBCleanResult;
export async function getCurrentTab() {
// For popup pages, we need to get the active tab from the browser window that triggered the popup.
// We should ONLY care about the currently active tab in the last focused window.
// If it's a restricted URL, we return it anyway and let the caller handle the error display.
const [tab] = await browser.tabs.query({
active: true,
lastFocusedWindow: true,
});
if (tab) {
return tab;
}
// Fallback for cases where lastFocusedWindow might not work as expected (e.g. certain sidepanel scenarios)
const [fallbackTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
return fallbackTab;
}
export async function getCookieSize(url: string): Promise<number> {
try {
const cookies = await browser.cookies.getAll({ url });
+28 -5
View File
@@ -6,11 +6,14 @@
import { getGeneratorById } from '@/lib/generators';
import type {
FieldConfig,
WorkerMessage,
WorkerRequestMessage,
GenerateResult,
GenerateProgress,
} from '@/types/testDataGenerator';
/** 每生成 N 行让出一次事件循环,以便处理 cancel 消息 */
const YIELD_EVERY = 100;
// 生成结果缓存
let generatedData: Record<string, unknown>[] = [];
let isCancelled = false;
@@ -18,7 +21,7 @@ let isCancelled = false;
/**
* Worker 消息处理器
*/
self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
self.onmessage = async (e: MessageEvent<WorkerRequestMessage>) => {
const data = e.data;
const { type } = data;
@@ -37,11 +40,12 @@ self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
* 处理开始生成消息
*/
async function handleStart(payload: {
generationId: number;
fields: FieldConfig[];
count: number;
csvMode: boolean;
}): Promise<void> {
const { fields, count } = payload;
const { generationId, fields, count } = payload;
generatedData = [];
try {
@@ -51,6 +55,7 @@ async function handleStart(payload: {
if (!generator) {
self.postMessage({
type: 'error',
generationId,
payload: { error: `生成器 "${field.generatorId}" 不存在` },
});
return;
@@ -65,6 +70,7 @@ async function handleStart(payload: {
if (isCancelled) {
self.postMessage({
type: 'complete',
generationId,
payload: {
success: false,
error: '生成已取消',
@@ -136,7 +142,23 @@ async function handleStart(payload: {
total: count,
estimatedTimeLeft: Math.round(((Date.now() - startTime) / (i + 1)) * (count - i - 1)),
};
self.postMessage({ type: 'progress', payload: progress });
self.postMessage({ type: 'progress', generationId, payload: progress });
}
// 定期让出事件循环,使 cancel 消息能被处理
if ((i + 1) % YIELD_EVERY === 0) {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
if (isCancelled) {
self.postMessage({
type: 'complete',
generationId,
payload: {
success: false,
error: '生成已取消',
},
});
return;
}
}
}
@@ -155,10 +177,11 @@ async function handleStart(payload: {
},
};
self.postMessage({ type: 'complete', payload: result });
self.postMessage({ type: 'complete', generationId, payload: result });
} catch (error) {
self.postMessage({
type: 'error',
generationId,
payload: { error: `生成失败: ${error}` },
});
}