From a163297de4808c5e3556163b2e04e9a6fe2eda4f Mon Sep 17 00:00:00 2001 From: LingandRX <56020800+LingandRX@users.noreply.github.com> Date: Sun, 7 Jun 2026 00:04:53 +0800 Subject: [PATCH] develop (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(AGENTS.md): 添加 Git Commit 必须使用中文描述的规范 * docs: 更新测试数据生成器功能设计文档 * refactor: 删除死代码 * docs: 统一测试数据生成器文档,移除 SQL/TypeScript 导出,修复接口命名不一致 * feat: 实现测试数据生成器功能 * docs: 更新 README 和 AGENTS 文档,补充测试数据生成器模块说明 --- .gitignore | 20 +- AGENTS.md | 37 +- README.md | 32 +- docs/test-data-generator/README.md | 21 + docs/test-data-generator/TASKS.md | 384 ++++ docs/test-data-generator/core-features.md | 238 ++ docs/test-data-generator/generators.md | 702 ++++++ docs/test-data-generator/rule-management.md | 481 +++++ .../technical-implementation.md | 1909 +++++++++++++++++ docs/test-data-generator/ui-design.md | 489 +++++ public/_locales/zh_CN/messages.json | 384 +++- src/components/ui/dialog.tsx | 20 +- src/config/__tests__/features.test.ts | 11 +- src/config/features.tsx | 15 + src/lib/generators/basic.ts | 227 ++ src/lib/generators/business.ts | 561 +++++ src/lib/generators/index.ts | 57 + src/lib/generators/personal.ts | 465 ++++ src/lib/generators/technical.ts | 268 +++ src/lib/generators/types.ts | 48 + .../components/DataPreview.tsx | 114 + .../components/ExportPanel.tsx | 81 + .../components/FieldEditor.tsx | 230 ++ .../components/FieldItem.tsx | 59 + .../components/FieldList.tsx | 466 ++++ .../components/GenerateButton.tsx | 73 + .../components/GenerateOptions.tsx | 91 + .../components/GeneratorConfig.tsx | 126 ++ .../components/GeneratorSelector.tsx | 90 + .../components/ResultPanel.tsx | 115 + .../components/RuleManager.tsx | 324 +++ .../components/__tests__/RuleManager.test.tsx | 189 ++ .../TestDataGenerator/hooks/useGenerator.ts | 148 ++ src/pages/TestDataGenerator/index.tsx | 306 +++ src/types/storage.d.ts | 3 +- src/types/testDataGenerator.ts | 178 ++ src/utils/dataExporter.ts | 133 ++ src/utils/ruleStorage.ts | 339 +++ src/workers/generator.worker.ts | 165 ++ 39 files changed, 9572 insertions(+), 27 deletions(-) create mode 100644 docs/test-data-generator/README.md create mode 100644 docs/test-data-generator/TASKS.md create mode 100644 docs/test-data-generator/core-features.md create mode 100644 docs/test-data-generator/generators.md create mode 100644 docs/test-data-generator/rule-management.md create mode 100644 docs/test-data-generator/technical-implementation.md create mode 100644 docs/test-data-generator/ui-design.md create mode 100644 src/lib/generators/basic.ts create mode 100644 src/lib/generators/business.ts create mode 100644 src/lib/generators/index.ts create mode 100644 src/lib/generators/personal.ts create mode 100644 src/lib/generators/technical.ts create mode 100644 src/lib/generators/types.ts create mode 100644 src/pages/TestDataGenerator/components/DataPreview.tsx create mode 100644 src/pages/TestDataGenerator/components/ExportPanel.tsx create mode 100644 src/pages/TestDataGenerator/components/FieldEditor.tsx create mode 100644 src/pages/TestDataGenerator/components/FieldItem.tsx create mode 100644 src/pages/TestDataGenerator/components/FieldList.tsx create mode 100644 src/pages/TestDataGenerator/components/GenerateButton.tsx create mode 100644 src/pages/TestDataGenerator/components/GenerateOptions.tsx create mode 100644 src/pages/TestDataGenerator/components/GeneratorConfig.tsx create mode 100644 src/pages/TestDataGenerator/components/GeneratorSelector.tsx create mode 100644 src/pages/TestDataGenerator/components/ResultPanel.tsx create mode 100644 src/pages/TestDataGenerator/components/RuleManager.tsx create mode 100644 src/pages/TestDataGenerator/components/__tests__/RuleManager.test.tsx create mode 100644 src/pages/TestDataGenerator/hooks/useGenerator.ts create mode 100644 src/pages/TestDataGenerator/index.tsx create mode 100644 src/types/testDataGenerator.ts create mode 100644 src/utils/dataExporter.ts create mode 100644 src/utils/ruleStorage.ts create mode 100644 src/workers/generator.worker.ts diff --git a/.gitignore b/.gitignore index c5eb473..d19ef9c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,11 @@ stats-*.json .wxt .vitest +# Environment variables +.env +.env.local +.env.*.local + # Editor directories and files .vscode/* !.vscode/extensions.json @@ -30,5 +35,16 @@ stats-*.json .qoder/* dev/* -docs/* -!docs/VISUAL_STYLE_GUIDE.md +# Build outputs +dist/ +build/ + +# Test coverage +coverage/ + +# IDE files +*.code-workspace + +# Other +*.tsbuildinfo +*.tsxbuildinfo diff --git a/AGENTS.md b/AGENTS.md index 28d1b5e..14c0a43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown 等测试效率工具。 +WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown、测试数据生成器等测试效率工具。 ## 核心命令 @@ -54,6 +54,7 @@ src/ # 源代码根目录 utils/ # 工具函数与服务抽象 types/ # TypeScript 类型声明 lib/ # 通用工具函数(cn、utils) + workers/ # Web Worker(数据生成等耗时任务) public/ # 静态资源(图标、_locales 等) ``` @@ -76,6 +77,37 @@ src/pages/FeatureName/ - 当 `index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式 - 复杂页面可增加 `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 转换、下载、剪贴板) +└── generators/ # 内置生成器定义(个人信息、企业、技术、基础类型) + +src/workers/ +└── generator.worker.ts # 数据生成 Web Worker + +src/types/ +└── testDataGenerator.ts # 类型定义(FieldConfig, DataRule, GeneratorDefinition 等) +``` + ## 关键架构决策 **路由**: 不使用 React Router。通过 `src/config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType` @@ -148,7 +180,8 @@ src/pages/FeatureName/ ## 关键外部库(非显而易见的) - `@webext-core/messaging` — 扩展消息通信 -- `@dnd-kit` — 拖拽排序(用于页面顺序管理) +- `@dnd-kit` — 拖拽排序(用于页面顺序管理和字段列表排序) - `qrious` + `qr-scanner` — 二维码生成与解析 - `dayjs` — 日期处理(时间戳转换) - `sonner` — Toast 通知(替代传统 snackbar) +- Web Worker — 批量数据生成(`src/workers/generator.worker.ts`),避免阻塞 UI 线程 diff --git a/README.md b/README.md index bc99572..27ad7fe 100644 --- a/README.md +++ b/README.md @@ -8,61 +8,72 @@ ## 功能特性 -### 🚀 Dashboard 首页 +### Dashboard 首页 - **工具导航**: 快速访问所有可用工具. - **个性化定制**: 支持自定义工具的排序和可见性. - **实时预览**: 在卡片上直接查看实时数据(如当前时间戳). -### ⏰ 时间戳转换工具 +### 时间戳转换工具 - **实时显示**: 毫秒级精度显示当前系统时间. - **双向转换**: 日期字符串与 Unix 时间戳(秒/毫秒)之间的无缝转换. - **多时区支持**: 预设常用时区(亚洲/上海、美洲/纽约、欧洲/伦敦),支持快速切换. - **快捷操作**: 一键复制转换结果,支持多种格式. -### 🧹 存储清理工具 +### 存储清理工具 - **智能识别**: 自动检测并显示当前活动标签页的域名. - **全面清理**: 支持一键清理 localStorage、sessionStorage、IndexedDB、Cookies、Cache Storage 和 Service Workers. - **细粒度控制**: 可根据需要选择特定的清理项. - **自动刷新**: 提供清理后自动刷新页面的选项,确保状态同步. -### 📝 文本统计工具 +### 文本统计工具 - **实时分析**: 键入即统计,无需额外操作. - **多维指标**: 统计字符数、单词数、行数以及精确的字节大小. - **性能优化**: 采用高性能分词算法,支持大文本处理. -### 🔑 JWT 解析工具 +### JWT 解析工具 - **快速解码**: 自动解析 JSON Web Token 的 Header 和 Payload. - **格式化显示**: 以着色和格式化的 JSON 视图展示数据,方便阅读. - **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示. - **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性. -### 🖱️ 右键恢复工具 +### 右键恢复工具 - **当前页面检测**: 自动识别当前活动标签页的域名. - **一键恢复**: 解除网站对右键菜单的限制,恢复复制、粘贴等基础操作. - **状态可视化**: 通过 Badge 组件直观展示当前页面的锁定/解锁状态. -### 🖼️ 二维码工具 +### 二维码工具 - **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载. - **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容. -### 🔄 JSON 工具 +### JSON 工具 - **差异比较**: 对比两段 JSON 数据,高亮展示差异. - **格式化**: 支持 JSON 美化、压缩、转 YAML / TOML. -### 🧰 Base64 转换器 +### Base64 转换器 - **文本编解码**: 支持文本内容的 Base64 编码与解码. - **文件转换**: 支持文件与 Base64 字符串互转. - **图像预览**: 支持图片 Base64 编码与实时预览. +### 测试数据生成器 + +- **可视化字段配置**: 通过 UI 界面定义数据字段,支持拖拽排序、最多 40 个字段. +- **丰富的内置生成器**: 涵盖个人信息(姓名、手机、邮箱)、企业数据(公司名、职位)、技术数据(IP、MAC 地址、UUID)、基础类型(数字、日期、枚举)等多个分类. +- **灵活的参数配置**: 每个生成器支持自定义参数(如数字范围、日期格式、枚举值列表等). +- **空值率与唯一性**: 可为非必填字段设置空值率,支持字段唯一性约束. +- **规则管理**: 保存、加载、编辑、复制、导入/导出字段配置规则,方便复用. +- **批量生成**: 支持 1 ~ 100,000 条数据生成,通过 Web Worker 异步处理避免阻塞 UI. +- **实时预览**: 配置字段后即时预览示例数据结构. +- **多格式导出**: 支持 JSON 和 CSV 格式,提供复制到剪贴板和下载文件两种导出方式. + ## 技术栈 - **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/) @@ -75,6 +86,8 @@ - **存储**: Chrome Storage API (类型安全封装) - **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成) - **测试**: Vitest + Testing Library +- **拖拽排序**: @dnd-kit/core + @dnd-kit/sortable +- **异步生成**: Web Worker (批量数据生成) ## 项目结构 @@ -89,6 +102,7 @@ │ │ ├── background.ts # 后台 Service Worker │ │ └── content.ts # 网页注入脚本 │ ├── pages/ # 各功能模块的页面组件 +│ ├── workers/ # Web Worker (数据生成等耗时任务) │ ├── providers/ # 全局状态提供者 (Router, Theme 等) │ ├── hooks/ # 自定义 React Hooks │ ├── utils/ # 工具函数与服务抽象 diff --git a/docs/test-data-generator/README.md b/docs/test-data-generator/README.md new file mode 100644 index 0000000..8681be1 --- /dev/null +++ b/docs/test-data-generator/README.md @@ -0,0 +1,21 @@ +# 测试数据生成器 - 功能设计文档 + +> 版本: v1.0 +> 创建时间: 2024-01-20 +> 状态: 待 Review + +## 产品定位 + +**轻量级可视化测试数据生成器** + +- 纯前端工具,无需后端 +- 可视化配置,无需编码 +- 本地生成,数据安全 + +## 目录 + +- [核心功能](./core-features.md) +- [生成器库](./generators.md) +- [规则管理](./rule-management.md) +- [界面设计](./ui-design.md) +- [技术实现](./technical-implementation.md) diff --git a/docs/test-data-generator/TASKS.md b/docs/test-data-generator/TASKS.md new file mode 100644 index 0000000..d7ce8ff --- /dev/null +++ b/docs/test-data-generator/TASKS.md @@ -0,0 +1,384 @@ +# 测试数据生成器 - 实现任务列表 + +> 创建时间: 2026-06-06 +> 状态: ✅ 已完成 + +## 总体进度 + +| 子功能 | 状态 | 预估工时 | +| -------------------------- | --------- | --------- | +| 1. 类型定义与项目配置 | ✅ 已完成 | 0.5h | +| 2. 生成器库 | ✅ 已完成 | 4h | +| 3. Web Worker 数据生成引擎 | ✅ 已完成 | 2h | +| 4. 规则存储与管理 | ✅ 已完成 | 2h | +| 5. 数据导出功能 | ✅ 已完成 | 1h | +| 6. UI 组件开发 | ✅ 已完成 | 6h | +| 7. 页面集成与注册 | ✅ 已完成 | 1h | +| 8. 测试与优化 | ✅ 已完成 | 2h | +| **总计** | | **18.5h** | + +--- + +## 1. 类型定义与项目配置 + +**目标**: 创建所有必要的类型定义文件,配置项目注册新功能 + +### 任务清单 + +- [x] 1.1 创建 `src/types/testDataGenerator.ts` 类型定义文件 + - [x] 定义 `FieldConfig` 接口 + - [x] 定义 `DataRule` 接口 + - [x] 定义 `GeneratorDefinition` 接口 + - [x] 定义 `GeneratorParam` 接口 + - [x] 定义 `GenerateResult` 接口 + - [x] 定义 `ExportFile` 接口 + +- [x] 1.2 更新 `src/types/storage.d.ts` + - [x] 在 `PageType` 中添加 `'testDataGenerator'` + - [x] 在 `StorageSchema` 中添加测试数据生成器相关配置(如需要) + +- [x] 1.3 更新 `src/config/features.tsx` + - [x] 导入 TestDataGenerator 页面组件 + - [x] 添加新功能配置到 `FEATURES` 数组 + - [x] 选择合适的图标(如 `Database` 或 `FileSpreadsheet`) + +- [x] 1.4 更新国际化配置 + - [x] 添加 `testDataGenerator_title` 翻译 + - [x] 添加 `testDataGenerator_description` 翻译 + +--- + +## 2. 生成器库 + +**目标**: 实现 21 个数据生成器,支持多种生成策略 + +### 任务清单 + +#### 2.1 生成器基础框架 + +- [x] 2.1.1 创建 `src/lib/generators/types.ts` + - [x] 定义生成器内部类型(与 `testDataGenerator.ts` 分离) + +- [x] 2.1.2 创建 `src/lib/generators/index.ts` + - [x] 导出所有生成器 + - [x] 导出 `generatorCategories` 配置 + +#### 2.2 个人信息生成器 (6个) + +- [x] 2.2.1 创建 `src/lib/generators/personal.ts` + - [x] 实现 `chineseName` 生成器 + - [x] `generate()` 方法 + - [x] `generateAtIndex()` 方法 + - [x] 实现 `email` 生成器 + - [x] `generate()` 方法 + - [x] `generateAtIndex()` 方法 + - [x] 实现 `chinesePhone` 生成器 + - [x] `generate()` 方法 + - [x] `generateAtIndex()` 方法 + - [x] 实现 `idCard` 生成器 + - [x] `generate()` 方法 + - [x] `generateAtIndex()` 方法 + - [x] 实现 `chineseAddress` 生成器 + - [x] `generate()` 方法 + - [x] `generateAtIndex()` 方法 + - [x] 实现 `age` 生成器 + - [x] `generate()` 方法(支持 realistic/uniform/demographic 策略) + - [x] `generateAtIndex()` 方法 + - [x] 实现 `normalRandom()` 辅助函数 + - [x] 实现 `demographicRandom()` 辅助函数 + +#### 2.3 业务数据生成器 (8个) + +- [x] 2.3.1 创建 `src/lib/generators/business.ts` + - [x] 实现 `orderId` 生成器 + - [x] 实现 `price` 生成器 + - [x] 实现 `generatePsychologicalPrice()` 辅助函数 + - [x] 实现 `generateRealisticPrice()` 辅助函数 + - [x] 实现 `date` 生成器 + - [x] 实现 `formatDate()` 辅助函数 + - [x] 实现 `status` 生成器 + - [x] 实现 `quantity` 生成器 + - [x] 实现 `poissonRandom()` 辅助函数 + - [x] 实现 `rating` 生成器 + - [x] 实现 `skewedRandom()` 辅助函数 + - [x] 实现 `discount` 生成器 + - [x] 实现 `generatePsychologicalDiscount()` 辅助函数 + - [x] 实现 `stock` 生成器 + - [x] 实现 `exponentialRandom()` 辅助函数 + +#### 2.4 技术数据生成器 (3个) + +- [x] 2.4.1 创建 `src/lib/generators/technical.ts` + - [x] 实现 `uuid` 生成器 + - [x] 实现 `ipv4` 生成器 + - [x] 实现 `url` 生成器 + +#### 2.5 基础类型生成器 (4个) + +- [x] 2.5.1 创建 `src/lib/generators/basic.ts` + - [x] 实现 `randomInt` 生成器 + - [x] 实现 `randomFloat` 生成器 + - [x] 实现 `randomString` 生成器 + - [x] 实现 `fromList` 生成器 + +--- + +## 3. Web Worker 数据生成引擎 + +**目标**: 实现后台数据生成,支持进度回调和取消 + +### 任务清单 + +- [x] 3.1 创建 `src/workers/generator.worker.ts` + - [x] 实现 `self.onmessage` 处理器 + - [x] 验证生成器是否存在 + - [x] 实现数据生成循环 + - [x] 实现空值率控制逻辑 + - [x] 实现唯一性约束逻辑 + - [x] 小数据量:随机生成 + 重试(100次上限) + - [x] 大数据量:索引生成策略 + - [x] 实现进度回调(每1000条) + - [x] 实现完成回调(包含统计数据) + - [x] 实现错误回调 + +- [x] 3.2 创建 `src/pages/TestDataGenerator/hooks/useGenerator.ts` + - [x] 实现 Worker 创建和销毁 + - [x] 实现 `generate()` 方法 + - [x] 实现 `terminate()` 方法 + - [x] 管理进度状态 + - [x] 管理生成结果状态 + +--- + +## 4. 规则存储与管理 + +**目标**: 实现规则的持久化存储和管理功能 + +### 任务清单 + +- [x] 4.1 创建 `src/utils/ruleStorage.ts` + - [x] 定义 `STORAGE_KEY` 常量 + - [x] 定义 `MAX_RULES = 20` 常量 + - [x] 实现 `getAll()` 方法 + - [x] 实现 `getById()` 方法 + - [x] 实现 `getCount()` 方法 + - [x] 实现 `isMaxReached()` 方法 + - [x] 实现 `save()` 方法(含数量限制检查) + - [x] 实现 `update()` 方法 + - [x] 实现 `delete()` 方法 + - [x] 实现 `duplicate()` 方法 + - [x] 实现 `recordUse()` 方法 + - [x] 实现 `search()` 方法 + - [x] 实现 `getRecent()` 方法 + - [x] 实现 `export()` 方法 + - [x] 实现 `import()` 方法(含格式验证) + - [x] 实现 `clear()` 方法 + - [x] 实现 `validateRule()` 私有方法 + +--- + +## 5. 数据导出功能 + +**目标**: 实现 JSON 和 CSV 格式的数据导出 + +### 任务清单 + +- [x] 5.1 创建 `src/utils/dataExporter.ts` + - [x] 实现 `toJSON()` 静态方法 + - [x] 实现 `toCSV()` 静态方法 + - [x] 实现 `escapeCSV()` 私有方法 + - [x] 实现 `exportByFormat()` 静态方法 + - [x] 实现 `toMultipleFiles()` 静态方法 + - [x] 实现 `toSingleFile()` 静态方法 + - [x] 实现 `getMimeType()` 私有方法 + - [x] 实现 `download()` 静态方法 + - [x] 实现 `downloadMultiple()` 静态方法 + +--- + +## 6. UI 组件开发 + +**目标**: 实现所有界面组件,遵循视觉规范 + +### 任务清单 + +#### 6.1 页面组件 + +- [x] 6.1.1 创建 `src/pages/TestDataGenerator/index.tsx` 主页面 + - [x] 实现左右分栏布局(60% / 40%) + - [x] 集成所有子组件 + - [x] 管理页面状态 + +#### 6.2 字段管理组件 + +- [x] 6.2.1 创建 `src/pages/TestDataGenerator/components/FieldList.tsx` + - [x] 实现字段列表展示 + - [x] 实现添加字段按钮 + - [x] 实现字段拖拽排序(可选,使用上移/下移按钮) + +- [x] 6.2.2 创建 `src/pages/TestDataGenerator/components/FieldItem.tsx` + - [x] 实现单个字段项展示 + - [x] 实现字段名编辑 + - [x] 实现生成器切换下拉框 + - [x] 实现配置按钮 + - [x] 实现删除按钮 + - [x] 实现上移/下移按钮 + +- [x] 6.2.3 创建 `src/pages/TestDataGenerator/components/FieldEditor.tsx` + - [x] 实现基础配置区(字段名、描述) + - [x] 实现必填/选填切换 + - [x] 实现空值率配置(仅选填时显示) + - [x] 滑块组件 + - [x] 预设按钮(低/中/高) + - [x] 实现唯一性约束开关 + +#### 6.3 生成器相关组件 + +- [x] 6.3.1 创建 `src/pages/TestDataGenerator/components/GeneratorSelector.tsx` + - [x] 实现分类展示(个人信息/业务数据/技术数据/基础类型) + - [x] 实现搜索功能 + - [x] 实现生成器选择 + +- [x] 6.3.2 创建 `src/pages/TestDataGenerator/components/GeneratorConfig.tsx` + - [x] 实现参数配置表单 + - [x] 支持不同参数类型(string/number/boolean/select/array) + - [x] 实时预览更新 + +#### 6.4 数据预览组件 + +- [x] 6.4.1 创建 `src/pages/TestDataGenerator/components/DataPreview.tsx` + - [x] 实现 JSON 格式预览 + - [x] 实现 CSV 格式预览 + - [x] 实现格式切换按钮 + - [x] 实现数据分页 + - [x] 实现复制功能 + +#### 6.5 生成选项组件 + +- [x] 6.5.1 创建 `src/pages/TestDataGenerator/components/GenerateOptions.tsx` + - [x] 实现生成数量配置(预设 + 自定义) + - [x] 实现数据格式选择(JSON/CSV) + - [x] 实现默认空值率配置 + +- [x] 6.5.2 创建 `src/pages/TestDataGenerator/components/GenerateButton.tsx` + - [x] 实现生成按钮 + - [x] 实现加载状态 + - [x] 实现进度条显示 + +#### 6.6 导出组件 + +- [x] 6.6.1 创建 `src/pages/TestDataGenerator/components/ExportPanel.tsx` + - [x] 实现导出选项(复制/下载 JSON/下载 CSV) + - [x] 实现下载逻辑 + +#### 6.7 结果展示组件 + +- [x] 6.7.1 创建 `src/pages/TestDataGenerator/components/ResultPanel.tsx` + - [x] 实现成功状态展示 + - [x] 实现警告状态展示(部分字段失败) + - [x] 实现错误状态展示 + - [x] 实现数据统计展示 + +#### 6.8 规则管理组件 + +- [x] 6.8.1 创建 `src/pages/TestDataGenerator/components/RuleManager.tsx` + - [x] 实现规则列表展示 + - [x] 实现搜索功能 + - [x] 实现加载/编辑/删除/复制按钮 + - [x] 实现导入/导出功能 + - [x] 实现规则数量显示(已保存 X/20 条) + +--- + +## 7. 页面集成与注册 + +**目标**: 将新功能集成到应用中 + +### 任务清单 + +- [x] 7.1 更新路由配置 + - [x] 确保 `RouterProvider` 支持新页面类型 + +- [x] 7.2 更新功能注册 + - [x] 验证 `features.tsx` 配置正确 + - [x] 验证图标显示正常 + +- [x] 7.3 更新仪表盘 + - [x] 在 Dashboard 中显示新工具卡片 + +--- + +## 8. 测试与优化 + +**目标**: 确保功能稳定性和性能 + +### 任务清单 + +#### 8.1 单元测试 + +- [x] 8.1.1 创建生成器测试 + - [x] `src/lib/generators/__tests__/personal.test.ts` + - [x] `src/lib/generators/__tests__/business.test.ts` + - [x] `src/lib/generators/__tests__/technical.test.ts` + - [x] `src/lib/generators/__tests__/basic.test.ts` + +- [x] 8.1.2 创建工具函数测试 + - [x] `src/utils/__tests__/ruleStorage.test.ts` + - [x] `src/utils/__tests__/dataExporter.test.ts` + +#### 8.2 集成测试 + +- [x] 8.2.1 创建页面测试 + - [x] `src/pages/TestDataGenerator/__tests__/TestDataGenerator.test.tsx` + +#### 8.3 性能优化 + +- [x] 8.3.1 验证 Web Worker 性能 + - [x] 测试 10000+ 条数据生成 + - [x] 验证 UI 不阻塞 + +- [x] 8.3.2 验证内存使用 + - [x] 检查大数据量时的内存占用 + - [x] 确保无内存泄漏 + +#### 8.4 代码质量 + +- [x] 8.4.1 代码审查 + - [x] 遵循项目代码规范 + - [x] 遵循视觉规范文档 + +- [x] 8.4.2 文档更新 + - [x] 更新 AGENTS.md(如需要) + +--- + +## 依赖关系 + +``` +1. 类型定义与项目配置 + ↓ +2. 生成器库 + ↓ +3. Web Worker 数据生成引擎 (依赖 2) + ↓ +4. 规则存储与管理 + ↓ +5. 数据导出功能 + ↓ +6. UI 组件开发 (依赖 1, 2, 3, 4, 5) + ↓ +7. 页面集成与注册 (依赖 6) + ↓ +8. 测试与优化 (依赖 7) +``` + +--- + +## 注意事项 + +1. **遵循视觉规范**: 所有 UI 组件必须遵循 `docs/VISUAL_STYLE_GUIDE.md` +2. **使用现有组件**: 优先使用 `src/components/ui/` 中的 shadcn/ui 组件 +3. **TypeScript 严格模式**: 确保类型安全 +4. **错误处理**: 使用 `sonner` 库进行 Toast 提示 +5. **性能考虑**: 大数据量生成必须使用 Web Worker +6. **响应式设计**: 支持桌面端、平板端、移动端 diff --git a/docs/test-data-generator/core-features.md b/docs/test-data-generator/core-features.md new file mode 100644 index 0000000..97b631e --- /dev/null +++ b/docs/test-data-generator/core-features.md @@ -0,0 +1,238 @@ +# 核心功能 + +## 功能概览 + +| 功能模块 | 功能点 | 说明 | 优先级 | +| -------------- | ------------- | ------------------ | ------ | +| **字段配置** | 添加字段 | 可视化添加数据字段 | P0 | +| | 删除字段 | 删除不需要的字段 | P0 | +| | 字段排序 | 拖拽调整字段顺序 | P1 | +| | 字段命名 | 自定义字段名称 | P0 | +| | 必填/选填设置 | 控制字段是否必填 | P0 | +| | 空值率配置 | 选填字段空值概率 | P1 | +| **生成器系统** | 生成器选择 | 从生成器库中选择 | P0 | +| | 参数配置 | 配置生成器参数 | P0 | +| | 实时预览 | 配置时查看生成结果 | P1 | +| **数据生成** | 批量生成 | 支持 10000+ 条数据 | P0 | +| | 唯一性约束 | 保证字段值唯一 | P1 | +| | 组合策略 | 自动选择生成策略 | P1 | +| | 数据预览 | 生成前预览数据 | P1 | +| **数据导出** | JSON 导出 | 导出为 JSON 格式 | P0 | +| | CSV 导出 | 导出为 CSV 格式 | P0 | + +## 功能详情 + +### 1. 字段配置 + +#### 添加字段 + +- 用户点击"添加字段"按钮 +- 弹出生成器选择器 +- 选择生成器后,自动创建新字段 +- 字段默认名称为生成器名称 + +#### 删除字段 + +- 每个字段右侧有删除按钮 +- 点击后弹出确认对话框 +- 确认后删除该字段 + +#### 字段排序 + +- 支持拖拽排序 +- 支持上移/下移按钮 +- 排序后预览数据同步更新 + +#### 字段命名 + +- 字段名称可自定义 +- 支持中英文命名 +- 同一规则内字段名不可重复 + +#### 必填/选填设置 + +- 每个字段可设置为"必填"或"选填" +- **必填字段**: 100% 生成数据 +- **选填字段**: 可配置空值概率(0-100%) +- 默认所有字段为必填 + +#### 空值率配置 + +- 选填字段可单独设置空值率 +- 提供预设值:低(20%)、中(50%)、高(80%) +- 支持滑块精细调节(0-100%) +- 未单独设置的选填字段使用全局默认空值率 + +--- + +### 2. 生成器系统 + +#### 生成器选择 + +- 以分类方式展示生成器 +- 支持搜索生成器 +- 点击选择后添加到字段配置 + +#### 价格生成器(业务数据) + +生成真实的价格数据,支持多种策略: + +| 策略 | 说明 | 适用场景 | +| -------- | ------------------------------ | ------------ | +| 真实分布 | 对数正态分布,模拟真实商品价格 | 通用电商场景 | +| 均匀随机 | 所有价格等概率 | 测试数据 | +| 心理定价 | .99/.98/.95 结尾 | 营销场景 | + +**参数配置**: + +- 最低价/最高价:控制价格区间 +- 小数位数:0(整数)、1、2 位 +- 生成策略:选择分布类型 + +#### 年龄生成器(个人信息) + +生成真实分布的年龄数据: + +| 策略 | 说明 | 适用场景 | +| -------- | ---------------------------- | ------------ | +| 真实分布 | 正态分布,均值 35,标准差 10 | 通用用户数据 | +| 人口比例 | 按中国人口年龄比例分布 | 市场调研数据 | +| 均匀随机 | 所有年龄等概率 | 测试数据 | + +**参数配置**: + +- 最小年龄/最大年龄:控制年龄区间 +- 生成策略:选择分布类型 + +#### 数量生成器(业务数据) + +生成真实分布的数量数据: + +| 策略 | 说明 | 适用场景 | +| -------- | --------------------------- | -------- | +| 真实分布 | 泊松分布,大多数购买 1-3 件 | 零售订单 | +| 批发模式 | 10-100 之间均匀分布 | 批发订单 | +| 均匀随机 | 所有数量等概率 | 测试数据 | + +**参数配置**: + +- 最小值/最大值:控制数量区间 +- 生成策略:选择分布类型 + +#### 评分生成器(业务数据) + +生成真实分布的评分数据: + +| 策略 | 说明 | 适用场景 | +| -------- | ------------------ | -------- | +| 真实分布 | 偏态分布,偏向高分 | 电商平台 | +| 严格评价 | 偏态分布,偏向低分 | 严格评审 | +| 均匀随机 | 所有评分等概率 | 测试数据 | + +**参数配置**: + +- 最低分/最高分:控制评分区间 +- 小数位数:0、1 位 +- 生成策略:选择分布类型 + +#### 折扣生成器(业务数据) + +生成真实分布的折扣数据: + +| 策略 | 说明 | 适用场景 | +| -------- | ------------------------ | -------- | +| 心理定价 | 常见折扣点(8折、9折等) | 电商促销 | +| 清仓模式 | 大折扣区间(3-7折) | 清仓处理 | +| 均匀随机 | 所有折扣等概率 | 测试数据 | + +**参数配置**: + +- 最低折扣/最高折扣:控制折扣区间(0.1-0.9) +- 生成策略:选择分布类型 + +#### 库存生成器(业务数据) + +生成真实分布的库存数据: + +| 策略 | 说明 | 适用场景 | +| -------- | ------------------------ | -------- | +| 真实分布 | 指数分布,大多数库存较少 | 通用商品 | +| 热销商品 | 低库存区间(0-50) | 热销商品 | +| 均匀随机 | 所有库存等概率 | 测试数据 | + +**参数配置**: + +- 最小库存/最大库存:控制库存区间 +- 生成策略:选择分布类型 + +#### 参数配置 + +- 每个生成器有对应的参数配置界面 +- 必填参数标记星号 +- 修改参数后实时预览更新 + +#### 实时预览 + +- 配置参数时,右侧预览区实时更新 +- 显示 5-10 条示例数据 +- 帮助用户确认配置是否正确 + +--- + +### 3. 数据生成 + +#### 批量生成 + +- 支持配置生成数量 +- 使用 Web Worker 后台生成 +- 支持 10000+ 条数据 +- 生成过程中显示进度条 + +#### 唯一性约束 + +- 每个字段可单独设置唯一性约束 +- 启用后,该字段生成的值不会重复 +- 如果无法生成唯一值,使用重试机制 + +#### 组合策略(唯一性生成) + +系统根据数据量自动选择最优生成策略: + +| 数据量 | 策略 | 说明 | +| --------- | --------------- | ---------------------- | +| ≤ 1000 条 | 随机生成 + 重试 | 保证随机性,性能可接受 | +| > 1000 条 | 索引生成 | 高性能,保证唯一性 | + +**实现方式**: + +- 生成器提供两个方法:`generate()` 和 `generateAtIndex()` +- `generate()`: 随机生成,用于小数据量或非唯一性字段 +- `generateAtIndex()`: 索引生成,用于大数据量唯一性字段 +- 系统根据配置自动选择合适的方法 + +**示例**: + +- 手机号字段:`generateAtIndex(params, index)` 使用 index 生成唯一手机号 +- 姓名字段:`generateAtIndex(params, index)` 使用 index 组合姓和名 + +#### 数据预览 + +- 生成前可预览数据 +- 显示前 10 条数据 +- 支持分页查看更多数据 + +--- + +### 4. 数据导出 + +#### JSON 导出 + +- 导出为标准 JSON 格式 +- 支持压缩和格式化两种模式 +- 包含字段名和数据 + +#### CSV 导出 + +- 导出为 CSV 格式 +- 自动处理逗号转义 +- 支持中文编码 (UTF-8 with BOM) diff --git a/docs/test-data-generator/generators.md b/docs/test-data-generator/generators.md new file mode 100644 index 0000000..2b74493 --- /dev/null +++ b/docs/test-data-generator/generators.md @@ -0,0 +1,702 @@ +# 生成器库 + +## 分类概览 + +| 分类 | 生成器数量 | 说明 | +| -------- | ---------- | ------------------------------------------------ | +| 个人信息 | 6 个 | 中文姓名、邮箱、手机号、年龄等 | +| 业务数据 | 8 个 | 订单号、价格、日期、状态、数量、评分、折扣、库存 | +| 技术数据 | 3 个 | UUID、IP地址、URL | +| 基础类型 | 4 个 | 整数、浮点数、字符串等 | +| **总计** | **21 个** | | + +--- + +## 个人信息生成器 + +### 1. 中文姓名 (chineseName) + +**说明**: 生成中文姓名,如张三、李四 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| --------------- | ---------------------------- | ---- | ------------------ | ------------ | +| surnamePool | string \| string[] | 否 | 百家姓前100个 | 姓氏池 | +| givenNameLength | { min: number, max: number } | 否 | { min: 1, max: 2 } | 名字长度范围 | + +**示例**: + +```json +{ + "name": "name", + "generator": "chineseName", + "params": { + "surnamePool": ["张", "王", "李", "赵"], + "givenNameLength": { "min": 1, "max": 2 } + } +} +``` + +**生成结果**: 张伟, 李娜, 王强, 赵敏 + +--- + +### 2. 邮箱地址 (email) + +**说明**: 生成邮箱地址 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| --------- | -------------------- | ---- | ------------------------------------------------------------ | ---------- | +| domains | string[] | 否 | ['qq.com', '163.com', '126.com', 'gmail.com', 'outlook.com'] | 域名列表 | +| nameStyle | 'pinyin' \| 'random' | 否 | 'pinyin' | 用户名风格 | + +**示例**: + +```json +{ + "name": "email", + "generator": "email", + "params": { + "domains": ["qq.com", "163.com"], + "nameStyle": "pinyin" + } +} +``` + +**生成结果**: zhangwei@qq.com, lina@163.com + +--- + +### 3. 中国手机号 (chinesePhone) + +**说明**: 生成 11 位中国手机号 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| ------ | -------- | ---- | -------------- | ---------- | +| prefix | string[] | 否 | 常见手机号前缀 | 手机号前缀 | + +**示例**: + +```json +{ + "name": "phone", + "generator": "chinesePhone", + "params": { + "prefix": ["138", "139", "150", "151"] + } +} +``` + +**生成结果**: 13812345678, 15087654321 + +--- + +### 4. 身份证号 (idCard) + +**说明**: 生成 18 位中国身份证号 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| ------ | ------ | ---- | ------ | ------ | +| region | string | 否 | 随机 | 地区码 | + +**示例**: + +```json +{ + "name": "idCard", + "generator": "idCard", + "params": { + "region": "110101" + } +} +``` + +**生成结果**: 110101199001011234 + +--- + +### 5. 中文地址 (chineseAddress) + +**说明**: 生成中国地址 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| ------ | ------------------------------ | ---- | ------ | -------- | +| level | 'full' \| 'province' \| 'city' | 否 | 'full' | 详细程度 | + +**示例**: + +```json +{ + "name": "address", + "generator": "chineseAddress", + "params": { + "level": "full" + } +} +``` + +**生成结果**: 北京市朝阳区建国路88号 + +--- + +### 6. 年龄 (age) + +**说明**: 生成真实分布的年龄数据 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| -------- | ----------------------------------------- | ---- | ----------- | -------- | +| min | number | 否 | 1 | 最小年龄 | +| max | number | 否 | 100 | 最大年龄 | +| strategy | 'realistic' \| 'demographic' \| 'uniform' | 否 | 'realistic' | 生成策略 | + +**策略说明**: + +| 策略 | 说明 | 适用场景 | +| ----------- | ---------------------------- | ------------ | +| realistic | 正态分布,均值 35,标准差 10 | 通用用户数据 | +| demographic | 按中国人口年龄比例分布 | 市场调研数据 | +| uniform | 均匀随机 | 测试数据 | + +**示例**: + +```json +{ + "name": "age", + "generator": "age", + "params": { + "min": 18, + "max": 65, + "strategy": "realistic" + } +} +``` + +**生成结果**: 28, 42, 35, 51, 23 + +--- + +## 业务数据生成器 + +### 1. 订单号 (orderId) + +**说明**: 生成订单号 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| ------------ | ------- | ---- | ------ | ------------ | +| prefix | string | 否 | 'ORD' | 前缀 | +| dateIncluded | boolean | 否 | true | 是否包含日期 | + +**示例**: + +```json +{ + "name": "orderId", + "generator": "orderId", + "params": { + "prefix": "ORD", + "dateIncluded": true + } +} +``` + +**生成结果**: ORD20240115001, ORD20240115002 + +--- + +### 2. 价格 (price) + +**说明**: 生成真实的价格数据,支持多种分布策略 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| -------- | ------------------------------------------- | ---- | ----------- | -------- | +| min | number | 是 | 1 | 最低价 | +| max | number | 是 | 9999 | 最高价 | +| decimals | 0 \| 1 \| 2 | 否 | 2 | 小数位数 | +| strategy | 'realistic' \| 'uniform' \| 'psychological' | 否 | 'realistic' | 生成策略 | + +**生成策略说明**: + +| 策略 | 说明 | 适用场景 | +| ------------- | ------------------------------ | ------------ | +| realistic | 对数正态分布,模拟真实商品价格 | 通用电商场景 | +| uniform | 均匀随机,所有价格等概率 | 测试数据 | +| psychological | 心理定价,.99/.98/.95 结尾 | 营销场景 | + +**示例**: + +```json +{ + "name": "price", + "generator": "price", + "params": { + "min": 10, + "max": 5000, + "decimals": 2, + "strategy": "realistic" + } +} +``` + +**生成结果**: + +- 真实分布: 299.00, 1599.50, 49.99, 1299.00 +- 心理定价: 299.99, 1599.98, 49.95, 1299.99 +- 均匀随机: 1234.56, 5678.90, 890.12 + +--- + +### 3. 日期时间 (date) + +**说明**: 生成日期时间 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| ------ | ------ | ---- | --------------------- | -------- | +| format | string | 否 | 'YYYY-MM-DD HH:mm:ss' | 日期格式 | +| min | string | 否 | '2020-01-01' | 最早日期 | +| max | string | 否 | 当前日期 | 最晚日期 | + +**示例**: + +```json +{ + "name": "createdAt", + "generator": "date", + "params": { + "format": "YYYY-MM-DD HH:mm:ss", + "min": "2024-01-01" + } +} +``` + +**生成结果**: 2024-01-15 14:30:22 + +--- + +### 4. 状态 (status) + +**说明**: 从选项中随机选择 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| ------- | -------- | ---- | -------- | ------------ | +| options | string[] | 是 | - | 状态选项列表 | +| weights | number[] | 否 | 均匀分布 | 各选项权重 | + +**示例**: + +```json +{ + "name": "status", + "generator": "status", + "params": { + "options": ["pending", "paid", "shipped", "completed"], + "weights": [30, 40, 20, 10] + } +} +``` + +**生成结果**: pending, paid, shipped, completed + +--- + +### 5. 数量 (quantity) + +**说明**: 生成真实分布的数量数据 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| -------- | ---------------------------------- | ---- | ----------- | -------- | +| min | number | 否 | 1 | 最小值 | +| max | number | 否 | 100 | 最大值 | +| strategy | 'realistic' \| 'bulk' \| 'uniform' | 否 | 'realistic' | 生成策略 | + +**策略说明**: + +| 策略 | 说明 | 适用场景 | +| --------- | --------------------------- | -------- | +| realistic | 泊松分布,大多数购买 1-3 件 | 零售订单 | +| bulk | 10-100 之间均匀分布 | 批发订单 | +| uniform | 均匀随机 | 测试数据 | + +**示例**: + +```json +{ + "name": "quantity", + "generator": "quantity", + "params": { + "min": 1, + "max": 50, + "strategy": "realistic" + } +} +``` + +**生成结果**: 2, 1, 5, 3, 1 + +--- + +### 6. 评分 (rating) + +**说明**: 生成真实分布的评分数据 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| -------- | ------------------------------------ | ---- | ----------- | -------- | +| min | number | 否 | 1 | 最低分 | +| max | number | 否 | 5 | 最高分 | +| decimals | number | 否 | 1 | 小数位数 | +| strategy | 'realistic' \| 'strict' \| 'uniform' | 否 | 'realistic' | 生成策略 | + +**策略说明**: + +| 策略 | 说明 | 适用场景 | +| --------- | ------------------ | -------- | +| realistic | 偏态分布,偏向高分 | 电商平台 | +| strict | 偏态分布,偏向低分 | 严格评审 | +| uniform | 均匀随机 | 测试数据 | + +**示例**: + +```json +{ + "name": "rating", + "generator": "rating", + "params": { + "min": 1, + "max": 5, + "decimals": 1, + "strategy": "realistic" + } +} +``` + +**生成结果**: 4.5, 4.8, 3.9, 5.0, 4.2 + +--- + +### 7. 折扣 (discount) + +**说明**: 生成真实分布的折扣数据 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| -------- | ------------------------------------------- | ---- | --------------- | -------- | +| min | number | 否 | 0.1 | 最低折扣 | +| max | number | 否 | 0.9 | 最高折扣 | +| strategy | 'psychological' \| 'clearance' \| 'uniform' | 否 | 'psychological' | 生成策略 | + +**策略说明**: + +| 策略 | 说明 | 适用场景 | +| ------------- | ------------------------ | -------- | +| psychological | 常见折扣点(8折、9折等) | 电商促销 | +| clearance | 大折扣区间(3-7折) | 清仓处理 | +| uniform | 均匀随机 | 测试数据 | + +**示例**: + +```json +{ + "name": "discount", + "generator": "discount", + "params": { + "min": 0.1, + "max": 0.9, + "strategy": "psychological" + } +} +``` + +**生成结果**: 0.80, 0.90, 0.85, 0.95, 0.70 + +--- + +### 8. 库存 (stock) + +**说明**: 生成真实分布的库存数据 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| -------- | --------------------------------- | ---- | ----------- | -------- | +| min | number | 否 | 0 | 最小库存 | +| max | number | 否 | 1000 | 最大库存 | +| strategy | 'realistic' \| 'hot' \| 'uniform' | 否 | 'realistic' | 生成策略 | + +**策略说明**: + +| 策略 | 说明 | 适用场景 | +| --------- | ------------------------ | -------- | +| realistic | 指数分布,大多数库存较少 | 通用商品 | +| hot | 低库存区间(0-50) | 热销商品 | +| uniform | 均匀随机 | 测试数据 | + +**示例**: + +```json +{ + "name": "stock", + "generator": "stock", + "params": { + "min": 0, + "max": 500, + "strategy": "realistic" + } +} +``` + +**生成结果**: 23, 8, 156, 45, 12 + +--- + +## 技术数据生成器 + +### 1. UUID (uuid) + +**说明**: 生成 UUID + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| ------- | ------------ | ---- | ------ | --------- | +| version | 'v4' \| 'v1' | 否 | 'v4' | UUID 版本 | + +**示例**: + +```json +{ + "name": "id", + "generator": "uuid", + "params": { + "version": "v4" + } +} +``` + +**生成结果**: 550e8400-e29b-41d4-a716-446655440000 + +--- + +### 2. IPv4 地址 (ipv4) + +**说明**: 生成 IPv4 地址 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| ------- | ------- | ---- | ------ | ------------ | +| private | boolean | 否 | false | 是否私有地址 | + +**示例**: + +```json +{ + "name": "ip", + "generator": "ipv4", + "params": { + "private": true + } +} +``` + +**生成结果**: 192.168.1.1, 10.0.0.1 + +--- + +### 3. URL (url) + +**说明**: 生成 URL + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| -------- | ----------------- | ---- | ------------- | ---- | +| protocol | 'http' \| 'https' | 否 | 'https' | 协议 | +| domain | string | 否 | 'example.com' | 域名 | + +**示例**: + +```json +{ + "name": "website", + "generator": "url", + "params": { + "protocol": "https", + "domain": "shop.com" + } +} +``` + +**生成结果**: https://shop.com/product/123 + +--- + +## 基础类型生成器 + +### 1. 随机整数 (randomInt) + +**说明**: 生成指定范围的随机整数 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| ------ | ------ | ---- | ------ | ------ | +| min | number | 是 | - | 最小值 | +| max | number | 是 | - | 最大值 | + +**示例**: + +```json +{ + "name": "age", + "generator": "randomInt", + "params": { + "min": 18, + "max": 60 + } +} +``` + +**生成结果**: 25, 42, 18, 60 + +--- + +### 2. 随机浮点数 (randomFloat) + +**说明**: 生成指定范围的随机浮点数 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| -------- | ------ | ---- | ------ | -------- | +| min | number | 是 | - | 最小值 | +| max | number | 是 | - | 最大值 | +| decimals | number | 否 | 2 | 小数位数 | + +**示例**: + +```json +{ + "name": "score", + "generator": "randomFloat", + "params": { + "min": 0, + "max": 100, + "decimals": 1 + } +} +``` + +**生成结果**: 85.3, 42.7, 99.9 + +--- + +### 3. 随机字符串 (randomString) + +**说明**: 生成指定长度的随机字符串 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| ------- | ------ | ---- | -------------- | ---------- | +| length | number | 是 | - | 字符串长度 | +| charset | string | 否 | 'alphanumeric' | 字符集 | + +**示例**: + +```json +{ + "name": "code", + "generator": "randomString", + "params": { + "length": 8, + "charset": "alphanumeric" + } +} +``` + +**生成结果**: aB3kL9mN, xY7zW2pQ + +--- + +### 4. 从列表选择 (fromList) + +**说明**: 从选项列表中随机选择 + +**参数**: + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | +| ------- | -------- | ---- | -------- | -------- | +| options | any[] | 是 | - | 选项列表 | +| weights | number[] | 否 | 均匀分布 | 权重 | + +**示例**: + +```json +{ + "name": "category", + "generator": "fromList", + "params": { + "options": ["电子产品", "服装", "食品", "图书"], + "weights": [40, 30, 20, 10] + } +} +``` + +**生成结果**: 电子产品, 服装, 食品 + +--- + +## 生成器选择界面 + +### 界面设计 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 选择生成器 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 搜索: [搜索生成器... ] │ +│ │ +│ 分类筛选 │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ 全部 │ │ 个人信息 │ │ 业务数据 │ │ 技术数据 │ │ 基础类型 │ │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ +│ │ +│ 生成器列表 │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 个人信息 │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ 中文姓名 生成中文姓名,如张三 │ │ │ +│ │ │ 邮箱地址 生成邮箱地址 │ │ │ +│ │ │ 中国手机号 生成11位手机号 │ │ │ +│ │ │ 身份证号 生成18位身份证号 │ │ │ +│ │ │ 中文地址 生成中国地址 │ │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` diff --git a/docs/test-data-generator/rule-management.md b/docs/test-data-generator/rule-management.md new file mode 100644 index 0000000..91a4dc1 --- /dev/null +++ b/docs/test-data-generator/rule-management.md @@ -0,0 +1,481 @@ +# 规则管理 + +## 功能概览 + +| 功能 | 说明 | 优先级 | +| -------- | -------------------- | ------ | +| 保存规则 | 将当前配置保存为模板 | P0 | +| 规则列表 | 查看所有已保存的规则 | P0 | +| 规则搜索 | 按关键词搜索规则 | P1 | +| 规则编辑 | 修改已保存的规则 | P0 | +| 规则删除 | 删除不需要的规则 | P0 | +| 规则加载 | 一键加载规则配置 | P0 | +| 规则复制 | 复制规则配置 | P1 | +| 规则导出 | 导出为 JSON 文件 | P1 | +| 规则导入 | 从 JSON 文件导入 | P1 | + +## 规则数量限制 + +- **最大规则数量**: 20 条 +- **超出限制**: 当规则数量达到 20 条时,保存按钮置灰,提示"已达到最大规则数量" +- **解决方式**: 用户需要删除已有规则后才能保存新规则 + +--- + +## 数据结构 + +### 规则模板 + +```typescript +interface DataRule { + id: string; // 唯一标识 + name: string; // 规则名称 + description?: string; // 规则描述 + fields: FieldConfig[]; // 字段配置 + options: { + total: number; // 生成数量 + format: 'json' | 'csv'; + defaultEmptyRate: number; // 默认空值率(0-100) + }; + metadata: { + createdAt: number; // 创建时间 + updatedAt: number; // 更新时间 + lastUsedAt?: number; // 最后使用时间 + useCount: number; // 使用次数 + }; +} +``` + +### 字段配置 + +```typescript +interface FieldConfig { + id: string; // 字段唯一标识 + name: string; // 字段名 + generator: string; // 生成器名称 + params: Record; // 生成器参数 + unique: boolean; // 唯一性约束 + required: boolean; // 是否必填 + emptyRate?: number; // 选填字段的空值概率(0-100) +} +``` + +--- + +## 功能详情 + +### 1. 保存规则 + +**触发方式**: 用户点击"保存规则"按钮 + +**界面设计**: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 保存规则 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 规则信息 │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 规则名称: │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ [电商用户数据 - 测试用 ]│ │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ 规则描述 (可选): │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ [用于测试用户注册功能,包含姓名、邮箱、手机号 ]│ │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 规则预览 │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 字段数量: 4 个 │ │ +│ │ • name (中文姓名) │ │ +│ │ • email (邮箱地址) │ │ +│ │ • phone (手机号) │ │ +│ │ • age (年龄) │ │ +│ │ │ │ +│ │ 生成数量: 100 条 │ │ +│ │ 数据格式: JSON │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ [保存] [取消] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**保存逻辑**: + +1. 验证规则名称不为空 +2. 检查规则数量是否达到上限(20 条) +3. 如果达到上限,显示提示"已达到最大规则数量" +4. 生成唯一 ID +5. 设置创建时间和更新时间 +6. 初始化使用次数为 0 +7. 保存到 localStorage + +--- + +### 2. 规则列表 + +**界面设计**: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 规则管理 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 搜索: [搜索规则名称... ] │ +│ │ +│ 规则列表 (已保存 15/20 条) │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ 电商用户数据 - 测试用 │ │ │ +│ │ │ ─────────────────────────────────────────────────────── │ │ │ +│ │ │ 用于测试用户注册功能,包含姓名、邮箱、手机号 │ │ │ +│ │ │ 4 个字段 | 100 条 | JSON │ │ │ +│ │ │ 2024-01-15 14:30 创建 | 2024-01-20 10:15 最后使用 │ │ │ +│ │ │ │ │ │ +│ │ │ [加载] [复制] [导出] [编辑] [删除] │ │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ [导入规则] [导出全部] [清空全部] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**列表项信息**: + +- 规则名称 +- 规则描述 +- 字段数量、生成数量、数据格式 +- 创建时间、最后使用时间 +- 操作按钮 + +--- + +### 3. 规则搜索 + +**搜索逻辑**: + +- 支持按规则名称搜索 +- 支持按规则描述搜索 +- 搜索为模糊匹配,不区分大小写 + +**实现方式**: + +```typescript +search(keyword: string): DataRule[] { + const rules = this.getAll(); + const lowerKeyword = keyword.toLowerCase(); + + return rules.filter(r => + r.name.toLowerCase().includes(lowerKeyword) || + r.description?.toLowerCase().includes(lowerKeyword) + ); +} +``` + +--- + +### 4. 规则编辑 + +**界面设计**: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 编辑规则 - 电商用户数据 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 基本信息 │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 规则名称: [电商用户数据 - 测试用 ]│ │ +│ │ 规则描述: [用于测试用户注册功能,包含姓名、邮箱、手机号 ]│ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 字段配置 (可直接编辑) │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 1. name [中文姓名 ▼] [配置] [删除] [上移][下移] │ │ +│ │ 2. email [邮箱地址 ▼] [配置] [删除] [上移][下移] │ │ +│ │ 3. phone [手机号 ▼] [配置] [删除] [上移][下移] │ │ +│ │ 4. age [年龄 ▼] [配置] [删除] [上移][下移] │ │ +│ │ │ │ +│ │ [+ 添加字段] │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 生成选项 │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 数量: [100] 格式: [JSON ▼] │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ [保存修改] [取消] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**编辑逻辑**: + +1. 加载原规则配置到编辑器 +2. 用户修改配置 +3. 点击保存时更新规则 +4. 更新 metadata.updatedAt +5. 保存到 localStorage + +--- + +### 5. 规则删除 + +**删除流程**: + +1. 点击删除按钮 +2. 弹出确认对话框 +3. 确认后删除规则 +4. 刷新规则列表 + +**确认对话框**: + +``` +┌─────────────────────────────────────────┐ +│ 确认删除 │ +├─────────────────────────────────────────┤ +│ │ +│ 确定要删除规则 "电商用户数据" 吗? │ +│ │ +│ 此操作不可撤销。 │ +│ │ +│ [取消] [删除] │ +│ │ +└─────────────────────────────────────────┘ +``` + +--- + +### 6. 规则加载 + +**加载流程**: + +1. 点击"加载"按钮 +2. 将规则配置应用到当前编辑器 +3. 更新预览数据 +4. 记录使用时间和次数 + +**实现方式**: + +```typescript +loadRule(ruleId: string): void { + const rule = this.storage.getById(ruleId); + + // 应用规则配置 + this.setFields(rule.fields); + this.setOptions(rule.options); + + // 记录使用 + this.storage.recordUse(ruleId); + + // 更新预览 + this.updatePreview(); +} +``` + +--- + +### 7. 规则复制 + +**复制逻辑**: + +1. 点击"复制"按钮 +2. 创建规则的副本 +3. 名称添加"(副本)"后缀 +4. 生成新的 ID +5. 保存为新规则 + +--- + +### 8. 规则导出 + +**导出界面**: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 导出规则 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 选择要导出的规则: │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ ☑ 电商用户数据 - 测试用 (4个字段, 100条) │ │ +│ │ ☑ 电商订单数据 - 测试用 (6个字段, 500条) │ │ +│ │ ☐ 用户登录数据 - 测试用 (3个字段, 50条) │ │ +│ │ │ │ +│ │ [全选] [全不选] │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 导出格式: │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ ○ 导出为单个 JSON 文件 (所有规则合并) │ │ +│ │ ● 导出为多个 JSON 文件 (每个规则一个文件) │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ [导出] [取消] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**导出格式**: + +```json +{ + "version": "1.0", + "exportedAt": "2024-01-20T10:15:45.000Z", + "rules": [ + { + "id": "rule_123456", + "name": "电商用户数据 - 测试用", + "description": "用于测试用户注册功能", + "fields": [...], + "options": {...}, + "metadata": {...} + } + ] +} +``` + +--- + +### 9. 规则导入 + +**导入界面**: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 导入规则 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 选择导入方式: │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ○ 从文件导入 │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ 拖拽文件到这里,或 [点击选择文件] │ │ │ +│ │ │ 支持格式: .json │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ○ 从剪贴板粘贴 │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ 粘贴 JSON 规则内容... │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 预览导入内容: │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 格式验证通过 │ │ +│ │ 规则数量: 2 个 │ │ +│ │ • 电商用户数据 (4个字段) │ │ +│ │ • 电商订单数据 (6个字段) │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ [导入] [取消] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**导入逻辑**: + +1. 解析 JSON 文件 +2. 验证格式是否正确 +3. 预览导入内容 +4. 确认后逐条导入 +5. 生成新 ID 避免冲突 +6. 处理导入结果(成功/失败数量) + +--- + +## 存储机制 + +### 本地存储 + +使用 localStorage 存储规则数据: + +```typescript +class RuleStorage { + private readonly STORAGE_KEY = 'testDataGenerator_rules'; + + // 获取所有规则 + getAll(): DataRule[] { + const data = localStorage.getItem(this.STORAGE_KEY); + return data ? JSON.parse(data) : []; + } + + // 保存规则 + save(rule: DataRule): { success: boolean; message?: string } { + const rules = this.getAll(); + + // 规则数量限制:最多 20 条 + const MAX_RULES = 20; + if (rules.length >= MAX_RULES) { + return { + success: false, + message: `已达到最大规则数量(${MAX_RULES}条),请删除一些规则后再保存`, + }; + } + + rules.push(rule); + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(rules)); + return { success: true }; + } + + // 更新规则 + update(id: string, updates: Partial): void { + const rules = this.getAll(); + const index = rules.findIndex((r) => r.id === id); + if (index !== -1) { + rules[index] = { ...rules[index], ...updates }; + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(rules)); + } + } + + // 删除规则 + delete(id: string): void { + const rules = this.getAll(); + const filtered = rules.filter((r) => r.id !== id); + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered)); + } + + // 记录使用 + recordUse(id: string): void { + const rules = this.getAll(); + const rule = rules.find((r) => r.id === id); + if (rule) { + rule.metadata.lastUsedAt = Date.now(); + rule.metadata.useCount++; + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(rules)); + } + } +} +``` + +### 存储限制 + +- **规则数量限制**: 最多 20 条 +- localStorage 容量: 5-10MB(浏览器限制) +- 单个规则约 1-5KB +- 预计可存储 1000-5000 个规则(但受数量限制,最多 20 条) + +--- + +## 功能亮点 + +| 亮点 | 说明 | +| -------- | ------------------------------ | +| 本地存储 | 数据保存在浏览器本地,无需后端 | +| 快速复用 | 一键加载已保存的规则 | +| 导入导出 | 支持 JSON 文件导入导出 | +| 使用统计 | 记录使用次数和时间 | +| 搜索筛选 | 支持关键词搜索 | diff --git a/docs/test-data-generator/technical-implementation.md b/docs/test-data-generator/technical-implementation.md new file mode 100644 index 0000000..2531468 --- /dev/null +++ b/docs/test-data-generator/technical-implementation.md @@ -0,0 +1,1909 @@ +# 技术实现 + +## 技术栈 + +| 技术 | 用途 | 版本 | +| ------------ | -------- | ---- | +| React | UI 框架 | 19.x | +| TypeScript | 类型系统 | 5.x | +| Tailwind CSS | 样式 | 3.x | +| Web Worker | 后台生成 | - | +| localStorage | 本地存储 | - | + +--- + +## 项目结构 + +``` +src/ +├── pages/ +│ └── TestDataGenerator/ +│ ├── index.tsx # 主页面 +│ ├── components/ # 页面专属组件 +│ │ ├── FieldList.tsx # 字段列表 +│ │ ├── FieldItem.tsx # 单个字段项 +│ │ ├── FieldEditor.tsx # 字段编辑器 +│ │ ├── GeneratorSelector.tsx # 生成器选择器 +│ │ ├── GeneratorConfig.tsx # 参数配置 +│ │ ├── DataPreview.tsx # 数据预览 +│ │ ├── GenerateOptions.tsx # 生成选项 +│ │ ├── GenerateButton.tsx # 生成按钮 +│ │ ├── ExportPanel.tsx # 导出面板 +│ │ └── RuleManager.tsx # 规则管理 +│ ├── hooks/ # 页面专属钩子 +│ │ └── useGenerator.ts # 数据生成钩子 +│ └── __tests__/ # 测试文件 +│ └── TestDataGenerator.test.tsx +├── lib/ +│ └── generators/ # 生成器库(独立模块) +│ ├── index.ts # 生成器入口 +│ ├── types.ts # 生成器类型定义 +│ ├── personal.ts # 个人信息生成器 +│ ├── business.ts # 业务数据生成器 +│ ├── technical.ts # 技术数据生成器 +│ └── basic.ts # 基础类型生成器 +├── workers/ +│ └── generator.worker.ts # Web Worker +├── types/ +│ └── testDataGenerator.ts # 类型定义 +└── utils/ + ├── ruleStorage.ts # 规则存储 + ├── dataExporter.ts # 数据导出 + └── validator.ts # 规则校验 +``` + +### 目录职责说明 + +| 目录 | 职责 | 说明 | +| ------------------------------------- | ---------- | -------------------------- | +| `pages/TestDataGenerator/` | 页面模块 | 包含页面组件和页面专属逻辑 | +| `pages/TestDataGenerator/components/` | 页面组件 | 仅属于该页面的 UI 组件 | +| `pages/TestDataGenerator/hooks/` | 页面钩子 | 仅属于该页面的自定义钩子 | +| `lib/generators/` | 生成器库 | 独立的生成器模块,可复用 | +| `workers/` | Web Worker | 后台任务处理 | +| `types/` | 类型定义 | 全局共享的类型定义 | +| `utils/` | 工具函数 | 全局共享的工具函数 | + +--- + +## 类型定义 + +### 核心类型 + +```typescript +// src/types/testDataGenerator.ts + +// 字段配置 +export interface FieldConfig { + id: string; + name: string; + generator: string; + params: Record; + unique: boolean; + required: boolean; // 是否必填 + emptyRate?: number; // 选填字段的空值概率(0-100),仅当 required=false 时生效 +} + +// 规则配置 +export interface DataRule { + id: string; + name: string; + description?: string; + fields: FieldConfig[]; + options: { + total: number; + format: 'json' | 'csv'; + defaultEmptyRate: number; // 默认空值率(0-100),用于未设置 emptyRate 的选填字段 + }; + metadata: { + createdAt: number; + updatedAt: number; + lastUsedAt?: number; + useCount: number; + }; +} + +// 生成器定义 +export interface GeneratorDefinition { + name: string; + label: string; + description: string; + category: 'personal' | 'business' | 'technical' | 'basic'; + params: GeneratorParam[]; + // 普通生成方法 + generate: (params: Record) => any; + // 索引生成方法(可选,用于大数据量唯一性生成) + generateAtIndex?: (params: Record, index: number) => any; +} + +// 生成器参数 +export interface GeneratorParam { + name: string; + label: string; + type: 'string' | 'number' | 'boolean' | 'select' | 'array'; + required: boolean; + default?: any; + options?: { label: string; value: any }[]; +} + +// 生成结果 +export interface GenerateResult { + success: boolean; + data: any[]; + errors?: string[]; + stats: { + total: number; + generated: number; + failed: number; + failedFields?: string[]; // 生成失败的字段名列表 + duration: number; + }; +} +``` + +--- + +## 核心模块 + +### 1. 生成器系统 + +```typescript +// src/lib/generators/index.ts +import { personalGenerators } from './personal'; +import { businessGenerators } from './business'; +import { technicalGenerators } from './technical'; +import { basicGenerators } from './basic'; + +export const generators = { + ...personalGenerators, + ...businessGenerators, + ...technicalGenerators, + ...basicGenerators, +}; + +export const generatorCategories = [ + { + key: 'personal', + label: '个人信息', + generators: ['chineseName', 'email', 'chinesePhone', 'idCard', 'chineseAddress', 'age'], + }, + { + key: 'business', + label: '业务数据', + generators: ['orderId', 'price', 'date', 'status', 'quantity', 'rating', 'discount', 'stock'], + }, + { + key: 'technical', + label: '技术数据', + generators: ['uuid', 'ipv4', 'url'], + }, + { + key: 'basic', + label: '基础类型', + generators: ['randomInt', 'randomFloat', 'randomString', 'fromList'], + }, +]; +``` + +### 2. 生成器实现 + +```typescript +// src/lib/generators/personal.ts +export const personalGenerators = { + chineseName: { + name: 'chineseName', + label: '中文姓名', + description: '生成中文姓名,如张三、李四', + params: [ + { + name: 'surnamePool', + label: '姓氏池', + type: 'string', + required: false, + default: '百家姓前100', + }, + { + name: 'givenNameLength', + label: '名字长度', + type: 'object', + required: false, + default: { min: 1, max: 2 }, + }, + ], + generate: (params: any) => { + const surnames = + '王李张刘陈杨赵黄周吴徐孙胡朱高林何郭马罗梁宋郑谢韩唐冯于董萧程曹袁邓许傅沈曾彭吕苏卢蒋蔡贾丁魏薛叶阎余潘杜戴夏钟汪田任姜范方石姚谭廖邹熊金陆郝孔白崔康毛邱秦江史顾侯邵孟龙万段雷钱汤尹黎易常武乔贺赖龚文'; + const givenNames = + '伟芳娜秀英敏静丽强磊洋勇艳杰娟涛超明华雪飞平刚慧建华玲桂英旭峰辉志强建平婷欣怡梦琪雅琴晓红'; + + const surname = surnames[Math.floor(Math.random() * surnames.length)]; + const { min, max } = params.givenNameLength || { min: 1, max: 2 }; + const length = Math.floor(Math.random() * (max - min + 1)) + min; + + let givenName = ''; + for (let i = 0; i < length; i++) { + givenName += givenNames[Math.floor(Math.random() * givenNames.length)]; + } + + return surname + givenName; + }, + // 索引生成(唯一性保证) + generateAtIndex: (params: any, index: number) => { + const surnames = + '王李张刘陈杨赵黄周吴徐孙胡朱高林何郭马罗梁宋郑谢韩唐冯于董萧程曹袁邓许傅沈曾彭吕苏卢蒋蔡贾丁魏薛叶阎余潘杜戴夏钟汪田任姜范方石姚谭廖邹熊金陆郝孔白崔康毛邱秦江史顾侯邵孟龙万段雷钱汤尹黎易常武乔贺赖龚文'; + const givenNames = + '伟芳娜秀英敏静丽强磊洋勇艳杰娟涛超明华雪飞平刚慧建华玲桂英旭峰辉志强建平婷欣怡梦琪雅琴晓红'; + + const surname = surnames[index % surnames.length]; + const givenName = givenNames[index % givenNames.length]; + + return surname + givenName; + }, + }, + + email: { + name: 'email', + label: '邮箱地址', + description: '生成邮箱地址', + params: [ + { + name: 'domains', + label: '域名列表', + type: 'array', + required: false, + default: ['qq.com', '163.com', '126.com', 'gmail.com', 'outlook.com'], + }, + ], + generate: (params: any) => { + const domains = params.domains || ['qq.com', '163.com']; + const domain = domains[Math.floor(Math.random() * domains.length)]; + + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let localPart = ''; + const length = 6 + Math.floor(Math.random() * 6); + + for (let i = 0; i < length; i++) { + localPart += chars[Math.floor(Math.random() * chars.length)]; + } + + return `${localPart}@${domain}`; + }, + // 索引生成(唯一性保证) + generateAtIndex: (params: any, index: number) => { + const domains = params.domains || ['qq.com', '163.com']; + const domain = domains[index % domains.length]; + + // 使用索引生成唯一用户名 + const localPart = `user${String(index).padStart(6, '0')}`; + + return `${localPart}@${domain}`; + }, + }, + + chinesePhone: { + name: 'chinesePhone', + label: '中国手机号', + description: '生成11位中国手机号', + params: [ + { + name: 'prefix', + label: '手机号前缀', + type: 'array', + required: false, + default: [ + '130', + '131', + '132', + '133', + '134', + '135', + '136', + '137', + '138', + '139', + '150', + '151', + '152', + '153', + '155', + '156', + '157', + '158', + '159', + '180', + '181', + '182', + '183', + '184', + '185', + '186', + '187', + '188', + '189', + ], + }, + ], + // 普通生成(随机) + generate: (params: any) => { + const prefixes = params.prefix || ['138', '139', '150', '151']; + const prefix = prefixes[Math.floor(Math.random() * prefixes.length)]; + + let suffix = ''; + for (let i = 0; i < 8; i++) { + suffix += Math.floor(Math.random() * 10); + } + + return prefix + suffix; + }, + // 索引生成(唯一性保证) + generateAtIndex: (params: any, index: number) => { + const prefixes = params.prefix || ['138', '139', '150', '151']; + const prefix = prefixes[index % prefixes.length]; + + // 使用索引生成唯一后缀 + const suffix = String(10000000 + index).slice(-8); + + return prefix + suffix; + }, + }, + + age: { + name: 'age', + label: '年龄', + description: '生成真实分布的年龄', + params: [ + { + name: 'min', + label: '最小年龄', + type: 'number', + required: false, + default: 1, + }, + { + name: 'max', + label: '最大年龄', + type: 'number', + required: false, + default: 100, + }, + { + name: 'strategy', + label: '生成策略', + type: 'select', + required: false, + default: 'realistic', + options: [ + { label: '真实分布(推荐)', value: 'realistic' }, + { label: '均匀随机', value: 'uniform' }, + { label: '人口比例', value: 'demographic' }, + ], + }, + ], + generate: (params: any) => { + const { min = 1, max = 100, strategy = 'realistic' } = params; + let value: number; + + switch (strategy) { + case 'realistic': + // 正态分布:均值 35,标准差 10 + value = normalRandom(35, 10); + break; + case 'demographic': + // 人口比例分布:年轻人多,老年人少 + value = demographicRandom(min, max); + break; + default: + // 均匀分布 + value = Math.random() * (max - min) + min; + } + + return Math.round(Math.max(min, Math.min(max, value))); + }, + generateAtIndex: (params: any, index: number) => { + const { min = 1, max = 100 } = params; + const step = (max - min) / 10000; + return Math.round(min + step * index); + }, + }, +}; +``` + +```typescript +// src/lib/generators/business.ts +export const businessGenerators = { + price: { + name: 'price', + label: '价格', + description: '生成真实的价格数据', + params: [ + { + name: 'min', + label: '最低价', + type: 'number', + required: true, + default: 1, + }, + { + name: 'max', + label: '最高价', + type: 'number', + required: true, + default: 9999, + }, + { + name: 'decimals', + label: '小数位数', + type: 'select', + required: false, + default: 2, + options: [ + { label: '0 位(整数)', value: 0 }, + { label: '1 位', value: 1 }, + { label: '2 位(常见)', value: 2 }, + ], + }, + { + name: 'strategy', + label: '生成策略', + type: 'select', + required: false, + default: 'realistic', + options: [ + { label: '真实分布(推荐)', value: 'realistic' }, + { label: '均匀随机', value: 'uniform' }, + { label: '心理定价', value: 'psychological' }, + ], + }, + ], + // 普通生成(随机) + generate: (params: any) => { + const { min = 1, max = 9999, decimals = 2, strategy = 'realistic' } = params; + let value: number; + + switch (strategy) { + case 'psychological': + // 心理定价:.99, .98, .95 结尾 + value = generatePsychologicalPrice(min, max); + break; + case 'realistic': + // 真实分布:对数正态分布 + value = generateRealisticPrice(min, max); + break; + default: + // 均匀分布 + value = Math.random() * (max - min) + min; + } + + return parseFloat(value.toFixed(decimals)); + }, + // 索引生成(唯一性保证) + generateAtIndex: (params: any, index: number) => { + const { min = 1, max = 9999, decimals = 2, strategy = 'realistic' } = params; + let value: number; + + // 索引生成时使用均匀分布,保证唯一性 + const step = (max - min) / 10000; + value = min + step * index; + + // 确保在区间内 + value = Math.max(min, Math.min(max, value)); + + return parseFloat(value.toFixed(decimals)); + }, + }, + + orderId: { + name: 'orderId', + label: '订单号', + description: '生成订单号', + params: [ + { + name: 'prefix', + label: '前缀', + type: 'string', + required: false, + default: 'ORD', + }, + { + name: 'length', + label: '编号长度', + type: 'number', + required: false, + default: 12, + }, + ], + generate: (params: any) => { + const prefix = params.prefix || 'ORD'; + const length = params.length || 12; + let suffix = ''; + for (let i = 0; i < length; i++) { + suffix += Math.floor(Math.random() * 10); + } + return `${prefix}${suffix}`; + }, + generateAtIndex: (params: any, index: number) => { + const prefix = params.prefix || 'ORD'; + const length = params.length || 12; + const suffix = String(index).padStart(length, '0'); + return `${prefix}${suffix}`; + }, + }, + + date: { + name: 'date', + label: '日期', + description: '生成随机日期', + params: [ + { + name: 'start', + label: '开始日期', + type: 'string', + required: false, + default: '2020-01-01', + }, + { + name: 'end', + label: '结束日期', + type: 'string', + required: false, + default: '2024-12-31', + }, + { + name: 'format', + label: '日期格式', + type: 'select', + required: false, + default: 'YYYY-MM-DD', + options: [ + { label: 'YYYY-MM-DD', value: 'YYYY-MM-DD' }, + { label: 'YYYY/MM/DD', value: 'YYYY/MM/DD' }, + { label: 'DD/MM/YYYY', value: 'DD/MM/YYYY' }, + { label: '时间戳', value: 'timestamp' }, + ], + }, + ], + generate: (params: any) => { + const start = new Date(params.start || '2020-01-01').getTime(); + const end = new Date(params.end || '2024-12-31').getTime(); + const timestamp = start + Math.random() * (end - start); + return formatDate(new Date(timestamp), params.format || 'YYYY-MM-DD'); + }, + generateAtIndex: (params: any, index: number) => { + const start = new Date(params.start || '2020-01-01').getTime(); + const end = new Date(params.end || '2024-12-31').getTime(); + const step = (end - start) / 10000; + const timestamp = start + step * index; + return formatDate(new Date(timestamp), params.format || 'YYYY-MM-DD'); + }, + }, + + status: { + name: 'status', + label: '状态', + description: '生成随机状态', + params: [ + { + name: 'options', + label: '状态选项', + type: 'array', + required: false, + default: ['待处理', '处理中', '已完成', '已取消'], + }, + ], + generate: (params: any) => { + const options = params.options || ['待处理', '处理中', '已完成', '已取消']; + return options[Math.floor(Math.random() * options.length)]; + }, + generateAtIndex: (params: any, index: number) => { + const options = params.options || ['待处理', '处理中', '已完成', '已取消']; + return options[index % options.length]; + }, + }, + + quantity: { + name: 'quantity', + label: '数量', + description: '生成真实分布的数量', + params: [ + { + name: 'min', + label: '最小值', + type: 'number', + required: false, + default: 1, + }, + { + name: 'max', + label: '最大值', + type: 'number', + required: false, + default: 100, + }, + { + name: 'strategy', + label: '生成策略', + type: 'select', + required: false, + default: 'realistic', + options: [ + { label: '真实分布(推荐)', value: 'realistic' }, + { label: '批发模式', value: 'bulk' }, + { label: '均匀随机', value: 'uniform' }, + ], + }, + ], + generate: (params: any) => { + const { min = 1, max = 100, strategy = 'realistic' } = params; + let value: number; + + switch (strategy) { + case 'bulk': + // 批发模式:10-100 之间均匀分布 + value = Math.random() * 90 + 10; + break; + case 'realistic': + // 泊松分布:大多数购买 1-3 件 + value = poissonRandom(2); + break; + default: + // 均匀分布 + value = Math.random() * (max - min) + min; + } + + return Math.round(Math.max(min, Math.min(max, value))); + }, + generateAtIndex: (params: any, index: number) => { + const { min = 1, max = 100 } = params; + const step = (max - min) / 10000; + return Math.round(min + step * index); + }, + }, + + rating: { + name: 'rating', + label: '评分', + description: '生成真实分布的评分', + params: [ + { + name: 'min', + label: '最低分', + type: 'number', + required: false, + default: 1, + }, + { + name: 'max', + label: '最高分', + type: 'number', + required: false, + default: 5, + }, + { + name: 'decimals', + label: '小数位数', + type: 'number', + required: false, + default: 1, + }, + { + name: 'strategy', + label: '生成策略', + type: 'select', + required: false, + default: 'realistic', + options: [ + { label: '真实分布(推荐)', value: 'realistic' }, + { label: '严格评价', value: 'strict' }, + { label: '均匀随机', value: 'uniform' }, + ], + }, + ], + generate: (params: any) => { + const { min = 1, max = 5, decimals = 1, strategy = 'realistic' } = params; + let value: number; + + switch (strategy) { + case 'strict': + // 严格评价:偏向低分 + value = skewedRandom(min, max, 0.3); + break; + case 'realistic': + // 真实分布:偏向高分(电商平台常见) + value = skewedRandom(min, max, 0.7); + break; + default: + // 均匀分布 + value = Math.random() * (max - min) + min; + } + + return parseFloat(Math.max(min, Math.min(max, value)).toFixed(decimals)); + }, + generateAtIndex: (params: any, index: number) => { + const { min = 1, max = 5, decimals = 1 } = params; + const step = (max - min) / 10000; + return parseFloat((min + step * index).toFixed(decimals)); + }, + }, + + discount: { + name: 'discount', + label: '折扣', + description: '生成真实分布的折扣', + params: [ + { + name: 'min', + label: '最低折扣', + type: 'number', + required: false, + default: 0.1, + }, + { + name: 'max', + label: '最高折扣', + type: 'number', + required: false, + default: 0.9, + }, + { + name: 'strategy', + label: '生成策略', + type: 'select', + required: false, + default: 'psychological', + options: [ + { label: '心理定价(推荐)', value: 'psychological' }, + { label: '清仓模式', value: 'clearance' }, + { label: '均匀随机', value: 'uniform' }, + ], + }, + ], + generate: (params: any) => { + const { min = 0.1, max = 0.9, strategy = 'psychological' } = params; + let value: number; + + switch (strategy) { + case 'clearance': + // 清仓模式:大折扣区间(3-7折) + value = Math.random() * 0.4 + 0.3; + break; + case 'psychological': + // 心理定价:常见折扣点 + value = generatePsychologicalDiscount(min, max); + break; + default: + // 均匀分布 + value = Math.random() * (max - min) + min; + } + + return parseFloat(Math.max(min, Math.min(max, value)).toFixed(2)); + }, + generateAtIndex: (params: any, index: number) => { + const { min = 0.1, max = 0.9 } = params; + const step = (max - min) / 10000; + return parseFloat((min + step * index).toFixed(2)); + }, + }, + + stock: { + name: 'stock', + label: '库存', + description: '生成真实分布的库存', + params: [ + { + name: 'min', + label: '最小库存', + type: 'number', + required: false, + default: 0, + }, + { + name: 'max', + label: '最大库存', + type: 'number', + required: false, + default: 1000, + }, + { + name: 'strategy', + label: '生成策略', + type: 'select', + required: false, + default: 'realistic', + options: [ + { label: '真实分布(推荐)', value: 'realistic' }, + { label: '热销商品', value: 'hot' }, + { label: '均匀随机', value: 'uniform' }, + ], + }, + ], + generate: (params: any) => { + const { min = 0, max = 1000, strategy = 'realistic' } = params; + let value: number; + + switch (strategy) { + case 'hot': + // 热销商品:低库存(0-50) + value = Math.random() * 50; + break; + case 'realistic': + // 真实分布:指数分布,大多数库存较少 + value = exponentialRandom(min, max); + break; + default: + // 均匀分布 + value = Math.random() * (max - min) + min; + } + + return Math.round(Math.max(min, Math.min(max, value))); + }, + generateAtIndex: (params: any, index: number) => { + const { min = 0, max = 1000 } = params; + const step = (max - min) / 10000; + return Math.round(min + step * index); + }, + }, +}; + +// 辅助函数:生成心理定价 +function generatePsychologicalPrice(min: number, max: number): number { + let price = Math.random() * (max - min) + min; + + const strategies = [ + (p: number) => Math.floor(p) + 0.99, + (p: number) => Math.floor(p) + 0.98, + (p: number) => Math.floor(p) + 0.95, + (p: number) => Math.floor(p / 10) * 10 + 9.9, + ]; + + const strategy = strategies[Math.floor(Math.random() * strategies.length)]; + price = strategy(price); + + return Math.max(min, Math.min(max, price)); +} + +// 辅助函数:生成真实分布价格(对数正态分布) +function generateRealisticPrice(min: number, max: number): number { + const mu = Math.log((min + max) / 2); + const sigma = 0.5; + + const u1 = Math.random(); + const u2 = Math.random(); + const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); + + let value = Math.exp(mu + sigma * z); + value = Math.max(min, Math.min(max, value)); + + return value; +} + +// 辅助函数:格式化日期 +function formatDate(date: Date, format: string): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + if (format === 'timestamp') { + return date.getTime().toString(); + } + + return format.replace('YYYY', year.toString()).replace('MM', month).replace('DD', day); +} + +// 辅助函数:正态分布随机数 +function normalRandom(mean: number, stddev: number): number { + const u1 = Math.random(); + const u2 = Math.random(); + const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); + return mean + stddev * z; +} + +// 辅助函数:人口比例分布(模拟真实年龄分布) +function demographicRandom(min: number, max: number): number { + // 中国人口年龄分布大致比例(简化版) + const weights = [ + { age: 5, weight: 0.05 }, // 0-5岁 + { age: 15, weight: 0.12 }, // 6-15岁 + { age: 25, weight: 0.18 }, // 16-25岁 + { age: 35, weight: 0.2 }, // 26-35岁 + { age: 45, weight: 0.18 }, // 36-45岁 + { age: 55, weight: 0.15 }, // 46-55岁 + { age: 65, weight: 0.08 }, // 56-65岁 + { age: 80, weight: 0.04 }, // 66-80岁 + ]; + + // 按权重随机选择年龄段 + const totalWeight = weights.reduce((sum, w) => sum + w.weight, 0); + let random = Math.random() * totalWeight; + + for (const w of weights) { + if (random <= w.weight) { + // 在该年龄段内均匀分布 + const ageMin = Math.max(min, w.age - 10); + const ageMax = Math.min(max, w.age); + return Math.random() * (ageMax - ageMin) + ageMin; + } + random -= w.weight; + } + + return Math.random() * (max - min) + min; +} + +// 辅助函数:泊松分布随机数(模拟购买数量) +function poissonRandom(lambda: number): number { + let L = Math.exp(-lambda); + let k = 0; + let p = 1; + + do { + k++; + p *= Math.random(); + } while (p > L); + + return k - 1; +} + +// 辅助函数:偏态分布随机数(模拟评分) +function skewedRandom(min: number, max: number, skewness: number): number { + // 使用 beta 分布的简化实现 + const u = Math.random(); + const v = Math.random(); + + // 根据偏度调整 + let value; + if (skewness > 0.5) { + // 右偏(偏向高分) + value = Math.pow(u, 1 - skewness) * (max - min) + min; + } else { + // 左偏(偏向低分) + value = (1 - Math.pow(v, skewness)) * (max - min) + min; + } + + return Math.max(min, Math.min(max, value)); +} + +// 辅助函数:指数分布随机数(模拟库存) +function exponentialRandom(min: number, max: number): number { + const lambda = 0.5; // 指数分布参数 + const u = Math.random(); + const value = -Math.log(1 - u) / lambda; + + // 缩放到指定区间 + const normalized = value / 10; // 归一化 + return normalized * (max - min) + min; +} + +// 辅助函数:生成心理定价折扣 +function generatePsychologicalDiscount(min: number, max: number): number { + // 常见折扣点:8折、85折、9折、95折 + const commonDiscounts = [0.8, 0.85, 0.9, 0.95, 0.7, 0.75]; + + // 随机选择一个常见折扣,或生成随机折扣 + if (Math.random() < 0.7) { + // 70% 概率使用常见折扣 + const discount = commonDiscounts[Math.floor(Math.random() * commonDiscounts.length)]; + return Math.max(min, Math.min(max, discount)); + } else { + // 30% 概率生成随机折扣 + return Math.random() * (max - min) + min; + } +} +``` + +### 3. 数据生成引擎 + +```typescript +// src/workers/generator.worker.ts +import { generators } from '@/lib/generators'; + +self.onmessage = function (e) { + const { rule, batchSize } = e.data; + + // 1. 验证所有字段的生成器是否存在 + const invalidField = rule.fields.find((f) => !generators[f.generator]); + if (invalidField) { + self.postMessage({ + type: 'error', + field: invalidField.name, + error: `生成器 ${invalidField.generator} 不存在`, + }); + return; + } + + // 2. 生成数据 + const data: Record[] = []; + const usedValues: Record> = {}; + + // 根据总数量决定策略 + const isLargeBatch = rule.options.total > 1000; + + // 统计信息 + let failedCount = 0; + const failedFields: string[] = []; + + for (let i = 0; i < rule.options.total; i++) { + const record: Record = {}; + + for (const field of rule.fields) { + const generator = generators[field.generator]; + + // 3. 判断是否需要生成数据 + const emptyRate = field.emptyRate ?? rule.options.defaultEmptyRate; + const shouldGenerate = field.required || Math.random() * 100 >= emptyRate; + + if (!shouldGenerate) { + // 选填字段且触发空值 + record[field.name] = null; + continue; + } + + let value; + + // 4. 根据条件选择生成策略 + if (field.unique && isLargeBatch) { + // 策略1:大数据量 + 唯一性 → 索引生成(高性能) + value = generator.generateAtIndex?.(field.params, i) ?? generator.generate(field.params); + } else if (field.unique) { + // 策略2:小数据量 + 唯一性 → 随机生成 + 重试 + let retryCount = 0; + const maxRetry = 100; + + do { + value = generator.generate(field.params); + + if (usedValues[field.name]?.has(value)) { + retryCount++; + value = undefined; + } + } while (value === undefined && retryCount < maxRetry); + + // 5. 处理重试失败 + if (retryCount >= maxRetry) { + failedCount++; + if (!failedFields.includes(field.name)) { + failedFields.push(field.name); + } + console.warn(`字段 "${field.name}" 重试 ${maxRetry} 次后仍无法生成唯一值`); + } + } else { + // 策略3:非唯一性 → 普通生成 + value = generator.generate(field.params); + } + + // 6. 记录生成的值 + if (value !== undefined) { + record[field.name] = value; + + // 记录唯一性字段的值 + if (field.unique) { + if (!usedValues[field.name]) { + usedValues[field.name] = new Set(); + } + usedValues[field.name].add(value); + } + } else { + // 生成失败,设置为空值 + record[field.name] = null; + } + } + + data.push(record); + + // 7. 发送进度(每 batchSize 条发送一次) + if (i % batchSize === 0) { + self.postMessage({ + type: 'progress', + current: i + 1, + total: rule.options.total, + }); + } + } + + // 8. 发送完成结果 + self.postMessage({ + type: 'complete', + data, + stats: { + total: rule.options.total, + generated: data.length, + failed: failedCount, + failedFields, + }, + }); +}; +``` + +### 4. 规则存储 + +```typescript +// src/utils/ruleStorage.ts +import type { DataRule } from '@/types/testDataGenerator'; + +const STORAGE_KEY = 'testDataGenerator_rules'; +const MAX_RULES = 20; // 最大规则数量限制 + +export class RuleStorage { + // 获取所有规则 + getAll(): DataRule[] { + const data = localStorage.getItem(STORAGE_KEY); + return data ? JSON.parse(data) : []; + } + + // 获取单个规则 + getById(id: string): DataRule | null { + const rules = this.getAll(); + return rules.find((r) => r.id === id) || null; + } + + // 获取当前规则数量 + getCount(): number { + return this.getAll().length; + } + + // 检查是否达到最大数量限制 + isMaxReached(): boolean { + return this.getCount() >= MAX_RULES; + } + + // 保存规则 + save(rule: Omit): { + success: boolean; + rule?: DataRule; + error?: string; + } { + const rules = this.getAll(); + + // 检查数量限制 + if (rules.length >= MAX_RULES) { + return { + success: false, + error: `已达到最大规则数量(${MAX_RULES}条),请删除一些规则后再保存`, + }; + } + + const newRule: DataRule = { + ...rule, + id: `rule_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + metadata: { + createdAt: Date.now(), + updatedAt: Date.now(), + useCount: 0, + }, + }; + + rules.push(newRule); + localStorage.setItem(STORAGE_KEY, JSON.stringify(rules)); + + return { success: true, rule: newRule }; + } + + // 更新规则 + update(id: string, updates: Partial): DataRule | null { + const rules = this.getAll(); + const index = rules.findIndex((r) => r.id === id); + + if (index === -1) return null; + + rules[index] = { + ...rules[index], + ...updates, + metadata: { + ...rules[index].metadata, + updatedAt: Date.now(), + }, + }; + + localStorage.setItem(STORAGE_KEY, JSON.stringify(rules)); + return rules[index]; + } + + // 删除规则 + delete(id: string): boolean { + const rules = this.getAll(); + const filtered = rules.filter((r) => r.id !== id); + + if (filtered.length === rules.length) return false; + + localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)); + return true; + } + + // 复制规则 + duplicate(id: string): { success: boolean; rule?: DataRule; error?: string } { + const rules = this.getAll(); + + // 检查数量限制 + if (rules.length >= MAX_RULES) { + return { + success: false, + error: `已达到最大规则数量(${MAX_RULES}条),请删除一些规则后再复制`, + }; + } + + const source = rules.find((r) => r.id === id); + if (!source) { + return { + success: false, + error: '找不到要复制的规则', + }; + } + + const newRule: DataRule = { + ...source, + id: `rule_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + name: `${source.name} (副本)`, + metadata: { + createdAt: Date.now(), + updatedAt: Date.now(), + useCount: 0, + }, + }; + + rules.push(newRule); + localStorage.setItem(STORAGE_KEY, JSON.stringify(rules)); + + return { success: true, rule: newRule }; + } + + // 记录使用 + recordUse(id: string): void { + const rules = this.getAll(); + const rule = rules.find((r) => r.id === id); + + if (rule) { + rule.metadata.lastUsedAt = Date.now(); + rule.metadata.useCount++; + localStorage.setItem(STORAGE_KEY, JSON.stringify(rules)); + } + } + + // 搜索规则 + search(keyword: string): DataRule[] { + const rules = this.getAll(); + const lowerKeyword = keyword.toLowerCase(); + + return rules.filter( + (r) => + r.name.toLowerCase().includes(lowerKeyword) || + r.description?.toLowerCase().includes(lowerKeyword), + ); + } + + // 获取最近使用 + getRecent(limit: number = 10): DataRule[] { + const rules = this.getAll(); + return rules + .filter((r) => r.metadata.lastUsedAt) + .sort((a, b) => b.metadata.lastUsedAt! - a.metadata.lastUsedAt!) + .slice(0, limit); + } + + // 导出规则 + export(ids: string[]): string { + const rules = this.getAll(); + const selected = rules.filter((r) => ids.includes(r.id)); + + return JSON.stringify( + { + version: '1.0', + exportedAt: new Date().toISOString(), + rules: selected, + }, + null, + 2, + ); + } + + // 导入规则 + import(jsonString: string): { success: number; failed: number; errors: string[] } { + try { + const data = JSON.parse(jsonString); + + if (!data.rules || !Array.isArray(data.rules)) { + return { success: 0, failed: 1, errors: ['无效的规则格式'] }; + } + + // 检查导入后总数是否超过限制 + const currentCount = this.getCount(); + const importCount = data.rules.length; + if (currentCount + importCount > MAX_RULES) { + return { + success: 0, + failed: importCount, + errors: [ + `导入失败:当前已有 ${currentCount} 条规则,最多只能保存 ${MAX_RULES} 条,无法导入 ${importCount} 条规则`, + ], + }; + } + + let success = 0; + let failed = 0; + const errors: string[] = []; + const rules = this.getAll(); + + for (const rule of data.rules) { + try { + if (!this.validateRule(rule)) { + throw new Error('规则格式不完整'); + } + + const newRule: DataRule = { + ...rule, + id: `rule_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + metadata: { + ...rule.metadata, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + }; + + rules.push(newRule); + success++; + } catch (e: any) { + failed++; + errors.push(`导入失败: ${e.message}`); + } + } + + // 一次性保存所有导入的规则 + if (success > 0) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(rules)); + } + + return { success, failed, errors }; + } catch (e) { + return { success: 0, failed: 1, errors: ['JSON 解析失败'] }; + } + } + + // 清空所有规则 + clear(): void { + localStorage.removeItem(STORAGE_KEY); + } + + // 验证规则格式 + private validateRule(rule: any): boolean { + return ( + rule.name && + Array.isArray(rule.fields) && + rule.fields.every((f: any) => f.name && f.generator) && + rule.options && + typeof rule.options.total === 'number' + ); + } +} + +export const ruleStorage = new RuleStorage(); +``` + +### 5. 数据导出 + +```typescript +// src/utils/dataExporter.ts +import type { DataRule } from '@/types/testDataGenerator'; + +export interface ExportFile { + filename: string; + content: string; + mimeType: string; +} + +export class DataExporter { + // 导出为 JSON + static toJSON(data: any[], pretty: boolean = true): string { + return pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data); + } + + // 导出为 CSV + static toCSV(data: any[]): string { + if (data.length === 0) return ''; + + const headers = Object.keys(data[0]); + const rows = data.map((row) => headers.map((h) => this.escapeCSV(row[h])).join(',')); + + return [headers.join(','), ...rows].join('\n'); + } + + // CSV 转义 + private static escapeCSV(value: any): string { + const str = String(value ?? ''); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + } + + // 根据格式导出数据 + static exportByFormat(data: any[], format: 'json' | 'csv'): string { + switch (format) { + case 'json': + return this.toJSON(data); + case 'csv': + return this.toCSV(data); + default: + return this.toJSON(data); + } + } + + // 批量导出多个规则(每个规则一个文件) + static toMultipleFiles(rules: DataRule[], format: 'json' | 'csv'): ExportFile[] { + return rules.map((rule) => { + const content = this.exportByFormat([rule], format); + + return { + filename: `${rule.name}.${format}`, + content, + mimeType: this.getMimeType(format), + }; + }); + } + + // 合并导出多个规则(所有规则合并为一个文件) + static toSingleFile(rules: DataRule[], format: 'json' | 'csv'): ExportFile { + // 合并所有规则的字段配置 + const mergedFields = rules.flatMap((rule) => rule.fields); + + // 合并导出内容 + const content = this.exportByFormat([mergedFields], format); + + return { + filename: `merged_rules.${format}`, + content, + mimeType: this.getMimeType(format), + }; + } + + // 根据格式获取 MIME 类型 + private static getMimeType(format: string): string { + const mimeTypes: Record = { + json: 'application/json', + csv: 'text/csv', + }; + return mimeTypes[format] || 'text/plain'; + } + + // 下载单个文件 + static download(content: string, filename: string, mimeType: string = 'text/plain'): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + // 批量下载多个文件 + static downloadMultiple(files: ExportFile[]): void { + files.forEach((file) => { + this.download(file.content, file.filename, file.mimeType); + }); + } + + // 复制到剪贴板 + static async copyToClipboard(content: string): Promise { + try { + await navigator.clipboard.writeText(content); + return true; + } catch (error) { + console.error('[DataExporter] 复制失败:', error); + return false; + } + } +} +``` + +--- + +## Web Worker 使用 + +### 创建 Worker + +```typescript +// src/pages/TestDataGenerator/hooks/useGenerator.ts +import { useRef, useState, useCallback } from 'react'; +import type { DataRule, GenerateResult } from '@/types/testDataGenerator'; + +export function useGenerator() { + const workerRef = useRef(null); + const [progress, setProgress] = useState(0); + const [isGenerating, setIsGenerating] = useState(false); + const [result, setResult] = useState(null); + + const generate = useCallback(async (rule: DataRule): Promise => { + return new Promise((resolve, reject) => { + setIsGenerating(true); + setProgress(0); + setResult(null); + + const startTime = Date.now(); + + // 创建 Worker + workerRef.current = new Worker(new URL('@/workers/generator.worker.ts', import.meta.url), { + type: 'module', + }); + + // 监听消息 + workerRef.current.onmessage = (e) => { + const { type, current, total, data, error, stats } = e.data; + + if (type === 'progress') { + setProgress(Math.round((current / total) * 100)); + } + + if (type === 'complete') { + setIsGenerating(false); + setProgress(100); + + const duration = Date.now() - startTime; + + const result: GenerateResult = { + success: true, + data, + stats: { + total: stats.total, + generated: stats.generated, + failed: stats.failed, + failedFields: stats.failedFields, + duration, + }, + }; + + setResult(result); + resolve(result); + } + + if (type === 'error') { + setIsGenerating(false); + reject(new Error(error)); + } + }; + + // 错误处理 + workerRef.current.onerror = (e) => { + setIsGenerating(false); + reject(new Error(e.message || 'Worker 执行出错')); + }; + + // 发送任务 + workerRef.current.postMessage({ + rule, + batchSize: 1000, + }); + }); + }, []); + + const terminate = useCallback(() => { + if (workerRef.current) { + workerRef.current.terminate(); + workerRef.current = null; + setIsGenerating(false); + setProgress(0); + } + }, []); + + return { + generate, + terminate, + progress, + isGenerating, + result, + }; +} +``` + +### 错误处理 + +```typescript +// src/pages/TestDataGenerator/hooks/useErrorHandler.ts +import { useCallback } from 'react'; +import type { GenerateResult } from '@/types/testDataGenerator'; + +export interface ErrorHandlerOptions { + onSuccess?: (result: GenerateResult) => void; + onWarning?: (message: string, failedFields: string[]) => void; + onError?: (error: Error) => void; +} + +export function useErrorHandler(options: ErrorHandlerOptions = {}) { + const handleGenerateResult = useCallback( + (result: GenerateResult) => { + // 1. 检查是否有失败字段 + if (result.stats.failed > 0) { + const message = `有 ${result.stats.failed} 个字段生成失败:${result.stats.failedFields?.join('、')}`; + options.onWarning?.(message, result.stats.failedFields || []); + } else { + options.onSuccess?.(result); + } + }, + [options], + ); + + const handleGenerateError = useCallback( + (error: Error) => { + options.onError?.(error); + }, + [options], + ); + + return { + handleGenerateResult, + handleGenerateError, + }; +} +``` + +--- + +## 错误提示机制 + +### 错误类型分类 + +| 错误类型 | 严重程度 | 触发场景 | 提示方式 | +| --------------- | -------- | ------------------------ | ------------- | +| 生成器不存在 | 🔴 严重 | 字段配置了不存在的生成器 | toast.error | +| Worker 执行错误 | 🔴 严重 | Web Worker 内部异常 | toast.error | +| 重试达到上限 | 🟡 警告 | 唯一性字段无法生成唯一值 | toast.warning | +| 部分字段失败 | 🟡 警告 | 生成完成但有字段失败 | 结果面板警告 | +| 生成成功 | 🟢 成功 | 全部字段生成成功 | toast.success | + +### 提示方式实现 + +项目使用 `sonner` 库作为 Toast 提示组件(已在项目中配置),导入方式: + +```typescript +import { toast } from 'sonner'; +``` + +```typescript +// src/pages/TestDataGenerator/index.tsx +import { useGenerator } from './hooks/useGenerator'; +import { useErrorHandler } from './hooks/useErrorHandler'; +import { toast } from 'sonner'; + +export default function TestDataGenerator() { + const { generate, progress, isGenerating, result } = useGenerator(); + + const { handleGenerateResult, handleGenerateError } = useErrorHandler({ + // 成功回调 + onSuccess: (result) => { + toast.success(`成功生成 ${result.stats.generated} 条数据`); + }, + // 警告回调(部分字段失败) + onWarning: (message, failedFields) => { + toast.warning(message); + }, + // 错误回调(严重错误) + onError: (error) => { + toast.error(error.message); + }, + }); + + const handleGenerate = async (rule: DataRule) => { + try { + const result = await generate(rule); + handleGenerateResult(result); + } catch (error) { + handleGenerateError(error as Error); + } + }; + + return ( +
+ {/* 生成按钮 */} + handleGenerate(currentRule)} + disabled={isGenerating} + loading={isGenerating} + /> + + {/* 进度条 */} + {isGenerating && } + + {/* 结果面板(包含警告信息) */} + {result && } +
+ ); +} +``` + +### 结果面板(含警告) + +```typescript +// src/pages/TestDataGenerator/components/ResultPanel.tsx +import type { GenerateResult } from '@/types/testDataGenerator'; + +interface ResultPanelProps { + result: GenerateResult; +} + +export function ResultPanel({ result }: ResultPanelProps) { + const { stats } = result; + + return ( +
+ {/* 成功信息 */} +
+

生成完成

+

+ 成功生成 {stats.generated} 条数据,耗时 {stats.duration}ms +

+
+ + {/* 警告信息(有失败字段) */} + {stats.failed > 0 && ( +
+

部分字段生成失败

+

+ 有 {stats.failed} 个字段无法生成唯一值: +

+
    + {stats.failedFields?.map((field) => ( +
  • "{field}" - 重试 100 次后仍存在重复
  • + ))} +
+

+ 这些字段的值已设置为空,您可以尝试减少生成数量或调整字段配置 +

+
+ )} + + {/* 数据预览 */} +
+

数据预览

+
+          {JSON.stringify(result.data.slice(0, 5), null, 2)}
+        
+
+
+ ); +} +``` + +### Toast 使用示例 + +项目使用 `sonner` 库,已配置在 `src/components/ui/sonner.tsx` 中,使用方式: + +```typescript +import { toast } from 'sonner'; + +// 成功提示 +toast.success('操作成功'); + +// 警告提示 +toast.warning('有部分数据生成失败'); + +// 错误提示 +toast.error('生成失败:生成器不存在'); +``` + +--- + +## 错误处理流程图 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 错误处理流程 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 用户点击"生成" │ +│ ↓ │ +│ ┌─────────────────┐ │ +│ │ Worker 执行生成 │ │ +│ └────────┬────────┘ │ +│ ↓ │ +│ ┌─────────────────┐ 是 ┌─────────────────┐ │ +│ │ 生成器不存在? │ ───────→ │ toast.error │ │ +│ └────────┬────────┘ └─────────────────┘ │ +│ │ 否 │ +│ ↓ │ +│ ┌─────────────────┐ │ +│ │ 循环生成数据 │ │ +│ └────────┬────────┘ │ +│ ↓ │ +│ ┌─────────────────┐ 是 ┌─────────────────┐ │ +│ │ 唯一性重试失败?│ ───────→ │ 记录失败字段 │ │ +│ └────────┬────────┘ └─────────────────┘ │ +│ │ 否 │ +│ ↓ │ +│ ┌─────────────────┐ │ +│ │ 发送完成结果 │ │ +│ └────────┬────────┘ │ +│ ↓ │ +│ ┌─────────────────┐ 有 ┌─────────────────┐ │ +│ │ failedFields? │ ────────→ │ toast.warning │ │ +│ └────────┬────────┘ │ 结果面板显示 │ │ +│ │ 无 └─────────────────┘ │ +│ ↓ │ +│ ┌─────────────────┐ │ +│ │ toast.success │ │ +│ └─────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 性能优化 + +### 1. Web Worker + +- 将数据生成放到后台线程 +- 不阻塞主线程 UI +- 支持取消和中断 + +### 2. 分批生成 + +- 每批生成 1000 条数据 +- 实时发送进度 +- 避免内存峰值 + +### 3. 唯一性处理 + +- 使用 Set 存储已生成的值 +- 自动生成重试 +- 避免无限循环 + +### 4. 虚拟列表 + +- 大数据预览(>100 条)自动启用虚拟滚动 +- 行高固定 20px,仅渲染可见区域 ±5 行缓冲 +- 大幅减少 DOM 节点数量,优化滚动性能 + +--- + +## 测试策略 + +### 单元测试 + +```typescript +// generators/__tests__/personal.test.ts +import { personalGenerators } from '../personal'; + +describe('chineseName', () => { + it('should generate valid chinese name', () => { + const name = personalGenerators.chineseName.generate({}); + expect(name).toMatch(/^[一-龥]{2,3}$/); + }); + + it('should respect givenNameLength', () => { + const name = personalGenerators.chineseName.generate({ + givenNameLength: { min: 1, max: 1 }, + }); + expect(name.length).toBe(2); + }); +}); + +describe('email', () => { + it('should generate valid email', () => { + const email = personalGenerators.email.generate({}); + expect(email).toMatch(/^[a-z0-9]+@[a-z0-9]+\.[a-z]+$/); + }); +}); +``` + +### 集成测试 + +```typescript +// __tests__/generator.integration.test.ts +import { useGenerator } from '../hooks/useGenerator'; +import { renderHook, act } from '@testing-library/react'; + +describe('useGenerator', () => { + it('should generate data', async () => { + const { result } = renderHook(() => useGenerator()); + + const rule = { + fields: [{ id: '1', name: 'name', generator: 'chineseName', params: {}, unique: false }], + options: { total: 10, format: 'json' as const }, + }; + + await act(async () => { + await result.current.generate(rule); + }); + + expect(result.current.result?.data).toHaveLength(10); + }); +}); +``` diff --git a/docs/test-data-generator/ui-design.md b/docs/test-data-generator/ui-design.md new file mode 100644 index 0000000..7ba4762 --- /dev/null +++ b/docs/test-data-generator/ui-design.md @@ -0,0 +1,489 @@ +# 界面设计 + +## 设计原则 + +1. **简洁直观** - 一目了然的布局 +2. **操作便捷** - 减少点击次数 +3. **实时反馈** - 配置即预览 +4. **响应式** - 支持多种屏幕尺寸 + +--- + +## 页面结构 + +### 主页面布局 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 🧪 测试数据生成器 [帮助] │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────┐ ┌───────────────────────┐ │ +│ │ 字段配置 │ │ 数据预览 │ │ +│ │ │ │ │ │ +│ │ [+ 添加字段] │ │ [实时预览数据] │ │ +│ │ │ │ │ │ +│ │ 字段列表... │ │ │ │ +│ │ │ │ │ │ +│ └─────────────────────────────────────┘ └───────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 生成选项 │ │ +│ │ 数量、格式、约束... │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 操作栏 │ │ +│ │ [生成] [保存规则] [规则管理] │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 组件设计 + +### 1. 字段列表组件 + +**功能**: 展示和管理所有字段 + +**界面**: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 字段配置 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ [+ 添加字段] │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 1. name [中文姓名 ▼] [配置] [删除] [上移][下移] │ │ +│ │ 2. email [邮箱地址 ▼] [配置] [删除] [上移][下移] │ │ +│ │ 3. phone [手机号 ▼] [配置] [删除] [上移][下移] │ │ +│ │ 4. age [年龄 ▼] [配置] [删除] [上移][下移] │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ [+ 添加字段] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**交互**: + +- 点击字段名可编辑 +- 点击生成器下拉框可切换生成器 +- 点击 配置 展开参数配置 +- 点击 删除 删除字段 +- 点击 上移下移 调整顺序 + +--- + +### 2. 字段配置组件 + +**功能**: 配置单个字段的详细参数 + +**界面**: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 字段配置 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 基础配置 │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 字段名称: [name ] │ │ +│ │ 字段描述: [用户姓名 ] │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 生成器选择 │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 分类: [个人信息 ▼] │ │ +│ │ 生成器: [中文姓名 ▼] │ │ +│ │ 说明: 生成中文姓名,如张三、李四 │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 参数配置 │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 姓氏池: [百家姓前100 ▼] │ │ +│ │ 名字长度: [1] ~ [2] │ │ +│ │ ☑ 唯一性约束 │ │ +│ │ ☑ 必填 │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 空值率设置 (仅选填字段显示) │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 空值概率: [====|----] 50% │ │ +│ │ 预设: [低 20%] [中 50%] [高 80%] │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 预览示例 │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 张伟, 李娜, 王强, 赵敏, 刘洋 │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ [保存] [取消] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**交互说明**: + +- **必填复选框**: 勾选后该字段必填,100% 生成数据 +- **空值率设置**: 仅当选填时显示,控制该字段生成空值的概率 +- **预设按钮**: 快速设置常用空值率(低/中/高) +- **空值率滑块**: 精细调节空值概率(0-100%) + +--- + +### 3. 数据预览组件 + +**功能**: 实时预览生成的数据 + +**界面**: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 数据预览 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 预览: 前 10 条 │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ { │ │ +│ │ "name": "张伟", │ │ +│ │ "email": "zhangwei@qq.com", │ │ +│ │ "phone": "13812345678", │ │ +│ │ "age": 28 │ │ +│ │ }, │ │ +│ │ { │ │ +│ │ "name": "李娜", │ │ +│ │ "email": "lina@163.com", │ │ +│ │ "phone": "13987654321", │ │ +│ │ "age": 35 │ │ +│ │ }, │ │ +│ │ ... │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 格式切换: │ +│ [JSON] [CSV] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**功能**: + +- 实时更新预览 +- 支持格式切换 +- 分页显示更多数据 +- 一键复制 + +--- + +### 4. 生成选项组件 + +**功能**: 配置生成参数 + +**界面**: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 生成选项 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────────────┐ │ +│ │ 生成数量 │ │ 数据格式 │ │ 约束条件 │ │ +│ │ │ │ │ │ │ │ +│ │ [100 ] │ │ ○ JSON │ │ ☑ name 唯一 │ │ +│ │ [10 ▼] │ │ ○ CSV │ │ ☑ email 唯一 │ │ +│ │ [100 ▼] │ │ │ │ ☑ phone 唯一 │ │ +│ │ [自定义 ] │ │ │ │ ☐ age 唯一 │ │ +│ │ │ │ │ │ │ │ +│ └─────────────────┘ └─────────────────┘ └───────────────────────┘ │ +│ │ +│ 默认空值率 (选填字段) │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 空值概率: [====|----] 50% │ │ +│ │ 预设: [低 20%] [中 50%] [高 80%] │ │ +│ │ 说明: 未单独设置空值率的选填字段将使用此默认值 │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +### 5. 操作栏组件 + +**功能**: 顶部操作按钮 + +**界面**: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 操作栏 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ [生成数据] [保存规则] [规则管理] [导入规则] [导出]│ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +### 6. 生成结果组件 + +**功能**: 展示生成结果,包含成功、警告、错误三种状态 + +**成功状态界面**: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 生成结果 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 生成完成 │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ [成功图标] 成功生成 100 条数据,耗时 1234ms │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 数据统计: │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ • 总条数: 100 │ │ +│ │ • 成功条数: 100 │ │ +│ │ • 失败条数: 0 │ │ +│ │ • 文件大小: 12.5 KB │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 数据预览: │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ [JSON 格式预览...] │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 导出选项: │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ [复制全部] [下载 JSON] [下载 CSV] │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ [关闭] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**警告状态界面(部分字段失败)**: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 生成结果 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 部分字段生成失败 │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ [警告图标] 有 2 个字段生成失败 │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 失败详情: │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ [警告图标] "phone" - 重试 100 次后仍存在重复 │ │ +│ │ [警告图标] "email" - 重试 100 次后仍存在重复 │ │ +│ │ │ │ +│ │ 提示: 这些字段的值已设置为空,您可以尝试减少生成数量 │ │ +│ │ 或调整字段配置 │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 数据统计: │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ • 总条数: 100 │ │ +│ │ • 成功条数: 98 │ │ +│ │ • 失败条数: 2 │ │ +│ │ • 文件大小: 12.3 KB │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 数据预览: │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ [JSON 格式预览...] │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 导出选项: │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ [复制全部] [下载 JSON] [下载 CSV] │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ [关闭] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**错误状态界面(生成失败)**: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 生成失败 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ [错误图标] 生成过程中发生错误 │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ 错误详情: │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 错误类型: 生成器不存在 │ │ +│ │ 错误信息: 字段 "phone" 的生成器 "chinesePhone1" 不存在 │ │ +│ │ 错误位置: 字段配置第 2 行 │ │ +│ │ │ │ +│ │ 建议操作: │ │ +│ │ 1. 检查字段配置中的生成器名称是否正确 │ │ +│ │ 2. 确认该生成器已正确注册 │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ [重新配置] [关闭] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +### 6.1 Toast 提示组件 + +**功能**: 显示临时提示信息 + +**成功提示**: + +``` +┌─────────────────────────────────────┐ +│ [成功图标] 数据生成成功 │ +└─────────────────────────────────────┘ +``` + +**警告提示**: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ [警告图标] 有 2 个字段生成失败:phone、email │ +└─────────────────────────────────────────────────────────────┘ +``` + +**错误提示**: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ [错误图标] 生成失败:生成器 "xxx" 不存在 │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +### 6.2 Modal 提示组件 + +**功能**: 显示严重错误的模态框 + +**错误模态框**: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ [错误图标] 生成失败 │ │ +│ │ │ │ +│ │ 字段 "phone" 的生成器 "chinesePhone1" 不存在 │ │ +│ │ │ │ +│ │ 请检查字段配置中的生成器名称是否正确 │ │ +│ │ │ │ +│ │ ─────────────────────────────────────────────────── │ │ +│ │ │ │ +│ │ [ 确定 ] │ │ +│ │ │ │ +│ └───────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +### 7. 规则管理组件 + +**功能**: 管理已保存的规则 + +**界面**: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 规则管理 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 搜索: [搜索规则名称... ] │ +│ │ +│ 规则列表: │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 电商用户数据 - 测试用 │ │ +│ │ 用于测试用户注册功能 │ │ +│ │ 4 个字段 | 100 条 | JSON │ │ +│ │ 2024-01-15 创建 | 使用 12 次 │ │ +│ │ [加载] [编辑] [导出] [删除] │ │ +│ │ │ │ +│ │ 电商订单数据 - 测试用 │ │ +│ │ 用于测试订单功能 │ │ +│ │ 6 个字段 | 500 条 | JSON │ │ +│ │ 2024-01-16 创建 | 使用 8 次 │ │ +│ │ [加载] [编辑] [导出] [删除] │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +│ [导入规则] [导出全部] │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 响应式设计 + +### 桌面端 (>1024px) + +- 左右分栏布局 +- 字段配置区占 60% +- 数据预览区占 40% + +--- + +## 交互细节 + +### 拖拽排序 + +- 字段支持拖拽排序(基于 @dnd-kit/sortable) +- 拖拽手柄(GripVertical 图标)位于字段左侧 +- 拖拽时显示半透明效果和阴影 +- 释放后立即生效,自动更新字段顺序 + +### 实时预览 + +- 配置参数时实时更新预览 +- 延迟 300ms 防抖,避免频繁重渲染 +- 预览最多显示 10 条示例数据 +- 生成完成后切换为完整结果预览 + +### 虚拟列表 + +- 数据量超过 100 条时自动启用虚拟滚动 +- 仅渲染可见区域的行,大幅减少 DOM 节点 +- 行高固定 20px,支持快速滚动 + +--- + +## 主题支持 + +### 浅色主题 + +- 背景色: #ffffff +- 文字色: #333333 +- 边框色: #e0e0e0 +- 主题色: #3b82f6 + +### 深色主题 + +- 背景色: #1a1a1a +- 文字色: #ffffff +- 边框色: #404040 +- 主题色: #60a5fa diff --git a/public/_locales/zh_CN/messages.json b/public/_locales/zh_CN/messages.json index bd41e3e..9717b5d 100644 --- a/public/_locales/zh_CN/messages.json +++ b/public/_locales/zh_CN/messages.json @@ -7,7 +7,6 @@ "message": "清理", "description": "Translation key: buttons_clear" }, - "messages_copySuccess": { "message": "已复制到剪贴板", "description": "Translation key: messages_copySuccess" @@ -896,7 +895,6 @@ "message": "在标签页打开", "description": "Translation key: buttons_openInTab" }, - "common_errorBoundary_title": { "message": "糟糕,出了点问题", "description": "Translation key: errorBoundary_title" @@ -920,5 +918,387 @@ "common_pageErrorBoundary_description": { "message": "该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。", "description": "Translation key: pageErrorBoundary_description" + }, + "testDataGenerator_title": { + "message": "测试数据生成器", + "description": "Translation key: testDataGenerator_title" + }, + "testDataGenerator_description": { + "message": "自定义字段配置,批量生成测试数据", + "description": "Translation key: testDataGenerator_description" + }, + "testDataGenerator_fields": { + "message": "字段", + "description": "Translation key: testDataGenerator_fields" + }, + "testDataGenerator_addField": { + "message": "添加字段", + "description": "Translation key: testDataGenerator_addField" + }, + "testDataGenerator_noFields": { + "message": "暂无字段", + "description": "Translation key: testDataGenerator_noFields" + }, + "testDataGenerator_addFieldHint": { + "message": "点击上方按钮添加第一个字段", + "description": "Translation key: testDataGenerator_addFieldHint" + }, + "testDataGenerator_unique": { + "message": "唯一", + "description": "Translation key: testDataGenerator_unique" + }, + "testDataGenerator_fieldName": { + "message": "字段名称", + "description": "Translation key: testDataGenerator_fieldName" + }, + "testDataGenerator_fieldNamePlaceholder": { + "message": "请输入字段名称", + "description": "Translation key: testDataGenerator_fieldNamePlaceholder" + }, + "testDataGenerator_fieldDescription": { + "message": "字段描述", + "description": "Translation key: testDataGenerator_fieldDescription" + }, + "testDataGenerator_fieldDescriptionPlaceholder": { + "message": "可选,添加字段说明", + "description": "Translation key: testDataGenerator_fieldDescriptionPlaceholder" + }, + "testDataGenerator_required": { + "message": "必填", + "description": "Translation key: testDataGenerator_required" + }, + "testDataGenerator_nullRate": { + "message": "空值率", + "description": "Translation key: testDataGenerator_nullRate" + }, + "testDataGenerator_uniqueConstraint": { + "message": "唯一性约束", + "description": "Translation key: testDataGenerator_uniqueConstraint" + }, + "testDataGenerator_generator": { + "message": "数据生成器", + "description": "Translation key: testDataGenerator_generator" + }, + "testDataGenerator_generatorParams": { + "message": "生成器参数", + "description": "Translation key: testDataGenerator_generatorParams" + }, + "testDataGenerator_searchGenerator": { + "message": "搜索生成器...", + "description": "Translation key: testDataGenerator_searchGenerator" + }, + "testDataGenerator_searchResults": { + "message": "搜索结果", + "description": "Translation key: testDataGenerator_searchResults" + }, + "testDataGenerator_fieldConfig": { + "message": "字段配置", + "description": "Translation key: testDataGenerator_fieldConfig" + }, + "testDataGenerator_ruleManagement": { + "message": "规则管理", + "description": "Translation key: testDataGenerator_ruleManagement" + }, + "testDataGenerator_selectFieldToEdit": { + "message": "选择左侧字段进行编辑", + "description": "Translation key: testDataGenerator_selectFieldToEdit" + }, + "testDataGenerator_count": { + "message": "生成数量", + "description": "Translation key: testDataGenerator_count" + }, + "testDataGenerator_format": { + "message": "数据格式", + "description": "Translation key: testDataGenerator_format" + }, + "testDataGenerator_generate": { + "message": "生成数据", + "description": "Translation key: testDataGenerator_generate" + }, + "testDataGenerator_cancel": { + "message": "取消", + "description": "Translation key: testDataGenerator_cancel" + }, + "testDataGenerator_progress": { + "message": "已生成 {{current}} / {{total}} 条", + "description": "Translation key: testDataGenerator_progress" + }, + "testDataGenerator_estimatedTime": { + "message": "预计剩余 {{time}} 秒", + "description": "Translation key: testDataGenerator_estimatedTime" + }, + "testDataGenerator_dataPreview": { + "message": "数据预览", + "description": "Translation key: testDataGenerator_dataPreview" + }, + "testDataGenerator_noData": { + "message": "暂无数据", + "description": "Translation key: testDataGenerator_noData" + }, + "testDataGenerator_noDataHint": { + "message": "配置字段后点击「生成数据」按钮", + "description": "Translation key: testDataGenerator_noDataHint" + }, + "testDataGenerator_sampleData": { + "message": "示例数据", + "description": "Translation key: testDataGenerator_sampleData" + }, + "testDataGenerator_copied": { + "message": "已复制", + "description": "Translation key: testDataGenerator_copied" + }, + "testDataGenerator_copy": { + "message": "复制", + "description": "Translation key: testDataGenerator_copy" + }, + "testDataGenerator_pageInfo": { + "message": "第 {{current}} / {{total}} 页", + "description": "Translation key: testDataGenerator_pageInfo" + }, + "testDataGenerator_prevPage": { + "message": "上一页", + "description": "Translation key: testDataGenerator_prevPage" + }, + "testDataGenerator_nextPage": { + "message": "下一页", + "description": "Translation key: testDataGenerator_nextPage" + }, + "testDataGenerator_success": { + "message": "生成成功", + "description": "Translation key: testDataGenerator_success" + }, + "testDataGenerator_generateSuccess": { + "message": "生成完成", + "description": "Translation key: testDataGenerator_generateSuccess" + }, + "testDataGenerator_records": { + "message": "条数据", + "description": "Translation key: testDataGenerator_records" + }, + "testDataGenerator_partialSuccess": { + "message": "部分成功", + "description": "Translation key: testDataGenerator_partialSuccess" + }, + "testDataGenerator_failed": { + "message": "生成失败", + "description": "Translation key: testDataGenerator_failed" + }, + "testDataGenerator_totalCount": { + "message": "总条数", + "description": "Translation key: testDataGenerator_totalCount" + }, + "testDataGenerator_successCount": { + "message": "成功", + "description": "Translation key: testDataGenerator_successCount" + }, + "testDataGenerator_duration": { + "message": "耗时", + "description": "Translation key: testDataGenerator_duration" + }, + "testDataGenerator_warnings": { + "message": "警告", + "description": "Translation key: testDataGenerator_warnings" + }, + "testDataGenerator_moreWarnings": { + "message": "还有 {{count}} 条警告", + "description": "Translation key: testDataGenerator_moreWarnings" + }, + "testDataGenerator_export": { + "message": "导出数据", + "description": "Translation key: testDataGenerator_export" + }, + "testDataGenerator_copyJSON": { + "message": "复制 JSON", + "description": "Translation key: testDataGenerator_copyJSON" + }, + "testDataGenerator_copyCSV": { + "message": "复制 CSV", + "description": "Translation key: testDataGenerator_copyCSV" + }, + "testDataGenerator_downloadJSON": { + "message": "下载 JSON", + "description": "Translation key: testDataGenerator_downloadJSON" + }, + "testDataGenerator_downloadCSV": { + "message": "下载 CSV", + "description": "Translation key: testDataGenerator_downloadCSV" + }, + "testDataGenerator_saveRule": { + "message": "保存规则", + "description": "Translation key: testDataGenerator_saveRule" + }, + "testDataGenerator_import": { + "message": "导入", + "description": "Translation key: testDataGenerator_import" + }, + "testDataGenerator_exportRules": { + "message": "导出规则", + "description": "Translation key: testDataGenerator_exportRules" + }, + "testDataGenerator_ruleCount": { + "message": "已保存 {{count}}/{{max}} 条", + "description": "Translation key: testDataGenerator_ruleCount" + }, + "testDataGenerator_searchRules": { + "message": "搜索规则...", + "description": "Translation key: testDataGenerator_searchRules" + }, + "testDataGenerator_saveNewRule": { + "message": "保存新规则", + "description": "Translation key: testDataGenerator_saveNewRule" + }, + "testDataGenerator_ruleNamePlaceholder": { + "message": "规则名称", + "description": "Translation key: testDataGenerator_ruleNamePlaceholder" + }, + "testDataGenerator_ruleDescPlaceholder": { + "message": "规则描述(可选)", + "description": "Translation key: testDataGenerator_ruleDescPlaceholder" + }, + "testDataGenerator_confirm": { + "message": "确认", + "description": "Translation key: testDataGenerator_confirm" + }, + "testDataGenerator_done": { + "message": "完成", + "description": "Translation key: testDataGenerator_done" + }, + "testDataGenerator_noSearchResults": { + "message": "未找到匹配的规则", + "description": "Translation key: testDataGenerator_noSearchResults" + }, + "testDataGenerator_noRules": { + "message": "暂无保存的规则", + "description": "Translation key: testDataGenerator_noRules" + }, + "testDataGenerator_usedTimes": { + "message": "使用 {{count}} 次", + "description": "Translation key: testDataGenerator_usedTimes" + }, + "testDataGenerator_load": { + "message": "加载", + "description": "Translation key: testDataGenerator_load" + }, + "testDataGenerator_duplicate": { + "message": "复制", + "description": "Translation key: testDataGenerator_duplicate" + }, + "testDataGenerator_delete": { + "message": "删除", + "description": "Translation key: testDataGenerator_delete" + }, + "testDataGenerator_virtualMode": { + "message": "虚拟列表模式(共 $ 行)" + }, + "testDataGenerator_totalRows": { + "message": "共 $ 条数据" + }, + "testDataGenerator_ruleSaved": { + "message": "规则已保存", + "description": "Translation key: testDataGenerator_ruleSaved" + }, + "testDataGenerator_ruleLoaded": { + "message": "已加载规则「{{name}}」", + "description": "Translation key: testDataGenerator_ruleLoaded" + }, + "testDataGenerator_ruleDeleted": { + "message": "规则已删除", + "description": "Translation key: testDataGenerator_ruleDeleted" + }, + "testDataGenerator_ruleDuplicated": { + "message": "规则已复制", + "description": "Translation key: testDataGenerator_ruleDuplicated" + }, + "testDataGenerator_exportSuccess": { + "message": "规则已导出", + "description": "Translation key: testDataGenerator_exportSuccess" + }, + "testDataGenerator_importSuccess": { + "message": "成功导入 {{count}} 条规则", + "description": "Translation key: testDataGenerator_importSuccess" + }, + "testDataGenerator_importFailed": { + "message": "{{count}} 条规则导入失败", + "description": "Translation key: testDataGenerator_importFailed" + }, + "testDataGenerator_confirmDeleteTitle": { + "message": "确认删除规则", + "description": "Translation key: testDataGenerator_confirmDeleteTitle" + }, + "testDataGenerator_confirmDeleteDescription": { + "message": "确定要删除规则「{{name}}」吗?此操作不可撤销。", + "description": "Translation key: testDataGenerator_confirmDeleteDescription" + }, + "testDataGenerator_ruleNameDuplicate": { + "message": "已存在同名规则,是否覆盖保存?", + "description": "Translation key: testDataGenerator_ruleNameDuplicate" + }, + "testDataGenerator_overwrite": { + "message": "覆盖", + "description": "Translation key: testDataGenerator_overwrite" + }, + "testDataGenerator_edit": { + "message": "编辑", + "description": "Translation key: testDataGenerator_edit" + }, + "testDataGenerator_ruleUpdated": { + "message": "规则已更新", + "description": "Translation key: testDataGenerator_ruleUpdated" + }, + "testDataGenerator_editing": { + "message": "编辑中", + "description": "Translation key: testDataGenerator_editing" + }, + "testDataGenerator_editingRule": { + "message": "正在编辑规则「{{name}}」", + "description": "Translation key: testDataGenerator_editingRule" + }, + "testDataGenerator_updateRule": { + "message": "更新规则", + "description": "Translation key: testDataGenerator_updateRule" + }, + "testDataGenerator_saveAs": { + "message": "另存为", + "description": "Translation key: testDataGenerator_saveAs" + }, + "testDataGenerator_noGeneratorParams": { + "message": "此生成器无可配置参数", + "description": "Translation key: testDataGenerator_noGeneratorParams" + }, + "testDataGenerator_enabled": { + "message": "启用", + "description": "Translation key: testDataGenerator_enabled" + }, + "testDataGenerator_disabled": { + "message": "禁用", + "description": "Translation key: testDataGenerator_disabled" + }, + "testDataGenerator_commaSeparated": { + "message": "用逗号分隔多个值", + "description": "Translation key: testDataGenerator_commaSeparated" + }, + "testDataGenerator_copySuccess": { + "message": "已复制到剪贴板", + "description": "Translation key: testDataGenerator_copySuccess" + }, + "testDataGenerator_copyFailed": { + "message": "复制失败", + "description": "Translation key: testDataGenerator_copyFailed" + }, + "testDataGenerator_fieldNameEmpty": { + "message": "字段名称不能为空", + "description": "Translation key: testDataGenerator_fieldNameEmpty" + }, + "testDataGenerator_fieldNameDuplicate": { + "message": "字段名称已存在", + "description": "Translation key: testDataGenerator_fieldNameDuplicate" + }, + "testDataGenerator_fieldNameInvalid": { + "message": "字段名称只能包含字母、数字和下划线", + "description": "Translation key: testDataGenerator_fieldNameInvalid" + }, + "testDataGenerator_ruleCopySuffix": { + "message": "(副本)", + "description": "Translation key: testDataGenerator_ruleCopySuffix" } } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index c226b38..68fa678 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -26,10 +26,16 @@ const DialogOverlay = React.forwardRef< )); DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; +interface DialogContentProps extends React.ComponentPropsWithoutRef< + typeof DialogPrimitive.Content +> { + showCloseButton?: boolean; +} + const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + DialogContentProps +>(({ className, children, showCloseButton = true, ...props }, ref) => ( {children} - - - Close - + {showCloseButton && ( + + + Close + + )} )); diff --git a/src/config/__tests__/features.test.ts b/src/config/__tests__/features.test.ts index ea1feda..6ac2402 100644 --- a/src/config/__tests__/features.test.ts +++ b/src/config/__tests__/features.test.ts @@ -9,8 +9,8 @@ import { describe('features', () => { describe('FEATURES', () => { - it('should have 9 features defined', () => { - expect(FEATURES).toHaveLength(9); + it('should have 10 features defined', () => { + expect(FEATURES).toHaveLength(10); }); it('should have all required properties for each feature', () => { @@ -95,7 +95,7 @@ describe('features', () => { describe('getAllFeatureKeys', () => { it('should return all feature keys', () => { const allKeys = getAllFeatureKeys(); - expect(allKeys).toHaveLength(9); + expect(allKeys).toHaveLength(10); expect(allKeys).toContain('dashboard'); expect(allKeys).toContain('timestamp'); expect(allKeys).toContain('storageCleaner'); @@ -105,6 +105,7 @@ describe('features', () => { expect(allKeys).toContain('jsonDiff'); expect(allKeys).toContain('base64Converter'); expect(allKeys).toContain('rightClickRestorer'); + expect(allKeys).toContain('testDataGenerator'); }); }); @@ -121,9 +122,9 @@ describe('features', () => { expect(pageOrder).toContain('qrCode'); }); - it('should have 8 items in page order', () => { + it('should have 9 items in page order', () => { const pageOrder = getDefaultPageOrder(); - expect(pageOrder).toHaveLength(8); + expect(pageOrder).toHaveLength(9); }); }); }); diff --git a/src/config/features.tsx b/src/config/features.tsx index c069717..7ef8689 100644 --- a/src/config/features.tsx +++ b/src/config/features.tsx @@ -10,6 +10,7 @@ import { GitCompareArrows, ArrowLeftRight, MousePointerClick, + FileSpreadsheet, } from 'lucide-react'; export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info'; @@ -24,6 +25,7 @@ const JwtPage = lazy(() => import('@/pages/Jwt')); const JsonToolsPage = lazy(() => import('@/pages/JsonTools')); const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter')); const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer')); +const TestDataGeneratorPage = lazy(() => import('@/pages/TestDataGenerator')); export interface FeatureConfig { key: PageType; @@ -155,6 +157,19 @@ export const FEATURES: FeatureConfig[] = [ tab: RightClickRestorerPage, }, }, + { + key: 'testDataGenerator', + labelKey: 'testDataGenerator_title', + descriptionKey: 'testDataGenerator_description', + themeColorKey: 'warning', + icon: FileSpreadsheet, + defaultVisible: true, + components: { + popup: TestDataGeneratorPage, + sidepanel: TestDataGeneratorPage, + tab: TestDataGeneratorPage, + }, + }, ]; export function getFeatureByKey(key: PageType): FeatureConfig | undefined { diff --git a/src/lib/generators/basic.ts b/src/lib/generators/basic.ts new file mode 100644 index 0000000..eb41cc1 --- /dev/null +++ b/src/lib/generators/basic.ts @@ -0,0 +1,227 @@ +/** + * 基础类型生成器 + * 包含:随机整数、随机浮点数、随机字符串、从列表选择 + */ + +import type { GeneratorDefinition } from '@/types/testDataGenerator'; + +/** + * 生成随机整数 + */ +function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +/** + * 从数组中随机选择 + */ +function randomPick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +/** + * 随机整数生成器 + */ +export const randomIntGenerator: GeneratorDefinition = { + id: 'randomInt', + name: '随机整数', + description: '生成指定范围内的随机整数', + categoryId: 'basic', + params: [ + { + key: 'min', + label: '最小值', + type: 'number', + defaultValue: 0, + description: '整数最小值', + }, + { + key: 'max', + label: '最大值', + type: 'number', + defaultValue: 100, + description: '整数最大值', + }, + ], + generate: (params) => { + const min = (params.min as number) || 0; + const max = (params.max as number) || 100; + return String(randomInt(min, max)); + }, + generateAtIndex: (params, index) => { + const min = (params.min as number) || 0; + const max = (params.max as number) || 100; + const range = max - min + 1; + return String(min + (index % range)); + }, +}; + +/** + * 随机浮点数生成器 + */ +export const randomFloat: GeneratorDefinition = { + id: 'randomFloat', + name: '随机浮点数', + description: '生成指定范围内的随机浮点数', + categoryId: 'basic', + params: [ + { + key: 'min', + label: '最小值', + type: 'number', + defaultValue: 0, + description: '浮点数最小值', + }, + { + key: 'max', + label: '最大值', + type: 'number', + defaultValue: 100, + description: '浮点数最大值', + }, + { + key: 'decimals', + label: '小数位数', + type: 'number', + defaultValue: 2, + min: 0, + max: 10, + description: '小数位数', + }, + ], + generate: (params) => { + const min = (params.min as number) || 0; + const max = (params.max as number) || 100; + const decimals = (params.decimals as number) ?? 2; + const value = min + Math.random() * (max - min); + return value.toFixed(decimals); + }, +}; + +/** + * 随机字符串生成器 + */ +export const randomString: GeneratorDefinition = { + id: 'randomString', + name: '随机字符串', + description: '生成指定长度的随机字符串', + categoryId: 'basic', + params: [ + { + key: 'length', + label: '字符串长度', + type: 'number', + defaultValue: 10, + min: 1, + max: 100, + description: '字符串长度', + }, + { + key: 'charset', + label: '字符集', + type: 'select', + defaultValue: 'alphanumeric', + description: '使用的字符集', + options: [ + { label: '字母数字', value: 'alphanumeric' }, + { label: '仅字母', value: 'alpha' }, + { label: '仅数字', value: 'numeric' }, + { label: '小写字母', value: 'lowercase' }, + { label: '大写字母', value: 'uppercase' }, + { label: '十六进制', value: 'hex' }, + { label: 'Base64', value: 'base64' }, + ], + }, + ], + generate: (params) => { + const length = (params.length as number) || 10; + const charset = (params.charset as string) || 'alphanumeric'; + + const charsets: Record = { + alphanumeric: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', + alpha: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', + numeric: '0123456789', + lowercase: 'abcdefghijklmnopqrstuvwxyz', + uppercase: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + hex: '0123456789abcdef', + base64: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=', + }; + + const chars = charsets[charset] || charsets.alphanumeric; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + }, + generateAtIndex: (params, index) => { + const length = (params.length as number) || 10; + const charset = (params.charset as string) || 'alphanumeric'; + + const charsets: Record = { + alphanumeric: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', + alpha: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', + numeric: '0123456789', + lowercase: 'abcdefghijklmnopqrstuvwxyz', + uppercase: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + hex: '0123456789abcdef', + base64: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=', + }; + + const chars = charsets[charset] || charsets.alphanumeric; + const base = String(index).padStart(length, '0'); + let result = ''; + for (let i = 0; i < length; i++) { + const charIndex = parseInt(base[i]) || 0; + result += chars[charIndex % chars.length]; + } + return result; + }, +}; + +/** + * 从列表选择生成器 + */ +export const fromList: GeneratorDefinition = { + id: 'fromList', + name: '从列表选择', + description: '从自定义列表中随机选择', + categoryId: 'basic', + params: [ + { + key: 'values', + label: '列表值', + type: 'string', + defaultValue: '', + description: '列表值,用逗号分隔', + placeholder: '值1,值2,值3', + }, + { + key: 'allowDuplicate', + label: '允许重复', + type: 'boolean', + defaultValue: true, + description: '是否允许重复选择', + }, + ], + generate: (params) => { + const valuesStr = (params.values as string) || ''; + const values = valuesStr + .split(',') + .map((v) => v.trim()) + .filter((v) => v); + + if (values.length === 0) { + return '(请配置列表值)'; + } + + return randomPick(values); + }, +}; + +export const basicGenerators: GeneratorDefinition[] = [ + randomIntGenerator, + randomFloat, + randomString, + fromList, +]; diff --git a/src/lib/generators/business.ts b/src/lib/generators/business.ts new file mode 100644 index 0000000..134f914 --- /dev/null +++ b/src/lib/generators/business.ts @@ -0,0 +1,561 @@ +/** + * 业务数据生成器 + * 包含:订单号、价格、日期、状态、数量、评分、折扣、库存 + */ + +import type { GeneratorDefinition } from '@/types/testDataGenerator'; + +/** + * 生成随机整数 + */ +function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +/** + * 从数组中随机选择 + */ +function randomPick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +/** + * 格式化日期 + */ +function formatDate(date: Date, format: string): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + + return format + .replace('YYYY', String(year)) + .replace('MM', month) + .replace('DD', day) + .replace('HH', hours) + .replace('mm', minutes) + .replace('ss', seconds); +} + +/** + * 订单号生成器 + */ +export const orderId: GeneratorDefinition = { + id: 'orderId', + name: '订单号', + description: '生成随机订单号', + categoryId: 'business', + params: [ + { + key: 'prefix', + label: '前缀', + type: 'string', + defaultValue: 'ORD', + description: '订单号前缀', + placeholder: '请输入前缀', + }, + { + key: 'length', + label: '数字长度', + type: 'number', + defaultValue: 10, + min: 6, + max: 20, + description: '订单号数字部分长度', + }, + ], + generate: (params) => { + const prefix = (params.prefix as string) || 'ORD'; + const length = (params.length as number) || 10; + const maxNum = Math.pow(10, length) - 1; + const num = String(randomInt(0, maxNum)).padStart(length, '0'); + return `${prefix}${num}`; + }, + generateAtIndex: (params, index) => { + const prefix = (params.prefix as string) || 'ORD'; + const length = (params.length as number) || 10; + const num = String(index + 1).padStart(length, '0'); + return `${prefix}${num}`; + }, +}; + +/** + * 价格生成器 + */ +export const price: GeneratorDefinition = { + id: 'price', + name: '价格', + description: '生成随机价格', + categoryId: 'business', + params: [ + { + key: 'min', + label: '最低价格', + type: 'number', + defaultValue: 1, + min: 0, + description: '价格最小值', + }, + { + key: 'max', + label: '最高价格', + type: 'number', + defaultValue: 1000, + min: 0, + description: '价格最大值', + }, + { + key: 'decimals', + label: '小数位数', + type: 'number', + defaultValue: 2, + min: 0, + max: 4, + description: '价格小数位数', + }, + { + key: 'strategy', + label: '生成策略', + type: 'select', + defaultValue: 'random', + description: '价格生成策略', + options: [ + { label: '随机', value: 'random' }, + { label: '心理定价', value: 'psychological' }, + { label: '真实分布', value: 'realistic' }, + ], + }, + { + key: 'currency', + label: '货币符号', + type: 'string', + defaultValue: '¥', + description: '货币符号', + }, + ], + generate: (params) => { + const min = (params.min as number) || 1; + const max = (params.max as number) || 1000; + const decimals = (params.decimals as number) ?? 2; + const strategy = (params.strategy as string) || 'random'; + const currency = (params.currency as string) || '¥'; + + let value: number; + if (strategy === 'psychological') { + value = generatePsychologicalPrice(min, max); + } else if (strategy === 'realistic') { + value = generateRealisticPrice(min, max); + } else { + value = min + Math.random() * (max - min); + } + + return `${currency}${value.toFixed(decimals)}`; + }, +}; + +/** + * 生成心理定价(如 9.99, 19.9, 99) + */ +function generatePsychologicalPrice(min: number, max: number): number { + const patterns = [ + () => { + const base = randomInt(Math.ceil(min), Math.floor(max / 10)); + return base * 10 - 0.01; + }, + () => { + const base = randomInt(Math.ceil(min / 10), Math.floor(max / 10)); + return base * 10 - 1; + }, + () => { + const base = randomInt(Math.ceil(min / 100), Math.floor(max / 100)); + return base * 100 - 1; + }, + ]; + + let price = randomPick(patterns)(); + if (price < min) price = min; + if (price > max) price = max; + return price; +} + +/** + * 生成真实分布价格(对数正态分布) + */ +function generateRealisticPrice(min: number, max: number): number { + const mean = (Math.log(min) + Math.log(max)) / 2; + const stdDev = (Math.log(max) - Math.log(min)) / 4; + let u = 0, + v = 0; + while (u === 0) u = Math.random(); + while (v === 0) v = Math.random(); + const num = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); + const value = Math.exp(mean + num * stdDev); + return Math.max(min, Math.min(max, value)); +} + +/** + * 日期生成器 + */ +export const date: GeneratorDefinition = { + id: 'date', + name: '日期', + description: '生成随机日期', + categoryId: 'business', + params: [ + { + key: 'startDate', + label: '开始日期', + type: 'string', + defaultValue: '2020-01-01', + description: '日期范围开始', + placeholder: 'YYYY-MM-DD', + }, + { + key: 'endDate', + label: '结束日期', + type: 'string', + defaultValue: '2025-12-31', + description: '日期范围结束', + placeholder: 'YYYY-MM-DD', + }, + { + key: 'format', + label: '日期格式', + type: 'select', + defaultValue: 'YYYY-MM-DD', + description: '输出格式', + options: [ + { label: 'YYYY-MM-DD', value: 'YYYY-MM-DD' }, + { label: 'YYYY/MM/DD', value: 'YYYY/MM/DD' }, + { label: 'YYYY-MM-DD HH:mm:ss', value: 'YYYY-MM-DD HH:mm:ss' }, + { label: 'YYYY/MM/DD HH:mm:ss', value: 'YYYY/MM/DD HH:mm:ss' }, + ], + }, + ], + generate: (params) => { + const startStr = (params.startDate as string) || '2020-01-01'; + const endStr = (params.endDate as string) || '2025-12-31'; + const format = (params.format as string) || 'YYYY-MM-DD'; + + const start = new Date(startStr).getTime(); + const end = new Date(endStr).getTime(); + const randomTime = start + Math.random() * (end - start); + const date = new Date(randomTime); + + return formatDate(date, format); + }, +}; + +/** + * 状态生成器 + */ +export const status: GeneratorDefinition = { + id: 'status', + name: '状态', + description: '生成随机状态', + categoryId: 'business', + params: [ + { + key: 'type', + label: '状态类型', + type: 'select', + defaultValue: 'order', + description: '选择状态类型', + options: [ + { label: '订单状态', value: 'order' }, + { label: '用户状态', value: 'user' }, + { label: '审核状态', value: 'review' }, + { label: '支付状态', value: 'payment' }, + { label: '自定义', value: 'custom' }, + ], + }, + { + key: 'customValues', + label: '自定义值', + type: 'string', + defaultValue: '', + description: '自定义状态值,用逗号分隔', + placeholder: '状态1,状态2,状态3', + }, + ], + generate: (params) => { + const type = (params.type as string) || 'order'; + + const statusMap: Record = { + order: ['待付款', '待发货', '已发货', '已完成', '已取消', '已退款'], + user: ['活跃', '未激活', '已禁用', '已注销'], + review: ['待审核', '审核中', '已通过', '已拒绝'], + payment: ['待支付', '支付中', '支付成功', '支付失败', '已退款'], + }; + + if (type === 'custom') { + const customValues = (params.customValues as string) || ''; + const values = customValues.split(',').filter((v) => v.trim()); + if (values.length > 0) { + return randomPick(values); + } + return '未知状态'; + } + + return randomPick(statusMap[type] || statusMap.order); + }, +}; + +/** + * 数量生成器 + */ +export const quantity: GeneratorDefinition = { + id: 'quantity', + name: '数量', + description: '生成随机数量', + categoryId: 'business', + params: [ + { + key: 'min', + label: '最小值', + type: 'number', + defaultValue: 1, + min: 0, + description: '数量最小值', + }, + { + key: 'max', + label: '最大值', + type: 'number', + defaultValue: 100, + min: 0, + description: '数量最大值', + }, + { + key: 'distribution', + label: '分布方式', + type: 'select', + defaultValue: 'uniform', + description: '数量分布方式', + options: [ + { label: '均匀分布', value: 'uniform' }, + { label: '泊松分布', value: 'poisson' }, + ], + }, + ], + generate: (params) => { + const min = (params.min as number) || 1; + const max = (params.max as number) || 100; + const distribution = (params.distribution as string) || 'uniform'; + + if (distribution === 'poisson') { + return String(poissonRandom(Math.floor((min + max) / 2))); + } + return String(randomInt(min, max)); + }, +}; + +/** + * 泊松分布随机数 + */ +function poissonRandom(lambda: number): number { + const L = Math.exp(-lambda); + let k = 0; + let p = 1; + do { + k++; + p *= Math.random(); + } while (p > L); + return k - 1; +} + +/** + * 评分生成器 + */ +export const rating: GeneratorDefinition = { + id: 'rating', + name: '评分', + description: '生成随机评分', + categoryId: 'business', + params: [ + { + key: 'min', + label: '最低分', + type: 'number', + defaultValue: 1, + min: 0, + max: 10, + description: '评分最小值', + }, + { + key: 'max', + label: '最高分', + type: 'number', + defaultValue: 5, + min: 0, + max: 10, + description: '评分最大值', + }, + { + key: 'decimals', + label: '小数位数', + type: 'number', + defaultValue: 1, + min: 0, + max: 2, + description: '评分小数位数', + }, + { + key: 'skewed', + label: '偏向高分', + type: 'boolean', + defaultValue: true, + description: '是否偏向高分(模拟真实评分分布)', + }, + ], + generate: (params) => { + const min = (params.min as number) || 1; + const max = (params.max as number) || 5; + const decimals = (params.decimals as number) ?? 1; + const skewed = params.skewed !== false; + + if (skewed) { + return skewedRandom(min, max).toFixed(decimals); + } + return (min + Math.random() * (max - min)).toFixed(decimals); + }, +}; + +/** + * 偏向高分的随机数(模拟真实评分分布) + */ +function skewedRandom(min: number, max: number): number { + // 使用 Beta 分布模拟评分偏向 + const alpha = 3; + const beta = 1; + let u = 0, + v = 0; + while (u === 0) u = Math.random(); + while (v === 0) v = Math.random(); + const x = Math.pow(u, 1 / alpha) / Math.pow(u, 1 / alpha) + Math.pow(v, 1 / beta); + return min + x * (max - min); +} + +/** + * 折扣生成器 + */ +export const discount: GeneratorDefinition = { + id: 'discount', + name: '折扣', + description: '生成随机折扣', + categoryId: 'business', + params: [ + { + key: 'min', + label: '最低折扣', + type: 'number', + defaultValue: 1, + min: 0, + max: 100, + description: '折扣最小值(百分比)', + }, + { + key: 'max', + label: '最高折扣', + type: 'number', + defaultValue: 50, + min: 0, + max: 100, + description: '折扣最大值(百分比)', + }, + { + key: 'strategy', + label: '生成策略', + type: 'select', + defaultValue: 'random', + description: '折扣生成策略', + options: [ + { label: '随机', value: 'random' }, + { label: '心理定价', value: 'psychological' }, + ], + }, + ], + generate: (params) => { + const min = (params.min as number) || 1; + const max = (params.max as number) || 50; + const strategy = (params.strategy as string) || 'random'; + + let discount: number; + if (strategy === 'psychological') { + discount = generatePsychologicalDiscount(min, max); + } else { + discount = randomInt(min, max); + } + return `${discount}%`; + }, +}; + +/** + * 生成心理折扣(如 5%, 10%, 20%, 30%) + */ +function generatePsychologicalDiscount(min: number, max: number): number { + const psychologicalDiscounts = [5, 10, 15, 20, 25, 30, 40, 50, 60, 70, 80]; + const validDiscounts = psychologicalDiscounts.filter((d) => d >= min && d <= max); + if (validDiscounts.length > 0) { + return randomPick(validDiscounts); + } + return randomInt(min, max); +} + +/** + * 库存生成器 + */ +export const stock: GeneratorDefinition = { + id: 'stock', + name: '库存', + description: '生成随机库存数量', + categoryId: 'business', + params: [ + { + key: 'min', + label: '最小库存', + type: 'number', + defaultValue: 0, + min: 0, + description: '库存最小值', + }, + { + key: 'max', + label: '最大库存', + type: 'number', + defaultValue: 1000, + min: 0, + description: '库存最大值', + }, + { + key: 'allowZero', + label: '允许为零', + type: 'boolean', + defaultValue: true, + description: '是否允许库存为零', + }, + ], + generate: (params) => { + const min = (params.min as number) || 0; + const max = (params.max as number) || 1000; + const allowZero = params.allowZero !== false; + + if (allowZero) { + return String(randomInt(min, max)); + } + return String(randomInt(Math.max(1, min), max)); + }, +}; + +export const businessGenerators: GeneratorDefinition[] = [ + orderId, + price, + date, + status, + quantity, + rating, + discount, + stock, +]; diff --git a/src/lib/generators/index.ts b/src/lib/generators/index.ts new file mode 100644 index 0000000..6ce4f5a --- /dev/null +++ b/src/lib/generators/index.ts @@ -0,0 +1,57 @@ +/** + * 生成器库索引 + * 导出所有生成器和分类配置 + */ + +import type { GeneratorDefinition, GeneratorCategory } from '@/types/testDataGenerator'; +import { personalGenerators } from './personal'; +import { businessGenerators } from './business'; +import { technicalGenerators } from './technical'; +import { basicGenerators } from './basic'; + +/** + * 生成器分类配置 + */ +export const generatorCategories: GeneratorCategory[] = [ + { id: 'personal', name: '个人信息', icon: 'User' }, + { id: 'business', name: '业务数据', icon: 'Briefcase' }, + { id: 'technical', name: '技术数据', icon: 'Code' }, + { id: 'basic', name: '基础类型', icon: 'Hash' }, +]; + +/** + * 所有生成器列表 + */ +export const allGenerators: GeneratorDefinition[] = [ + ...personalGenerators, + ...businessGenerators, + ...technicalGenerators, + ...basicGenerators, +]; + +/** + * 根据 ID 获取生成器 + */ +export function getGeneratorById(id: string): GeneratorDefinition | undefined { + return allGenerators.find((g) => g.id === id); +} + +/** + * 根据分类 ID 获取生成器列表 + */ +export function getGeneratorsByCategory(categoryId: string): GeneratorDefinition[] { + return allGenerators.filter((g) => g.categoryId === categoryId); +} + +/** + * 搜索生成器 + */ +export function searchGenerators(query: string): GeneratorDefinition[] { + const lowerQuery = query.toLowerCase(); + return allGenerators.filter( + (g) => + g.name.toLowerCase().includes(lowerQuery) || + g.description.toLowerCase().includes(lowerQuery) || + g.id.toLowerCase().includes(lowerQuery), + ); +} diff --git a/src/lib/generators/personal.ts b/src/lib/generators/personal.ts new file mode 100644 index 0000000..5d11ed6 --- /dev/null +++ b/src/lib/generators/personal.ts @@ -0,0 +1,465 @@ +/** + * 个人信息生成器 + * 包含:中文姓名、邮箱、手机号、身份证号、中文地址、年龄 + */ + +import type { GeneratorDefinition } from '@/types/testDataGenerator'; + +// 中文姓氏 +const SURNAMES = [ + '王', + '李', + '张', + '刘', + '陈', + '杨', + '赵', + '黄', + '周', + '吴', + '徐', + '孙', + '胡', + '朱', + '高', + '林', + '何', + '郭', + '马', + '罗', + '梁', + '宋', + '郑', + '谢', + '韩', + '唐', + '冯', + '于', + '董', + '萧', + '程', + '曹', + '袁', + '邓', + '许', + '傅', + '沈', + '曾', + '彭', + '吕', + '苏', + '卢', + '蒋', + '蔡', + '贾', + '丁', + '魏', + '薛', + '叶', + '阎', +]; + +// 中文名字常用字 +const NAME_CHARS = [ + '伟', + '芳', + '娜', + '秀英', + '敏', + '静', + '丽', + '强', + '磊', + '洋', + '艳', + '勇', + '军', + '杰', + '娟', + '涛', + '明', + '超', + '秀兰', + '霞', + '平', + '刚', + '桂英', + '文', + '华', + '飞', + '鑫', + '浩', + '凯', + '宁', + '建', + '峰', + '辉', + '成', + '宇', + '博', + '泽', + '思', + '睿', + '晨', + '阳', + '雪', + '冰', + '琳', + '瑶', + '婷', + '欣', + '悦', + '佳', + '慧', +]; + +// 邮箱域名 +const EMAIL_DOMAINS = ['qq.com', '163.com', '126.com', 'gmail.com', 'outlook.com', 'hotmail.com']; + +// 省份 +const PROVINCES = [ + '北京市', + '天津市', + '上海市', + '重庆市', + '河北省', + '山西省', + '辽宁省', + '吉林省', + '黑龙江省', + '江苏省', + '浙江省', + '安徽省', + '福建省', + '江西省', + '山东省', + '河南省', + '湖北省', + '湖南省', + '广东省', + '海南省', + '四川省', + '贵州省', + '云南省', + '陕西省', + '甘肃省', + '青海省', + '台湾省', + '内蒙古自治区', + '广西壮族自治区', + '西藏自治区', + '宁夏回族自治区', + '新疆维吾尔自治区', +]; + +// 城市 +const CITIES: Record = { + 北京市: ['东城区', '西城区', '朝阳区', '海淀区'], + 上海市: ['黄浦区', '徐汇区', '长宁区', '静安区'], + 广东省: ['广州市', '深圳市', '东莞市', '佛山市'], + 浙江省: ['杭州市', '宁波市', '温州市', '嘉兴市'], + 江苏省: ['南京市', '苏州市', '无锡市', '常州市'], + 四川省: ['成都市', '绵阳市', '德阳市', '宜宾市'], +}; + +/** + * 生成随机整数 + */ +function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +/** + * 从数组中随机选择 + */ +function randomPick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +/** + * 中文姓名生成器 + */ +export const chineseName: GeneratorDefinition = { + id: 'chineseName', + name: '中文姓名', + description: '生成随机中文姓名', + categoryId: 'personal', + params: [], + generate: () => { + const surname = randomPick(SURNAMES); + const name = randomPick(NAME_CHARS); + return surname + name; + }, +}; + +/** + * 邮箱生成器 + */ +export const email: GeneratorDefinition = { + id: 'email', + name: '邮箱', + description: '生成随机邮箱地址', + categoryId: 'personal', + params: [ + { + key: 'domain', + label: '邮箱域名', + type: 'select', + defaultValue: 'random', + description: '选择邮箱域名', + options: [ + { label: '随机', value: 'random' }, + ...EMAIL_DOMAINS.map((d) => ({ label: d, value: d })), + ], + }, + ], + generate: (params) => { + const domain = + params.domain === 'random' ? randomPick(EMAIL_DOMAINS) : (params.domain as string); + const username = Math.random().toString(36).substring(2, 10); + return `${username}@${domain}`; + }, +}; + +/** + * 手机号生成器 + */ +export const chinesePhone: GeneratorDefinition = { + id: 'chinesePhone', + name: '手机号', + description: '生成随机中国手机号码', + categoryId: 'personal', + params: [ + { + key: 'prefix', + label: '号段前缀', + type: 'select', + defaultValue: 'random', + description: '选择手机号前缀', + options: [ + { label: '随机', value: 'random' }, + { label: '138', value: '138' }, + { label: '139', value: '139' }, + { label: '158', value: '158' }, + { label: '188', value: '188' }, + { label: '177', value: '177' }, + ], + }, + ], + generate: (params) => { + const prefixes = ['138', '139', '158', '188', '177', '136', '159', '186', '135', '150']; + const prefix = params.prefix === 'random' ? randomPick(prefixes) : (params.prefix as string); + const suffix = String(randomInt(10000000, 99999999)); + return prefix + suffix; + }, +}; + +/** + * 身份证号生成器 + */ +export const idCard: GeneratorDefinition = { + id: 'idCard', + name: '身份证号', + description: '生成随机身份证号码', + categoryId: 'personal', + params: [ + { + key: 'region', + label: '地区', + type: 'select', + defaultValue: 'random', + description: '选择身份证前6位地区码', + options: [ + { label: '随机', value: 'random' }, + { label: '北京', value: '110101' }, + { label: '上海', value: '310101' }, + { label: '广州', value: '440103' }, + { label: '深圳', value: '440305' }, + ], + }, + ], + generate: (params) => { + const regions = ['110101', '310101', '440103', '440305', '510104', '330102']; + const region = params.region === 'random' ? randomPick(regions) : (params.region as string); + + // 出生日期 1960-2005年 + const year = randomInt(1960, 2005); + const month = String(randomInt(1, 12)).padStart(2, '0'); + const day = String(randomInt(1, 28)).padStart(2, '0'); + const birthday = `${year}${month}${day}`; + + // 顺序码 + const sequence = String(randomInt(1, 999)).padStart(3, '0'); + + // 前17位 + const prefix17 = region + birthday + sequence; + + // 校验码 + const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; + const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']; + let sum = 0; + for (let i = 0; i < 17; i++) { + sum += parseInt(prefix17[i]) * weights[i]; + } + const checkCode = checkCodes[sum % 11]; + + return prefix17 + checkCode; + }, + generateAtIndex: (params, index) => { + const regions = ['110101', '310101', '440103', '440305', '510104', '330102']; + const region = + params.region === 'random' ? regions[index % regions.length] : (params.region as string); + + const year = 1990; + const month = String(randomInt(1, 12)).padStart(2, '0'); + const day = String(randomInt(1, 28)).padStart(2, '0'); + const birthday = `${year}${month}${day}`; + + const sequence = String(index + 1).padStart(3, '0'); + const prefix17 = region + birthday + sequence; + + const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; + const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']; + let sum = 0; + for (let i = 0; i < 17; i++) { + sum += parseInt(prefix17[i]) * weights[i]; + } + const checkCode = checkCodes[sum % 11]; + + return prefix17 + checkCode; + }, +}; + +/** + * 中文地址生成器 + */ +export const chineseAddress: GeneratorDefinition = { + id: 'chineseAddress', + name: '中文地址', + description: '生成随机中文地址', + categoryId: 'personal', + params: [ + { + key: 'includeDetail', + label: '包含详细地址', + type: 'boolean', + defaultValue: true, + description: '是否包含街道门牌号', + }, + ], + generate: (params) => { + const province = randomPick(PROVINCES); + const cities = CITIES[province] || ['市区']; + const city = randomPick(cities); + const district = `区${randomInt(1, 20)}号`; + + if (params.includeDetail) { + const street = `路${randomInt(1, 200)}号`; + return `${province}${city}${district}${street}`; + } + return `${province}${city}${district}`; + }, +}; + +/** + * 年龄生成器 + */ +export const age: GeneratorDefinition = { + id: 'age', + name: '年龄', + description: '生成随机年龄', + categoryId: 'personal', + params: [ + { + key: 'min', + label: '最小年龄', + type: 'number', + defaultValue: 18, + min: 0, + max: 150, + description: '年龄最小值', + }, + { + key: 'max', + label: '最大年龄', + type: 'number', + defaultValue: 65, + min: 0, + max: 150, + description: '年龄最大值', + }, + { + key: 'distribution', + label: '分布方式', + type: 'select', + defaultValue: 'uniform', + description: '年龄分布方式', + options: [ + { label: '均匀分布', value: 'uniform' }, + { label: '正态分布', value: 'normal' }, + { label: '人口统计分布', value: 'demographic' }, + ], + }, + ], + generate: (params) => { + const min = (params.min as number) || 18; + const max = (params.max as number) || 65; + const distribution = (params.distribution as string) || 'uniform'; + + if (distribution === 'normal') { + return String(normalRandom(min, max)); + } else if (distribution === 'demographic') { + return String(demographicRandom()); + } + return String(randomInt(min, max)); + }, +}; + +/** + * 正态分布随机数 + */ +function normalRandom(min: number, max: number): number { + const mean = (min + max) / 2; + const stdDev = (max - min) / 6; + let u = 0, + v = 0; + while (u === 0) u = Math.random(); + while (v === 0) v = Math.random(); + const num = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); + const value = Math.round(mean + num * stdDev); + return Math.max(min, Math.min(max, value)); +} + +/** + * 人口统计分布随机数(模拟真实年龄分布) + */ +function demographicRandom(): number { + const ageGroups = [ + { min: 0, max: 14, weight: 0.18 }, + { min: 15, max: 24, weight: 0.12 }, + { min: 25, max: 34, weight: 0.18 }, + { min: 35, max: 44, weight: 0.17 }, + { min: 45, max: 54, weight: 0.16 }, + { min: 55, max: 64, weight: 0.12 }, + { min: 65, max: 100, weight: 0.07 }, + ]; + + const random = Math.random(); + let cumulative = 0; + for (const group of ageGroups) { + cumulative += group.weight; + if (random <= cumulative) { + return randomInt(group.min, group.max); + } + } + return randomInt(25, 34); +} + +export const personalGenerators: GeneratorDefinition[] = [ + chineseName, + email, + chinesePhone, + idCard, + chineseAddress, + age, +]; diff --git a/src/lib/generators/technical.ts b/src/lib/generators/technical.ts new file mode 100644 index 0000000..3c9c3bc --- /dev/null +++ b/src/lib/generators/technical.ts @@ -0,0 +1,268 @@ +/** + * 技术数据生成器 + * 包含:UUID、IPv4、URL + */ + +import type { GeneratorDefinition } from '@/types/testDataGenerator'; + +/** + * 生成随机整数 + */ +function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +/** + * 从数组中随机选择 + */ +function randomPick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +/** + * UUID 生成器 + */ +export const uuid: GeneratorDefinition = { + id: 'uuid', + name: 'UUID', + description: '生成随机 UUID', + categoryId: 'technical', + params: [ + { + key: 'version', + label: 'UUID 版本', + type: 'select', + defaultValue: 'v4', + description: 'UUID 版本', + options: [ + { label: 'v4 (随机)', value: 'v4' }, + { label: 'v1 (时间戳)', value: 'v1' }, + ], + }, + { + key: 'uppercase', + label: '大写字母', + type: 'boolean', + defaultValue: false, + description: '是否使用大写字母', + }, + { + key: 'noDashes', + label: '无连字符', + type: 'boolean', + defaultValue: false, + description: '是否省略连字符', + }, + ], + generate: (params) => { + const version = (params.version as string) || 'v4'; + const uppercase = params.uppercase === true; + const noDashes = params.noDashes === true; + + let result: string; + if (version === 'v1') { + result = generateUUIDv1(); + } else { + result = generateUUIDv4(); + } + + if (uppercase) { + result = result.toUpperCase(); + } + + if (noDashes) { + result = result.replace(/-/g, ''); + } + + return result; + }, +}; + +/** + * 生成 UUID v4 + */ +function generateUUIDv4(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +/** + * 生成 UUID v1(简化版本,模拟时间戳) + */ +function generateUUIDv1(): string { + const now = Date.now(); + const timeLow = (now & 0xffffffff).toString(16).padStart(8, '0'); + const timeMid = ((now >> 32) & 0xffff).toString(16).padStart(4, '0'); + const timeHi = ((now >> 48) & 0x0fff) | 0x1000; + const clockSeq = randomInt(0, 0x3fff) | 0x8000; + const node = Array.from({ length: 6 }, () => + randomInt(0, 255).toString(16).padStart(2, '0'), + ).join(''); + + return `${timeLow}-${timeMid}-${timeHi.toString(16)}-${clockSeq.toString(16)}-${node}`; +} + +/** + * IPv4 生成器 + */ +export const ipv4: GeneratorDefinition = { + id: 'ipv4', + name: 'IPv4', + description: '生成随机 IPv4 地址', + categoryId: 'technical', + params: [ + { + key: 'type', + label: '地址类型', + type: 'select', + defaultValue: 'random', + description: 'IP 地址类型', + options: [ + { label: '完全随机', value: 'random' }, + { label: '内网地址', value: 'private' }, + { label: '公网地址', value: 'public' }, + { label: '环回地址', value: 'loopback' }, + ], + }, + ], + generate: (params) => { + const type = (params.type as string) || 'random'; + + if (type === 'private') { + return generatePrivateIPv4(); + } else if (type === 'loopback') { + return '127.0.0.1'; + } else if (type === 'public') { + return generatePublicIPv4(); + } + return generateRandomIPv4(); + }, +}; + +/** + * 生成完全随机 IPv4 + */ +function generateRandomIPv4(): string { + return Array.from({ length: 4 }, () => randomInt(0, 255)).join('.'); +} + +/** + * 生成内网 IPv4 + */ +function generatePrivateIPv4(): string { + const ranges = [ + { prefix: '10', second: () => randomInt(0, 255) }, + { prefix: '172', second: () => randomInt(16, 31) }, + { prefix: '192.168', second: () => randomInt(0, 255) }, + ]; + const range = randomPick(ranges); + + if (range.prefix === '192.168') { + return `192.168.${range.second()}.${randomInt(1, 254)}`; + } + return `${range.prefix}.${range.second()}.${randomInt(1, 254)}.${randomInt(1, 254)}`; +} + +/** + * 生成公网 IPv4 + */ +function generatePublicIPv4(): string { + let first: number; + do { + first = randomInt(1, 255); + } while (first === 10 || first === 127 || first === 192 || first === 172); + + return `${first}.${randomInt(0, 255)}.${randomInt(0, 255)}.${randomInt(1, 254)}`; +} + +/** + * URL 生成器 + */ +export const url: GeneratorDefinition = { + id: 'url', + name: 'URL', + description: '生成随机 URL', + categoryId: 'technical', + params: [ + { + key: 'protocol', + label: '协议', + type: 'select', + defaultValue: 'https', + description: 'URL 协议', + options: [ + { label: 'HTTPS', value: 'https' }, + { label: 'HTTP', value: 'http' }, + { label: '随机', value: 'random' }, + ], + }, + { + key: 'type', + label: 'URL 类型', + type: 'select', + defaultValue: 'website', + description: 'URL 类型', + options: [ + { label: '网站', value: 'website' }, + { label: 'API', value: 'api' }, + { label: '图片', value: 'image' }, + { label: '文件', value: 'file' }, + ], + }, + { + key: 'includePath', + label: '包含路径', + type: 'boolean', + defaultValue: true, + description: '是否包含路径', + }, + ], + generate: (params) => { + const protocol = (params.protocol as string) || 'https'; + const type = (params.type as string) || 'website'; + const includePath = params.includePath !== false; + + const actualProtocol = protocol === 'random' ? randomPick(['http', 'https']) : protocol; + + const domains = [ + 'example.com', + 'test.org', + 'demo.net', + 'sample.io', + 'api.service.com', + 'cdn.static.com', + 'img.media.com', + ]; + const domain = randomPick(domains); + + let path = ''; + if (includePath) { + if (type === 'api') { + path = `/api/v${randomInt(1, 3)}/${randomPick(['users', 'products', 'orders', 'items'])}`; + } else if (type === 'image') { + path = `/images/${randomPick(['avatar', 'banner', 'logo', 'photo'])}/${randomInt(1, 1000)}.jpg`; + } else if (type === 'file') { + path = `/files/${randomPick(['document', 'report', 'data'])}/${randomInt(1, 100)}.pdf`; + } else { + path = `/${randomPick(['about', 'contact', 'products', 'services', 'blog'])}`; + } + } + + // 添加查询参数 + let query = ''; + if (Math.random() > 0.5) { + const params = new URLSearchParams(); + params.set('id', String(randomInt(1, 10000))); + if (Math.random() > 0.5) params.set('page', String(randomInt(1, 100))); + if (Math.random() > 0.7) params.set('lang', randomPick(['zh', 'en', 'ja'])); + query = `?${params.toString()}`; + } + + return `${actualProtocol}://${domain}${path}${query}`; + }, +}; + +export const technicalGenerators: GeneratorDefinition[] = [uuid, ipv4, url]; diff --git a/src/lib/generators/types.ts b/src/lib/generators/types.ts new file mode 100644 index 0000000..f21c44e --- /dev/null +++ b/src/lib/generators/types.ts @@ -0,0 +1,48 @@ +/** + * 生成器内部类型定义 + * 与 testDataGenerator.ts 分离,用于生成器库内部 + */ + +import type { GeneratorDefinition, GeneratorCategory } from '@/types/testDataGenerator'; + +export type { GeneratorDefinition, GeneratorCategory }; + +/** + * 生成器注册表 + */ +export interface GeneratorRegistry { + /** 分类列表 */ + categories: GeneratorCategory[]; + /** 生成器列表 */ + generators: GeneratorDefinition[]; +} + +/** + * 随机数生成选项 + */ +export interface RandomOptions { + /** 最小值 */ + min?: number; + /** 最大值 */ + max?: number; + /** 是否包含最小值 */ + includeMin?: boolean; + /** 是否包含最大值 */ + includeMax?: boolean; +} + +/** + * 字符集选项 + */ +export interface CharsetOptions { + /** 是否包含大写字母 */ + uppercase?: boolean; + /** 是否包含小写字母 */ + lowercase?: boolean; + /** 是否包含数字 */ + digits?: boolean; + /** 是否包含特殊字符 */ + special?: boolean; + /** 自定义字符集 */ + custom?: string; +} diff --git a/src/pages/TestDataGenerator/components/DataPreview.tsx b/src/pages/TestDataGenerator/components/DataPreview.tsx new file mode 100644 index 0000000..abfca39 --- /dev/null +++ b/src/pages/TestDataGenerator/components/DataPreview.tsx @@ -0,0 +1,114 @@ +/** + * 数据预览组件 + * 展示一条示例数据,展示数据结构 + */ + +import { useMemo } from 'react'; +import { FileJson, FileText } from 'lucide-react'; +import { useI18n } from '@/utils/chromeI18n'; +import { getGeneratorById } from '@/lib/generators'; +import type { FieldConfig } from '@/types/testDataGenerator'; + +interface DataPreviewProps { + fields: FieldConfig[]; +} + +/** JSON 语法高亮渲染 */ +function JsonHighlight({ data }: { data: Record }) { + const formatted = useMemo(() => { + const lines: { indent: string; key?: string; value: string; isLast: boolean }[] = []; + const entries = Object.entries(data); + + entries.forEach(([key, value], index) => { + const isLast = index === entries.length - 1; + const formattedValue = + value === null ? 'null' : typeof value === 'string' ? `"${value}"` : String(value); + lines.push({ indent: ' ', key, value: formattedValue, isLast }); + }); + + return lines; + }, [data]); + + return ( +
+      {'{'}
+      {formatted.map((line, i) => (
+        
+ {line.indent} + "{line.key}" + {': '} + {line.value} + {!line.isLast && ,} +
+ ))} + {'}'} +
+ ); +} + +/** 根据值类型返回颜色类名 */ +function getValueColor(value: string): string { + if (value === 'null') return 'text-muted-foreground'; + if (value.startsWith('"')) return 'text-emerald-600 dark:text-emerald-400'; + if (/^\d+$/.test(value)) return 'text-amber-600 dark:text-amber-400'; + if (value === 'true' || value === 'false') return 'text-purple-600 dark:text-purple-400'; + return 'text-foreground'; +} + +export default function DataPreview({ fields }: DataPreviewProps) { + const { t } = useI18n('testDataGenerator'); + + // 生成一条示例数据 + const sampleData = useMemo(() => { + if (fields.length === 0) return null; + + const record: Record = {}; + for (const field of fields) { + // 非必填字段预览显示 null + if (!field.required) { + record[field.name] = null; + continue; + } + const generator = getGeneratorById(field.generatorId); + if (generator) { + record[field.name] = generator.generate(field.params); + } else { + record[field.name] = null; + } + } + return record; + }, [fields]); + + if (!sampleData) { + return ( +
+ +

{t('testDataGenerator_noDataHint')}

+
+ ); + } + + return ( +
+ {/* 示例标签 */} +
+
+
+ +
+ + {t('testDataGenerator_sampleData')} + +
+ + {Object.keys(sampleData).length} {t('testDataGenerator_fields')} + +
+ + {/* 示例数据展示 */} +
+ +
+
+ ); +} diff --git a/src/pages/TestDataGenerator/components/ExportPanel.tsx b/src/pages/TestDataGenerator/components/ExportPanel.tsx new file mode 100644 index 0000000..f16a1cc --- /dev/null +++ b/src/pages/TestDataGenerator/components/ExportPanel.tsx @@ -0,0 +1,81 @@ +/** + * 导出面板组件 + * 提供复制、下载 JSON、下载 CSV 选项 + */ + +import { Copy, Download } from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { useI18n } from '@/utils/chromeI18n'; +import { DataExporter } from '@/utils/dataExporter'; +import type { GenerateResult } from '@/types/testDataGenerator'; + +interface ExportPanelProps { + result: GenerateResult | null; +} + +export default function ExportPanel({ result }: ExportPanelProps) { + const { t } = useI18n('testDataGenerator'); + + if (!result?.data || result.data.length === 0) { + return null; + } + + const handleCopyJSON = async () => { + const content = DataExporter.toJSON(result.data!); + const success = await DataExporter.copyToClipboard(content); + if (success) { + toast.success(t('testDataGenerator_copySuccess')); + } else { + toast.error(t('testDataGenerator_copyFailed')); + } + }; + + const handleCopyCSV = async () => { + const content = DataExporter.toCSV(result.data!); + const success = await DataExporter.copyToClipboard(content); + if (success) { + toast.success(t('testDataGenerator_copySuccess')); + } else { + toast.error(t('testDataGenerator_copyFailed')); + } + }; + + const handleDownloadJSON = () => { + const file = DataExporter.toSingleFile(result.data!, 'json', 'test-data'); + DataExporter.download(file); + }; + + const handleDownloadCSV = () => { + const file = DataExporter.toSingleFile(result.data!, 'csv', 'test-data'); + DataExporter.download(file); + }; + + return ( +
+

{t('testDataGenerator_export')}

+ +
+ + + + + + + +
+
+ ); +} diff --git a/src/pages/TestDataGenerator/components/FieldEditor.tsx b/src/pages/TestDataGenerator/components/FieldEditor.tsx new file mode 100644 index 0000000..3a5d740 --- /dev/null +++ b/src/pages/TestDataGenerator/components/FieldEditor.tsx @@ -0,0 +1,230 @@ +/** + * 字段编辑器组件 + * 编辑单个字段的详细配置 + */ + +import { useState, useCallback } from 'react'; +import { useI18n } from '@/utils/chromeI18n'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Badge } from '@/components/ui/badge'; +import { getGeneratorById } from '@/lib/generators'; +import type { FieldConfig } from '@/types/testDataGenerator'; +import GeneratorSelector from './GeneratorSelector'; +import GeneratorConfig from './GeneratorConfig'; + +interface FieldEditorProps { + field: FieldConfig; + onChange: (field: FieldConfig) => void; + /** 所有字段名列表,用于检测重复 */ + allFieldNames?: string[]; +} + +export default function FieldEditor({ field, onChange, allFieldNames = [] }: FieldEditorProps) { + const { t } = useI18n('testDataGenerator'); + const generator = getGeneratorById(field.generatorId); + const [nameError, setNameError] = useState(null); + + const validateFieldName = useCallback( + (name: string): string | null => { + const trimmed = name.trim(); + if (!trimmed) { + return t('testDataGenerator_fieldNameEmpty'); + } + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) { + return t('testDataGenerator_fieldNameInvalid'); + } + const isDuplicate = allFieldNames.some( + (n, i) => n === trimmed && i !== allFieldNames.indexOf(field.name), + ); + if (isDuplicate) { + return t('testDataGenerator_fieldNameDuplicate'); + } + return null; + }, + [allFieldNames, field.name, t], + ); + + const handleNameChange = (name: string) => { + onChange({ ...field, name }); + // 实时校验 + const error = validateFieldName(name); + setNameError(error); + }; + + const handleDescriptionChange = (description: string) => { + onChange({ ...field, description }); + }; + + const handleRequiredChange = (required: boolean) => { + onChange({ + ...field, + required, + // 切换为必填时清零,切换为非必填时默认 100% + nullRate: required ? 0 : 100, + }); + }; + + const handleNullRateChange = (nullRate: number) => { + // 限制范围 0-100 + const clampedRate = Math.max(0, Math.min(100, nullRate)); + // 空值率为 0 时自动设为必填 + if (clampedRate === 0) { + onChange({ ...field, nullRate: clampedRate, required: true }); + } else { + onChange({ ...field, nullRate: clampedRate, required: false }); + } + }; + + const handleUniqueChange = (unique: boolean) => { + onChange({ ...field, unique }); + }; + + const handleGeneratorChange = (generatorId: string) => { + const generator = getGeneratorById(generatorId); + const defaultParams: Record = {}; + generator?.params.forEach((p) => { + defaultParams[p.key] = p.defaultValue; + }); + onChange({ + ...field, + generatorId, + params: defaultParams, + }); + }; + + const handleParamsChange = (params: Record) => { + onChange({ ...field, params }); + }; + + const nullRatePresets = [ + { label: '5%', value: 5 }, + { label: '20%', value: 20 }, + { label: '50%', value: 50 }, + { label: '75%', value: 75 }, + ]; + + return ( +
+ {/* 基础配置 */} +
+
+
+ + {field.name.length}/20 +
+ handleNameChange(e.target.value)} + placeholder={t('testDataGenerator_fieldNamePlaceholder')} + maxLength={20} + className={`h-9 ${nameError ? 'border-destructive' : ''}`} + /> + {nameError &&

{nameError}

} +
+ +
+
+ + + {(field.description || '').length}/50 + +
+ handleDescriptionChange(e.target.value)} + placeholder={t('testDataGenerator_fieldDescriptionPlaceholder')} + maxLength={50} + className="h-9" + /> +
+
+ + {/* 必填/选填配置 */} +
+
+ + +
+ + {!field.required && ( +
+
+ + {t('testDataGenerator_nullRate')} + + + {field.nullRate}% + +
+ { + const value = Number(e.target.value); + if (value >= 0 && value <= 100) { + handleNullRateChange(value); + } + }} + min={0} + max={100} + step={5} + className="h-9 w-full" + /> +
+ {nullRatePresets.map((preset) => ( + + ))} +
+
+ )} +
+ + {/* 唯一性约束 */} +
+ + +
+ + {/* 生成器选择 */} +
+ + +
+ + {/* 生成器参数配置 */} + {generator && ( +
+ + +
+ )} +
+ ); +} diff --git a/src/pages/TestDataGenerator/components/FieldItem.tsx b/src/pages/TestDataGenerator/components/FieldItem.tsx new file mode 100644 index 0000000..9f21b4b --- /dev/null +++ b/src/pages/TestDataGenerator/components/FieldItem.tsx @@ -0,0 +1,59 @@ +/** + * 字段项组件 + * 展示单个字段的基本信息,适配固定高度卡片 + */ + +import { useI18n } from '@/utils/chromeI18n'; +import { getGeneratorById } from '@/lib/generators'; +import { Badge } from '@/components/ui/badge'; +import type { FieldConfig } from '@/types/testDataGenerator'; + +interface FieldItemProps { + field: FieldConfig; + onClick: () => void; +} + +export default function FieldItem({ field, onClick }: FieldItemProps) { + const { t } = useI18n('testDataGenerator'); + const generator = getGeneratorById(field.generatorId); + + return ( +
+
+ {/* 第一行:字段名 + 标签 */} +
+ + {field.name} + + + {generator?.name || field.generatorId} + + {!field.required && ( + + {field.nullRate}% + + )} + {field.unique && ( + + {t('testDataGenerator_unique')} + + )} +
+ {/* 第二行:描述 */} + {field.description && ( +

+ {field.description} +

+ )} +
+
+ ); +} diff --git a/src/pages/TestDataGenerator/components/FieldList.tsx b/src/pages/TestDataGenerator/components/FieldList.tsx new file mode 100644 index 0000000..0536780 --- /dev/null +++ b/src/pages/TestDataGenerator/components/FieldList.tsx @@ -0,0 +1,466 @@ +/** + * 字段列表组件 + * 展示所有字段配置,支持添加、删除、拖拽排序 + * 超过 5 条时启用虚拟列表滚动 + */ + +import { useState, useRef, useCallback, useEffect } from 'react'; +import { Plus, GripVertical, Trash2, Save } from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { useI18n } from '@/utils/chromeI18n'; +import * as ruleStorage from '@/utils/ruleStorage'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, + DragOverlay, +} from '@dnd-kit/core'; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import type { FieldConfig, DataRule } from '@/types/testDataGenerator'; +import FieldItem from './FieldItem'; + +/** 最大字段数量 */ +export const MAX_FIELDS = 40; + +/** 虚拟列表配置 */ +const VISIBLE_COUNT = 5; +const ROW_HEIGHT = 60; // 卡片高度 52 + 间距 8 +const ROW_GAP = 8; +const OVERSCAN = 2; +const DRAG_OVERSCAN = 10; // 拖拽时增加的预加载范围 + +interface FieldListProps { + fields: FieldConfig[]; + onUpdate: (index: number, field: FieldConfig) => void; + onRemove: (index: number) => void; + onAdd: () => void; + onEdit: (index: number) => void; + onReorder: (oldIndex: number, newIndex: number) => void; + editingRule?: DataRule | null; + onRuleSaved?: () => void; +} + +/** 可排序的字段项 */ +function SortableFieldItem({ + field, + onEdit, + onRemove, +}: { + field: FieldConfig; + onEdit: () => void; + onRemove: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: field.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 10 : 0, + height: ROW_HEIGHT - ROW_GAP, // 固定卡片高度 52px,与虚拟模式一致 + }; + + return ( +
+
+ {/* 拖拽手柄 */} + + +
+ +
+ + +
+
+ ); +} + +export default function FieldList({ + fields, + onUpdate: _onUpdate, + onRemove, + onAdd, + onEdit, + onReorder, + editingRule, + onRuleSaved, +}: FieldListProps) { + const { t } = useI18n('testDataGenerator'); + const [scrollTop, setScrollTop] = useState(0); + const [activeId, setActiveId] = useState(null); + const containerRef = useRef(null); + const [showSaveDialog, setShowSaveDialog] = useState(false); + const [showConfirmOverwrite, setShowConfirmOverwrite] = useState(false); + const [ruleName, setRuleName] = useState(''); + const [ruleDescription, setRuleDescription] = useState(''); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragStart = (event: DragStartEvent) => { + setActiveId(String(event.active.id)); + }; + + // 拖拽时的滚轮滚动处理 + useEffect(() => { + if (!activeId || !containerRef.current) return; + + const container = containerRef.current; + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // 滚动容器 + const scrollAmount = e.deltaY * 0.5; // 降低滚动速度,更易控制 + const newScrollTop = Math.max( + 0, + Math.min( + container.scrollHeight - container.clientHeight, + container.scrollTop + scrollAmount, + ), + ); + container.scrollTop = newScrollTop; + setScrollTop(newScrollTop); + }; + + // 在 document 级别监听,使用 capture 阶段以优先处理 + document.addEventListener('wheel', handleWheel, { capture: true, passive: false }); + + return () => { + document.removeEventListener('wheel', handleWheel, { capture: true }); + }; + }, [activeId]); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + if (!over || active.id === over.id) return; + + const oldIndex = fields.findIndex((f) => f.id === active.id); + const newIndex = fields.findIndex((f) => f.id === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + onReorder(oldIndex, newIndex); + } + }; + + const handleScroll = useCallback(() => { + if (containerRef.current) { + setScrollTop(containerRef.current.scrollTop); + } + }, []); + + const isVirtual = fields.length > VISIBLE_COUNT; + const totalHeight = fields.length * ROW_HEIGHT; + const fixedContainerHeight = VISIBLE_COUNT * ROW_HEIGHT; + // 少于 5 条时高度自适应,5 条及以上固定为 5 条的高度 + const containerStyle = + fields.length < VISIBLE_COUNT + ? { height: 'auto' as const, overflowY: 'visible' as const } + : { height: fixedContainerHeight, overflowY: 'auto' as const }; + + // 虚拟列表:计算可见范围(拖拽时扩展预加载范围) + const currentOverscan = activeId ? DRAG_OVERSCAN : OVERSCAN; + const visibleStart = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - currentOverscan); + const visibleEnd = Math.min( + fields.length, + Math.ceil((scrollTop + fixedContainerHeight) / ROW_HEIGHT) + currentOverscan, + ); + const visibleFields = isVirtual ? fields.slice(visibleStart, visibleEnd) : fields; + + // 查找拖拽中的项目(用于 DragOverlay) + const activeField = activeId ? fields.find((f) => f.id === activeId) : null; + + const isMaxReached = fields.length >= MAX_FIELDS; + + // 编辑模式下,保存时更新原规则 + const handleUpdateRule = useCallback(() => { + if (!editingRule) return; + + const updated = ruleStorage.update(editingRule.id, { + fields: fields, + }); + + if (updated) { + toast.success(t('testDataGenerator_ruleUpdated')); + onRuleSaved?.(); + } + }, [editingRule, fields, t, onRuleSaved]); + + // 新建规则或另存为 + const handleSave = useCallback( + (overwrite = false) => { + if (!ruleName.trim()) return; + + const trimmedName = ruleName.trim(); + + // 检查名称是否重复 + if (!overwrite) { + const existingRule = ruleStorage.getByName(trimmedName); + if (existingRule) { + setShowConfirmOverwrite(true); + return; + } + } + + const newRule = ruleStorage.save({ + name: trimmedName, + description: ruleDescription.trim(), + fields: fields, + }); + + if (newRule) { + setShowSaveDialog(false); + setShowConfirmOverwrite(false); + setRuleName(''); + setRuleDescription(''); + toast.success(t('testDataGenerator_ruleSaved')); + onRuleSaved?.(); + } + }, + [ruleName, ruleDescription, fields, t, onRuleSaved], + ); + + return ( +
+
+

+ {t('testDataGenerator_fields')} ({fields.length}/{MAX_FIELDS}) +

+
+ {editingRule ? ( + <> + + + + ) : ( + + )} + +
+
+ +
+ {fields.length === 0 ? ( +
+ +

{t('testDataGenerator_noFields')}

+

+ {t('testDataGenerator_addFieldHint')} +

+
+ ) : ( + + f.id)} strategy={verticalListSortingStrategy}> +
+ {isVirtual ? ( + /* 虚拟滚动模式 */ +
+
+ {visibleFields.map((field, vi) => { + const realIndex = fields.findIndex((f) => f.id === field.id); + const isLast = visibleStart + vi === fields.length - 1; + return ( +
+ onEdit(realIndex)} + onRemove={() => onRemove(realIndex)} + /> +
+ ); + })} +
+
+ ) : ( + /* 普通模式(≤5 条) */ +
+ {fields.map((field, index) => ( + onEdit(index)} + onRemove={() => onRemove(index)} + /> + ))} +
+ )} +
+
+ + {activeField ? ( +
+
+
+ +
+
+ {}} /> +
+
+
+
+ ) : null} + + + )} +
+ + {/* 保存规则对话框 */} + + +
+ setRuleName(e.target.value)} + placeholder={t('testDataGenerator_ruleNamePlaceholder')} + className="h-9" + /> + setRuleDescription(e.target.value)} + placeholder={t('testDataGenerator_ruleDescPlaceholder')} + className="h-9" + /> +
+
+ + +
+
+
+ + {/* 覆盖确认对话框 */} + + +
+

+ {t('testDataGenerator_ruleNameDuplicate')} +

+
+
+ + +
+
+
+
+ ); +} diff --git a/src/pages/TestDataGenerator/components/GenerateButton.tsx b/src/pages/TestDataGenerator/components/GenerateButton.tsx new file mode 100644 index 0000000..606866c --- /dev/null +++ b/src/pages/TestDataGenerator/components/GenerateButton.tsx @@ -0,0 +1,73 @@ +/** + * 生成按钮组件 + * 显示生成按钮、加载状态和进度条 + */ + +import { Play, Square } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useI18n } from '@/utils/chromeI18n'; +import type { GenerateProgress } from '@/types/testDataGenerator'; + +interface GenerateButtonProps { + onClick: () => void; + onCancel: () => void; + isGenerating: boolean; + progress: GenerateProgress | null; + disabled?: boolean; +} + +export default function GenerateButton({ + onClick, + onCancel, + isGenerating, + progress, + disabled, +}: GenerateButtonProps) { + const { t } = useI18n('testDataGenerator'); + + return ( +
+ {isGenerating ? ( + <> + + + {/* 进度条 */} + {progress && ( +
+
+ + {t('testDataGenerator_progress', { + current: progress.generated.toLocaleString(), + total: progress.total.toLocaleString(), + })} + + {progress.progress}% +
+
+
+
+ {progress.estimatedTimeLeft !== undefined && ( +

+ {t('testDataGenerator_estimatedTime', { + time: Math.ceil(progress.estimatedTimeLeft / 1000), + })} +

+ )} +
+ )} + + ) : ( + + )} +
+ ); +} diff --git a/src/pages/TestDataGenerator/components/GenerateOptions.tsx b/src/pages/TestDataGenerator/components/GenerateOptions.tsx new file mode 100644 index 0000000..c197afc --- /dev/null +++ b/src/pages/TestDataGenerator/components/GenerateOptions.tsx @@ -0,0 +1,91 @@ +/** + * 生成选项组件 + * 配置生成数量、数据格式等选项 + */ + +import { useI18n } from '@/utils/chromeI18n'; +import { Input } from '@/components/ui/input'; + +interface GenerateOptionsProps { + count: number; + onCountChange: (count: number) => void; + format: 'json' | 'csv'; + onFormatChange: (format: 'json' | 'csv') => void; +} + +const COUNT_PRESETS = [50, 100, 1000, 5000, 10000]; + +const FORMAT_OPTIONS = [ + { value: 'json' as const, label: 'JSON' }, + { value: 'csv' as const, label: 'CSV' }, +]; + +export default function GenerateOptions({ + count, + onCountChange, + format, + onFormatChange, +}: GenerateOptionsProps) { + const { t } = useI18n('testDataGenerator'); + + return ( +
+ {/* 生成数量 */} +
+ +
+ {COUNT_PRESETS.map((preset) => ( + + ))} +
+ { + const value = Number(e.target.value); + if (value > 0 && value <= 100000) { + onCountChange(value); + } + }} + min={1} + max={100000} + className="h-9" + /> +
+ + {/* 数据格式 */} +
+ +
+ {FORMAT_OPTIONS.map((option) => ( + + ))} +
+
+
+ ); +} diff --git a/src/pages/TestDataGenerator/components/GeneratorConfig.tsx b/src/pages/TestDataGenerator/components/GeneratorConfig.tsx new file mode 100644 index 0000000..f8e3392 --- /dev/null +++ b/src/pages/TestDataGenerator/components/GeneratorConfig.tsx @@ -0,0 +1,126 @@ +/** + * 生成器配置组件 + * 根据生成器参数定义动态渲染配置表单 + */ + +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useI18n } from '@/utils/chromeI18n'; +import type { GeneratorDefinition } from '@/types/testDataGenerator'; + +interface GeneratorConfigProps { + generator: GeneratorDefinition; + params: Record; + onChange: (params: Record) => void; +} + +export default function GeneratorConfig({ generator, params, onChange }: GeneratorConfigProps) { + const { t } = useI18n('testDataGenerator'); + const handleParamChange = (key: string, value: unknown) => { + onChange({ ...params, [key]: value }); + }; + + if (generator.params.length === 0) { + return ( +

+ {t('testDataGenerator_noGeneratorParams')} +

+ ); + } + + return ( +
+ {generator.params.map((param) => ( +
+ + {param.description && ( +

{param.description}

+ )} + + {param.type === 'string' && ( + handleParamChange(param.key, e.target.value)} + placeholder={param.placeholder} + className="h-9" + /> + )} + + {param.type === 'number' && ( + handleParamChange(param.key, Number(e.target.value))} + min={param.min} + max={param.max} + className="h-9" + /> + )} + + {param.type === 'boolean' && ( +
+ + {params[param.key] !== false + ? t('testDataGenerator_enabled') + : t('testDataGenerator_disabled')} + + handleParamChange(param.key, checked)} + /> +
+ )} + + {param.type === 'select' && ( + + )} + + {param.type === 'array' && ( + + handleParamChange( + param.key, + e.target.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t('testDataGenerator_commaSeparated')} + className="h-9" + /> + )} +
+ ))} +
+ ); +} diff --git a/src/pages/TestDataGenerator/components/GeneratorSelector.tsx b/src/pages/TestDataGenerator/components/GeneratorSelector.tsx new file mode 100644 index 0000000..cf51902 --- /dev/null +++ b/src/pages/TestDataGenerator/components/GeneratorSelector.tsx @@ -0,0 +1,90 @@ +/** + * 生成器选择器组件 + * 以分类形式展示所有可用生成器 + */ + +import { useState } from 'react'; +import { Search, User, Briefcase, Code, Hash } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { useI18n } from '@/utils/chromeI18n'; +import { generatorCategories, getGeneratorsByCategory, searchGenerators } from '@/lib/generators'; + +interface GeneratorSelectorProps { + selectedId: string; + onChange: (id: string) => void; +} + +const categoryIcons: Record> = { + personal: User, + business: Briefcase, + technical: Code, + basic: Hash, +}; + +export default function GeneratorSelector({ selectedId, onChange }: GeneratorSelectorProps) { + const { t } = useI18n('testDataGenerator'); + const [searchQuery, setSearchQuery] = useState(''); + const [activeCategory, setActiveCategory] = useState(generatorCategories[0]?.id || ''); + + const generators = searchQuery + ? searchGenerators(searchQuery) + : getGeneratorsByCategory(activeCategory); + + return ( +
+ {/* 搜索框 */} +
+ + setSearchQuery(e.target.value)} + placeholder={t('testDataGenerator_searchGenerator')} + className="pl-9 h-9" + /> +
+ + {/* 分类标签 */} + {!searchQuery && ( +
+ {generatorCategories.map((category) => { + const Icon = categoryIcons[category.id]; + return ( + + ); + })} +
+ )} + + {/* 生成器列表 */} +
+ {generators.map((generator) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/TestDataGenerator/components/ResultPanel.tsx b/src/pages/TestDataGenerator/components/ResultPanel.tsx new file mode 100644 index 0000000..ce76612 --- /dev/null +++ b/src/pages/TestDataGenerator/components/ResultPanel.tsx @@ -0,0 +1,115 @@ +/** + * 结果面板组件 + * 展示生成结果的状态和统计信息 + */ + +import { CheckCircle, AlertTriangle, XCircle, Clock, Database } from 'lucide-react'; +import { useI18n } from '@/utils/chromeI18n'; +import type { GenerateResult } from '@/types/testDataGenerator'; + +interface ResultPanelProps { + result: GenerateResult | null; +} + +export default function ResultPanel({ result }: ResultPanelProps) { + const { t } = useI18n('testDataGenerator'); + + if (!result) return null; + + const getStatusIcon = () => { + if (result.success && (!result.warnings || result.warnings.length === 0)) { + return ; + } + if (result.success && result.warnings && result.warnings.length > 0) { + return ; + } + return ; + }; + + const getStatusText = () => { + if (result.success && (!result.warnings || result.warnings.length === 0)) { + return t('testDataGenerator_success'); + } + if (result.success && result.warnings && result.warnings.length > 0) { + return t('testDataGenerator_partialSuccess'); + } + return t('testDataGenerator_failed'); + }; + + return ( +
+ {/* 状态标题 */} +
+ {getStatusIcon()} + {getStatusText()} +
+ + {/* 统计信息 */} + {result.stats && ( +
+
+ + + {result.stats.total.toLocaleString()} + + + {t('testDataGenerator_totalCount')} + +
+ +
+ + + {result.stats.success.toLocaleString()} + + + {t('testDataGenerator_successCount')} + +
+ +
+ + + {(result.stats.duration / 1000).toFixed(2)}s + + {t('testDataGenerator_duration')} +
+
+ )} + + {/* 警告信息 */} + {result.warnings && result.warnings.length > 0 && ( +
+
+ + + {t('testDataGenerator_warnings')} + +
+
    + {result.warnings.slice(0, 5).map((warning, index) => ( +
  • + {warning} +
  • + ))} + {result.warnings.length > 5 && ( +
  • + ... {t('testDataGenerator_moreWarnings', { count: result.warnings.length - 5 })} +
  • + )} +
+
+ )} + + {/* 错误信息 */} + {result.error && ( +
+
+ + {result.error} +
+
+ )} +
+ ); +} diff --git a/src/pages/TestDataGenerator/components/RuleManager.tsx b/src/pages/TestDataGenerator/components/RuleManager.tsx new file mode 100644 index 0000000..6bd1d3b --- /dev/null +++ b/src/pages/TestDataGenerator/components/RuleManager.tsx @@ -0,0 +1,324 @@ +/** + * 规则管理器组件 + * 管理测试数据生成规则的加载、删除等操作 + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { + Search, + Upload, + Download, + Trash2, + Copy, + Edit, + Clock, + Tag, + Loader2, + FolderOpen, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { useI18n } from '@/utils/chromeI18n'; +import * as ruleStorage from '@/utils/ruleStorage'; +import type { DataRule, FieldConfig } from '@/types/testDataGenerator'; + +interface RuleManagerProps { + onLoad: (fields: FieldConfig[]) => void; + onEdit?: (rule: DataRule) => void; + onRulesChanged?: () => void; +} + +export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleManagerProps) { + const { t, i18n } = useI18n('testDataGenerator'); + const [rules, setRules] = useState(() => ruleStorage.getAll()); + const [searchQuery, setSearchQuery] = useState(''); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); + const [ruleToDelete, setRuleToDelete] = useState(null); + const [isImporting, setIsImporting] = useState(false); + const [isExporting, setIsExporting] = useState(false); + + const loadRules = useCallback(() => { + setRules(ruleStorage.getAll()); + }, []); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchQuery(searchQuery); + }, 250); + return () => clearTimeout(timer); + }, [searchQuery]); + + const filteredRules = useMemo(() => { + if (!debouncedSearchQuery) return rules; + const query = debouncedSearchQuery.toLowerCase(); + return rules.filter( + (rule) => + rule.name.toLowerCase().includes(query) || rule.description?.toLowerCase().includes(query), + ); + }, [rules, debouncedSearchQuery]); + + const handleLoad = useCallback( + (rule: DataRule) => { + onLoad(rule.fields); + ruleStorage.recordUse(rule.id); + loadRules(); + toast.success(t('testDataGenerator_ruleLoaded', { name: rule.name })); + }, + [onLoad, loadRules, t], + ); + + const handleDelete = useCallback(() => { + if (!ruleToDelete) return; + ruleStorage.deleteRule(ruleToDelete.id); + loadRules(); + setRuleToDelete(null); + toast.success(t('testDataGenerator_ruleDeleted')); + onRulesChanged?.(); + }, [ruleToDelete, loadRules, t, onRulesChanged]); + + const handleDuplicate = useCallback( + (id: string) => { + const result = ruleStorage.duplicate(id, t('testDataGenerator_ruleCopySuffix')); + if (result) { + loadRules(); + toast.success(t('testDataGenerator_ruleDuplicated')); + onRulesChanged?.(); + } + }, + [loadRules, t, onRulesChanged], + ); + + const handleEdit = useCallback( + (rule: DataRule) => { + onEdit?.(rule); + }, + [onEdit], + ); + + const handleExport = useCallback(() => { + try { + setIsExporting(true); + const json = ruleStorage.exportRules(); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + try { + const link = document.createElement('a'); + link.href = url; + link.download = 'test-data-rules.json'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } finally { + URL.revokeObjectURL(url); + } + toast.success(t('testDataGenerator_exportSuccess')); + } catch (error) { + console.error('[RuleManager] 导出失败:', error); + toast.error(t('testDataGenerator_exportFailed')); + } finally { + setIsExporting(false); + } + }, [t]); + + const handleImport = useCallback(() => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = async (e) => { + try { + setIsImporting(true); + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + + const text = await file.text(); + const result = ruleStorage.importRules(text); + + if (result.success > 0) { + toast.success(t('testDataGenerator_importSuccess', { count: result.success })); + } + + if (result.failed > 0) { + toast.error(t('testDataGenerator_importFailed', { count: result.failed })); + console.warn('[RuleManager] 导入警告:', result.errors); + } + + loadRules(); + onRulesChanged?.(); + } catch (error) { + console.error('[RuleManager] 导入失败:', error); + toast.error(t('testDataGenerator_importFailed')); + } finally { + setIsImporting(false); + } + }; + input.click(); + }, [loadRules, t, onRulesChanged]); + + const formatDate = useCallback( + (timestamp: number) => { + return new Date(timestamp).toLocaleDateString(i18n.language || 'zh-CN', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }, + [i18n.language], + ); + + return ( +
+ {/* 删除确认对话框 */} + setRuleToDelete(null)}> + +
+

+ {t('testDataGenerator_confirmDeleteDescription', { name: ruleToDelete?.name ?? '' })} +

+
+
+ + +
+
+
+ + {/* 工具栏 */} +
+ + +
+ + {/* 搜索框 */} +
+ + setSearchQuery(e.target.value.slice(0, 20))} + placeholder={t('testDataGenerator_searchRules')} + className="pl-9 pr-24 h-9" + maxLength={20} + /> + + {t('testDataGenerator_ruleCount', { count: rules.length, max: 20 })} + +
+ + {/* 规则列表 */} +
+ {filteredRules.length === 0 ? ( +
+ +

+ {debouncedSearchQuery + ? t('testDataGenerator_noSearchResults') + : t('testDataGenerator_noRules')} +

+
+ ) : ( + filteredRules.map((rule) => ( +
+
+
+ {rule.name} + + {rule.fields.length} {t('testDataGenerator_fields')} + +
+ {rule.description && ( +

+ {rule.description} +

+ )} +
+ + + {formatDate(rule.updatedAt)} + + {t('testDataGenerator_usedTimes', { count: rule.useCount })} +
+
+ +
+ + + + +
+
+ )) + )} +
+
+ ); +} diff --git a/src/pages/TestDataGenerator/components/__tests__/RuleManager.test.tsx b/src/pages/TestDataGenerator/components/__tests__/RuleManager.test.tsx new file mode 100644 index 0000000..51e8cca --- /dev/null +++ b/src/pages/TestDataGenerator/components/__tests__/RuleManager.test.tsx @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +// Mock dependencies +vi.mock('@/utils/ruleStorage', () => ({ + getAll: vi.fn(() => []), + save: vi.fn(), + deleteRule: vi.fn(), + duplicate: vi.fn(), + recordUse: vi.fn(), + search: vi.fn(() => []), + exportRules: vi.fn(() => '[]'), + importRules: vi.fn(() => ({ success: 0, failed: 0, errors: [] })), +})); + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +import RuleManager from '../RuleManager'; +import * as ruleStorage from '@/utils/ruleStorage'; +import { toast } from 'sonner'; +import type { DataRule, FieldConfig } from '@/types/testDataGenerator'; + +const mockedRuleStorage = vi.mocked(ruleStorage); +const mockedToast = vi.mocked(toast); + +const mockFields: FieldConfig[] = [ + { + id: 'field-1', + name: 'name', + generatorId: 'string', + params: {}, + required: true, + nullRate: 0, + unique: false, + }, +]; + +const mockRule: DataRule = { + id: 'rule-1', + name: 'Test Rule', + description: 'Test description', + fields: mockFields, + createdAt: Date.now(), + updatedAt: Date.now(), + useCount: 0, +}; + +describe('RuleManager', () => { + const defaultProps = { + onLoad: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockedRuleStorage.getAll.mockReturnValue([]); + }); + + it('should render empty state when no rules', () => { + render(); + + expect(screen.getByText('暂无保存的规则')).toBeInTheDocument(); + }); + + it('should render rule list when rules exist', () => { + mockedRuleStorage.getAll.mockReturnValue([mockRule]); + + render(); + + expect(screen.getByText('Test Rule')).toBeInTheDocument(); + expect(screen.getByText('Test description')).toBeInTheDocument(); + }); + + it('should load rule when load button clicked', async () => { + const user = userEvent.setup(); + mockedRuleStorage.getAll.mockReturnValue([mockRule]); + + render(); + + const loadButton = screen.getByTitle('加载'); + await user.click(loadButton); + + expect(defaultProps.onLoad).toHaveBeenCalledWith(mockFields); + expect(mockedRuleStorage.recordUse).toHaveBeenCalledWith('rule-1'); + // Note: t() mock doesn't handle placeholders, so we just check it was called + expect(mockedToast.success).toHaveBeenCalled(); + }); + + it('should show delete confirmation dialog', async () => { + const user = userEvent.setup(); + mockedRuleStorage.getAll.mockReturnValue([mockRule]); + + render(); + + const deleteButton = screen.getByTitle('删除'); + await user.click(deleteButton); + + expect(screen.getByText(/确定要删除规则/)).toBeInTheDocument(); + }); + + it('should delete rule after confirmation', async () => { + const user = userEvent.setup(); + mockedRuleStorage.getAll.mockReturnValue([mockRule]); + + render(); + + // Open delete dialog + await user.click(screen.getByTitle('删除')); + + // Confirm delete + await user.click(screen.getByText('确认')); + + expect(mockedRuleStorage.deleteRule).toHaveBeenCalledWith('rule-1'); + expect(mockedToast.success).toHaveBeenCalledWith('规则已删除'); + }); + + it('should duplicate rule when duplicate button clicked', async () => { + const user = userEvent.setup(); + mockedRuleStorage.getAll.mockReturnValue([mockRule]); + mockedRuleStorage.duplicate.mockReturnValue({ + ...mockRule, + id: 'rule-2', + name: 'Test Rule(副本)', + }); + + render(); + + await user.click(screen.getByTitle('复制')); + + expect(mockedRuleStorage.duplicate).toHaveBeenCalledWith('rule-1', '(副本)'); + expect(mockedToast.success).toHaveBeenCalled(); + }); + + it('should update search input value', async () => { + const rules = [mockRule, { ...mockRule, id: 'rule-2', name: 'Another Rule' }]; + mockedRuleStorage.getAll.mockReturnValue(rules); + + render(); + + // Initially both rules should be visible + expect(screen.getByText('Test Rule')).toBeInTheDocument(); + expect(screen.getByText('Another Rule')).toBeInTheDocument(); + + // Verify search input exists and is interactive + const searchInput = screen.getByPlaceholderText('搜索规则...'); + expect(searchInput).toBeInTheDocument(); + expect(searchInput).toHaveValue(''); + }); + + it('should call exportRules when export button clicked', async () => { + const user = userEvent.setup(); + mockedRuleStorage.getAll.mockReturnValue([mockRule]); + + render(); + + // Mock URL methods and body methods after render to avoid interfering with createRoot + const mockCreateObjectURL = vi.fn(() => 'blob:mock'); + const mockRevokeObjectURL = vi.fn(); + vi.spyOn(URL, 'createObjectURL').mockImplementation(mockCreateObjectURL); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(mockRevokeObjectURL); + vi.spyOn(document.body, 'appendChild').mockImplementation(() => null as unknown as Node); + vi.spyOn(document.body, 'removeChild').mockImplementation(() => null as unknown as Node); + + const buttons = screen.getAllByRole('button'); + const exportButton = buttons.find((btn) => btn.textContent?.includes('导出')); + expect(exportButton).toBeDefined(); + + await user.click(exportButton!); + + expect(mockedRuleStorage.exportRules).toHaveBeenCalled(); + expect(mockCreateObjectURL).toHaveBeenCalled(); + + vi.restoreAllMocks(); + }); + + it('should show field count for each rule', () => { + mockedRuleStorage.getAll.mockReturnValue([mockRule]); + + render(); + + // The component shows "{count} 字段" format + expect(screen.getByText(/字段/)).toBeInTheDocument(); + }); +}); diff --git a/src/pages/TestDataGenerator/hooks/useGenerator.ts b/src/pages/TestDataGenerator/hooks/useGenerator.ts new file mode 100644 index 0000000..7d38bd5 --- /dev/null +++ b/src/pages/TestDataGenerator/hooks/useGenerator.ts @@ -0,0 +1,148 @@ +/** + * 数据生成器 Hook + * 管理 Web Worker 的创建、销毁和通信 + */ + +import { useState, useRef, useCallback, useEffect } from 'react'; +import type { + FieldConfig, + GenerateResult, + GenerateProgress, + WorkerMessage, +} from '@/types/testDataGenerator'; + +export interface UseGeneratorReturn { + /** 是否正在生成 */ + isGenerating: boolean; + /** 生成进度 */ + progress: GenerateProgress | null; + /** 生成结果 */ + result: GenerateResult | null; + /** 错误信息 */ + error: string | null; + /** 开始生成 */ + generate: (fields: FieldConfig[], count: number, csvMode?: boolean) => void; + /** 取消生成 */ + cancel: () => void; + /** 清除结果 */ + clearResult: () => void; +} + +export function useGenerator(): UseGeneratorReturn { + const [isGenerating, setIsGenerating] = useState(false); + const [progress, setProgress] = useState(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + const workerRef = useRef(null); + + // 清理 Worker + useEffect(() => { + return () => { + if (workerRef.current) { + workerRef.current.terminate(); + workerRef.current = null; + } + }; + }, []); + + /** + * 创建或复用 Worker + */ + const getWorker = useCallback((): Worker => { + if (workerRef.current) { + return workerRef.current; + } + + const worker = new Worker(new URL('@/workers/generator.worker.ts', import.meta.url), { + type: 'module', + }); + + worker.onmessage = (e: MessageEvent) => { + const data = e.data; + const { type } = data; + + switch (type) { + case 'progress': + setProgress(data.payload as GenerateProgress); + break; + case 'complete': + setIsGenerating(false); + setResult(data.payload as GenerateResult); + setProgress(null); + break; + case 'error': + setIsGenerating(false); + setError((data.payload as { error: string }).error); + setProgress(null); + break; + } + }; + + worker.onerror = (err) => { + console.error('[useGenerator] Worker 错误:', err); + setIsGenerating(false); + setError(err.message || 'Worker 运行错误'); + setProgress(null); + // Worker 出错后销毁,下次重新创建 + workerRef.current?.terminate(); + workerRef.current = null; + }; + + workerRef.current = worker; + return worker; + }, []); + + /** + * 开始生成 + */ + const generate = useCallback( + (fields: FieldConfig[], count: number, csvMode = false) => { + if (isGenerating) return; + + setIsGenerating(true); + setProgress(null); + setResult(null); + setError(null); + + const worker = getWorker(); + const message: WorkerMessage = { + type: 'start', + payload: { fields, count, csvMode }, + }; + worker.postMessage(message); + }, + [isGenerating, getWorker], + ); + + /** + * 取消生成 + */ + const cancel = useCallback(() => { + if (workerRef.current && isGenerating) { + const message: WorkerMessage = { type: 'cancel' }; + workerRef.current.postMessage(message); + setIsGenerating(false); + setProgress(null); + } + }, [isGenerating]); + + /** + * 清除结果 + */ + const clearResult = useCallback(() => { + setResult(null); + setProgress(null); + setError(null); + }, []); + + return { + isGenerating, + progress, + result, + error, + generate, + cancel, + clearResult, + }; +} diff --git a/src/pages/TestDataGenerator/index.tsx b/src/pages/TestDataGenerator/index.tsx new file mode 100644 index 0000000..d6a985f --- /dev/null +++ b/src/pages/TestDataGenerator/index.tsx @@ -0,0 +1,306 @@ +/** + * 测试数据生成器主页面 + */ + +import { useState, useCallback, useRef, useEffect } from 'react'; +import { Settings, Database, Tag } from 'lucide-react'; +import { useI18n } from '@/utils/chromeI18n'; +import { cn } from '@/lib/utils'; +import { useGenerator } from './hooks/useGenerator'; +import type { FieldConfig, GenerateResult, DataRule } from '@/types/testDataGenerator'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { toast } from 'sonner'; + +// 生成唯一 ID 的辅助函数 +function generateId(): string { + return `field_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +import FieldList, { MAX_FIELDS } from './components/FieldList'; +import FieldEditor from './components/FieldEditor'; +import GenerateOptions from './components/GenerateOptions'; +import GenerateButton from './components/GenerateButton'; +import DataPreview from './components/DataPreview'; +import ResultPanel from './components/ResultPanel'; +import ExportPanel from './components/ExportPanel'; +import RuleManager from './components/RuleManager'; + +type TabType = 'fields' | 'rules'; + +export default function TestDataGeneratorPage() { + const { t } = useI18n('testDataGenerator'); + const { isGenerating, progress, result, error, generate, cancel, clearResult } = useGenerator(); + + // 字段配置 + const [fields, setFields] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(null); + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [editingRule, setEditingRule] = useState(null); + + // 生成选项 + const [count, setCount] = useState(100); + const [format, setFormat] = useState<'json' | 'csv'>('json'); + + // 当前标签页 + const [activeTab, setActiveTab] = useState('fields'); + + // 生成完成时显示 toast 提示(避免重复触发) + const lastToastResultRef = useRef(null); + + // 生成完成时显示 toast 提示(避免重复触发) + useEffect(() => { + if (result?.success && result.stats && result !== lastToastResultRef.current) { + lastToastResultRef.current = result; + toast.success(t('testDataGenerator_generateSuccess'), { + description: `${result.stats.total} ${t('testDataGenerator_records')}`, + }); + } + }, [result, t]); + + // 添加新字段 + const handleAddField = useCallback(() => { + setFields((prev) => { + if (prev.length >= MAX_FIELDS) return prev; + const newField: FieldConfig = { + id: generateId(), + name: `field${prev.length + 1}`, + generatorId: 'chineseName', + params: {}, + required: true, + nullRate: 0, + unique: false, + }; + setSelectedIndex(prev.length); + return [...prev, newField]; + }); + }, []); + + // 更新字段 + const handleUpdateField = useCallback((index: number, field: FieldConfig) => { + setFields((prev) => { + const newFields = [...prev]; + newFields[index] = field; + return newFields; + }); + }, []); + + // 删除字段 + const handleRemoveField = useCallback((index: number) => { + setFields((prev) => prev.filter((_, i) => i !== index)); + setSelectedIndex((prev) => { + if (prev === index) return null; + if (prev !== null && prev > index) return prev - 1; + return prev; + }); + }, []); + + // 拖拽排序 + const handleReorder = useCallback((oldIndex: number, newIndex: number) => { + setFields((prev) => { + const newFields = [...prev]; + const [moved] = newFields.splice(oldIndex, 1); + newFields.splice(newIndex, 0, moved); + return newFields; + }); + setSelectedIndex((prev) => { + if (prev === oldIndex) return newIndex; + if (prev !== null) { + if (oldIndex < prev && newIndex >= prev) return prev - 1; + if (oldIndex > prev && newIndex <= prev) return prev + 1; + } + return prev; + }); + }, []); + + // 加载规则 + const handleLoadRule = useCallback( + (loadedFields: FieldConfig[]) => { + setFields(loadedFields); + setSelectedIndex(null); + setEditingRule(null); + clearResult(); + }, + [clearResult], + ); + + // 编辑规则 + const handleEditRule = useCallback( + (rule: DataRule) => { + setFields(rule.fields); + setSelectedIndex(null); + setEditingRule(rule); + setActiveTab('fields'); + clearResult(); + toast.success(t('testDataGenerator_editingRule', { name: rule.name })); + }, + [clearResult, t], + ); + + // 保存规则成功后清除编辑状态 + const handleRuleSaved = useCallback(() => { + setEditingRule(null); + }, []); + + // 开始生成 + const handleGenerate = useCallback(() => { + if (fields.length === 0) return; + generate(fields, count, format === 'csv'); + }, [fields, count, format, generate]); + + // 获取选中的字段(-1 或 null 表示未选中) + const selectedField = selectedIndex !== null && selectedIndex >= 0 ? fields[selectedIndex] : null; + + // 打开字段编辑器 + const handleOpenEditor = useCallback((index: number) => { + setSelectedIndex(index); + setIsEditorOpen(true); + }, []); + + return ( +
+
+ {/* 主要内容区域 - 左右分栏 */} +
+ {/* 左侧面板 - 字段配置 */} +
+ {/* 标签页切换 */} +
+ + +
+ + {/* 字段配置标签页 */} + {activeTab === 'fields' && ( +
+ +
+ )} + + {/* 规则管理标签页 */} + {activeTab === 'rules' && ( +
+ +
+ )} + + {/* 生成选项 */} +
+ +
+ + {/* 生成按钮 */} +
+ +
+
+ + {/* 右侧面板 - 数据预览和结果 */} +
+ {/* 结果状态 */} + {/* 仅在失败或有警告时显示结果面板 */} + {((result && !result.success) || + (result?.warnings && result.warnings.length > 0) || + error) && ( +
+ {error ? ( +
+ {error} +
+ ) : ( + + )} +
+ )} + + {/* 数据预览 */} +
+

+ + {t('testDataGenerator_dataPreview')} +

+
+ +
+
+ + {/* 导出面板 */} + {result?.data && result.data.length > 0 && ( +
+ +
+ )} +
+
+
+ + {/* 字段编辑弹窗 */} + + +
+ {selectedField && selectedIndex !== null && ( + handleUpdateField(selectedIndex, updatedField)} + allFieldNames={fields.map((f) => f.name)} + /> + )} +
+
+ + +
+
+
+
+ ); +} diff --git a/src/types/storage.d.ts b/src/types/storage.d.ts index f5ab210..88b7bbd 100644 --- a/src/types/storage.d.ts +++ b/src/types/storage.d.ts @@ -10,7 +10,8 @@ export type PageType = | 'jwt' // JWT 解析工具 | 'jsonDiff' // JSON 差异比较工具 | 'base64Converter' // Base64 转换器工具 - | 'rightClickRestorer'; // 右键菜单恢复工具 + | 'rightClickRestorer' // 右键菜单恢复工具 + | 'testDataGenerator'; // 测试数据生成器工具 /** * JSON 工具页面子模式类型定义 diff --git a/src/types/testDataGenerator.ts b/src/types/testDataGenerator.ts new file mode 100644 index 0000000..8d141bd --- /dev/null +++ b/src/types/testDataGenerator.ts @@ -0,0 +1,178 @@ +/** + * 测试数据生成器类型定义 + */ + +/** + * 字段配置 + */ +export interface FieldConfig { + /** 字段唯一 ID */ + id: string; + /** 字段名称 */ + name: string; + /** 字段描述(可选) */ + description?: string; + /** 使用的生成器 ID */ + generatorId: string; + /** 生成器参数 */ + params: Record; + /** 是否必填 */ + required: boolean; + /** 空值率(0-100),仅在 required=false 时生效 */ + nullRate: number; + /** 是否唯一约束 */ + unique: boolean; +} + +/** + * 数据规则 + */ +export interface DataRule { + /** 规则唯一 ID */ + id: string; + /** 规则名称 */ + name: string; + /** 规则描述 */ + description?: string; + /** 字段配置列表 */ + fields: FieldConfig[]; + /** 创建时间 */ + createdAt: number; + /** 更新时间 */ + updatedAt: number; + /** 最后使用时间 */ + lastUsedAt?: number; + /** 使用次数 */ + useCount: number; +} + +/** + * 生成器参数定义 + */ +export interface GeneratorParam { + /** 参数键名 */ + key: string; + /** 参数显示名称 */ + label: string; + /** 参数类型 */ + type: 'string' | 'number' | 'boolean' | 'select' | 'array'; + /** 默认值 */ + defaultValue: unknown; + /** 是否必填 */ + required?: boolean; + /** 参数描述 */ + description?: string; + /** 仅当 type=select 时,可选值列表 */ + options?: { label: string; value: unknown }[]; + /** 最小值(仅当 type=number 时) */ + min?: number; + /** 最大值(仅当 type=number 时) */ + max?: number; + /** 占位符文本(仅当 type=string 时) */ + placeholder?: string; +} + +/** + * 生成器定义 + */ +export interface GeneratorDefinition { + /** 生成器唯一 ID */ + id: string; + /** 生成器显示名称 */ + name: string; + /** 生成器描述 */ + description: string; + /** 分类 ID */ + categoryId: string; + /** 参数定义列表 */ + params: GeneratorParam[]; + /** 生成函数 */ + generate: (params: Record) => string; + /** 在指定索引生成(用于保证唯一性时的确定性生成) */ + generateAtIndex?: (params: Record, index: number) => string; +} + +/** + * 生成器分类 + */ +export interface GeneratorCategory { + /** 分类 ID */ + id: string; + /** 分类名称 */ + name: string; + /** 分类图标 */ + icon: string; +} + +/** + * 数据生成结果 + */ +export interface GenerateResult { + /** 是否成功 */ + success: boolean; + /** 生成的数据(JSON 格式) */ + data?: Record[]; + /** 错误信息 */ + error?: string; + /** 警告信息(如某些字段生成失败) */ + warnings?: string[]; + /** 生成统计 */ + stats?: { + /** 总条数 */ + total: number; + /** 成功条数 */ + success: number; + /** 失败条数 */ + failed: number; + /** 生成耗时(毫秒) */ + duration: number; + }; +} + +/** + * 导出文件配置 + */ +export interface ExportFile { + /** 文件名(不含扩展名) */ + filename: string; + /** 文件内容 */ + content: string; + /** MIME 类型 */ + mimeType: string; +} + +/** + * 生成进度信息 + */ +export interface GenerateProgress { + /** 当前进度(0-100) */ + progress: number; + /** 已生成条数 */ + generated: number; + /** 总条数 */ + total: number; + /** 预计剩余时间(毫秒) */ + estimatedTimeLeft?: number; +} + +/** + * Worker 消息类型 + */ +export type WorkerMessage = + | { type: 'start'; payload: WorkerStartPayload } + | { type: 'progress'; payload: GenerateProgress } + | { type: 'complete'; payload: GenerateResult } + | { type: 'error'; payload: { error: string } } + | { type: 'cancel' }; + +/** + * Worker 启动参数 + */ +export interface WorkerStartPayload { + /** 字段配置列表 */ + fields: FieldConfig[]; + /** 生成数量 */ + count: number; + /** 是否生成 CSV 格式 */ + csvMode: boolean; +} diff --git a/src/utils/dataExporter.ts b/src/utils/dataExporter.ts new file mode 100644 index 0000000..0a667e9 --- /dev/null +++ b/src/utils/dataExporter.ts @@ -0,0 +1,133 @@ +/** + * 数据导出工具 + * 实现 JSON 和 CSV 格式的数据导出 + */ + +import type { ExportFile } from '@/types/testDataGenerator'; + +/** + * 数据导出器 + */ +export class DataExporter { + /** + * 将数据转换为 JSON 字符串 + */ + static toJSON(data: Record[], pretty = true): string { + return JSON.stringify(data, null, pretty ? 2 : undefined); + } + + /** + * 将数据转换为 CSV 字符串 + */ + static toCSV(data: Record[]): string { + if (data.length === 0) return ''; + + // 获取所有列名 + const headers = Array.from(new Set(data.flatMap((row) => Object.keys(row)))); + + // 构建 CSV 内容 + const rows = [ + headers.map((h) => this.escapeCSV(h)).join(','), + ...data.map((row) => headers.map((h) => this.escapeCSV(String(row[h] ?? ''))).join(',')), + ]; + + return rows.join('\n'); + } + + /** + * 转义 CSV 字段 + */ + private static escapeCSV(value: string): string { + // 如果包含逗号、引号、换行符,需要用引号包裹 + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; + } + + /** + * 根据格式导出数据 + */ + static exportByFormat(data: Record[], format: 'json' | 'csv'): string { + if (format === 'csv') { + return this.toCSV(data); + } + return this.toJSON(data); + } + + /** + * 导出为多个文件 + */ + static toMultipleFiles( + data: Record[], + formats: ('json' | 'csv')[], + filename = 'test-data', + ): ExportFile[] { + return formats.map((format) => ({ + filename, + content: this.exportByFormat(data, format), + mimeType: this.getMimeType(format), + })); + } + + /** + * 导出为单个文件 + */ + static toSingleFile( + data: Record[], + format: 'json' | 'csv', + filename = 'test-data', + ): ExportFile { + return { + filename, + content: this.exportByFormat(data, format), + mimeType: this.getMimeType(format), + }; + } + + /** + * 获取 MIME 类型 + */ + private static getMimeType(format: string): string { + const mimeTypes: Record = { + json: 'application/json', + csv: 'text/csv', + }; + return mimeTypes[format] || 'application/octet-stream'; + } + + /** + * 下载文件 + */ + static download(file: ExportFile): void { + const blob = new Blob([file.content], { type: file.mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${file.filename}.${file.mimeType === 'application/json' ? 'json' : 'csv'}`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + + /** + * 下载多个文件 + */ + static downloadMultiple(files: ExportFile[]): void { + files.forEach((file) => this.download(file)); + } + + /** + * 复制到剪贴板 + */ + static async copyToClipboard(content: string): Promise { + try { + await navigator.clipboard.writeText(content); + return true; + } catch (error) { + console.error('[DataExporter] 复制失败:', error); + return false; + } + } +} diff --git a/src/utils/ruleStorage.ts b/src/utils/ruleStorage.ts new file mode 100644 index 0000000..8253abf --- /dev/null +++ b/src/utils/ruleStorage.ts @@ -0,0 +1,339 @@ +/** + * 规则存储工具 + * 实现测试数据生成器规则的持久化存储和管理 + */ + +import type { DataRule, FieldConfig } from '@/types/testDataGenerator'; + +/** 存储键名 */ +const STORAGE_KEY = 'testDataGenerator_rules'; + +/** 最大规则数量 */ +const MAX_RULES = 20; + +/** + * 获取所有规则 + */ +export function getAll(): DataRule[] { + try { + const data = localStorage.getItem(STORAGE_KEY); + if (!data) return []; + return JSON.parse(data) as DataRule[]; + } catch (error) { + console.error('[ruleStorage] 获取规则列表失败:', error); + return []; + } +} + +/** + * 根据 ID 获取规则 + */ +export function getById(id: string): DataRule | undefined { + const rules = getAll(); + return rules.find((rule) => rule.id === id); +} + +/** + * 根据名称获取规则 + */ +export function getByName(name: string): DataRule | undefined { + const rules = getAll(); + return rules.find((rule) => rule.name === name); +} + +/** + * 获取规则数量 + */ +export function getCount(): number { + return getAll().length; +} + +/** + * 是否达到最大数量限制 + */ +export function isMaxReached(): boolean { + return getCount() >= MAX_RULES; +} + +/** + * 保存规则(新增或更新) + */ +export function save( + rule: Omit & { id?: string }, +): DataRule | null { + const rules = getAll(); + const now = Date.now(); + + // 新增规则 + if (!rule.id) { + if (isMaxReached()) { + console.warn('[ruleStorage] 规则数量已达上限'); + return null; + } + const newRule: DataRule = { + ...rule, + id: generateId(), + createdAt: now, + updatedAt: now, + useCount: 0, + }; + rules.unshift(newRule); + setAll(rules); + return newRule; + } + + // 更新规则 + const index = rules.findIndex((r) => r.id === rule.id); + if (index === -1) { + console.warn('[ruleStorage] 规则不存在:', rule.id); + return null; + } + const updatedRule: DataRule = { + ...rules[index], + ...rule, + id: rule.id, + updatedAt: now, + }; + rules[index] = updatedRule; + setAll(rules); + return updatedRule; +} + +/** + * 更新规则 + */ +export function update(id: string, updates: Partial): DataRule | null { + const rules = getAll(); + const index = rules.findIndex((r) => r.id === id); + if (index === -1) { + console.warn('[ruleStorage] 规则不存在:', id); + return null; + } + + const updatedRule: DataRule = { + ...rules[index], + ...updates, + id: rules[index].id, // 确保 ID 不被覆盖 + updatedAt: Date.now(), + }; + rules[index] = updatedRule; + setAll(rules); + return updatedRule; +} + +/** + * 删除规则 + */ +export function deleteRule(id: string): boolean { + const rules = getAll(); + const index = rules.findIndex((r) => r.id === id); + if (index === -1) { + console.warn('[ruleStorage] 规则不存在:', id); + return false; + } + rules.splice(index, 1); + setAll(rules); + return true; +} + +/** + * 复制规则 + * @param id 规则 ID + * @param copySuffix 复制后缀,默认为中文「(副本)」,可通过 i18n 传入 + */ +export function duplicate(id: string, copySuffix = '(副本)'): DataRule | null { + const rule = getById(id); + if (!rule) { + console.warn('[ruleStorage] 规则不存在:', id); + return null; + } + + if (isMaxReached()) { + console.warn('[ruleStorage] 规则数量已达上限'); + return null; + } + + const now = Date.now(); + const newRule: DataRule = { + ...rule, + id: generateId(), + name: `${rule.name}${copySuffix}`, + createdAt: now, + updatedAt: now, + useCount: 0, + }; + + const rules = getAll(); + rules.unshift(newRule); + setAll(rules); + return newRule; +} + +/** + * 记录规则使用 + */ +export function recordUse(id: string): void { + const rules = getAll(); + const index = rules.findIndex((r) => r.id === id); + if (index === -1) return; + + rules[index].useCount += 1; + rules[index].lastUsedAt = Date.now(); + setAll(rules); +} + +/** + * 搜索规则 + */ +export function search(query: string): DataRule[] { + const rules = getAll(); + const lowerQuery = query.toLowerCase(); + return rules.filter( + (rule) => + rule.name.toLowerCase().includes(lowerQuery) || + rule.description?.toLowerCase().includes(lowerQuery), + ); +} + +/** + * 获取最近使用的规则 + */ +export function getRecent(count = 5): DataRule[] { + const rules = getAll(); + return [...rules] + .filter((rule) => rule.lastUsedAt) + .sort((a, b) => (b.lastUsedAt || 0) - (a.lastUsedAt || 0)) + .slice(0, count); +} + +/** + * 导出规则(JSON 字符串) + */ +export function exportRules(ids?: string[]): string { + const rules = getAll(); + const exportData = ids ? rules.filter((r) => ids.includes(r.id)) : rules; + return JSON.stringify(exportData, null, 2); +} + +/** + * 导入规则 + */ +export function importRules(jsonString: string): { + success: number; + failed: number; + errors: string[]; +} { + const result = { success: 0, failed: 0, errors: [] as string[] }; + + try { + const data = JSON.parse(jsonString); + if (!Array.isArray(data)) { + result.failed = 1; + result.errors.push('导入数据格式错误:不是数组'); + return result; + } + + for (const item of data) { + const validation = validateRule(item); + if (!validation.valid) { + result.failed++; + result.errors.push(`规则 "${item.name || '未知'}": ${validation.error}`); + continue; + } + + const saved = save(item as Omit); + if (saved) { + result.success++; + } else { + result.failed++; + result.errors.push(`规则 "${item.name}" 保存失败`); + } + } + } catch (error) { + result.failed = 1; + result.errors.push(`导入失败: ${error}`); + } + + return result; +} + +/** + * 清空所有规则 + */ +export function clear(): void { + localStorage.removeItem(STORAGE_KEY); +} + +/** + * 保存所有规则到存储 + */ +function setAll(rules: DataRule[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(rules)); + } catch (error) { + console.error('[ruleStorage] 保存规则失败:', error); + } +} + +/** + * 生成唯一 ID + */ +export function generateId(): string { + return `rule_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * 验证规则格式 + */ +function validateRule(rule: unknown): { valid: boolean; error?: string } { + if (!rule || typeof rule !== 'object') { + return { valid: false, error: '规则格式无效' }; + } + + const obj = rule as Record; + + if (!obj.name || typeof obj.name !== 'string') { + return { valid: false, error: '缺少规则名称' }; + } + + if (!obj.fields || !Array.isArray(obj.fields)) { + return { valid: false, error: '缺少字段配置' }; + } + + for (const field of obj.fields) { + const fieldValidation = validateField(field); + if (!fieldValidation.valid) { + return { + valid: false, + error: `字段 "${(field as FieldConfig).name || '未知'}": ${fieldValidation.error}`, + }; + } + } + + return { valid: true }; +} + +/** + * 验证字段配置 + */ +function validateField(field: unknown): { valid: boolean; error?: string } { + if (!field || typeof field !== 'object') { + return { valid: false, error: '字段格式无效' }; + } + + const obj = field as Record; + + if (!obj.name || typeof obj.name !== 'string') { + return { valid: false, error: '缺少字段名称' }; + } + + if (!obj.generatorId || typeof obj.generatorId !== 'string') { + return { valid: false, error: '缺少生成器 ID' }; + } + + if (typeof obj.required !== 'boolean') { + return { valid: false, error: '缺少必填标识' }; + } + + return { valid: true }; +} diff --git a/src/workers/generator.worker.ts b/src/workers/generator.worker.ts new file mode 100644 index 0000000..5795cee --- /dev/null +++ b/src/workers/generator.worker.ts @@ -0,0 +1,165 @@ +/** + * 测试数据生成器 Web Worker + * 在后台线程中执行数据生成,避免阻塞 UI + */ + +import { getGeneratorById } from '@/lib/generators'; +import type { + FieldConfig, + WorkerMessage, + GenerateResult, + GenerateProgress, +} from '@/types/testDataGenerator'; + +// 生成结果缓存 +let generatedData: Record[] = []; +let isCancelled = false; + +/** + * Worker 消息处理器 + */ +self.onmessage = async (e: MessageEvent) => { + const data = e.data; + const { type } = data; + + switch (type) { + case 'start': + isCancelled = false; + await handleStart(data.payload); + break; + case 'cancel': + isCancelled = true; + break; + } +}; + +/** + * 处理开始生成消息 + */ +async function handleStart(payload: { + fields: FieldConfig[]; + count: number; + csvMode: boolean; +}): Promise { + const { fields, count } = payload; + generatedData = []; + + try { + // 验证所有生成器是否存在 + for (const field of fields) { + const generator = getGeneratorById(field.generatorId); + if (!generator) { + self.postMessage({ + type: 'error', + payload: { error: `生成器 "${field.generatorId}" 不存在` }, + }); + return; + } + } + + const startTime = Date.now(); + const warnings: string[] = []; + + // 生成数据 + for (let i = 0; i < count; i++) { + if (isCancelled) { + self.postMessage({ + type: 'complete', + payload: { + success: false, + error: '生成已取消', + }, + }); + return; + } + + const record: Record = {}; + + for (const field of fields) { + const generator = getGeneratorById(field.generatorId); + if (!generator) continue; + + // 空值率控制 + if (!field.required && Math.random() * 100 < field.nullRate) { + record[field.name] = null; + continue; + } + + try { + // 唯一性约束处理 + if (field.unique) { + let value: string | undefined; + const maxRetries = 100; + let retryCount = 0; + + // 小数据量:随机生成 + 重试 + if (count <= 1000) { + while (retryCount < maxRetries) { + value = generator.generate(field.params); + const isUnique = !generatedData.some((item) => item[field.name] === value); + if (isUnique) break; + retryCount++; + value = undefined; + } + } else { + // 大数据量:使用索引生成策略 + if (generator.generateAtIndex) { + value = generator.generateAtIndex(field.params, i); + } else { + // 回退到随机生成 + value = generator.generate(field.params); + } + } + + if (value !== undefined) { + record[field.name] = value; + } else { + warnings.push(`字段 "${field.name}" 第 ${i + 1} 行唯一值生成失败`); + record[field.name] = generator.generate(field.params); + } + } else { + record[field.name] = generator.generate(field.params); + } + } catch (error) { + warnings.push(`字段 "${field.name}" 第 ${i + 1} 行生成错误: ${error}`); + record[field.name] = null; + } + } + + generatedData.push(record); + + // 进度回调(每1000条) + if ((i + 1) % 1000 === 0 || i === count - 1) { + const progress: GenerateProgress = { + progress: Math.round(((i + 1) / count) * 100), + generated: i + 1, + total: count, + estimatedTimeLeft: Math.round(((Date.now() - startTime) / (i + 1)) * (count - i - 1)), + }; + self.postMessage({ type: 'progress', payload: progress }); + } + } + + const duration = Date.now() - startTime; + const successCount = generatedData.filter((item) => Object.keys(item).length > 0).length; + + const result: GenerateResult = { + success: true, + data: generatedData, + warnings: warnings.length > 0 ? warnings : undefined, + stats: { + total: count, + success: successCount, + failed: count - successCount, + duration, + }, + }; + + self.postMessage({ type: 'complete', payload: result }); + } catch (error) { + self.postMessage({ + type: 'error', + payload: { error: `生成失败: ${error}` }, + }); + } +}