Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ac7a92016 | |||
| 1041b3ece1 |
@@ -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`)
|
||||
- Prettier:100 字符宽、单引号、尾逗号 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.*` API(storage、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`
|
||||
@@ -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`
|
||||
|
||||
### CI(GitHub 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,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` 的不同配置实现独立路由
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
/** 生成数量 */
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
|
||||
@@ -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]>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}` },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user