Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ac7a92016 |
+38
-42
@@ -285,7 +285,8 @@ import { useThemeMode } from '@/providers/ThemeModeProvider';
|
|||||||
import { FeatureConfig, FEATURES } from '@/config/features';
|
import { FeatureConfig, FEATURES } from '@/config/features';
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
// 5. i18n
|
// 5. i18n
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n';
|
||||||
// 6. 本地组件
|
// 6. 本地组件
|
||||||
import TextMode from './TextMode';
|
import TextMode from './TextMode';
|
||||||
import { ZONES } from './constants';
|
import { ZONES } from './constants';
|
||||||
@@ -294,7 +295,7 @@ import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
// 8. 工具函数 / Hook
|
// 8. 工具函数 / Hook
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useStorageState } from '@/utils/useStorageState';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
// 9. 类型
|
// 9. 类型
|
||||||
import type { PageType, StorageSchema } from '@/types/storage';
|
import type { PageType, StorageSchema } from '@/types/storage';
|
||||||
```
|
```
|
||||||
@@ -400,22 +401,22 @@ className={cn(
|
|||||||
|
|
||||||
## 5. 错误处理
|
## 5. 错误处理
|
||||||
|
|
||||||
### 5.1 工具函数:可恢复错误返回可判断结果
|
### 5.1 工具函数:结果对象模式
|
||||||
|
|
||||||
可恢复的解析/校验错误应返回可判断的结果,避免工具层直接弹 Toast。确需保留底层异常的函数
|
工具函数**不抛异常**,返回包含 `hasError` 和 `error` 字段的结果对象:
|
||||||
(如 `formatJson` / `minifyJson`)必须在页面 Hook 或 UI 层捕获并转换为用户提示:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ✅ 可恢复校验返回错误消息,调用方据此展示 UI
|
// ✅ 结果对象模式
|
||||||
export function validateJson(text: string): string | null {
|
export function markdownToHtml(markdown: string): MarkdownToHtmlResult {
|
||||||
if (!text.trim()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
JSON.parse(text.trim());
|
...
|
||||||
return null;
|
return { html, originalLength, htmlLength, hasError: false };
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
return e instanceof SyntaxError ? e.message : 'Invalid JSON';
|
return {
|
||||||
|
html: '', ...
|
||||||
|
hasError: true,
|
||||||
|
error: error instanceof Error ? error.message : 'Markdown 解析失败',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -657,7 +658,7 @@ export function useTimestampConverter(): UseTimestampConverterReturn { ... }
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/utils/useStorageState.ts — Chrome Storage 状态持久化
|
src/utils/useStorageState.ts — Chrome Storage 状态持久化
|
||||||
src/utils/chromeI18n.ts — chrome.i18n wrapper 与 useI18n
|
src/utils/useLazyTranslation.ts — i18n 懒加载
|
||||||
src/utils/useContextMenuData.ts — 右键菜单数据
|
src/utils/useContextMenuData.ts — 右键菜单数据
|
||||||
src/utils/useDebounce.ts — 防抖
|
src/utils/useDebounce.ts — 防抖
|
||||||
src/pages/Timestamp/useTimestampConverter.ts — 页面级 Hook
|
src/pages/Timestamp/useTimestampConverter.ts — 页面级 Hook
|
||||||
@@ -670,48 +671,43 @@ src/pages/StorageCleaner/useStorageCleaner.ts — 页面级 Hook
|
|||||||
|
|
||||||
### 9.1 翻译键格式
|
### 9.1 翻译键格式
|
||||||
|
|
||||||
- 使用 Chrome 扩展标准的 `chrome.i18n`,通过 `src/utils/chromeI18n.ts` 暴露 `useI18n`
|
- 命名空间:`common`(默认)、`features`
|
||||||
- 翻译 key 存放在 `public/_locales/zh_CN/messages.json`
|
- 翻译键格式:`namespace:key`(如 `features:timestamp.title`)
|
||||||
- 直接 key:`t('dashboard_title')` → 查找 `dashboard_title`
|
- 语言:`zh`(默认)、`en`
|
||||||
- 命名空间兼容写法:`t('common:buttons.search')` → 查找 `common_buttons_search`
|
|
||||||
- 命名空间参数:`useI18n(['common', 'features'])` 会尝试 `common_key`、`features_key`
|
|
||||||
|
|
||||||
### 9.2 翻译文件结构
|
### 9.2 翻译文件结构
|
||||||
|
|
||||||
```
|
```
|
||||||
public/_locales/zh_CN/messages.json — Chrome 扩展默认语言包
|
i18n/locales/{zh,en}/common.json — 全局通用翻译
|
||||||
wxt.config.ts — manifest.default_locale = 'zh_CN'
|
i18n/locales/{zh,en}/features.json — 功能模块标题和描述
|
||||||
|
i18n/locales/{zh,en}/{功能名}.json — 各功能独立翻译
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.3 使用方式
|
### 9.3 使用方式
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ✅ 页面组件 / 子组件 — 使用 useI18n
|
// ✅ 页面组件 — 使用 useLazyTranslation
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { t } = useI18n('timestamp');
|
const { t } = useLazyTranslation('timestamp');
|
||||||
return <h1>{t('timestamp_title')}</h1>;
|
return <h1>{t('timestamp:title')}</h1>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ 带占位符
|
// ✅ 全局组件 — 使用 useTranslation
|
||||||
export function NotFoundMessage() {
|
import { useTranslation } from 'react-i18next';
|
||||||
const { t } = useI18n('router');
|
|
||||||
return <p>{t('router_notFoundDescription', { entryPointType: 'popup' })}</p>;
|
export function TopBar() {
|
||||||
|
const { t } = useTranslation(['common', 'features']);
|
||||||
|
return <span>{t('common:settings')}</span>;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.4 添加新翻译
|
### 9.4 添加新翻译
|
||||||
|
|
||||||
1. 在 `public/_locales/zh_CN/messages.json` 添加 Chrome 扩展格式的消息:
|
1. 在 `i18n/locales/{zh,en}/features.json` 添加功能标题和描述
|
||||||
```json
|
2. 创建 `i18n/locales/{zh,en}/{功能名}.json` 添加功能专属翻译
|
||||||
{
|
3. 在 `utils/useLazyTranslation.ts` 的 `localeModules` 中注册新命名空间
|
||||||
"feature_title": { "message": "功能标题" },
|
|
||||||
"feature_description": { "message": "功能描述" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
2. key 使用下划线分隔,避免点号;`useI18n` 会把 `namespace:key.path` 兼容转换为下划线
|
|
||||||
3. `chrome.i18n` 不支持运行时动态切换语言,浏览器语言变化后需要刷新扩展页面
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1087,7 +1083,7 @@ export default function Index() {
|
|||||||
4. ✅ 业务逻辑提取到 `useXxx.ts` Hook(index.tsx 不超过 150 行)
|
4. ✅ 业务逻辑提取到 `useXxx.ts` Hook(index.tsx 不超过 150 行)
|
||||||
5. ✅ 需要持久化的 UI 状态使用 `useStorageState`
|
5. ✅ 需要持久化的 UI 状态使用 `useStorageState`
|
||||||
6. ✅ 常量 ≥3 个时提取到 `constants.ts`
|
6. ✅ 常量 ≥3 个时提取到 `constants.ts`
|
||||||
7. ✅ 在 `public/_locales/zh_CN/messages.json` 添加翻译
|
7. ✅ 在 `i18n/locales/{zh,en}/` 添加翻译
|
||||||
8. ✅ 创建 `__tests__/index.test.tsx` 测试文件
|
8. ✅ 创建 `__tests__/index.test.tsx` 测试文件
|
||||||
9. ✅ 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
9. ✅ 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
||||||
10. ✅ 运行 `npm run lint && npm run typecheck && npm run test` 全部通过
|
10. ✅ 运行 `npm run lint && npm run typecheck && npm run test` 全部通过
|
||||||
@@ -1107,8 +1103,8 @@ export default function Index() {
|
|||||||
| `src/hooks/` | 自定义 React Hooks |
|
| `src/hooks/` | 自定义 React Hooks |
|
||||||
| `src/utils/` | 工具函数与服务抽象 |
|
| `src/utils/` | 工具函数与服务抽象 |
|
||||||
| `src/types/` | TypeScript 类型声明 |
|
| `src/types/` | TypeScript 类型声明 |
|
||||||
| `src/lib/` | 通用工具函数与生成器库(cn、utils、generators) |
|
| `src/lib/` | 通用工具函数(cn、utils) |
|
||||||
| `public/` | 静态资源与 Chrome `_locales` 语言包 |
|
| `public/` | 静态资源 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、测试数据生成器等测试效率工具。
|
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` | 自定义规则批量生成测试数据 |
|
||||||
|
|
||||||
## 核心命令
|
## 核心命令
|
||||||
|
|
||||||
@@ -93,38 +110,6 @@ src/pages/FeatureName/
|
|||||||
- 当 `index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式
|
- 当 `index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式
|
||||||
- 复杂页面可增加 `contexts/`、`hooks/`、`components/` 子目录
|
- 复杂页面可增加 `contexts/`、`hooks/`、`components/` 子目录
|
||||||
|
|
||||||
### 测试数据生成器模块
|
|
||||||
|
|
||||||
```
|
|
||||||
src/pages/TestDataGenerator/
|
|
||||||
├── index.tsx # 主页面(字段配置 + 标签页切换)
|
|
||||||
├── hooks/useGenerator.ts # Web Worker 管理 Hook(创建、复用、通信、销毁)
|
|
||||||
└── components/
|
|
||||||
├── FieldList.tsx # 字段列表(虚拟滚动 + @dnd-kit 拖拽排序 + 规则保存)
|
|
||||||
├── FieldItem.tsx # 字段卡片展示
|
|
||||||
├── FieldEditor.tsx # 字段编辑器(名称校验、生成器选择、参数配置)
|
|
||||||
├── GeneratorSelector.tsx # 生成器选择器(分类 + 搜索)
|
|
||||||
├── GeneratorConfig.tsx # 生成器参数表单(动态渲染 string/number/boolean/select/array)
|
|
||||||
├── GenerateOptions.tsx # 生成选项(数量、格式)
|
|
||||||
├── GenerateButton.tsx # 生成按钮 + 进度条
|
|
||||||
├── DataPreview.tsx # 示例数据预览(JSON 语法高亮)
|
|
||||||
├── ResultPanel.tsx # 生成结果状态面板
|
|
||||||
├── ExportPanel.tsx # 导出面板(复制/下载 JSON/CSV)
|
|
||||||
└── RuleManager.tsx # 规则管理(CRUD、搜索、导入/导出)
|
|
||||||
|
|
||||||
src/utils/
|
|
||||||
├── ruleStorage.ts # 规则持久化存储(localStorage)
|
|
||||||
└── dataExporter.ts # 数据导出工具(JSON/CSV 转换、下载、剪贴板)
|
|
||||||
|
|
||||||
src/lib/generators/ # 内置生成器定义(个人信息、企业、技术、基础类型)
|
|
||||||
|
|
||||||
src/workers/
|
|
||||||
└── generator.worker.ts # 数据生成 Web Worker
|
|
||||||
|
|
||||||
src/types/
|
|
||||||
└── testDataGenerator.ts # 类型定义(FieldConfig, DataRule, GeneratorDefinition 等)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 关键架构决策
|
## 关键架构决策
|
||||||
|
|
||||||
**路由**: 不使用 React Router。通过 `src/config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
|
**路由**: 不使用 React Router。通过 `src/config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
|
||||||
@@ -146,40 +131,11 @@ src/types/
|
|||||||
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
|
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
|
||||||
- Setup 文件: `vitest.setup.ts` 自动 mock:
|
- Setup 文件: `vitest.setup.ts` 自动 mock:
|
||||||
- `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等)
|
- `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等)
|
||||||
- `@/utils/chromeI18n` (从 `public/_locales/zh_CN/messages.json` 加载真实翻译)
|
|
||||||
- `window.matchMedia`
|
- `window.matchMedia`
|
||||||
- 测试文件命名: `__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
|
- 测试文件命名: `__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
|
||||||
- Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码
|
- Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码
|
||||||
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
|
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
|
||||||
|
|
||||||
## i18n (chrome.i18n)
|
|
||||||
|
|
||||||
项目使用 Chrome 扩展标准的 `chrome.i18n` API 进行本地化,通过 `src/utils/chromeI18n.ts` 提供类型安全的 React Hook 包装。
|
|
||||||
|
|
||||||
- **翻译文件**: `public/_locales/zh_CN/messages.json`(Chrome 扩展标准格式)
|
|
||||||
- **默认语言**: `zh_CN`(在 `wxt.config.ts` 的 `manifest.default_locale` 中配置)
|
|
||||||
- **使用方式**: `import { useI18n } from '@/utils/chromeI18n'`
|
|
||||||
- **翻译键格式**:
|
|
||||||
- 直接 key: `t('dashboard_title')` → 查找 `dashboard_title`
|
|
||||||
- 命名空间格式(兼容旧用法): `t('common:buttons.search')` → 查找 `common_buttons_search`
|
|
||||||
- 带命名空间参数: `useI18n(['common', 'features'])`,会自动尝试 `common_key`、`features_key`
|
|
||||||
- **占位符支持**: `t('router_notFoundDescription', { entryPointType: 'popup' })`
|
|
||||||
- **Hook 返回值**: `{ t, i18n: { language, changeLanguage }, isLoaded }`
|
|
||||||
- **回退策略**: 当翻译 key 未命中时,返回 key 本身(开发模式下在控制台记录 warning)
|
|
||||||
- **限制**: `chrome.i18n` 无法动态切换语言,语言跟随浏览器设置,切换后需刷新页面
|
|
||||||
|
|
||||||
## 新功能开发清单
|
|
||||||
|
|
||||||
1. 在 `src/types/storage.d.ts` 添加 `PageType` 联合类型
|
|
||||||
2. 在 `src/config/features.tsx` 的 `FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件)
|
|
||||||
3. 在 `src/pages/` 创建页面组件 (懒加载):
|
|
||||||
- `index.tsx` — UI 组件,使用 `useI18n` 获取翻译
|
|
||||||
- `useFeatureName.ts` — 业务逻辑 Hook
|
|
||||||
- `constants.ts` — 常量(可选)
|
|
||||||
4. 在 `public/_locales/zh_CN/messages.json` 添加翻译
|
|
||||||
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
|
||||||
6. 添加对应的单元测试
|
|
||||||
|
|
||||||
## 代码规范
|
## 代码规范
|
||||||
|
|
||||||
- 禁止使用 `any` (测试文件除外)
|
- 禁止使用 `any` (测试文件除外)
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
- **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库)
|
- **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库)
|
||||||
- **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数
|
- **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数
|
||||||
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
|
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
|
||||||
- **国际化**: Chrome `chrome.i18n` + `public/_locales/zh_CN/messages.json`
|
- **国际化**: i18next + react-i18next
|
||||||
- **通信**: @webext-core/messaging
|
- **通信**: @webext-core/messaging
|
||||||
- **存储**: Chrome Storage API (类型安全封装)
|
- **存储**: Chrome Storage API (类型安全封装)
|
||||||
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
|
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
|
||||||
@@ -107,8 +107,8 @@
|
|||||||
│ ├── hooks/ # 自定义 React Hooks
|
│ ├── hooks/ # 自定义 React Hooks
|
||||||
│ ├── utils/ # 工具函数与服务抽象
|
│ ├── utils/ # 工具函数与服务抽象
|
||||||
│ ├── types/ # TypeScript 类型声明
|
│ ├── types/ # TypeScript 类型声明
|
||||||
│ └── lib/ # 通用工具函数与生成器库 (cn, utils, generators 等)
|
│ └── lib/ # 通用工具函数 (cn, utils 等)
|
||||||
├── public/ # 静态资源 (图标、_locales 本地化资源等)
|
├── public/ # 静态资源 (图标、manifest 资源等)
|
||||||
├── wxt.config.ts # WXT 框架核心配置
|
├── wxt.config.ts # WXT 框架核心配置
|
||||||
└── package.json # 项目元数据与依赖管理
|
└── package.json # 项目元数据与依赖管理
|
||||||
```
|
```
|
||||||
@@ -155,6 +155,7 @@
|
|||||||
- `sidePanel`: 支持在浏览器侧边栏中运行.
|
- `sidePanel`: 支持在浏览器侧边栏中运行.
|
||||||
- `clipboardWrite`: 提供一键复制功能.
|
- `clipboardWrite`: 提供一键复制功能.
|
||||||
- `contextMenus`: 注册右键菜单,支持快捷操作.
|
- `contextMenus`: 注册右键菜单,支持快捷操作.
|
||||||
|
- `alarms`: 管理后台定时任务(如清理后自动刷新).
|
||||||
|
|
||||||
## 浏览器支持
|
## 浏览器支持
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 版本: v1.0
|
> 版本: v1.0
|
||||||
> 创建时间: 2024-01-20
|
> 创建时间: 2024-01-20
|
||||||
> 状态: 已实现(见 `src/pages/TestDataGenerator/`、`src/lib/generators/`、`src/workers/generator.worker.ts`)
|
> 状态: 待 Review
|
||||||
|
|
||||||
## 产品定位
|
## 产品定位
|
||||||
|
|
||||||
@@ -19,13 +19,3 @@
|
|||||||
- [规则管理](./rule-management.md)
|
- [规则管理](./rule-management.md)
|
||||||
- [界面设计](./ui-design.md)
|
- [界面设计](./ui-design.md)
|
||||||
- [技术实现](./technical-implementation.md)
|
- [技术实现](./technical-implementation.md)
|
||||||
|
|
||||||
## 当前实现入口
|
|
||||||
|
|
||||||
- 页面入口:`src/pages/TestDataGenerator/index.tsx`
|
|
||||||
- Worker:`src/workers/generator.worker.ts`
|
|
||||||
- 生成器库:`src/lib/generators/`
|
|
||||||
- 规则存储:`src/utils/ruleStorage.ts`
|
|
||||||
- 导出工具:`src/utils/dataExporter.ts`
|
|
||||||
|
|
||||||
实现约束与任务完成状态记录在 [TASKS.md](./TASKS.md)。
|
|
||||||
|
|||||||
@@ -12,24 +12,8 @@
|
|||||||
| `icon/48.png` | 48×48 图标(扩展管理页) |
|
| `icon/48.png` | 48×48 图标(扩展管理页) |
|
||||||
| `icon/96.png` | 96×96 图标 |
|
| `icon/96.png` | 96×96 图标 |
|
||||||
| `icon/128.png` | 128×128 图标(Chrome Web Store) |
|
| `icon/128.png` | 128×128 图标(Chrome Web Store) |
|
||||||
| `_locales/` | Chrome 扩展本地化资源 |
|
|
||||||
|
|
||||||
## 本地化资源
|
|
||||||
|
|
||||||
项目使用 Chrome 扩展标准 `chrome.i18n`,默认语言在 `wxt.config.ts` 中配置为 `zh_CN`。
|
|
||||||
|
|
||||||
| 文件/目录 | 用途 |
|
|
||||||
| ------------------------------ | -------------------------------------------------- |
|
|
||||||
| `_locales/zh_CN/messages.json` | 默认中文语言包,包含功能名、描述、按钮、提示等翻译 |
|
|
||||||
|
|
||||||
添加或修改文案时:
|
|
||||||
|
|
||||||
- 使用 Chrome 扩展消息格式:`"key": { "message": "文本" }`
|
|
||||||
- key 使用下划线分隔,例如 `testDataGenerator_title`
|
|
||||||
- 代码中通过 `useI18n` 或 `getMessage` 读取,不要新增 `i18n/locales` 目录
|
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 修改图标后需同步更新 `wxt.config.ts` 中的 manifest 配置
|
- 修改图标后需同步更新 `wxt.config.ts` 中的 manifest 配置
|
||||||
- 图标格式推荐使用 PNG,确保透明背景
|
- 图标格式推荐使用 PNG,确保透明背景
|
||||||
- 修改 `_locales` 后需确认 `manifest.default_locale` 与语言目录名一致
|
|
||||||
|
|||||||
+3
-33
@@ -4,10 +4,9 @@
|
|||||||
|
|
||||||
## 文件说明
|
## 文件说明
|
||||||
|
|
||||||
| 文件/目录 | 用途 |
|
| 文件 | 用途 |
|
||||||
| ------------- | -------------------------------------- |
|
| ---------- | ------------------------------------ |
|
||||||
| `utils.ts` | `cn()` 函数 — shadcn/ui 标准工具函数 |
|
| `utils.ts` | `cn()` 函数 — shadcn/ui 标准工具函数 |
|
||||||
| `generators/` | 测试数据生成器内置生成器库和分类注册表 |
|
|
||||||
|
|
||||||
## cn()
|
## cn()
|
||||||
|
|
||||||
@@ -25,32 +24,3 @@ import { cn } from '@/lib/utils';
|
|||||||
```
|
```
|
||||||
|
|
||||||
所有需要动态合并 Tailwind 类名的场景都应使用 `cn()`,而非手动拼接字符串。
|
所有需要动态合并 Tailwind 类名的场景都应使用 `cn()`,而非手动拼接字符串。
|
||||||
|
|
||||||
## generators/
|
|
||||||
|
|
||||||
测试数据生成器的核心生成器库,供 `src/workers/generator.worker.ts` 在后台线程中按字段配置生成数据。
|
|
||||||
|
|
||||||
| 文件 | 用途 |
|
|
||||||
| -------------- | ---------------------------------------------- |
|
|
||||||
| `index.ts` | 聚合所有生成器,导出分类、查找和搜索函数 |
|
|
||||||
| `personal.ts` | 个人信息生成器(姓名、邮箱、手机号、身份证等) |
|
|
||||||
| `business.ts` | 业务数据生成器(订单号、价格、日期、状态等) |
|
|
||||||
| `technical.ts` | 技术数据生成器(UUID、IPv4、URL) |
|
|
||||||
| `basic.ts` | 基础类型生成器(整数、浮点数、布尔值、字符串) |
|
|
||||||
| `types.ts` | 生成器库内部类型与通用选项 |
|
|
||||||
|
|
||||||
使用示例:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { getGeneratorById, searchGenerators } from '@/lib/generators';
|
|
||||||
|
|
||||||
const generator = getGeneratorById('chineseName');
|
|
||||||
const value = generator?.generate({});
|
|
||||||
const matched = searchGenerators('uuid');
|
|
||||||
```
|
|
||||||
|
|
||||||
约束:
|
|
||||||
|
|
||||||
- 新增生成器必须实现 `GeneratorDefinition`,并加入所属分类文件的导出列表。
|
|
||||||
- 需要唯一值或大批量稳定输出时,优先实现 `generateAtIndex(params, index)`。
|
|
||||||
- 新增分类时同步更新 `generatorCategories`,并确保 `GeneratorSelector` 能展示该分类。
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||||
import { useRightClickRestorer } from '../useRightClickRestorer';
|
import { useRightClickRestorer } from '../useRightClickRestorer';
|
||||||
import { sendMessageToContent } from '@/utils/messages';
|
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', () => ({
|
vi.mock('@/utils/messages', () => ({
|
||||||
MessageAction: {
|
MessageAction: {
|
||||||
@@ -14,8 +17,9 @@ vi.mock('@/utils/messages', () => ({
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockTabsQuery.mockResolvedValue([{ url: 'https://example.com/path' }]);
|
vi.mocked(getCurrentTab).mockResolvedValue({
|
||||||
chrome.tabs.query = mockTabsQuery;
|
url: 'https://example.com/path',
|
||||||
|
} as chrome.tabs.Tab);
|
||||||
vi.mocked(sendMessageToContent).mockResolvedValue({ success: true, restored: false });
|
vi.mocked(sendMessageToContent).mockResolvedValue({ success: true, restored: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,8 +35,7 @@ describe('useRightClickRestorer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should mark internal pages as unsupported', async () => {
|
it('should mark internal pages as unsupported', async () => {
|
||||||
mockTabsQuery.mockResolvedValue([{ url: 'chrome://newtab/' }]);
|
vi.mocked(getCurrentTab).mockResolvedValue({ url: 'chrome://newtab/' } as chrome.tabs.Tab);
|
||||||
chrome.tabs.query = mockTabsQuery;
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useRightClickRestorer());
|
const { result } = renderHook(() => useRightClickRestorer());
|
||||||
|
|
||||||
@@ -59,8 +62,7 @@ describe('useRightClickRestorer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not unlock unsupported pages', async () => {
|
it('should not unlock unsupported pages', async () => {
|
||||||
mockTabsQuery.mockResolvedValue([{ url: 'chrome://settings/' }]);
|
vi.mocked(getCurrentTab).mockResolvedValue({ url: 'chrome://settings/' } as chrome.tabs.Tab);
|
||||||
chrome.tabs.query = mockTabsQuery;
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useRightClickRestorer());
|
const { result } = renderHook(() => useRightClickRestorer());
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getCurrentTab } from '@/utils/chromeTabs';
|
||||||
import { MessageAction, sendMessageToContent } from '@/utils/messages';
|
import { MessageAction, sendMessageToContent } from '@/utils/messages';
|
||||||
import { isUnsupportedPageUrl } from '@/utils/restrictedUrls';
|
import { isUnsupportedPageUrl } from '@/utils/restrictedUrls';
|
||||||
import type { RestorerStatus } from './constants';
|
import type { RestorerStatus } from './constants';
|
||||||
@@ -24,7 +25,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
const tab = await getCurrentTab();
|
||||||
const url = tab?.url;
|
const url = tab?.url;
|
||||||
|
|
||||||
setDomain(url ? new URL(url).hostname : '');
|
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 { beforeEach, describe, it, expect, vi } from 'vitest';
|
||||||
import { browser } from 'wxt/browser';
|
import { browser } from 'wxt/browser';
|
||||||
import Index from '../index';
|
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';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
// Mock the chrome APIs
|
// 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' }),
|
getCurrentTab: vi.fn().mockResolvedValue({ id: 1, url: 'https://example.com' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/utils/storageCleaner', () => ({
|
||||||
getCookieSize: vi.fn().mockResolvedValue(0),
|
getCookieSize: vi.fn().mockResolvedValue(0),
|
||||||
getLocalStorageSize: vi.fn().mockResolvedValue(0),
|
getLocalStorageSize: vi.fn().mockResolvedValue(0),
|
||||||
getSessionStorageSize: vi.fn().mockResolvedValue(0),
|
getSessionStorageSize: vi.fn().mockResolvedValue(0),
|
||||||
@@ -103,31 +107,4 @@ describe('StorageCleaner 页面', () => {
|
|||||||
expect(screen.getByRole('button', { name: /立即清理/ })).not.toBeDisabled();
|
expect(screen.getByRole('button', { name: /立即清理/ })).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('展示的数据与当前标签页不一致时不应执行清理', async () => {
|
|
||||||
let currentTabId = 1;
|
|
||||||
vi.mocked(getCurrentTab).mockImplementation(
|
|
||||||
async () =>
|
|
||||||
({
|
|
||||||
id: currentTabId,
|
|
||||||
url: currentTabId === 1 ? 'https://a.example.com' : 'https://b.example.com',
|
|
||||||
}) as any,
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<Index />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole('button', { name: /立即清理/ })).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
currentTabId = 2;
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /立即清理/ }));
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /确认清理/ }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(clearStorage).not.toHaveBeenCalled();
|
|
||||||
expect(toast.warning).toHaveBeenCalledWith('当前标签页已切换,请等待数据刷新后再清理');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export default function Index() {
|
|||||||
sizes,
|
sizes,
|
||||||
reloadAfterClean,
|
reloadAfterClean,
|
||||||
loading,
|
loading,
|
||||||
isRefreshingSizes,
|
|
||||||
result,
|
result,
|
||||||
showConfirm,
|
showConfirm,
|
||||||
setShowConfirm,
|
setShowConfirm,
|
||||||
@@ -27,7 +26,7 @@ export default function Index() {
|
|||||||
handleClean,
|
handleClean,
|
||||||
} = useStorageCleaner();
|
} = useStorageCleaner();
|
||||||
|
|
||||||
const isButtonDisabled = !(someSelected || allSelected) || loading || isRefreshingSizes;
|
const isButtonDisabled = !(someSelected || allSelected) || loading;
|
||||||
|
|
||||||
if (isInitializing) {
|
if (isInitializing) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import {
|
|||||||
clearStorage,
|
clearStorage,
|
||||||
getCacheStorageSize,
|
getCacheStorageSize,
|
||||||
getCookieSize,
|
getCookieSize,
|
||||||
getCurrentTab,
|
|
||||||
getLocalStorageSize,
|
getLocalStorageSize,
|
||||||
getOriginStorageEstimate,
|
getOriginStorageEstimate,
|
||||||
getServiceWorkerCount,
|
getServiceWorkerCount,
|
||||||
getSessionStorageSize,
|
getSessionStorageSize,
|
||||||
} from '@/utils/storageCleaner';
|
} from '@/utils/storageCleaner';
|
||||||
|
import { getCurrentTab } from '@/utils/chromeTabs';
|
||||||
import { isRestrictedUrl } from '@/utils/restrictedUrls';
|
import { isRestrictedUrl } from '@/utils/restrictedUrls';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -75,7 +75,6 @@ export interface UseStorageCleanerReturn {
|
|||||||
sizes: Record<string, StorageSizeInfo>;
|
sizes: Record<string, StorageSizeInfo>;
|
||||||
reloadAfterClean: boolean;
|
reloadAfterClean: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
isRefreshingSizes: boolean;
|
|
||||||
result: CleaningResult | null;
|
result: CleaningResult | null;
|
||||||
showConfirm: boolean;
|
showConfirm: boolean;
|
||||||
setShowConfirm: (show: boolean) => void;
|
setShowConfirm: (show: boolean) => void;
|
||||||
@@ -96,12 +95,10 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
const [sizes, setSizes] = useState<Record<string, StorageSizeInfo>>({});
|
const [sizes, setSizes] = useState<Record<string, StorageSizeInfo>>({});
|
||||||
const [reloadAfterClean, setReloadAfterClean] = useState<boolean>(true);
|
const [reloadAfterClean, setReloadAfterClean] = useState<boolean>(true);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [isRefreshingSizes, setIsRefreshingSizes] = useState<boolean>(false);
|
|
||||||
const [result, setResult] = useState<CleaningResult | null>(null);
|
const [result, setResult] = useState<CleaningResult | null>(null);
|
||||||
const [showConfirm, setShowConfirm] = useState<boolean>(false);
|
const [showConfirm, setShowConfirm] = useState<boolean>(false);
|
||||||
|
|
||||||
const requestIdRef = useRef<number>(0);
|
const requestIdRef = useRef<number>(0);
|
||||||
const boundTabRef = useRef<{ id: number; url: string } | null>(null);
|
|
||||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const storageTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const storageTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const loadingRef = useRef(loading);
|
const loadingRef = useRef(loading);
|
||||||
@@ -120,7 +117,6 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
// 核心数据拉取链条
|
// 核心数据拉取链条
|
||||||
const loadInfo = useCallback(async () => {
|
const loadInfo = useCallback(async () => {
|
||||||
const currentRequestId = ++requestIdRef.current;
|
const currentRequestId = ++requestIdRef.current;
|
||||||
setIsRefreshingSizes(true);
|
|
||||||
try {
|
try {
|
||||||
const tab = await getCurrentTab();
|
const tab = await getCurrentTab();
|
||||||
if (currentRequestId !== requestIdRef.current) return;
|
if (currentRequestId !== requestIdRef.current) return;
|
||||||
@@ -163,7 +159,6 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
cacheStorage: { value: cacheCount, displayType: 'count' },
|
cacheStorage: { value: cacheCount, displayType: 'count' },
|
||||||
serviceWorkers: { value: swCount, displayType: 'count' },
|
serviceWorkers: { value: swCount, displayType: 'count' },
|
||||||
});
|
});
|
||||||
boundTabRef.current = { id: tabId, url };
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load storage cleaner info:', err);
|
console.error('Failed to load storage cleaner info:', err);
|
||||||
if (currentRequestId === requestIdRef.current) {
|
if (currentRequestId === requestIdRef.current) {
|
||||||
@@ -172,7 +167,6 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
} finally {
|
} finally {
|
||||||
if (currentRequestId === requestIdRef.current) {
|
if (currentRequestId === requestIdRef.current) {
|
||||||
setIsInitializing(false);
|
setIsInitializing(false);
|
||||||
setIsRefreshingSizes(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -262,13 +256,6 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const boundTab = boundTabRef.current;
|
|
||||||
if (!boundTab || boundTab.id !== tab.id) {
|
|
||||||
toast.warning('当前标签页已切换,请等待数据刷新后再清理');
|
|
||||||
setShowConfirm(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setShowConfirm(false);
|
setShowConfirm(false);
|
||||||
try {
|
try {
|
||||||
@@ -310,7 +297,6 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
sizes,
|
sizes,
|
||||||
reloadAfterClean,
|
reloadAfterClean,
|
||||||
loading,
|
loading,
|
||||||
isRefreshingSizes,
|
|
||||||
result,
|
result,
|
||||||
showConfirm,
|
showConfirm,
|
||||||
setShowConfirm,
|
setShowConfirm,
|
||||||
|
|||||||
@@ -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,
|
FieldConfig,
|
||||||
GenerateResult,
|
GenerateResult,
|
||||||
GenerateProgress,
|
GenerateProgress,
|
||||||
WorkerMessage,
|
WorkerRequestMessage,
|
||||||
|
WorkerResponseMessage,
|
||||||
} from '@/types/testDataGenerator';
|
} from '@/types/testDataGenerator';
|
||||||
|
|
||||||
export interface UseGeneratorReturn {
|
export interface UseGeneratorReturn {
|
||||||
@@ -35,6 +36,7 @@ export function useGenerator(): UseGeneratorReturn {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const workerRef = useRef<Worker | null>(null);
|
const workerRef = useRef<Worker | null>(null);
|
||||||
|
const generationIdRef = useRef(0);
|
||||||
|
|
||||||
// 清理 Worker
|
// 清理 Worker
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -58,22 +60,30 @@ export function useGenerator(): UseGeneratorReturn {
|
|||||||
type: 'module',
|
type: 'module',
|
||||||
});
|
});
|
||||||
|
|
||||||
worker.onmessage = (e: MessageEvent<WorkerMessage>) => {
|
worker.onmessage = (e: MessageEvent<WorkerResponseMessage>) => {
|
||||||
const data = e.data;
|
const data = e.data;
|
||||||
const { type } = data;
|
const { type } = data;
|
||||||
|
|
||||||
|
if (data.generationId !== generationIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'progress':
|
case 'progress':
|
||||||
setProgress(data.payload as GenerateProgress);
|
setProgress(data.payload);
|
||||||
break;
|
break;
|
||||||
case 'complete':
|
case 'complete':
|
||||||
setIsGenerating(false);
|
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);
|
setProgress(null);
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
setError((data.payload as { error: string }).error);
|
setError(data.payload.error);
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -100,15 +110,17 @@ export function useGenerator(): UseGeneratorReturn {
|
|||||||
(fields: FieldConfig[], count: number, csvMode = false) => {
|
(fields: FieldConfig[], count: number, csvMode = false) => {
|
||||||
if (isGenerating) return;
|
if (isGenerating) return;
|
||||||
|
|
||||||
|
const generationId = ++generationIdRef.current;
|
||||||
|
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const worker = getWorker();
|
const worker = getWorker();
|
||||||
const message: WorkerMessage = {
|
const message: WorkerRequestMessage = {
|
||||||
type: 'start',
|
type: 'start',
|
||||||
payload: { fields, count, csvMode },
|
payload: { generationId, fields, count, csvMode },
|
||||||
};
|
};
|
||||||
worker.postMessage(message);
|
worker.postMessage(message);
|
||||||
},
|
},
|
||||||
@@ -120,7 +132,8 @@ export function useGenerator(): UseGeneratorReturn {
|
|||||||
*/
|
*/
|
||||||
const cancel = useCallback(() => {
|
const cancel = useCallback(() => {
|
||||||
if (workerRef.current && isGenerating) {
|
if (workerRef.current && isGenerating) {
|
||||||
const message: WorkerMessage = { type: 'cancel' };
|
++generationIdRef.current;
|
||||||
|
const message: WorkerRequestMessage = { type: 'cancel' };
|
||||||
workerRef.current.postMessage(message);
|
workerRef.current.postMessage(message);
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
|
|||||||
@@ -105,8 +105,6 @@ export function RouterProvider({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const hasUserNavigatedRef = useRef(false);
|
|
||||||
const canPersistRef = useRef(false);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从异步存储中安全溯源初始数据
|
* 从异步存储中安全溯源初始数据
|
||||||
@@ -121,7 +119,7 @@ export function RouterProvider({
|
|||||||
const savedPageOrder = await storageUtil.get(pageOrderKey, getDefaultPageOrder());
|
const savedPageOrder = await storageUtil.get(pageOrderKey, getDefaultPageOrder());
|
||||||
const savedRecentTools = await storageUtil.get('app/recentlyUsedTools', []);
|
const savedRecentTools = await storageUtil.get('app/recentlyUsedTools', []);
|
||||||
|
|
||||||
if (isValidPage(savedRoute) && syncRoute && !hasUserNavigatedRef.current) {
|
if (isValidPage(savedRoute) && syncRoute) {
|
||||||
setCurrentPage(savedRoute);
|
setCurrentPage(savedRoute);
|
||||||
}
|
}
|
||||||
if (isValidPageList(savedVisiblePages)) {
|
if (isValidPageList(savedVisiblePages)) {
|
||||||
@@ -133,11 +131,9 @@ export function RouterProvider({
|
|||||||
if (isValidPageList(savedRecentTools)) {
|
if (isValidPageList(savedRecentTools)) {
|
||||||
setRecentlyUsedTools(savedRecentTools);
|
setRecentlyUsedTools(savedRecentTools);
|
||||||
}
|
}
|
||||||
canPersistRef.current = true;
|
setIsLoaded(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Router Init Error] Core data fetch failed:', error);
|
console.error('[Router Init Error] Core data fetch failed:', error);
|
||||||
} finally {
|
|
||||||
setIsLoaded(true);
|
|
||||||
}
|
}
|
||||||
}, [defaultRoute, syncKey, syncRoute, visiblePagesKey, pageOrderKey]);
|
}, [defaultRoute, syncKey, syncRoute, visiblePagesKey, pageOrderKey]);
|
||||||
|
|
||||||
@@ -191,7 +187,7 @@ export function RouterProvider({
|
|||||||
}, [loadInitialData]);
|
}, [loadInitialData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoaded && canPersistRef.current && syncRoute) {
|
if (isLoaded && syncRoute) {
|
||||||
void storageUtil.set(syncKey, currentPage as PageType).catch(console.error);
|
void storageUtil.set(syncKey, currentPage as PageType).catch(console.error);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(`snapshot/${syncKey}`, JSON.stringify(currentPage));
|
localStorage.setItem(`snapshot/${syncKey}`, JSON.stringify(currentPage));
|
||||||
@@ -202,7 +198,7 @@ export function RouterProvider({
|
|||||||
}, [currentPage, isLoaded, syncRoute, syncKey]);
|
}, [currentPage, isLoaded, syncRoute, syncKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoaded && canPersistRef.current) {
|
if (isLoaded) {
|
||||||
void storageUtil.set(visiblePagesKey, visiblePages).catch(console.error);
|
void storageUtil.set(visiblePagesKey, visiblePages).catch(console.error);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(`snapshot/${visiblePagesKey}`, JSON.stringify(visiblePages));
|
localStorage.setItem(`snapshot/${visiblePagesKey}`, JSON.stringify(visiblePages));
|
||||||
@@ -213,7 +209,7 @@ export function RouterProvider({
|
|||||||
}, [visiblePages, isLoaded, visiblePagesKey]);
|
}, [visiblePages, isLoaded, visiblePagesKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoaded && canPersistRef.current) {
|
if (isLoaded) {
|
||||||
void storageUtil.set(pageOrderKey, pageOrder).catch(console.error);
|
void storageUtil.set(pageOrderKey, pageOrder).catch(console.error);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(`snapshot/${pageOrderKey}`, JSON.stringify(pageOrder));
|
localStorage.setItem(`snapshot/${pageOrderKey}`, JSON.stringify(pageOrder));
|
||||||
@@ -224,7 +220,7 @@ export function RouterProvider({
|
|||||||
}, [pageOrder, isLoaded, pageOrderKey]);
|
}, [pageOrder, isLoaded, pageOrderKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoaded && canPersistRef.current) {
|
if (isLoaded) {
|
||||||
void storageUtil.set('app/recentlyUsedTools', recentlyUsedTools).catch(console.error);
|
void storageUtil.set('app/recentlyUsedTools', recentlyUsedTools).catch(console.error);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('snapshot/app/recentlyUsedTools', JSON.stringify(recentlyUsedTools));
|
localStorage.setItem('snapshot/app/recentlyUsedTools', JSON.stringify(recentlyUsedTools));
|
||||||
@@ -287,8 +283,6 @@ export function RouterProvider({
|
|||||||
}, [syncRoute, syncKey, visiblePagesKey, pageOrderKey]);
|
}, [syncRoute, syncKey, visiblePagesKey, pageOrderKey]);
|
||||||
|
|
||||||
const navigateTo = (page: PageType) => {
|
const navigateTo = (page: PageType) => {
|
||||||
hasUserNavigatedRef.current = true;
|
|
||||||
canPersistRef.current = true;
|
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
setRecentlyUsedTools((prev) => {
|
setRecentlyUsedTools((prev) => {
|
||||||
const filtered = prev.filter((p) => p !== page);
|
const filtered = prev.filter((p) => p !== page);
|
||||||
@@ -297,8 +291,6 @@ export function RouterProvider({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const goHome = () => {
|
const goHome = () => {
|
||||||
hasUserNavigatedRef.current = true;
|
|
||||||
canPersistRef.current = true;
|
|
||||||
setCurrentPage('dashboard');
|
setCurrentPage('dashboard');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -323,51 +323,6 @@ describe('RouterProvider', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('初始化失败时仍应解除加载状态以便渲染页面', async () => {
|
|
||||||
(storageUtil.get as any).mockRejectedValue(new Error('Storage unavailable'));
|
|
||||||
|
|
||||||
render(
|
|
||||||
<RouterProvider>
|
|
||||||
<TestComponent />
|
|
||||||
</RouterProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('current-page')).toHaveTextContent('dashboard');
|
|
||||||
});
|
|
||||||
expect(storageUtil.set).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('用户在 loadInitialData 完成前导航时不应被存储路由覆盖', async () => {
|
|
||||||
let resolveGet: () => void;
|
|
||||||
const getBlocked = new Promise<void>((resolve) => {
|
|
||||||
resolveGet = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
(storageUtil.get as any).mockImplementation(async (key: string, defaultValue: unknown) => {
|
|
||||||
await getBlocked;
|
|
||||||
if (key === 'app/currentRoute') return 'dashboard';
|
|
||||||
return defaultValue;
|
|
||||||
});
|
|
||||||
|
|
||||||
render(
|
|
||||||
<RouterProvider>
|
|
||||||
<TestComponent />
|
|
||||||
</RouterProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByTestId('navigate-btn'));
|
|
||||||
});
|
|
||||||
expect(screen.getByTestId('current-page')).toHaveTextContent('timestamp');
|
|
||||||
|
|
||||||
resolveGet!();
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('current-page')).toHaveTextContent('timestamp');
|
|
||||||
expect(storageUtil.set).toHaveBeenCalledWith('app/currentRoute', 'timestamp');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('组件卸载时不应设置 isLoaded 状态(竞态条件防护)', async () => {
|
it('组件卸载时不应设置 isLoaded 状态(竞态条件防护)', async () => {
|
||||||
let resolveStorage: (value: unknown) => void;
|
let resolveStorage: (value: unknown) => void;
|
||||||
const storagePromise = new Promise((resolve) => {
|
const storagePromise = new Promise((resolve) => {
|
||||||
|
|||||||
+3
-11
@@ -10,10 +10,12 @@
|
|||||||
|
|
||||||
**页面类型:**
|
**页面类型:**
|
||||||
|
|
||||||
- `PageType` — 所有页面类型的联合类型(含 `dashboard`、`timestamp`、`storageCleaner`、`testDataGenerator` 等)
|
- `PageType` — 所有页面类型的联合类型(`dashboard` | `timestamp` | `storageCleaner` | ...)
|
||||||
- `JsonToolsPageMode` — JSON 工具子模式(`diff` | `format` | `yaml` | `toml` | `minify`)
|
- `JsonToolsPageMode` — JSON 工具子模式(`diff` | `format` | `yaml` | `toml` | `minify`)
|
||||||
- `Base64ConverterPageMode` — Base64 子模式(`text` | `file` | `image`)
|
- `Base64ConverterPageMode` — Base64 子模式(`text` | `file` | `image`)
|
||||||
- `Base64ConvertDirection` — 编解码方向(`encode` | `decode`)
|
- `Base64ConvertDirection` — 编解码方向(`encode` | `decode`)
|
||||||
|
- `MarkdownToHtmlPreviewMode` — Markdown 预览模式(`split` | `preview` | `html`)
|
||||||
|
- `HtmlToMarkdownPreviewMode` — HTML 预览模式(`split` | `preview` | `markdown`)
|
||||||
|
|
||||||
**存储 Schema:**
|
**存储 Schema:**
|
||||||
|
|
||||||
@@ -32,16 +34,6 @@
|
|||||||
|
|
||||||
`qrious` 库的类型声明,定义 QR 码生成选项和 `QRious` 类。
|
`qrious` 库的类型声明,定义 QR 码生成选项和 `QRious` 类。
|
||||||
|
|
||||||
### testDataGenerator.ts
|
|
||||||
|
|
||||||
测试数据生成器共享类型,包含:
|
|
||||||
|
|
||||||
- `FieldConfig` — 字段配置(字段名、生成器、参数、必填、空值率、唯一性)
|
|
||||||
- `DataRule` — 可保存/导入/导出的字段规则
|
|
||||||
- `GeneratorDefinition` / `GeneratorParam` — 内置生成器定义和参数 Schema
|
|
||||||
- `GenerateResult` / `GenerateProgress` / `WorkerMessage` — Worker 生成结果、进度和消息协议
|
|
||||||
- `ExportFile` — JSON/CSV 导出文件描述
|
|
||||||
|
|
||||||
## 修改 StorageSchema 的注意事项
|
## 修改 StorageSchema 的注意事项
|
||||||
|
|
||||||
修改 `StorageSchema` 时,必须:
|
修改 `StorageSchema` 时,必须:
|
||||||
|
|||||||
@@ -156,19 +156,29 @@ export interface GenerateProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Worker 消息类型
|
* Worker 请求消息类型
|
||||||
*/
|
*/
|
||||||
export type WorkerMessage =
|
export type WorkerRequestMessage =
|
||||||
| { type: 'start'; payload: WorkerStartPayload }
|
| { type: 'start'; payload: WorkerStartPayload }
|
||||||
| { type: 'progress'; payload: GenerateProgress }
|
|
||||||
| { type: 'complete'; payload: GenerateResult }
|
|
||||||
| { type: 'error'; payload: { error: string } }
|
|
||||||
| { type: 'cancel' };
|
| { 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 启动参数
|
* Worker 启动参数
|
||||||
*/
|
*/
|
||||||
export interface WorkerStartPayload {
|
export interface WorkerStartPayload {
|
||||||
|
/** 生成任务 ID,用于忽略过期 Worker 响应 */
|
||||||
|
generationId: number;
|
||||||
/** 字段配置列表 */
|
/** 字段配置列表 */
|
||||||
fields: FieldConfig[];
|
fields: FieldConfig[];
|
||||||
/** 生成数量 */
|
/** 生成数量 */
|
||||||
|
|||||||
+21
-22
@@ -4,40 +4,39 @@
|
|||||||
|
|
||||||
## 工具函数
|
## 工具函数
|
||||||
|
|
||||||
| 文件 | 用途 |
|
| 文件 | 用途 |
|
||||||
| ------------------------ | --------------------------------------------------------------------------------------------------- |
|
| -------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
| `chromeStorage.ts` | Chrome Storage API 封装:类型安全的 `StorageUtils` 类,提供 `get/set/remove` 方法 |
|
| `chromeStorage.ts` | Chrome Storage API 封装:类型安全的 `StorageUtils` 类,提供 `get/set/remove` 方法 |
|
||||||
| `chromeTabs.ts` | Chrome Tabs API 封装:获取活动标签页、获取域名、在新标签页打开扩展页面 |
|
| `chromeTabs.ts` | Chrome Tabs API 封装:获取活动标签页、获取域名、在新标签页打开扩展页面 |
|
||||||
| `clipboard.ts` | 剪贴板操作:`copyTextToClipboard`(文本)、`copyImageToClipboard`(图片) |
|
| `clipboard.ts` | 剪贴板操作:`copyTextToClipboard`(文本)、`copyImageToClipboard`(图片) |
|
||||||
| `messages.ts` | 扩展消息通信:基于 `@webext-core/messaging`,定义 `MessageAction` 枚举和 `ProtocolMap` 类型安全映射 |
|
| `messages.ts` | 扩展消息通信:基于 `@webext-core/messaging`,定义 `MessageAction` 枚举和 `ProtocolMap` 类型安全映射 |
|
||||||
| `contextMenu.ts` | 右键菜单配置与操作:定义菜单项、创建菜单、解析点击事件、ID→PageType 映射 |
|
| `contextMenu.ts` | 右键菜单配置与操作:定义菜单项、创建菜单、解析点击事件、ID→PageType 映射 |
|
||||||
| `base64Converter.ts` | Base64 编解码:文本↔Base64、文件↔Base64、图片预览,定义文件大小限制和图像 MIME 类型 |
|
| `base64Converter.ts` | Base64 编解码:文本↔Base64、文件↔Base64、图片预览,定义文件大小限制和图像 MIME 类型 |
|
||||||
| `jwt.ts` | JWT 解析:Base64URL 解码、解析 Header/Payload/Signature、JSON 格式化输出 |
|
| `jwt.ts` | JWT 解析:Base64URL 解码、解析 Header/Payload/Signature、JSON 格式化输出 |
|
||||||
| `jsonFormatter.ts` | JSON 格式化/压缩:支持缩进、按键排序、minify |
|
| `jsonFormatter.ts` | JSON 格式化/压缩:支持缩进、按键排序、minify |
|
||||||
| `jsonToYaml.ts` | JSON→YAML 转换 |
|
| `jsonToYaml.ts` | JSON→YAML 转换 |
|
||||||
| `jsonToToml.ts` | JSON→TOML 转换 |
|
| `jsonToToml.ts` | JSON→TOML 转换 |
|
||||||
| `qrCodeParser.ts` | 二维码解析:基于 `qr-scanner` 库从文件中解析二维码 |
|
| `markdownToHtml.ts` | Markdown→HTML 转换:基于 `marked` 库,支持 GFM 和换行转换 |
|
||||||
| `storageCleaner.ts` | 存储清理:获取当前标签页、检测受限 URL、计算 Cookie/Storage 大小、清理操作 |
|
| `htmlToMarkdown.ts` | HTML→Markdown 转换:基于 DOMParser 解析 |
|
||||||
| `textStatistics.ts` | 文本统计:使用 `Intl.Segmenter` 计算字符数/单词数/行数/字节大小 |
|
| `qrCodeParser.ts` | 二维码解析:基于 `qr-scanner` 库从文件中解析二维码 |
|
||||||
| `format.ts` | 通用格式化:`formatBytes` 将字节转为可读字符串(B/KB/MB/GB/TB) |
|
| `storageCleaner.ts` | 存储清理:获取当前标签页、检测受限 URL、计算 Cookie/Storage 大小、清理操作 |
|
||||||
| `dayjs.ts` | Day.js 初始化:扩展 UTC、Timezone、RelativeTime 插件,加载中文本地化 |
|
| `textStatistics.ts` | 文本统计:使用 `Intl.Segmenter` 计算字符数/单词数/行数/字节大小 |
|
||||||
| `chromeI18n.ts` | Chrome `chrome.i18n` 包装:提供 `getMessage` 和兼容 React 使用的 `useI18n` Hook |
|
| `format.ts` | 通用格式化:`formatBytes` 将字节转为可读字符串(B/KB/MB/GB/TB) |
|
||||||
| `ruleStorage.ts` | 测试数据生成器规则存储:基于 `localStorage` 的 CRUD、搜索、导入/导出和数量限制 |
|
| `dayjs.ts` | Day.js 初始化:扩展 UTC、Timezone、RelativeTime 插件,加载中文本地化 |
|
||||||
| `dataExporter.ts` | 测试数据导出:JSON/CSV 转换、文件下载和复制到剪贴板 |
|
|
||||||
| `rightClickInjection.ts` | 右键恢复注入脚本:在页面上下文恢复 contextmenu/copy/paste 等事件默认行为 |
|
|
||||||
|
|
||||||
## 自定义 Hooks
|
## 自定义 Hooks
|
||||||
|
|
||||||
| 文件 | 用途 |
|
| 文件 | 用途 |
|
||||||
| ----------------------- | -------------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
| `useStorageState.ts` | Chrome Storage 状态 Hook:类似 `useState`,值自动同步到 `chrome.storage`,使用 `localStorage` 快照消除首屏闪烁 |
|
| `useStorageState.ts` | Chrome Storage 状态 Hook:类似 `useState`,值自动同步到 `chrome.storage`,使用 `localStorage` 快照消除首屏闪烁 |
|
||||||
|
| `useLazyTranslation.ts` | 懒加载翻译 Hook:按需动态导入 i18n 命名空间,支持预加载和缓存 |
|
||||||
| `useContextMenuData.ts` | 右键菜单数据 Hook:从 storage 读取待处理数据,匹配 featureKey 后消费并触发回调 |
|
| `useContextMenuData.ts` | 右键菜单数据 Hook:从 storage 读取待处理数据,匹配 featureKey 后消费并触发回调 |
|
||||||
| `useDebounce.ts` | 防抖 Hook:对值进行延迟更新,避免频繁触发 |
|
| `useDebounce.ts` | 防抖 Hook:对值进行延迟更新,避免频繁触发 |
|
||||||
|
|
||||||
## 使用约定
|
## 使用约定
|
||||||
|
|
||||||
- 工具函数使用**命名导出**(`export function xxx()`)
|
- 工具函数使用**命名导出**(`export function xxx()`)
|
||||||
- 工具层不直接展示 Toast;可恢复错误返回可判断结果,需要抛出的解析/转换错误由页面 Hook 或 UI 层捕获
|
- 工具函数**不抛异常**,返回包含 `hasError` 和 `error` 字段的结果对象
|
||||||
- Hook 使用 `use` 前缀命名,定义返回值接口类型
|
- Hook 使用 `use` 前缀命名,定义返回值接口类型
|
||||||
- 存储操作使用 `chromeStorage.ts` 的 `storageUtil` 封装,不要直接调用 `chrome.storage`
|
- 存储操作使用 `chromeStorage.ts` 的 `storageUtil` 封装,不要直接调用 `chrome.storage`
|
||||||
- 消息通信使用 `messages.ts` 的 `sendMessage`/`onMessage`,不要使用原生 `chrome.runtime.sendMessage`
|
- 消息通信使用 `messages.ts` 的 `sendMessage`/`onMessage`,不要使用原生 `chrome.runtime.sendMessage`
|
||||||
|
|||||||
@@ -1,7 +1,37 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { openExtensionPage } from '@/utils/chromeTabs';
|
import { getCurrentTab, openExtensionPage } from '@/utils/chromeTabs';
|
||||||
|
|
||||||
describe('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', () => {
|
describe('openExtensionPage', () => {
|
||||||
it('应该在新标签页中打开扩展页面', async () => {
|
it('应该在新标签页中打开扩展页面', async () => {
|
||||||
await openExtensionPage('popup.html');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -166,18 +166,4 @@ describe('useStorageState', () => {
|
|||||||
|
|
||||||
expect(result.current[2]).toBe(false);
|
expect(result.current[2]).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('加载失败时不应把快照默认值写回 Chrome Storage', async () => {
|
|
||||||
localStorage.setItem('snapshot/app/searchHistory', JSON.stringify([]));
|
|
||||||
|
|
||||||
(storageUtil.get as any).mockRejectedValue(new Error('Storage read failed'));
|
|
||||||
|
|
||||||
renderHook(() =>
|
|
||||||
useStorageState('app/searchHistory', [], (val): val is string[] => Array.isArray(val)),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(storageUtil.set).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,31 @@
|
|||||||
|
import { browser } from 'wxt/browser';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chrome 标签页相关工具函数
|
* 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')
|
* @param page - 扩展页面路径(如 'popup.html')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineExtensionMessaging } from '@webext-core/messaging';
|
import { defineExtensionMessaging } from '@webext-core/messaging';
|
||||||
|
import { getCurrentTab } from '@/utils/chromeTabs';
|
||||||
|
|
||||||
export enum MessageAction {
|
export enum MessageAction {
|
||||||
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
|
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
|
||||||
@@ -38,7 +39,7 @@ export async function sendMessageToContent<K extends keyof ProtocolMap>(
|
|||||||
: [data: Parameters<ProtocolMap[K]>[0]]
|
: [data: Parameters<ProtocolMap[K]>[0]]
|
||||||
): Promise<ReturnType<ProtocolMap[K]>> {
|
): Promise<ReturnType<ProtocolMap[K]>> {
|
||||||
try {
|
try {
|
||||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
const tab = await getCurrentTab();
|
||||||
if (!tab?.id) {
|
if (!tab?.id) {
|
||||||
console.warn(`[Messaging] 无法获取当前标签页,无法发送动作: ${action}`);
|
console.warn(`[Messaging] 无法获取当前标签页,无法发送动作: ${action}`);
|
||||||
return { success: false, message: '无法获取当前标签页' } as ReturnType<ProtocolMap[K]>;
|
return { success: false, message: '无法获取当前标签页' } as ReturnType<ProtocolMap[K]>;
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ export function save(
|
|||||||
useCount: 0,
|
useCount: 0,
|
||||||
};
|
};
|
||||||
rules.unshift(newRule);
|
rules.unshift(newRule);
|
||||||
setAll(rules);
|
if (!setAll(rules)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return newRule;
|
return newRule;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +97,9 @@ export function save(
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
rules[index] = updatedRule;
|
rules[index] = updatedRule;
|
||||||
setAll(rules);
|
if (!setAll(rules)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return updatedRule;
|
return updatedRule;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +121,9 @@ export function update(id: string, updates: Partial<DataRule>): DataRule | null
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
rules[index] = updatedRule;
|
rules[index] = updatedRule;
|
||||||
setAll(rules);
|
if (!setAll(rules)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return updatedRule;
|
return updatedRule;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,8 +138,7 @@ export function deleteRule(id: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
rules.splice(index, 1);
|
rules.splice(index, 1);
|
||||||
setAll(rules);
|
return setAll(rules);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -165,7 +170,9 @@ export function duplicate(id: string, copySuffix = '(副本)'): DataRule | n
|
|||||||
|
|
||||||
const rules = getAll();
|
const rules = getAll();
|
||||||
rules.unshift(newRule);
|
rules.unshift(newRule);
|
||||||
setAll(rules);
|
if (!setAll(rules)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return newRule;
|
return newRule;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +248,14 @@ export function importRules(jsonString: string): {
|
|||||||
continue;
|
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) {
|
if (saved) {
|
||||||
result.success++;
|
result.success++;
|
||||||
} else {
|
} else {
|
||||||
@@ -267,11 +281,13 @@ export function clear(): void {
|
|||||||
/**
|
/**
|
||||||
* 保存所有规则到存储
|
* 保存所有规则到存储
|
||||||
*/
|
*/
|
||||||
function setAll(rules: DataRule[]): void {
|
function setAll(rules: DataRule[]): boolean {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(rules));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(rules));
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ruleStorage] 保存规则失败:', error);
|
console.error('[ruleStorage] 保存规则失败:', error);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,29 +11,6 @@ import { browser } from 'wxt/browser';
|
|||||||
|
|
||||||
type CleanScriptResult = IndexedDBCleanResult;
|
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> {
|
export async function getCookieSize(url: string): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const cookies = await browser.cookies.getAll({ url });
|
const cookies = await browser.cookies.getAll({ url });
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { useEffect, useRef, useState } from 'react';
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type Dispatch,
|
|
||||||
type SetStateAction,
|
|
||||||
} from 'react';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
import { getSyncSnapshot } from '@/utils/syncSnapshot';
|
import { getSyncSnapshot } from '@/utils/syncSnapshot';
|
||||||
import type { StorageSchema } from '@/types/storage';
|
import type { StorageSchema } from '@/types/storage';
|
||||||
@@ -15,18 +8,11 @@ export const useStorageState = <K extends keyof StorageSchema>(
|
|||||||
defaultValue: StorageSchema[K],
|
defaultValue: StorageSchema[K],
|
||||||
validator?: (val: unknown) => val is StorageSchema[K],
|
validator?: (val: unknown) => val is StorageSchema[K],
|
||||||
) => {
|
) => {
|
||||||
const [value, setValueInternal] = useState<StorageSchema[K]>(() =>
|
const [value, setValue] = useState<StorageSchema[K]>(() =>
|
||||||
getSyncSnapshot(key as string, defaultValue, validator),
|
getSyncSnapshot(key as string, defaultValue, validator),
|
||||||
);
|
);
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const hasLoadedFromStorage = useRef(false);
|
const hasLoadedFromStorage = useRef(false);
|
||||||
const loadSucceededRef = useRef(false);
|
|
||||||
const userModifiedRef = useRef(false);
|
|
||||||
|
|
||||||
const setValue = useCallback<Dispatch<SetStateAction<StorageSchema[K]>>>((next) => {
|
|
||||||
userModifiedRef.current = true;
|
|
||||||
setValueInternal(next);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Only load from storage once on mount
|
// Only load from storage once on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,17 +24,15 @@ export const useStorageState = <K extends keyof StorageSchema>(
|
|||||||
try {
|
try {
|
||||||
const savedValue = await storageUtil.get(key, defaultValue);
|
const savedValue = await storageUtil.get(key, defaultValue);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
loadSucceededRef.current = true;
|
|
||||||
if (savedValue !== undefined) {
|
if (savedValue !== undefined) {
|
||||||
if (validator) {
|
if (validator) {
|
||||||
setValueInternal(validator(savedValue) ? savedValue : defaultValue);
|
setValue(validator(savedValue) ? savedValue : defaultValue);
|
||||||
} else {
|
} else {
|
||||||
setValueInternal(savedValue);
|
setValue(savedValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`加载状态失败 (${key}):`, error);
|
console.error(`加载状态失败 (${key}):`, error);
|
||||||
loadSucceededRef.current = false;
|
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
@@ -67,7 +51,6 @@ export const useStorageState = <K extends keyof StorageSchema>(
|
|||||||
// Save to storage and localStorage snapshot when value changes (after initial load)
|
// Save to storage and localStorage snapshot when value changes (after initial load)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isInitialized) return;
|
if (!isInitialized) return;
|
||||||
if (!loadSucceededRef.current && !userModifiedRef.current) return;
|
|
||||||
|
|
||||||
const saveState = async () => {
|
const saveState = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,11 +6,14 @@
|
|||||||
import { getGeneratorById } from '@/lib/generators';
|
import { getGeneratorById } from '@/lib/generators';
|
||||||
import type {
|
import type {
|
||||||
FieldConfig,
|
FieldConfig,
|
||||||
WorkerMessage,
|
WorkerRequestMessage,
|
||||||
GenerateResult,
|
GenerateResult,
|
||||||
GenerateProgress,
|
GenerateProgress,
|
||||||
} from '@/types/testDataGenerator';
|
} from '@/types/testDataGenerator';
|
||||||
|
|
||||||
|
/** 每生成 N 行让出一次事件循环,以便处理 cancel 消息 */
|
||||||
|
const YIELD_EVERY = 100;
|
||||||
|
|
||||||
// 生成结果缓存
|
// 生成结果缓存
|
||||||
let generatedData: Record<string, unknown>[] = [];
|
let generatedData: Record<string, unknown>[] = [];
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
@@ -18,7 +21,7 @@ let isCancelled = false;
|
|||||||
/**
|
/**
|
||||||
* Worker 消息处理器
|
* Worker 消息处理器
|
||||||
*/
|
*/
|
||||||
self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
|
self.onmessage = async (e: MessageEvent<WorkerRequestMessage>) => {
|
||||||
const data = e.data;
|
const data = e.data;
|
||||||
const { type } = data;
|
const { type } = data;
|
||||||
|
|
||||||
@@ -37,11 +40,12 @@ self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
|
|||||||
* 处理开始生成消息
|
* 处理开始生成消息
|
||||||
*/
|
*/
|
||||||
async function handleStart(payload: {
|
async function handleStart(payload: {
|
||||||
|
generationId: number;
|
||||||
fields: FieldConfig[];
|
fields: FieldConfig[];
|
||||||
count: number;
|
count: number;
|
||||||
csvMode: boolean;
|
csvMode: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { fields, count } = payload;
|
const { generationId, fields, count } = payload;
|
||||||
generatedData = [];
|
generatedData = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -51,6 +55,7 @@ async function handleStart(payload: {
|
|||||||
if (!generator) {
|
if (!generator) {
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
generationId,
|
||||||
payload: { error: `生成器 "${field.generatorId}" 不存在` },
|
payload: { error: `生成器 "${field.generatorId}" 不存在` },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -65,6 +70,7 @@ async function handleStart(payload: {
|
|||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: 'complete',
|
type: 'complete',
|
||||||
|
generationId,
|
||||||
payload: {
|
payload: {
|
||||||
success: false,
|
success: false,
|
||||||
error: '生成已取消',
|
error: '生成已取消',
|
||||||
@@ -136,7 +142,23 @@ async function handleStart(payload: {
|
|||||||
total: count,
|
total: count,
|
||||||
estimatedTimeLeft: Math.round(((Date.now() - startTime) / (i + 1)) * (count - i - 1)),
|
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) {
|
} catch (error) {
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
generationId,
|
||||||
payload: { error: `生成失败: ${error}` },
|
payload: { error: `生成失败: ${error}` },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user