Compare commits

...

141 Commits

Author SHA1 Message Date
雨霖铃 ec92afa294 refactor(i18n): 移除 chrome.i18n 国际化,统一使用中文硬编码
移除 chromeI18n 工具、_locales 翻译文件和 manifest default_locale 配置,
将所有 UI 文案改为直接硬编码中文,并更新相关测试与文档。
2026-06-19 00:26:56 +08:00
雨霖铃 618f3c36f2 refactor: 更新测试用例和组件结构
- 在背景测试中更新菜单项标题,移除图标
- 在 StorageCleaner 页面中更新初始化加载状态的中文文案
- 在 QrCode 组件中修正导入路径
- 在 DecodeResultPaper 中使用 Label 组件替换原生 label 元素
- 移除不再使用的 formatFileSize 函数
2026-06-14 09:40:05 +08:00
雨霖铃 24c83de56d refactor(Timestamp): 规范化页面组件目录结构
- 将子组件移至 components/ 子目录(LiveClock、ResultView)
- 创建 __tests__/ 目录结构
- 更新所有相关导入路径

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 21:16:12 +08:00
雨霖铃 9c5f5c8cfe refactor(StorageCleaner): 规范化页面组件目录结构
- 将子组件移至 components/ 子目录(OptionItem、AutoRefreshToggle、CleaningResult、StorageCleanerConfirm、StorageOptionsGrid、ErrorDisplay)
- 创建 __tests__/ 目录结构
- 更新所有相关导入路径

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 20:56:38 +08:00
雨霖铃 016173a9c5 refactor(JsonTools): 规范化页面组件目录结构
- 将子组件移至 components/ 子目录(JsonDiffInput、DiffResult、JsonFormatSection、JsonConvertSection、DiffNavigator、JsonTree)
- 将 diffEngine.ts 工具函数移至 src/utils/
- 提取 ConvertFunction/ConvertResult 类型到 types.ts,解决循环依赖
- 创建 __tests__/ 目录结构
- 更新所有相关导入路径

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 20:39:40 +08:00
雨霖铃 30d3561726 refactor: 规范化页面组件目录结构
- 将 QrCodePreview、ImageUploader 移动到 QrCode/components/
- 将 DecodeResultPaper、Base64ConverterSection、TextMode 移动到 Base64Converter/components/
- 提取 useTextMode Hook,遵循 UI + Hook 分离模式
- 简化 SwitchButtonGroup 组件,提取常量到外部

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 20:11:35 +08:00
雨霖铃 1f9288f780 refactor: 使用 Label 组件替换原生 label 元素,并在 DecodeResultPaper 中引入 Input 组件 2026-06-10 08:15:09 +08:00
雨霖铃 355a94d116 refactor: 将 formatFileSize 重命名为 formatBytes 并移至通用工具模块 2026-06-10 00:32:29 +08:00
雨霖铃 3f73adc015 docs: 更新文档和配置,移除未使用的权限和过时描述 2026-06-07 09:34:20 +08:00
雨霖铃 86870864a9 docs: 更新 README 和 AGENTS 文档,补充测试数据生成器模块说明 2026-06-06 23:56:05 +08:00
雨霖铃 1a285fc4c1 Merge branch 'feature/test-data-generator' into develop 2026-06-06 23:42:14 +08:00
雨霖铃 9a0c8ba850 fix: 更新 features 测试断言以匹配新增的 testDataGenerator
fix: 修复测试数据生成器多项代码问题
- useCallback 闭包问题:改用函数式 setFields 更新,避免依赖过时引用
- Worker 复用:复用已有 Worker 而非每次 generate 都销毁重建
- 导出复制反馈:复制操作完成后添加 toast 成功/失败提示
- 字段名校验:添加空值、非法字符、重复名称校验
- i18n 硬编码:GeneratorConfig 和 ruleStorage 统一使用 t() 函数
2026-06-06 23:40:36 +08:00
雨霖铃 6c7b7294b4 feat: 优化规则管理功能
- 移动保存按钮到字段配置区域
- 添加规则编辑功能,支持更新和另存为
- 编辑模式下显示编辑状态标识
- 保存时检查规则名称重复,支持覆盖保存
- 优化弹窗样式,统一风格
- 更换加载图标为 FolderOpen
- 添加搜索防抖和错误处理
- 添加国际化文本
2026-06-06 23:08:05 +08:00
雨霖铃 a2e07a9115 refactor: 删除未使用的 defaultNullRate 功能
- 移除 GenerateOptions 中的 defaultNullRate 相关 props 和 UI
- 移除 messages.json 中的 testDataGenerator_defaultNullRate 翻译 key
2026-06-06 20:08:06 +08:00
雨霖铃 2464a5fea6 feat: 字段编辑弹窗添加「完成」按钮 去除关闭按钮,添加取消按钮
- 弹窗底部添加「完成」按钮,点击关闭弹窗
- DialogContent 支持 showCloseButton prop 控制关闭按钮显示
- 底部添加取消和完成两个按钮
2026-06-06 20:06:48 +08:00
雨霖铃 78f5292b00 fix: 切换为非必填时默认空值率 100%
- 必填 → 非必填:默认空值率 100%(始终生成 null)
- 非必填 → 必填:空值率清零
2026-06-06 19:42:55 +08:00
雨霖铃 5a2589cc57 fix: 修复空值率与必填状态的逻辑
- 空值率 0% 时自动设为必填(始终生成数据)
- 空值率 > 0% 时设为非必填(按概率生成 null)
- 移除空值率 100% 自动变为必填的错误逻辑
2026-06-06 19:41:42 +08:00
雨霖铃 64db60d75e fix: 非必填字段预览显示 null
- 预览示例数据中,非必填字段显示 null 值
- 保持与实际生成逻辑一致
2026-06-06 19:36:41 +08:00
雨霖铃 7090ba3251 refactor: 数据预览改为显示示例数据,简化组件逻辑
- DataPreview 改为接收字段配置,显示一条示例数据
- 移除虚拟列表、格式切换等复杂功能
- 添加 JSON 语法高亮(键名蓝色、字符串绿色、数字琥珀色、布尔紫色)
- 优化空状态样式,垂直居中显示
- 缩短预览区域高度 (400px → 280px)
- 添加 testDataGenerator_sampleData 翻译 key
2026-06-06 19:29:27 +08:00
雨霖铃 31b6edf06b feat: 生成成功改为 toast 提示
- 使用 sonner toast 显示成功提示
- 简化提示:生成完成 + 数据条数
- 仅在失败或有警告时显示结果面板
- 使用 useRef 记录已触发的 result
- 仅在新 result 时触发 toast
2026-06-06 11:05:42 +08:00
雨霖铃 a374bf456d feat: 数据格式改为按钮组选择
- 移除 Select 下拉框
- 改为 JSON/CSV 按钮组
- 选中状态与生成数量按钮样式统一

feat: 优化空值率设置

- 增加 75% 预设选项
- 设置 100% 时自动设为必填
- 限制输入范围 0-100

fix: 空值率预设改为纯数字显示

- 5%/20%/50%/75% 统一样式

fix: 移除空值率低/中/高 i18n 翻译键

fix: 精简生成数量预设选项

- 移除 10、500
- 保留 50、100、1000、5000、10000
2026-06-06 10:52:56 +08:00
雨霖铃 ea10b27c14 feat: 简化字段卡片交互 - 点击直接打开编辑弹窗
- 移除字段卡片选中样式
- 点击字段直接打开编辑弹窗
- 简化 FieldItem 组件,移除 isSelected 属性
- 简化 FieldList 接口,onSelect 改为 onEdit

fix: 优化字段编辑弹窗

- 去除弹窗描述文字
- 字段名称增加字符计数 (x/20)
- 字段描述增加字符计数 (x/50)
- 保持 placeholder 提示

fix: 修复弹窗顶部间距问题

- DialogContent 添加 pt-6 避开关闭按钮
- 内容区域移除顶部 padding,改为 pb-6

fix: 增加弹窗顶部间距 (pt-6 → pt-8)

fix: 优化弹窗尺寸和按钮间距

- 弹窗最大宽度 360→420px 适配标签页
- 增加按钮 padding (px-2→px-3, py-1→py-1.5)
- 添加按钮增加 padding (h-8→h-9, px-3)
2026-06-06 10:43:35 +08:00
雨霖铃 40443c8fc5 fix: 优化字段编辑弹窗样式
- 去除弹窗标题
- 描述文字显示选择字段提示
- 关闭按钮置顶,不随内容滚动
- 内容区域独立滚动
2026-06-06 10:32:19 +08:00
雨霖铃 ee4a45630e fix: 限制弹窗尺寸适配 popup 显示
- 最大宽度 360px
- 最大高度 calc(100vh - 4rem)
- 内容超出时支持滚动
- 宽度自适应 viewport
2026-06-06 10:31:07 +08:00
雨霖铃 4a423889d9 feat: 字段编辑改为弹窗显示
- 添加 Dialog 组件包裹 FieldEditor
- 点击字段卡片打开编辑弹窗
- 简化布局,移除右侧字段编辑面板
- 支持点击遮罩或关闭按钮关闭弹窗
2026-06-06 10:29:44 +08:00
雨霖铃 ee78e314d3 fix: 修复拖拽时滚轮滚动不生效问题
- 改为在 document 级别监听 wheel 事件
- 使用 capture 阶段优先处理事件
- 确保在 PointerSensor 拦截前捕获滚轮事件
2026-06-06 10:20:59 +08:00
雨霖铃 ee77c97b56 feat: 拖拽时支持鼠标滚轮滚动列表
- 拖拽状态下监听 wheel 事件
- 使用 preventDefault 阻止默认滚动
- 降低滚动速度 (0.5x) 提供更精确的控制
- 自动更新虚拟列表渲染范围
2026-06-06 10:18:13 +08:00
雨霖铃 aa8b84af50 fix: 修复虚拟列表拖拽时显示空白问题
- 添加 DragOverlay 独立渲染拖拽中的项目
- 拖拽时扩展预加载范围 (OVERSCAN 2→10)
- 添加 activeId 状态跟踪拖拽项目
- 拖拽中的卡片以半透明效果独立显示
2026-06-06 10:17:16 +08:00
雨霖铃 707f17ac84 feat: 字段列表优化 - 输入限制、选中样式、取消选中
- FieldEditor: 字段名称maxLength=20,描述maxLength=50
- FieldItem: 统一有无描述时的垂直居中布局,选中左侧竖线+背景高亮
- FieldList: 点击已选中字段可取消选中,选中态加shadow和ring效果
- index: 支持selectedIndex=-1取消选中
2026-06-06 10:09:48 +08:00
雨霖铃 af0b2d8ed1 fix: 统一字段列表卡片高度,最大字段数改为40
- MAX_FIELDS: 20 → 40
- 固定卡片高度52px,无论虚拟/非虚拟模式视觉一致
- 5条以上容器高度固定,启用虚拟滚动
- 主页面添加字段时检查MAX_FIELDS限制
2026-06-06 09:58:57 +08:00
雨霖铃 49e7f1ea55 feat: 实现虚拟列表、拖拽排序、实时预览防抖,更新文档
- DataPreview: 大数据量(>100条)自动启用虚拟滚动,仅渲染可见区域
- FieldList: 基于 @dnd-kit/sortable 实现拖拽排序
- index.tsx: 配置变更时300ms防抖生成预览数据
- ui-design.md: 去除平板/移动端响应式、快捷键,更新交互说明
- technical-implementation.md: 去除downloadAsZip,更新虚拟列表说明
2026-06-06 09:42:58 +08:00
雨霖铃 1a92e88934 feat: 实现测试数据生成器完整功能
- 添加类型定义 (FieldConfig, DataRule, GeneratorDefinition 等)
- 实现 21 个数据生成器 (个人信息/业务数据/技术数据/基础类型)
- 实现 Web Worker 后台数据生成引擎
- 实现规则存储与管理功能
- 实现 JSON/CSV 数据导出功能
- 开发完整 UI 组件 (字段管理/生成器选择/数据预览/导出面板等)
- 集成到应用功能配置,添加国际化支持

功能特性:
- 实时预览生成的数据
- 字段配置 (名称/描述/必填/空值率/唯一性约束)
- 规则管理 (保存/加载/搜索/导入导出)
- Web Worker 后台生成,支持进度显示和取消
- 响应式布局,支持多种设备

docs: 更新 TASKS.md 所有子任务复选框为已完成状态
2026-06-06 09:29:12 +08:00
雨霖铃 2b84eda24f docs: 统一测试数据生成器文档,移除 SQL/TypeScript 导出,修复接口命名不一致
- 移除所有文档中的 SQL 和 TypeScript 导出格式引用
- 统一数据导出格式为 JSON 和 CSV
- 移除 GeneratorDefinition 中的 icon 字段
- 更新 generatorCategories 配置,包含所有 21 个生成器
- 统一 rule-management.md 和 technical-implementation.md 中的接口名称为 DataRule
- 更新 ui-design.md 中的界面设计,移除 SQL/TypeScript 相关元素
2026-06-06 09:04:53 +08:00
雨霖铃 7f22b95aa2 refactor: 移除不必要的 export 关键字
中优先级任务:将仅内部使用的函数和类型移除 export 关键字

移除 export 的函数:
- uiPopover: showPopover
- storageCleaner: injectClearLocalStorage, injectClearSessionStorage,
  injectClearIndexedDB, injectClearCacheStorage, injectUnregisterServiceWorkers

移除 export 的类型和常量:
- base64Converter: SUPPORTED_IMAGE_TYPES, SUPPORTED_IMAGE_EXTENSIONS,
  TextToBase64Result, Base64ToBlobResult
- contextMenu: ContextMenuItemConfig, ContextMenuClickedInfo, ParseResult
- jsonFormatter: JsonMinifyResult
- jsonToToml: JsonToTomlResult
- jsonToYaml: JsonToYamlResult
- jwt: JwtHeader, JwtPayload
- qrCodeParser: QrCodeParseResult

所有 473 个测试通过,类型检查通过
2026-06-06 01:00:10 +08:00
雨霖铃 9990d86270 refactor: 删除高优先级死代码
删除未使用的代码以提高代码质量和可维护性:

删除的文件:
- src/pages/Dashboard/ToolCard.tsx:未被使用的组件
- src/utils/useDebounce.ts:未被使用的 hook
- src/components/__tests__/ToolCard.test.tsx:对应组件的测试文件

删除的导出函数:
- chromeTabs: getActiveTab, getActiveTabDomain, ensureContentScriptInjected
- clipboard: copyImageToClipboard
- storageCleaner: isEmptyResult
- chromeI18n: preloadNamespaces
- useContextMenuData: clearContextMenuData

更新了对应的测试文件以保持一致性

总计删除 525 行代码,所有 473 个测试通过
2026-06-06 01:00:10 +08:00
雨霖铃 ea0552e8aa feat: 替换项目图标为自定义 logo
- 替换 public/icon/ 目录下所有 PNG 图标文件为新 logo
- 删除 public/wxt.svg(不再需要)
- 在 wxt.config.ts 中添加 icons 和 default_icon 配置
- 更新 public/README.md 文档
2026-06-06 01:00:10 +08:00
雨霖铃 97098c3abe refactor: 移除不必要的 export 关键字
中优先级任务:将仅内部使用的函数和类型移除 export 关键字

移除 export 的函数:
- uiPopover: showPopover
- storageCleaner: injectClearLocalStorage, injectClearSessionStorage,
  injectClearIndexedDB, injectClearCacheStorage, injectUnregisterServiceWorkers

移除 export 的类型和常量:
- base64Converter: SUPPORTED_IMAGE_TYPES, SUPPORTED_IMAGE_EXTENSIONS,
  TextToBase64Result, Base64ToBlobResult
- contextMenu: ContextMenuItemConfig, ContextMenuClickedInfo, ParseResult
- jsonFormatter: JsonMinifyResult
- jsonToToml: JsonToTomlResult
- jsonToYaml: JsonToYamlResult
- jwt: JwtHeader, JwtPayload
- qrCodeParser: QrCodeParseResult

所有 473 个测试通过,类型检查通过
2026-06-06 00:55:05 +08:00
雨霖铃 7afce94d93 refactor: 删除高优先级死代码
删除未使用的代码以提高代码质量和可维护性:

删除的文件:
- src/pages/Dashboard/ToolCard.tsx:未被使用的组件
- src/utils/useDebounce.ts:未被使用的 hook
- src/components/__tests__/ToolCard.test.tsx:对应组件的测试文件

删除的导出函数:
- chromeTabs: getActiveTab, getActiveTabDomain, ensureContentScriptInjected
- clipboard: copyImageToClipboard
- storageCleaner: isEmptyResult
- chromeI18n: preloadNamespaces
- useContextMenuData: clearContextMenuData

更新了对应的测试文件以保持一致性

总计删除 525 行代码,所有 473 个测试通过
2026-06-06 00:49:57 +08:00
雨霖铃 e2b76a5f86 feat: 替换项目图标为自定义 logo
- 替换 public/icon/ 目录下所有 PNG 图标文件为新 logo
- 删除 public/wxt.svg(不再需要)
- 在 wxt.config.ts 中添加 icons 和 default_icon 配置
- 更新 public/README.md 文档
2026-06-05 23:57:04 +08:00
雨霖铃 1ab79785b6 docs(AGENTS.md): 添加 Git Commit 必须使用中文描述的规范 2026-06-05 23:41:04 +08:00
雨霖铃 c40b66b703 docs: 更新测试数据生成器功能设计文档
主要更新:
- 添加字段必填/选填设置和空值率配置
- 移除标签管理系统(20条规则无需标签)
- 添加组合策略保证唯一性生成
- 添加价格生成器(真实分布/心理定价)
- 添加场景化生成器(年龄/数量/评分/折扣/库存)
- 更新所有文档移除emoji,使用纯文字
- 更新类型定义和生成逻辑
2026-06-05 23:40:36 +08:00
雨霖铃 f55fd4338d docs(AGENTS.md): 添加 Git Commit 必须使用中文描述的规范 2026-06-05 22:54:29 +08:00
雨霖铃 27c1cf37e3 chore: update .gitignore with common ignore patterns
- Add environment variable files (.env, .env.local, .env.*.local)
- Add build output directories (dist/, build/)
- Add test coverage directory (coverage/)
- Add IDE workspace files (*.code-workspace)
- Add TypeScript build info files (*.tsbuildinfo, *.tsxbuildinfo)
- Remove docs/* to track documentation files
2026-06-05 22:53:39 +08:00
雨霖铃 2520d72eb1 refactor: 移动 CopyButton 测试文件到统一 __tests__ 目录 2026-06-05 21:57:19 +08:00
LingandRX 4d038203a7 fix(CopyButton): 修复 review 反馈并补充测试 (#66)
* fix: remove non-functional animation classes from CopyButton
* test(CopyButton): 补充单元测试覆盖边界场景
* fix: unify CopyButton imports to named export across all consumers
* fix(CopyButton): add aria-label for screen reader accessibility
2026-06-05 21:16:12 +08:00
LingandRX 2c45ddada9 fix: add missing Toaster component to enable toast display (#65)
The project was calling toast.success/error/warning from sonner in 7 files,
but the <Toaster> component was never rendered in the React tree. This meant
sonner stored toast data internally but no UI was ever displayed to users.

Changes:
- Create src/components/ui/sonner.tsx with shadcn/ui-style Toaster wrapper
  that integrates with ThemeModeProvider for light/dark theme support
- Add <Toaster /> to AppRoot.tsx so it works across all entry points
  (popup, sidepanel, browser-tab)

fix: always convert dots to underscores in i18n t() key lookup

The t() function only converted dots to underscores when the key contained
a colon (namespace:key format). Keys using dot notation like
'messages.copySuccess' were passed directly to chrome.i18n.getMessage()
without dot-to-underscore conversion, causing lookups to fail silently and
return empty strings - resulting in toast notifications with no text.

Now all separators (both ':' and '.') are consistently converted to '_'
regardless of format, matching the messages.json key convention.

fix: position toast at bottom-center instead of bottom-right
2026-06-05 19:34:09 +08:00
LingandRX a0b1ade662 Merge branch 'main' into develop 2026-06-05 13:10:34 +08:00
雨霖铃 d964e27c9d fix(hooks): 用 merge-base 替代 rev-parse 修复 pre-push 变更文件检测 2026-06-05 13:02:30 +08:00
LingandRX 359326c6da Hotfix/contextmenu srcurl generating fix (#63)
* fix: 修复右键菜单 srcUrl 误判、pre-push 全量扫描、generating 状态不可见问题

- contextMenu: srcUrl 检查限定为 qrCode-image 菜单项,避免其他菜单误返回图片 URL
- pre-push: eslint 改为只检查变更文件(git diff 过滤),tsc 保留全项目检查
- useQrCode: confirmGenerate 用 setTimeout 延迟生成,让 loading 状态先被 React 渲染
- tests: 生成相关测试改为 async + waitFor 适配异步 confirmGenerate

* fix(hooks): pre-push 无 upstream 时回退到 origin/main 对比
2026-06-05 12:53:19 +08:00
雨霖铃 10644fae95 fix(hooks): pre-push 无 upstream 时回退到 origin/main 对比 2026-06-05 12:50:55 +08:00
雨霖铃 77f722b575 fix: 修复右键菜单 srcUrl 误判、pre-push 全量扫描、generating 状态不可见问题
- contextMenu: srcUrl 检查限定为 qrCode-image 菜单项,避免其他菜单误返回图片 URL
- pre-push: eslint 改为只检查变更文件(git diff 过滤),tsc 保留全项目检查
- useQrCode: confirmGenerate 用 setTimeout 延迟生成,让 loading 状态先被 React 渲染
- tests: 生成相关测试改为 async + waitFor 适配异步 confirmGenerate
2026-06-05 12:49:43 +08:00
LingandRX 0624b609be Merge pull request #62 from LingandRX/hotfix/remove-unused-alarms
hotfix: remove unused alarms permission
2026-06-02 19:10:26 +08:00
Ubuntu f551428142 hotfix: remove unused alarms permission 2026-06-02 19:09:39 +08:00
LingandRX 1ae057ccfa Merge pull request #61 from LingandRX/hotfix/revert-version
hotfix: revert package.json version to 1.0.0
2026-06-02 14:59:34 +08:00
Ubuntu 400a1afb0c hotfix: revert package.json version to 1.0.0 2026-06-02 14:58:54 +08:00
LingandRX c6d0914d33 Merge pull request #60 from LingandRX/develop
fix: update _locales path in vitest.setup.ts
2026-06-02 14:38:05 +08:00
Ubuntu 7617e317f2 fix: update _locales path from zh to zh_CN in vitest.setup.ts 2026-06-02 14:35:56 +08:00
LingandRX 396b3d7013 Merge pull request #59 from LingandRX/develop
fix: correct default_locale to zh_CN
2026-06-02 14:18:20 +08:00
雨霖铃 92fb853329 fix: correct default_locale to zh_CN to match _locales directory 2026-06-02 14:16:33 +08:00
雨霖铃 e4e5464be0 chore(hooks): 优化 lint-staged 和 git hooks 配置
- lint-staged: 本地开发仅自动修复不阻塞,CI 环境严格检查
- pre-commit: 后台运行 tsc 类型检查,前台运行 lint-staged
- pre-push: 新增严格检查闸门(tsc + eslint --max-warnings=0)
2026-05-30 21:31:12 +08:00
LingandRX f4b6ae89b5 Merge pull request #58 from LingandRX/develop
chore: merge develop into main (v1.x.x)
2026-05-30 21:20:52 +08:00
雨霖铃 37bdeb5f3a fix(qrcode): 去除已上传图片区域重复的文本预览
- 移除图片预览区域中的解析结果文本预览
- 解析结果只在下方的 TextInputArea 中显示
- 避免重复显示相同内容
2026-05-30 21:03:35 +08:00
雨霖铃 e919af6857 fix(qrcode): 修复图片右键菜单功能描述和检测逻辑
- 修改菜单标题为「解析图片二维码」更准确
- 改进 isImageUrl 函数,支持更多图片URL格式
- 支持 data:image/ 格式
- 支持URL路径中包含图片相关关键词
- 更新国际化文案和测试用例
2026-05-30 21:01:33 +08:00
雨霖铃 5be15301b5 feat(qrcode): 实现图片转二维码右键菜单功能
- 添加图片类型的右键菜单配置
- 修改 parseContextMenuClick 支持 srcUrl 参数
- 实现图片URL自动解析功能
- 检测图片URL自动切换到解析模式
- 更新测试用例覆盖新功能
- 新增国际化文案
2026-05-30 20:58:28 +08:00
雨霖铃 306600a35c refactor(qrcode): 优化二维码转文本布局,与文本转二维码风格统一
- 未上传图片时只显示上传区域
- 上传图片后显示小图预览 + 解析结果
- 添加重新上传按钮
- 解析结果文本限制显示长度
- 新增国际化文案(已上传图片、重新上传、解析中等)
- 适配popup窗口紧凑布局
2026-05-30 20:51:48 +08:00
雨霖铃 a0345b384d style(qrcode): 优化预览态布局适配popup窗口
- 调整文本预览区域高度,单行显示截断文本
- 缩小二维码图片尺寸(56x56 -> 48x48)
- 减少内边距和间距,更紧凑的布局
- 确保完整显示在popup窗口中无需滚动
2026-05-30 20:46:47 +08:00
雨霖铃 9458d62a99 feat(qrcode): 右键菜单传入URL时自动生成二维码
- 右键菜单传入URL时直接生成二维码,跳转到预览态
- 保留手动输入文本并点击生成按钮的功能
- 优化用户体验,简化操作流程
2026-05-30 20:38:23 +08:00
雨霖铃 5b26287cff refactor(qrcode): 优化布局逻辑,输入态隐藏预览区域
- 输入态:只显示输入框和生成按钮,隐藏二维码预览
- 预览态:上方显示文本预览(超长文本截断),下方显示二维码
- 点击编辑返回输入态,隐藏二维码预览
- 更新测试用例适配新布局
2026-05-30 20:29:12 +08:00
雨霖铃 6dca673a5a refactor(qrcode): 优化文本转二维码为两步操作模式
- 将实时预览改为显式触发生成,用户体验更清晰
- 新增输入态(input)和预览态(preview)两种状态
- 输入态:显示输入框和生成按钮,右侧显示占位提示
- 预览态:显示只读文本预览和编辑按钮,右侧显示二维码
- 保存上次输入内容,返回编辑态时自动回填
- 添加空输入校验和错误提示
- 更新国际化文案
- 完善单元测试覆盖新功能
2026-05-30 20:23:05 +08:00
雨霖铃 afd57bb5b2 Merge branch 'main' into develop 2026-05-30 19:47:16 +08:00
LingandRX 3f447708b7 fix: replace undefined formatByteSize with formatBytes (#57)
JsonConvertSection.tsx and JsonFormatSection.tsx were calling
formatByteSize which doesn't exist. The correct function name
imported from @/utils/format is formatBytes.

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
2026-05-30 12:55:04 +08:00
雨霖铃 355ab0cf86 Merge remote-tracking branch 'origin/main' into develop 2026-05-29 22:59:10 +08:00
雨霖铃 13113f0ea2 refactor(StorageCleanerConfirm.test.tsx): update IndexedDB label to Chinese translation 2026-05-29 22:42:59 +08:00
雨霖铃 0d69695dbb refactor: simplify Timestamp and RightClickRestorer
Timestamp:
- Extract shared utilities (msToUnit, dayjsFromTimestamp, ModeType)
- Remove unnecessary useCallback/useMemo/React.memo
- Remove extra info section (relative time, ISO 8601, UTC)
- Fix missing translation key timestamp_unitS

RightClickRestorer:
- Remove website badge (右键已解锁 overlay)
- Remove mouse penetration logic for media elements
- Remove unnecessary useCallback
- Remove redundant setIsLoading call

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:40:11 +08:00
雨霖铃 093bf278da refactor: replace require with import for JSON module in vitest.setup.ts 2026-05-29 22:08:04 +08:00
雨霖铃 f9fbc79612 refactor(storageCleaner): simplify architecture and improve type semantics
- Remove RELOAD_TAB message chain, use chrome.tabs.reload() directly
- Rename IndexedDB label to '站点存储' for accuracy
- Introduce StorageSizeInfo type to distinguish bytes vs count
- Rename totalSize to totalBytes for clarity
- Merge runCleanScript into runScript to reduce duplication
- Rename CleaningResult.success to overallSuccess to avoid confusion
- Remove unused domain state and setDomain call

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 21:59:54 +08:00
雨霖铃 1b9f6d044d refactor: unify formatBytes usage and simplify useContextMenuData
1. Remove formatSize/formatByteSize delegation functions:
   - storageCleaner.ts:formatSize (was just formatBytes wrapper)
   - textStatistics.ts:formatByteSize (was just formatBytes wrapper)
   - Update all callers to import formatBytes directly from @/utils/format

2. Simplify useContextMenuData hook:
   - Remove unnecessary useCallback wrapping
   - Inline checkAndConsumeData logic directly in useEffect
   - Eliminate callback->effect dependency cycle

-69 lines, +67 lines (net -2 lines, but removes 2 indirection layers)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 21:08:27 +08:00
雨霖铃 b41cfb2a02 refactor: remove unnecessary useMemo calls across components
Replace over-cached simple computations with direct expressions:

- popup/App.tsx: getEntryPointType() and routerConfig (values are stable)
- Dashboard/index.tsx: visibleSet, visibleFeatures, recentFeatures
  (Set creation + array filter/map are cheaper than useMemo overhead)
- useTextStatistics.ts: getTextStats(text) (simple string processing)
- ThemeModeProvider.tsx: contextValue object (setMode is stable via useCallback)

-39 lines, +28 lines (net -11 lines)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:59:03 +08:00
雨霖铃 93019f56cf refactor(RouterContainer): remove unnecessary useMemo and hoist constant
- Replace useMemo with direct expression for animationClass
- Move entryPointType to module scope (value is fixed after mount)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:55:05 +08:00
雨霖铃 2727e60025 refactor: replace GlobalSnackbar with sonner toast
Remove the custom 373-line GlobalSnackbar component and its Provider/Hook
in favor of the already-installed sonner library.

Changes:
- Delete src/components/GlobalSnackbar.tsx (+ tests)
- Remove SnackbarProvider from popup/App.tsx and sidepanel/App.tsx
- Replace useSnackbar() calls with toast.success()/toast.error() in:
  - ImageUploader.tsx
  - useQrCode.ts
  - ParsePanel.tsx
  - LiveClock.tsx
- Update test mocks to use sonner instead of GlobalSnackbar

-554 lines, +43 lines (net -511 lines)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:47:16 +08:00
雨霖铃 c2e54b34cb refactor(base64Converter): simplify utility functions
- Mark formatFileSize() as @deprecated, delegate to formatBytes
- Replace manual for-loop in base64ToBytes() with Uint8Array.from()
- Replace nested loops in sniffMimeFromBytes() with Array.every()

5 insertions(+), 23 deletions(-)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:38:06 +08:00
雨霖铃 dbdec710ef refactor(storageCleaner): extract executeScript helpers
Extract two generic helpers to eliminate repetitive executeScript boilerplate:

- runScript<T>(): for size/query operations (5 functions)
- runCleanScript(): for cleanup operations (5 functions)

Also add CLEAN_OPTION_KEYS constant shared by formatCleaningResult
and isEmptyResult.

- 428 lines → 379 lines (-49 lines)
- Eliminates ~100 lines of duplicated try/catch/result parsing

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:32:58 +08:00
雨霖铃 7bd52bfc2b refactor(background): simplify RELOAD_TAB with setTimeout
Replace the 30-line chrome.alarms API implementation with a simple
setTimeout for delayed tab reloads.

Rationale: The only usage is a 1-second delay after storage cleaning.
Service workers don't go idle within 1 second, so alarms' persistence
benefit is unnecessary here.

- Removed: alarm naming, create/clear, listener register/unregister
- Added: 4-line setTimeout conditional

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:22:44 +08:00
雨霖铃 c9fe27a26b refactor(background): extract main world injection script
Move the 62-line inline main world injection function from background.ts
to a dedicated utils/rightClickInjection.ts file.

This separates the RightClickRestorer's DOM patching logic from the
service worker's message routing concerns.

- background.ts: -62 lines, now delegates to imported function
- utils/rightClickInjection.ts: new file with mainWorldInjectionScript()

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:20:11 +08:00
雨霖铃 e97f4cae9a refactor(RightClickRestorer): simplify setDomain logic
Replace nested if/try/catch with a single ternary expression.
isUnsupportedPage() already handles invalid URLs, so new URL() is safe here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:15:54 +08:00
雨霖铃 21a6430f0d refactor(content): simplify content script structure
- Remove messageHandler.ts (6-line pass-through facade)
- Import contextMenuHandler directly from content.ts
- Replace local getI18nText() with shared getMessage() from utils/chromeI18n

Reduces content script files from 3 to 2 and eliminates i18n logic duplication.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:10:45 +08:00
雨霖铃 dff2705ef8 docs(AGENTS): update i18n section to reflect chrome.i18n migration
- Replace react-i18next docs with chrome.i18n API documentation
- Update translation file paths from i18n/locales/ to public/_locales/
- Update test mock description for chromeI18n
- Remove references to useLazyTranslation, vendor-i18n chunk, and snackbar

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 19:56:52 +08:00
雨霖铃 f8c48a3047 docs: add comprehensive visual style guide
Add VISUAL_STYLE_GUIDE.md to standardize UI development:
- Design principles and keywords
- Complete color system (CSS variables, semantic usage)
- Typography hierarchy and patterns
- Spacing, layout, and container specs
- Border radius and shadow system
- Component specs (Button, Input, Card, Dialog, etc.)
- Interaction and animation guidelines
- Dark mode implementation rules
- Tool color palette and assignment
- Code organization standards
- Anti-patterns checklist
- Quick reference appendix

Update .gitignore to allow tracking the style guide.
2026-05-29 19:51:44 +08:00
雨霖铃 488e2e8e49 chore: add initial CLAUDE.md file with reference to @AGENT.md 2026-05-29 19:51:16 +08:00
雨霖铃 98abe59731 fix(StorageCleaner, Base64Converter): fix layout padding and checkbox alignment
- StorageCleaner: add px-3.5 to grid container to prevent content sticking to border
- StorageCleaner: adjust select-all bar to pl-3.5 pr-7 for checkbox alignment with grid items
- Base64Converter: add px-2 to TextMode and Base64ConverterSection for consistent spacing

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 19:38:28 +08:00
雨霖铃 77168e2652 refactor(StorageCleaner): improve style consistency and checkbox alignment
- Group storage options, auto-refresh toggle, and clean button into unified card
- Remove card wrappers from StorageOptionsGrid and AutoRefreshToggle (now sections within parent card)
- Align OptionItem and Select All checkboxes with consistent px-3.5 padding
- Distinguish indeterminate vs checked checkbox states visually
- Standardize spacing (space-y-4), add select-none to page root
- Fix import path for StorageCleanerConfirm to relative ./
2026-05-28 23:43:02 +08:00
雨霖铃 4f7e402dba refactor(pages): apply UI+Hook separation and unify component naming
- Dashboard, RightClickRestorer: rename page component to Index
- Jwt: extract useJwt.ts hook + JwtSection.tsx sub-component (127→76 lines)
- TextStatistics: extract useTextStatistics.ts hook (63→48 lines)
- JsonTools: extract useJsonTools.ts hook + constants.ts (199→109 lines)
- All pages now follow CODING_STANDARDS.md §11 pattern
2026-05-28 23:18:58 +08:00
雨霖铃 e33761381d docs: add comprehensive page development standards (CODING_STANDARDS.md §11)
- 新增 §11 页面开发规范(10 个子节),覆盖目录结构分4级、入口组件命名统一为 Index、业务 Hook 内部结构顺序、常量/子组件/存储键/模式切换/布局约定等规范
- 新增 §11.9 页面开发检查清单(10 步逐项确认)
- 更新 AGENTS.md 和 copilot-instructions.md 同步引用新规范章节
- 修正 useLazyTranslation → useI18n 与实际代码保持一致
2026-05-28 23:09:14 +08:00
雨霖铃 6bddc4fcfc Merge remote-tracking branch 'origin/main' into develop 2026-05-28 22:35:39 +08:00
雨霖铃 3df45f491c docs: 更新文档,移除设置页面引用
- README.md: 移除 entrypoints/options/ 目录说明
- AGENTS.md: 更新项目结构描述,移除 options/ 入口点
2026-05-28 22:20:16 +08:00
雨霖铃 0054e97a0a refactor: 移除设置页面
- 删除 options 入口点目录及相关文件
- 移除 wxt.config.ts 中 options_ui 配置
- 移除 TopBar 组件中的设置按钮和 onOpenOptions prop
- 移除 popup/sidepanel App.tsx 中的 handleOpenOptions
- 移除相关测试中的设置页面引用
- 移除 messages.json 中设置相关翻译键
- 通过 lint、typecheck、test 验证
2026-05-28 22:15:56 +08:00
雨霖铃 97159cb799 fix: useI18n fallback for empty string translations from chrome.i18n 2026-05-28 21:53:35 +08:00
雨霖铃 5175677b24 feat: improve search history to show recently used tools with direct navigation
- Store feature keys (PageType) in history instead of translated labels
- Show icon + name + description in history items (same as search results)
- Click or Enter on history item navigates directly to the tool page
- Remove History icon import (no longer needed)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 21:38:18 +08:00
雨霖铃 3400ffc9dc feat: optimize TopBar search with Cmd+K shortcut and improved styling
- Add global Cmd/Ctrl+K keyboard shortcut to focus search input
- Show keyboard shortcut hint (⌘K) in search input when empty
- Improve input styling: rounded-lg, softer borders, focus transitions
- Improve dropdown: rounded-lg, larger shadow, better spacing
- Larger result icons (h-8 w-8) with rounded-lg containers
- Refined history section with uppercase label and muted icon
- Search icon fades in on focus via group-focus-within

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 21:34:16 +08:00
雨霖铃 4955f617ee fix: remove redundant search input from Dashboard (header already has one)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 21:28:52 +08:00
雨霖铃 2cb5b0c1e7 feat: replace ToolCard grid with compact icon+name grid on Dashboard
- Replace large ToolCard layout with dense grid (3-6 columns responsive)
- Each item shows icon + tool name in a compact tile
- Hover highlights icon and text with smooth transitions
- Remove ToolCard import from Dashboard (component retained for reuse)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 21:25:47 +08:00
雨霖铃 c6cadda95b feat: redesign Dashboard layout with search and recently used tools
- Add search input at top to filter tools by name/description
- Add 'recently used' section showing last 3 used tools as compact chips
- Add 'all tools' section header for the tool card grid
- Track tool usage via new 'app/recentlyUsedTools' storage key
- Update navigateTo to record recently used tools (max 3, LRU order)
- Add i18n keys: dashboard_searchPlaceholder, dashboard_recentlyUsed, dashboard_allTools

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 21:22:30 +08:00
雨霖铃 067a20fb0a feat: remove HTML-Markdown conversion features (htmlToMarkdown & markdownToHtml)
Removed both conversion tools and all related code:
- Deleted page components: HtmlToMarkdown, MarkdownToHtml
- Deleted utility functions: htmlToMarkdown.ts, markdownToHtml.ts
- Deleted unit tests for both utilities
- Removed feature configs and PageType entries from storage.d.ts
- Removed preview mode types and StorageSchema keys
- Removed all i18n translation keys from messages.json
- Removed vendor-markdown chunk from wxt.config.ts
- Removed marked dependency from package.json
- Updated feature count in tests (11 -> 9, page order 10 -> 8)
- Updated README, AGENTS.md, and copilot-instructions.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 21:08:15 +08:00
雨霖铃 ca4bfc33fd refactor: clean up unused code and simplify imports
- Remove unused imports and variables across 35 files
- Simplify component logic and remove dead code
- Clean up test files by removing unnecessary setup
- Streamline CI workflow configuration

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 20:57:08 +08:00
雨霖铃 e140af7698 refactor: remove meaningless comments
Remove 50+ noise comments across the codebase:
- AI-generated verbose prose (💡 emoji, '超进化', '大闸', etc.)
- Comments that restate what the code obviously does
- Comments about deleted code
- Import-level noise comments
- Overly verbose Chinese section markers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 20:37:52 +08:00
雨霖铃 b79fe7dd49 refactor: reorganize directory structure into src/
Move all source code directories into src/ for cleaner project structure:
- pages/, components/, utils/, config/, providers/, types/, lib/, assets/, entrypoints/ → src/
- Use WXT srcDir config to resolve @/ alias to src/
- Update tsconfig, vitest, eslint, tailwind configs
- Remove scattered README.md files from subdirectories
- Update documentation (AGENTS.md, CODING_STANDARDS.md, README.md)
- Fix pre-existing lint error in RouterProvider.tsx

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 20:20:45 +08:00
雨霖铃 9903483f42 Merge branch 'feature/chrome-i18n' into develop
迁移 i18n 系统从 react-i18next 到 chrome.i18n:
- 移除 react-i18next 依赖,使用 chrome.i18n API
- 生成 _locales/zh/messages.json(245 个翻译 key)
- 清理未使用的翻译 key
- 移除语言切换按钮和英文翻译
2026-05-28 19:50:51 +08:00
雨霖铃 08911d4cba chore: 清理未使用的 i18n key(322 → 245)
移除 77 个未被源码引用的翻译 key,包括:
- jsonFormat_* (17): 未接入的子模式标题和按钮
- buttons_* (15): 代码使用 common: 命名空间格式,直接 key 未引用
- qrCode_* (14): 未使用的页面标题和状态提示
- timestamp/common/storageCleaner/jsonDiff 等零散未用 key

保留了通过模板字面量动态引用的 key(storageCleaner_options_*、common_buttons_themeMode_*、jsonFormat_*InputPlaceholder 等)。

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 19:48:38 +08:00
雨霖铃 1daf049737 refactor: 移除英文翻译,仅保留中文
删除 public/_locales/en/ 目录,清理 chromeI18n.ts 中未使用的语言切换相关导出。

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 19:27:15 +08:00
雨霖铃 8b6bac9f2c refactor: 移除语言切换按钮
chrome.i18n 语言由浏览器设置决定,不支持运行时切换,移除 TopBar 中的语言切换按钮及相关逻辑。

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 19:23:57 +08:00
雨霖铃 aa23a263fe refactor: 迁移 i18n 系统从 react-i18next 到 chrome.i18n
- 移除 react-i18next、i18next 及相关依赖
- 删除旧的 i18n/ 目录和 useLazyTranslation 工具
- 新增 utils/chromeI18n.ts 类型安全 wrapper(useI18n Hook + getMessage)
- 生成 public/_locales/{zh,en}/messages.json(298 个翻译 key)
- 批量更新 39+ 组件文件的导入和翻译调用
- 转换翻译键格式:namespace:key → namespace_key
- 修复 ErrorBoundary/PageErrorBoundary 从 withTranslation HOC 改为直接调用 getMessage
- 更新 vitest.setup.ts mock 加载实际翻译文本
- 修复 11 个测试文件的断言以匹配中文翻译
- TypeScript、ESLint、547 项测试全部通过

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 19:21:54 +08:00
雨霖铃 64e23994a6 fix(test): 修复 CopyButton mock 未捕获 clipboard writeText 异常 2026-05-28 00:39:47 +08:00
雨霖铃 528621f16e fix(tests): 修复 CI 失败的测试 - 补充 withTranslation 和 CopyButton mock
- vitest.setup.ts: react-i18next mock 添加 withTranslation HOC(读取 zh/common.json 翻译)
- vitest.setup.ts: 添加 @/components/CopyButton 全局 mock
- App 测试: 补充 withTranslation 导出
- TextMode 测试: 修复多个 CopyButton 实例导致的 getByTestId 冲突,改用 getByRole
2026-05-28 00:13:09 +08:00
雨霖铃 c735a0aa1a refactor(TextInputArea): 复用 CopyButton 组件替换内联复制逻辑 2026-05-27 22:55:28 +08:00
雨霖铃 62c880b0a8 fix(CopyButton, LiveClock): 修改按钮大小为 'icon',移除硬编码颜色配置 2026-05-27 17:07:39 +08:00
雨霖铃 e263b63ced fix(pages): 修复 TypeScript 错误、硬编码字符串,移除死代码
- 修复 LiveClock.tsx CopyButton size 属性类型错误 (small → sm)
- 修复 ToolCard.tsx 紫色 RGB 值拼写错误 (147,51,2 purple → 147,51,232)
- 替换 4 处硬编码中文为 i18n 调用 (StorageCleaner, JsonTools)
- MarkdownToHtml 预览链接色改为 CSS 变量以支持暗黑模式
- 移除 Base64Converter 中已被 Base64ConverterSection 替代的死代码
  (FileMode.tsx, ImageMode.tsx 及其测试文件)
- 更新 README.md 移除对已删除文件的引用

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Affected pages: Dashboard, Timestamp, Jwt, JsonTools, Base64Converter, HtmlToMarkdown, MarkdownToHtml, QrCode, TextStatistics
2026-05-25 20:29:12 +08:00
雨霖铃 af2262d2c9 fix: remove unnecessary animations from RightClickRestorer page 2026-05-25 20:15:23 +08:00
雨霖铃 e5ae802ab2 fix: remove unnecessary animations from StorageCleaner page
- Remove entrance animations (animate-in, fade-in, zoom-in) from all components
- Remove scale effect (active:scale-[0.99]) from clean button
- Remove transition effects (transition-all, transition-colors) from OptionItem, StorageOptionsGrid, AutoRefreshToggle
- Remove bouncing animation from error icon
- Remove pulsing animation from warning banner
- Keep animate-spin on button loading spinner as functional indicator
2026-05-25 20:11:39 +08:00
雨霖铃 dec43f89da docs: 更新 AGENTS.md,完善 CI 步骤和存储键名格式说明 2026-05-25 19:21:22 +08:00
雨霖铃 61e741f198 docs: 更新 AGENTS.md,添加测试工具和翻译文件结构说明 2026-05-25 19:15:31 +08:00
雨霖铃 c98f767809 Merge branch 'main' into develop 2026-05-24 08:44:32 +08:00
Ubuntu 63f47fd2b1 Merge branch 'main' into develop 2026-05-23 18:58:15 +08:00
Ubuntu e95ca1297c merge: resolve conflict by taking main version of release.yml 2026-05-23 14:46:44 +08:00
Ubuntu c8ad2a2283 fix(ci): fix release workflow artifact upload issue
- Split artifact upload into 3 separate steps (Chrome, Firefox, Source)
- Update download steps to match new artifact names
- Add if-no-files-found: error for better error handling
- Fix Node.js 20 compatibility issue with upload-artifact@v4 glob pattern
2026-05-23 14:37:39 +08:00
142 changed files with 10753 additions and 2637 deletions
+17
View File
@@ -0,0 +1,17 @@
# CI/CD 配置
## CI 步骤
严格顺序,任一步骤失败则停止并标记 CI 失败:
1. `setup`(安装依赖、`wxt prepare`
2. 并行运行 `lint``typecheck``test`(三者全部通过才继续)
3. `build`(仅当步骤 2 全部成功时执行)
## Pre-commit Hook
`.husky/pre-commit` 调用 `lint-staged`,任一步骤返回非零则终止提交:
1. 代码文件 (`*.{ts,tsx,js,jsx,mjs}`):运行 `eslint --fix --max-warnings=0 --no-warn-ignored`;若失败则终止并报告错误
2. 同一代码文件:运行 `prettier --write`
3. 其他文件 (`*.{json,css,scss,md}`):运行 `prettier --write`
+18 -2
View File
@@ -14,6 +14,11 @@ stats-*.json
.wxt .wxt
.vitest .vitest
# Environment variables
.env
.env.local
.env.*.local
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
@@ -30,5 +35,16 @@ stats-*.json
.qoder/* .qoder/*
dev/* dev/*
docs/* # Build outputs
!docs/VISUAL_STYLE_GUIDE.md dist/
build/
# Test coverage
coverage/
# IDE files
*.code-workspace
# Other
*.tsbuildinfo
*.tsxbuildinfo
+5 -1
View File
@@ -1 +1,5 @@
npx lint-staged # 后台运行类型检查,结果输出到 stderr 但不阻塞提交
npx tsc --noEmit &
# 前台运行 lint-staged(自动修复 + 格式化)
npx lint-staged
+13
View File
@@ -0,0 +1,13 @@
# pre-push: 严格检查,阻塞有问题的代码推送到远程
# 类型检查(全项目,因为类型错误可能跨文件传播)
npx tsc --noEmit
# ESLint 严格检查(仅检查本次推送的变更文件,不阻塞不相关的旧代码)
# 新分支无 upstream 时,回退到与 origin/main 对比
MERGE_BASE=$(git merge-base HEAD @{upstream} 2>/dev/null || git merge-base HEAD origin/main 2>/dev/null)
if [ -n "$MERGE_BASE" ]; then
CHANGED_FILES=$(git diff --name-only --diff-filter=d "$MERGE_BASE" HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' '*.mjs')
if [ -n "$CHANGED_FILES" ]; then
echo "$CHANGED_FILES" | xargs npx eslint --max-warnings=0
fi
fi
+11 -45
View File
@@ -1,6 +1,6 @@
# AGENTS.md # AGENTS.md
WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown 等测试效率工具。 WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown、测试数据生成器等测试效率工具。
## 核心命令 ## 核心命令
@@ -18,27 +18,9 @@ npm run test:watch # vitest 监视模式
npm run test:coverage # 带覆盖率的测试 npm run test:coverage # 带覆盖率的测试
``` ```
运行单个测试: `npx vitest run path/to/file.test.ts`
## 验证流程 ## 验证流程
CI 步骤(严格顺序,任一步骤失败则停止并标记 CI 失败): 详见 [CI 配置](./.github/CI.md)
1. `setup`(安装依赖、`wxt prepare`
2. 并行运行 `lint``typecheck``test`(三者全部通过才继续)
3. `build`(仅当步骤 2 全部成功时执行)
Pre-commit hook`.husky/pre-commit` 调用 `lint-staged`,任一步骤返回非零则终止提交):
1. 代码文件 (`*.{ts,tsx,js,jsx,mjs}`):运行 `eslint --fix --max-warnings=0 --no-warn-ignored`;若失败则终止并报告错误
2. 同一代码文件:运行 `prettier --write`
3. 其他文件 (`*.{json,css,scss,md}`):运行 `prettier --write`
## WXT 生成文件
- `.wxt/` 目录由 `postinstall` 自动执行 `wxt prepare` 生成,包含 TypeScript 类型声明和扩展的 tsconfig。
- 生产构建输出到 `.output/` 目录。
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`
## 项目结构 ## 项目结构
@@ -54,6 +36,7 @@ src/ # 源代码根目录
utils/ # 工具函数与服务抽象 utils/ # 工具函数与服务抽象
types/ # TypeScript 类型声明 types/ # TypeScript 类型声明
lib/ # 通用工具函数(cn、utils) lib/ # 通用工具函数(cn、utils)
workers/ # Web Worker(数据生成等耗时任务)
public/ # 静态资源(图标、_locales 等) public/ # 静态资源(图标、_locales 等)
``` ```
@@ -72,7 +55,7 @@ src/pages/FeatureName/
- 页面入口组件统一命名为 `Index`,通过 `export default function Index()` 导出 - 页面入口组件统一命名为 `Index`,通过 `export default function Index()` 导出
- Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面 - Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面
- 子组件可以独立调用 `useI18n`全局 Hook - 子组件可以独立调用全局 Hook
-`index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式 -`index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式
- 复杂页面可增加 `contexts/``hooks/``components/` 子目录 - 复杂页面可增加 `contexts/``hooks/``components/` 子目录
@@ -84,7 +67,6 @@ src/pages/FeatureName/
**存储**: 所有 Chrome Storage 键必须在 `src/types/storage.d.ts``StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`)。 **存储**: 所有 Chrome Storage 键必须在 `src/types/storage.d.ts``StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`)。
使用 `src/utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local``localStorage` 做快照以消除首屏闪烁。 使用 `src/utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local``localStorage` 做快照以消除首屏闪烁。
修改 StorageSchema 时,必须在 `src/utils/chromeStorage.ts` 添加版本迁移函数,并在测试中覆盖迁移场景。
**通信**: 使用 `@webext-core/messaging`,协议定义在 `src/utils/messages.ts` **通信**: 使用 `@webext-core/messaging`,协议定义在 `src/utils/messages.ts`
@@ -100,39 +82,21 @@ src/pages/FeatureName/
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入) - 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
- Setup 文件: `vitest.setup.ts` 自动 mock: - Setup 文件: `vitest.setup.ts` 自动 mock:
- `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等) - `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等)
- `@/utils/chromeI18n` (从 `public/_locales/zh/messages.json` 加载真实翻译)
- `window.matchMedia` - `window.matchMedia`
- 测试文件命名: `__tests__/*.test.{ts,tsx}``*.test.{ts,tsx}` - 测试文件命名: `__tests__/*.test.{ts,tsx}``*.test.{ts,tsx}`
- Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码 - Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试 - 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
## i18n (chrome.i18n)
项目使用 Chrome 扩展标准的 `chrome.i18n` API 进行本地化,通过 `src/utils/chromeI18n.ts` 提供类型安全的 React Hook 包装。
- **翻译文件**: `public/_locales/{zh,en}/messages.json`Chrome 扩展标准格式)
- **默认语言**: `zh`(在 `wxt.config.ts``manifest.default_locale` 中配置)
- **使用方式**: `import { useI18n } from '@/utils/chromeI18n'`
- **翻译键格式**:
- 直接 key: `t('dashboard_title')` → 查找 `dashboard_title`
- 命名空间格式(兼容旧用法): `t('common:buttons.search')` → 查找 `common_buttons_search`
- 带命名空间参数: `useI18n(['common', 'features'])`,会自动尝试 `common_key``features_key`
- **占位符支持**: `t('router_notFoundDescription', { entryPointType: 'popup' })`
- **Hook 返回值**: `{ t, i18n: { language, changeLanguage }, isLoaded }`
- **回退策略**: 当翻译 key 未命中时,返回 key 本身(开发模式下在控制台记录 warning)
- **限制**: `chrome.i18n` 无法动态切换语言,语言跟随浏览器设置,切换后需刷新页面
## 新功能开发清单 ## 新功能开发清单
1.`src/types/storage.d.ts` 添加 `PageType` 联合类型 1.`src/types/storage.d.ts` 添加 `PageType` 联合类型
2.`src/config/features.tsx``FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件) 2.`src/config/features.tsx``FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件)
3.`src/pages/` 创建页面组件 (懒加载) 3.`src/pages/` 创建页面组件 (懒加载)
- `index.tsx` — UI 组件,使用 `useI18n` 获取翻译 - `index.tsx` — UI 组件
- `useFeatureName.ts` — 业务逻辑 Hook - `use{FeatureName}.ts` — 业务逻辑 Hook
- `constants.ts` — 常量(可选) - `constants.ts` — 常量(可选)
4. `public/_locales/zh/messages.json`(及 `en/messages.json`)添加翻译 4. 如需新权限,更新 `wxt.config.ts``manifest.permissions`;如有不使用的权限,需移除
5. 如需新权限,更新 `wxt.config.ts``manifest.permissions` 5. 添加对应的单元测试
6. 添加对应的单元测试
## 代码规范 ## 代码规范
@@ -143,11 +107,13 @@ src/pages/FeatureName/
- 图标: 使用 `lucide-react` 图标库 - 图标: 使用 `lucide-react` 图标库
- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行) - 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
- ESLint 使用 `typescript-eslint``projectService: true`(无需手动维护 project 路径) - ESLint 使用 `typescript-eslint``projectService: true`(无需手动维护 project 路径)
- **Git Commit**: 必须使用中文描述,遵循 Conventional Commits 规范(如 `fix(组件名): 描述``feat(功能名): 描述`
- **测试维护**: 新增/编辑已有功能/组件时,需要针对相关的测试进行更新
## 关键外部库(非显而易见的) ## 关键外部库(非显而易见的)
- `@webext-core/messaging` — 扩展消息通信 - `@webext-core/messaging` — 扩展消息通信
- `@dnd-kit` — 拖拽排序(用于页面顺序管理) - `@dnd-kit` — 拖拽排序(用于页面顺序管理和字段列表排序
- `qrious` + `qr-scanner` — 二维码生成与解析 - `qrious` + `qr-scanner` — 二维码生成与解析
- `dayjs` — 日期处理(时间戳转换) - `dayjs` — 日期处理(时间戳转换)
- `sonner` — Toast 通知(替代传统 snackbar - `sonner` — Toast 通知(替代传统 snackbar
+23 -9
View File
@@ -8,61 +8,72 @@
## 功能特性 ## 功能特性
### 🚀 Dashboard 首页 ### Dashboard 首页
- **工具导航**: 快速访问所有可用工具. - **工具导航**: 快速访问所有可用工具.
- **个性化定制**: 支持自定义工具的排序和可见性. - **个性化定制**: 支持自定义工具的排序和可见性.
- **实时预览**: 在卡片上直接查看实时数据(如当前时间戳). - **实时预览**: 在卡片上直接查看实时数据(如当前时间戳).
### 时间戳转换工具 ### 时间戳转换工具
- **实时显示**: 毫秒级精度显示当前系统时间. - **实时显示**: 毫秒级精度显示当前系统时间.
- **双向转换**: 日期字符串与 Unix 时间戳(秒/毫秒)之间的无缝转换. - **双向转换**: 日期字符串与 Unix 时间戳(秒/毫秒)之间的无缝转换.
- **多时区支持**: 预设常用时区(亚洲/上海、美洲/纽约、欧洲/伦敦),支持快速切换. - **多时区支持**: 预设常用时区(亚洲/上海、美洲/纽约、欧洲/伦敦),支持快速切换.
- **快捷操作**: 一键复制转换结果,支持多种格式. - **快捷操作**: 一键复制转换结果,支持多种格式.
### 🧹 存储清理工具 ### 存储清理工具
- **智能识别**: 自动检测并显示当前活动标签页的域名. - **智能识别**: 自动检测并显示当前活动标签页的域名.
- **全面清理**: 支持一键清理 localStorage、sessionStorage、IndexedDB、Cookies、Cache Storage 和 Service Workers. - **全面清理**: 支持一键清理 localStorage、sessionStorage、IndexedDB、Cookies、Cache Storage 和 Service Workers.
- **细粒度控制**: 可根据需要选择特定的清理项. - **细粒度控制**: 可根据需要选择特定的清理项.
- **自动刷新**: 提供清理后自动刷新页面的选项,确保状态同步. - **自动刷新**: 提供清理后自动刷新页面的选项,确保状态同步.
### 📝 文本统计工具 ### 文本统计工具
- **实时分析**: 键入即统计,无需额外操作. - **实时分析**: 键入即统计,无需额外操作.
- **多维指标**: 统计字符数、单词数、行数以及精确的字节大小. - **多维指标**: 统计字符数、单词数、行数以及精确的字节大小.
- **性能优化**: 采用高性能分词算法,支持大文本处理. - **性能优化**: 采用高性能分词算法,支持大文本处理.
### 🔑 JWT 解析工具 ### JWT 解析工具
- **快速解码**: 自动解析 JSON Web Token 的 Header 和 Payload. - **快速解码**: 自动解析 JSON Web Token 的 Header 和 Payload.
- **格式化显示**: 以着色和格式化的 JSON 视图展示数据,方便阅读. - **格式化显示**: 以着色和格式化的 JSON 视图展示数据,方便阅读.
- **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示. - **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示.
- **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性. - **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性.
### 🖱️ 右键恢复工具 ### 右键恢复工具
- **当前页面检测**: 自动识别当前活动标签页的域名. - **当前页面检测**: 自动识别当前活动标签页的域名.
- **一键恢复**: 解除网站对右键菜单的限制,恢复复制、粘贴等基础操作. - **一键恢复**: 解除网站对右键菜单的限制,恢复复制、粘贴等基础操作.
- **状态可视化**: 通过 Badge 组件直观展示当前页面的锁定/解锁状态. - **状态可视化**: 通过 Badge 组件直观展示当前页面的锁定/解锁状态.
### 🖼️ 二维码工具 ### 二维码工具
- **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载. - **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载.
- **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容. - **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容.
### 🔄 JSON 工具 ### JSON 工具
- **差异比较**: 对比两段 JSON 数据,高亮展示差异. - **差异比较**: 对比两段 JSON 数据,高亮展示差异.
- **格式化**: 支持 JSON 美化、压缩、转 YAML / TOML. - **格式化**: 支持 JSON 美化、压缩、转 YAML / TOML.
### 🧰 Base64 转换器 ### Base64 转换器
- **文本编解码**: 支持文本内容的 Base64 编码与解码. - **文本编解码**: 支持文本内容的 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/) - **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
@@ -75,6 +86,8 @@
- **存储**: Chrome Storage API (类型安全封装) - **存储**: Chrome Storage API (类型安全封装)
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成) - **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
- **测试**: Vitest + Testing Library - **测试**: Vitest + Testing Library
- **拖拽排序**: @dnd-kit/core + @dnd-kit/sortable
- **异步生成**: Web Worker (批量数据生成)
## 项目结构 ## 项目结构
@@ -89,6 +102,7 @@
│ │ ├── background.ts # 后台 Service Worker │ │ ├── background.ts # 后台 Service Worker
│ │ └── content.ts # 网页注入脚本 │ │ └── content.ts # 网页注入脚本
│ ├── pages/ # 各功能模块的页面组件 │ ├── pages/ # 各功能模块的页面组件
│ ├── workers/ # Web Worker (数据生成等耗时任务)
│ ├── providers/ # 全局状态提供者 (Router, Theme 等) │ ├── providers/ # 全局状态提供者 (Router, Theme 等)
│ ├── hooks/ # 自定义 React Hooks │ ├── hooks/ # 自定义 React Hooks
│ ├── utils/ # 工具函数与服务抽象 │ ├── utils/ # 工具函数与服务抽象
+21
View File
@@ -0,0 +1,21 @@
# 测试数据生成器 - 功能设计文档
> 版本: v1.0
> 创建时间: 2024-01-20
> 状态: 待 Review
## 产品定位
**轻量级可视化测试数据生成器**
- 纯前端工具,无需后端
- 可视化配置,无需编码
- 本地生成,数据安全
## 目录
- [核心功能](./core-features.md)
- [生成器库](./generators.md)
- [规则管理](./rule-management.md)
- [界面设计](./ui-design.md)
- [技术实现](./technical-implementation.md)
+384
View File
@@ -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. **响应式设计**: 支持桌面端、平板端、移动端
+238
View File
@@ -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)
+702
View File
@@ -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位身份证号 │ │ │
│ │ │ 中文地址 生成中国地址 │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
+481
View File
@@ -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<string, any>; // 生成器参数
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<DataRule>): 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 文件导入导出 |
| 使用统计 | 记录使用次数和时间 |
| 搜索筛选 | 支持关键词搜索 |
File diff suppressed because it is too large Load Diff
+489
View File
@@ -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
+9 -1
View File
@@ -1,4 +1,12 @@
/* global process */
const isCI = process.env.CI === 'true';
export default { export default {
'*.{ts,tsx,js,jsx,mjs}': ['eslint --fix --max-warnings=0 --no-warn-ignored', 'prettier --write'], '*.{ts,tsx,js,jsx,mjs}': [
'eslint --fix --no-warn-ignored',
...(isCI ? ['eslint --max-warnings=0'] : []),
'prettier --write',
],
'*.{json,css,scss,md}': ['prettier --write'], '*.{json,css,scss,md}': ['prettier --write'],
}; };
+53 -143
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -12,7 +12,6 @@
| `icon/48.png` | 48×48 图标(扩展管理页) | | `icon/48.png` | 48×48 图标(扩展管理页) |
| `icon/96.png` | 96×96 图标 | | `icon/96.png` | 96×96 图标 |
| `icon/128.png` | 128×128 图标(Chrome Web Store | | `icon/128.png` | 128×128 图标(Chrome Web Store |
| `wxt.svg` | WXT 框架标志 SVG 图标 |
## 注意事项 ## 注意事项
-880
View File
@@ -1,880 +0,0 @@
{
"buttons_copy": {
"message": "复制",
"description": "Translation key: buttons_copy"
},
"buttons_clear": {
"message": "清理",
"description": "Translation key: buttons_clear"
},
"messages_copySuccess": {
"message": "已复制到剪贴板",
"description": "Translation key: messages_copySuccess"
},
"messages_copyError": {
"message": "复制失败",
"description": "Translation key: messages_copyError"
},
"messages_copyEmpty": {
"message": "无内容可复制",
"description": "Translation key: messages_copyEmpty"
},
"errorBoundary_title": {
"message": "糟糕,出了点问题",
"description": "Translation key: errorBoundary_title"
},
"errorBoundary_description": {
"message": "应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。",
"description": "Translation key: errorBoundary_description"
},
"errorBoundary_refresh": {
"message": "刷新应用",
"description": "Translation key: errorBoundary_refresh"
},
"errorBoundary_retry": {
"message": "重新尝试",
"description": "Translation key: errorBoundary_retry"
},
"pageErrorBoundary_title": {
"message": "该功能运行异常",
"description": "Translation key: pageErrorBoundary_title"
},
"pageErrorBoundary_description": {
"message": "该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。",
"description": "Translation key: pageErrorBoundary_description"
},
"router_notFound": {
"message": "页面未找到",
"description": "Translation key: router_notFound"
},
"router_notFoundDescription": {
"message": "该功能在当前运行环境({{entryPointType}})下不可用或已被移除。",
"description": "Translation key: router_notFoundDescription"
},
"textInputArea_clear": {
"message": "清空",
"description": "Translation key: textInputArea_clear"
},
"textInputArea_copyContent": {
"message": "复制内容",
"description": "Translation key: textInputArea_copyContent"
},
"textInputArea_cleared": {
"message": "已清空",
"description": "Translation key: textInputArea_cleared"
},
"textInputArea_placeholder": {
"message": "请输入文本",
"description": "Translation key: textInputArea_placeholder"
},
"dashboard_title": {
"message": "仪表盘",
"description": "Translation key: dashboard_title"
},
"dashboard_searchPlaceholder": {
"message": "搜索工具...",
"description": "Translation key: dashboard_searchPlaceholder"
},
"dashboard_recentlyUsed": {
"message": "最近使用",
"description": "Translation key: dashboard_recentlyUsed"
},
"dashboard_allTools": {
"message": "全部工具",
"description": "Translation key: dashboard_allTools"
},
"timestamp_title": {
"message": "时间戳",
"description": "Translation key: timestamp_title"
},
"timestamp_description": {
"message": "Unix 毫秒数转换与格式化",
"description": "Translation key: timestamp_description"
},
"storageCleaner_title": {
"message": "存储清理",
"description": "Translation key: storageCleaner_title"
},
"storageCleaner_description": {
"message": "清理缓存、Cookies 及本地存储",
"description": "Translation key: storageCleaner_description"
},
"qrCode_title": {
"message": "二维码工具",
"description": "Translation key: qrCode_title"
},
"qrCode_description": {
"message": "生成当前选中的 URL 的二维码",
"description": "Translation key: qrCode_description"
},
"textStatistics_title": {
"message": "文本统计",
"description": "Translation key: textStatistics_title"
},
"textStatistics_description": {
"message": "实时分析文本字符、单词及字节",
"description": "Translation key: textStatistics_description"
},
"jwt_title": {
"message": "JWT 解析",
"description": "Translation key: jwt_title"
},
"jwt_description": {
"message": "JSON Web Token 解码与查看",
"description": "Translation key: jwt_description"
},
"jsonDiff_title": {
"message": "JSON 工具",
"description": "Translation key: jsonDiff_title"
},
"jsonDiff_description": {
"message": "差异比较、格式化、YAML/TOML 转换及压缩",
"description": "Translation key: jsonDiff_description"
},
"base64Converter_title": {
"message": "Base64 转换器",
"description": "Translation key: base64Converter_title"
},
"base64Converter_description": {
"message": "文本、文件与图像的 Base64 编码转换",
"description": "Translation key: base64Converter_description"
},
"rightClickRestorer_title": {
"message": "右键恢复",
"description": "Translation key: rightClickRestorer_title"
},
"rightClickRestorer_description": {
"message": "检测并恢复被网站禁用的浏览器右键菜单",
"description": "Translation key: rightClickRestorer_description"
},
"base64Converter_pageTitle": {
"message": "Base64 转换器",
"description": "Translation key: base64Converter_pageTitle"
},
"base64Converter_textMode": {
"message": "文本",
"description": "Translation key: base64Converter_textMode"
},
"base64Converter_fileMode": {
"message": "文件",
"description": "Translation key: base64Converter_fileMode"
},
"base64Converter_imageMode": {
"message": "图像",
"description": "Translation key: base64Converter_imageMode"
},
"base64Converter_encode": {
"message": "编码",
"description": "Translation key: base64Converter_encode"
},
"base64Converter_decode": {
"message": "解码",
"description": "Translation key: base64Converter_decode"
},
"base64Converter_clear": {
"message": "清空",
"description": "Translation key: base64Converter_clear"
},
"base64Converter_textInputPlaceholder": {
"message": "输入需要编码为 Base64 的文本...",
"description": "Translation key: base64Converter_textInputPlaceholder"
},
"base64Converter_base64InputPlaceholder": {
"message": "输入需要解码的 Base64 字符串...",
"description": "Translation key: base64Converter_base64InputPlaceholder"
},
"base64Converter_base64Output": {
"message": "Base64 编码结果",
"description": "Translation key: base64Converter_base64Output"
},
"base64Converter_textOutput": {
"message": "解码文本结果",
"description": "Translation key: base64Converter_textOutput"
},
"base64Converter_copyRaw": {
"message": "复制纯 Base64",
"description": "Translation key: base64Converter_copyRaw"
},
"base64Converter_copyDataUri": {
"message": "复制 Data URI",
"description": "Translation key: base64Converter_copyDataUri"
},
"base64Converter_clickOrDropToFile": {
"message": "点击或拖拽文件到此处",
"description": "Translation key: base64Converter_clickOrDropToFile"
},
"base64Converter_clickOrDropToImage": {
"message": "点击或拖拽图像到此处",
"description": "Translation key: base64Converter_clickOrDropToImage"
},
"base64Converter_clickOrDropToReplace": {
"message": "点击或拖拽以替换文件",
"description": "Translation key: base64Converter_clickOrDropToReplace"
},
"base64Converter_maxFileSize": {
"message": "最大文件大小:{{max}}",
"description": "Translation key: base64Converter_maxFileSize"
},
"base64Converter_supportedFormats": {
"message": "支持 PNG、JPG、WEBP、GIF、BMP、SVG 等格式",
"description": "Translation key: base64Converter_supportedFormats"
},
"base64Converter_fileSizeExceeded": {
"message": "文件大小超出限制(最大 {{max}}",
"description": "Translation key: base64Converter_fileSizeExceeded"
},
"base64Converter_unsupportedImageType": {
"message": "不支持的图像格式",
"description": "Translation key: base64Converter_unsupportedImageType"
},
"base64Converter_conversionFailed": {
"message": "转换失败",
"description": "Translation key: base64Converter_conversionFailed"
},
"base64Converter_originalSize": {
"message": "原始大小",
"description": "Translation key: base64Converter_originalSize"
},
"base64Converter_encodedSize": {
"message": "编码大小",
"description": "Translation key: base64Converter_encodedSize"
},
"base64Converter_invalidBase64": {
"message": "Base64 字符串无效",
"description": "Translation key: base64Converter_invalidBase64"
},
"base64Converter_binaryDataDetected": {
"message": "输入似乎是二进制数据(如图片)。请切换到「图像」选项卡。",
"description": "Translation key: base64Converter_binaryDataDetected"
},
"base64Converter_imageDataUriHint": {
"message": "检测到图片的 data URI,请使用「图像」选项卡进行解码。",
"description": "Translation key: base64Converter_imageDataUriHint"
},
"base64Converter_switchToImageMode": {
"message": "切换到图像模式",
"description": "Translation key: base64Converter_switchToImageMode"
},
"base64Converter_download": {
"message": "下载",
"description": "Translation key: base64Converter_download"
},
"base64Converter_decodedFileName": {
"message": "解码后文件名",
"description": "Translation key: base64Converter_decodedFileName"
},
"base64Converter_decodeBase64Placeholder": {
"message": "输入需要解码的 Base64 或 data URI...",
"description": "Translation key: base64Converter_decodeBase64Placeholder"
},
"base64Converter_decodedFileOutput": {
"message": "解码文件",
"description": "Translation key: base64Converter_decodedFileOutput"
},
"base64Converter_decodedImageOutput": {
"message": "解码图像",
"description": "Translation key: base64Converter_decodedImageOutput"
},
"base64Converter_inferredMimeType": {
"message": "推断的 MIME 类型",
"description": "Translation key: base64Converter_inferredMimeType"
},
"base64Converter_decodedSize": {
"message": "解码大小",
"description": "Translation key: base64Converter_decodedSize"
},
"jsonDiff_pageTitle": {
"message": "JSON 差异比较",
"description": "Translation key: jsonDiff_pageTitle"
},
"jsonDiff_leftPlaceholder": {
"message": "输入原始 JSON...",
"description": "Translation key: jsonDiff_leftPlaceholder"
},
"jsonDiff_rightPlaceholder": {
"message": "输入目标 JSON...",
"description": "Translation key: jsonDiff_rightPlaceholder"
},
"jsonDiff_leftLabel": {
"message": "原始 JSON",
"description": "Translation key: jsonDiff_leftLabel"
},
"jsonDiff_rightLabel": {
"message": "目标 JSON",
"description": "Translation key: jsonDiff_rightLabel"
},
"jsonDiff_sideBySideMode": {
"message": "并排",
"description": "Translation key: jsonDiff_sideBySideMode"
},
"jsonDiff_unifiedMode": {
"message": "统一",
"description": "Translation key: jsonDiff_unifiedMode"
},
"jsonDiff_previousDiff": {
"message": "上一个",
"description": "Translation key: jsonDiff_previousDiff"
},
"jsonDiff_nextDiff": {
"message": "下一个",
"description": "Translation key: jsonDiff_nextDiff"
},
"jsonDiff_noDiffs": {
"message": "无差异",
"description": "Translation key: jsonDiff_noDiffs"
},
"jsonDiff_invalidJson": {
"message": "无效的 JSON 格式",
"description": "Translation key: jsonDiff_invalidJson"
},
"jsonDiff_emptyHint": {
"message": "输入两侧 JSON 后点击比较",
"description": "Translation key: jsonDiff_emptyHint"
},
"jsonDiff_fixErrorHint": {
"message": "请修正上方 JSON 的语法错误以开启实时流式比对",
"description": "Translation key: jsonDiff_fixErrorHint"
},
"jsonDiff_added": {
"message": "新增",
"description": "Translation key: jsonDiff_added"
},
"jsonDiff_removed": {
"message": "删除",
"description": "Translation key: jsonDiff_removed"
},
"jsonDiff_modified": {
"message": "修改",
"description": "Translation key: jsonDiff_modified"
},
"jsonFormat_inputPlaceholder": {
"message": "输入需要格式化的 JSON...",
"description": "Translation key: jsonFormat_inputPlaceholder"
},
"jsonFormat_sortKeys": {
"message": "键名排序",
"description": "Translation key: jsonFormat_sortKeys"
},
"jsonFormat_indentSize": {
"message": "缩进",
"description": "Translation key: jsonFormat_indentSize"
},
"jsonFormat_outputLabel": {
"message": "格式化结果",
"description": "Translation key: jsonFormat_outputLabel"
},
"jsonFormat_invalidJson": {
"message": "无效的 JSON 格式",
"description": "Translation key: jsonFormat_invalidJson"
},
"jsonFormat_emptyHint": {
"message": "输入 JSON 后点击格式化",
"description": "Translation key: jsonFormat_emptyHint"
},
"jsonFormat_fixErrorHint": {
"message": "请修正上方 JSON 的语法错误以开启实时流式格式化",
"description": "Translation key: jsonFormat_fixErrorHint"
},
"jsonFormat_originalSize": {
"message": "原始大小",
"description": "Translation key: jsonFormat_originalSize"
},
"jsonFormat_formattedSize": {
"message": "格式化后大小",
"description": "Translation key: jsonFormat_formattedSize"
},
"jsonFormat_diffMode": {
"message": "差异比较",
"description": "Translation key: jsonFormat_diffMode"
},
"jsonFormat_formatMode": {
"message": "格式化",
"description": "Translation key: jsonFormat_formatMode"
},
"jsonFormat_yamlMode": {
"message": "YAML",
"description": "Translation key: jsonFormat_yamlMode"
},
"jsonFormat_tomlMode": {
"message": "TOML",
"description": "Translation key: jsonFormat_tomlMode"
},
"jsonFormat_minifyMode": {
"message": "压缩",
"description": "Translation key: jsonFormat_minifyMode"
},
"jsonFormat_yamlModeInputPlaceholder": {
"message": "输入需要转换的 JSON...",
"description": "Translation key: jsonFormat_yamlModeInputPlaceholder"
},
"jsonFormat_yamlModeOutputLabel": {
"message": "YAML 结果",
"description": "Translation key: jsonFormat_yamlModeOutputLabel"
},
"jsonFormat_yamlModeEmptyHint": {
"message": "输入 JSON 后点击转换",
"description": "Translation key: jsonFormat_yamlModeEmptyHint"
},
"jsonFormat_tomlModeInputPlaceholder": {
"message": "输入需要转换的 JSON...",
"description": "Translation key: jsonFormat_tomlModeInputPlaceholder"
},
"jsonFormat_tomlModeOutputLabel": {
"message": "TOML 结果",
"description": "Translation key: jsonFormat_tomlModeOutputLabel"
},
"jsonFormat_tomlModeEmptyHint": {
"message": "输入 JSON 后点击转换",
"description": "Translation key: jsonFormat_tomlModeEmptyHint"
},
"jsonFormat_minifyModeInputPlaceholder": {
"message": "输入需要压缩的 JSON...",
"description": "Translation key: jsonFormat_minifyModeInputPlaceholder"
},
"jsonFormat_minifyModeOutputLabel": {
"message": "压缩结果",
"description": "Translation key: jsonFormat_minifyModeOutputLabel"
},
"jsonFormat_minifyModeEmptyHint": {
"message": "输入 JSON 后点击压缩",
"description": "Translation key: jsonFormat_minifyModeEmptyHint"
},
"jwt_pageTitle": {
"message": "JWT 解析",
"description": "Translation key: jwt_pageTitle"
},
"jwt_placeholder": {
"message": "在此粘贴 JWT 令牌 (Encoded JWT)...",
"description": "Translation key: jwt_placeholder"
},
"jwt_headerTitle": {
"message": "HEADER: 算法 & 令牌类型",
"description": "Translation key: jwt_headerTitle"
},
"jwt_payloadTitle": {
"message": "PAYLOAD: 数据",
"description": "Translation key: jwt_payloadTitle"
},
"jwt_signatureTitle": {
"message": "签名",
"description": "Translation key: jwt_signatureTitle"
},
"jwt_noSignature": {
"message": "无签名",
"description": "Translation key: jwt_noSignature"
},
"jwt_invalidFormat": {
"message": "无法解析",
"description": "Translation key: jwt_invalidFormat"
},
"jwt_errors_invalidBase64String": {
"message": "无效的 Base64URL 字符串",
"description": "Translation key: jwt_errors_invalidBase64String"
},
"jwt_errors_failedToDecode": {
"message": "Base64URL 解码失败:",
"description": "Translation key: jwt_errors_failedToDecode"
},
"jwt_errors_invalidFormat": {
"message": "JWT 格式错误:必须包含三个由 . 分隔的部分",
"description": "Translation key: jwt_errors_invalidFormat"
},
"jwt_errors_parseHeaderFailed": {
"message": "解析 Header 失败:",
"description": "Translation key: jwt_errors_parseHeaderFailed"
},
"jwt_errors_parsePayloadFailed": {
"message": "解析 Payload 失败:",
"description": "Translation key: jwt_errors_parsePayloadFailed"
},
"qrCode_pageTitle": {
"message": "二维码工具",
"description": "Translation key: qrCode_pageTitle"
},
"qrCode_urlToQr": {
"message": "文本转二维码",
"description": "Translation key: qrCode_urlToQr"
},
"qrCode_qrToUrl": {
"message": "二维码转文本",
"description": "Translation key: qrCode_qrToUrl"
},
"qrCode_urlInputLabel": {
"message": "输入 URL 或文本",
"description": "Translation key: qrCode_urlInputLabel"
},
"qrCode_urlInputPlaceholder": {
"message": "请输入 URL 或文本内容,将自动生成二维码",
"description": "Translation key: qrCode_urlInputPlaceholder"
},
"qrCode_generating": {
"message": "生成中...",
"description": "Translation key: qrCode_generating"
},
"qrCode_qrCodeWillShow": {
"message": "二维码将显示在这里",
"description": "Translation key: qrCode_qrCodeWillShow"
},
"qrCode_downloadButton": {
"message": "下载二维码",
"description": "Translation key: qrCode_downloadButton"
},
"qrCode_copyQrButton": {
"message": "复制二维码",
"description": "Translation key: qrCode_copyQrButton"
},
"qrCode_qrCodeDownloadSuccess": {
"message": "二维码下载成功",
"description": "Translation key: qrCode_qrCodeDownloadSuccess"
},
"qrCode_qrCodeCopySuccess": {
"message": "二维码已复制到剪贴板",
"description": "Translation key: qrCode_qrCodeCopySuccess"
},
"qrCode_parseSuccess": {
"message": "二维码解析成功",
"description": "Translation key: qrCode_parseSuccess"
},
"qrCode_noQrDetected": {
"message": "未检测到二维码,请确保图片清晰且包含二维码",
"description": "Translation key: qrCode_noQrDetected"
},
"qrCode_parseError": {
"message": "解析二维码失败,请重试",
"description": "Translation key: qrCode_parseError"
},
"qrCode_copyError": {
"message": "复制失败,请重试",
"description": "Translation key: qrCode_copyError"
},
"qrCode_imagePasted": {
"message": "图片粘贴成功,正在解析...",
"description": "Translation key: qrCode_imagePasted"
},
"qrCode_imagePasteError": {
"message": "粘贴图片失败,请重试",
"description": "Translation key: qrCode_imagePasteError"
},
"qrCode_imageCleared": {
"message": "图片已清除",
"description": "Translation key: qrCode_imageCleared"
},
"qrCode_clickToUpload": {
"message": "点击、拖拽或粘贴上传二维码图片",
"description": "Translation key: qrCode_clickToUpload"
},
"qrCode_supportFormats": {
"message": "支持 PNG、JPG、WEBP、Base64 格式",
"description": "Translation key: qrCode_supportFormats"
},
"qrCode_resultLabel": {
"message": "解析结果",
"description": "Translation key: qrCode_resultLabel"
},
"qrCode_clickToChange": {
"message": "点击更换图片",
"description": "Translation key: qrCode_clickToChange"
},
"rightClickRestorer_loading": {
"message": "正在加载...",
"description": "Translation key: rightClickRestorer_loading"
},
"rightClickRestorer_currentDomain": {
"message": "当前域名",
"description": "Translation key: rightClickRestorer_currentDomain"
},
"rightClickRestorer_statusLocked": {
"message": "未解锁",
"description": "Translation key: rightClickRestorer_statusLocked"
},
"rightClickRestorer_statusUnlocked": {
"message": "已解锁",
"description": "Translation key: rightClickRestorer_statusUnlocked"
},
"rightClickRestorer_unsupported": {
"message": "不支持",
"description": "Translation key: rightClickRestorer_unsupported"
},
"rightClickRestorer_unsupportedDesc": {
"message": "当前页面为浏览器内部页面或扩展页面,无法解锁右键功能。请切换到普通网页后重试。",
"description": "Translation key: rightClickRestorer_unsupportedDesc"
},
"rightClickRestorer_unlockDesc": {
"message": "点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。",
"description": "Translation key: rightClickRestorer_unlockDesc"
},
"rightClickRestorer_unlockBtn": {
"message": "解锁当前网站右键",
"description": "Translation key: rightClickRestorer_unlockBtn"
},
"rightClickRestorer_alreadyUnlocked": {
"message": "右键已解锁",
"description": "Translation key: rightClickRestorer_alreadyUnlocked"
},
"storageCleaner_pageTitle": {
"message": "存储清理",
"description": "Translation key: storageCleaner_pageTitle"
},
"storageCleaner_loading": {
"message": "加载中...",
"description": "Translation key: storageCleaner_loading"
},
"storageCleaner_initializing": {
"message": "正在读取站点数据...",
"description": "Translation key: storageCleaner_initializing"
},
"storageCleaner_cleaning": {
"message": "正在清理...",
"description": "Translation key: storageCleaner_cleaning"
},
"storageCleaner_cleanNow": {
"message": "立即清理",
"description": "Translation key: storageCleaner_cleanNow"
},
"storageCleaner_autoRefresh": {
"message": "清理后自动刷新页面",
"description": "Translation key: storageCleaner_autoRefresh"
},
"storageCleaner_selectAll": {
"message": "全选所有项",
"description": "Translation key: storageCleaner_selectAll"
},
"storageCleaner_noData": {
"message": "无数据",
"description": "Translation key: storageCleaner_noData"
},
"storageCleaner_errorNoTab": {
"message": "无法获取当前标签页",
"description": "Translation key: storageCleaner_errorNoTab"
},
"storageCleaner_errorRestricted": {
"message": "存储清理功能不支持此页面",
"description": "Translation key: storageCleaner_errorRestricted"
},
"storageCleaner_cleanSuccessReload": {
"message": "清理成功,即将刷新页面",
"description": "Translation key: storageCleaner_cleanSuccessReload"
},
"storageCleaner_errorStandardOnly": {
"message": "存储清理功能仅适用于标准网页",
"description": "Translation key: storageCleaner_errorStandardOnly"
},
"storageCleaner_confirmTitle": {
"message": "确认清理数据?",
"description": "Translation key: storageCleaner_confirmTitle"
},
"storageCleaner_confirmDesc": {
"message": "您将永久删除当前页面的以下选定存储项。",
"description": "Translation key: storageCleaner_confirmDesc"
},
"storageCleaner_irreversible": {
"message": "此操作不可撤销",
"description": "Translation key: storageCleaner_irreversible"
},
"storageCleaner_confirmAction": {
"message": "确认清理",
"description": "Translation key: storageCleaner_confirmAction"
},
"storageCleaner_cleanedSummary": {
"message": "清理了 {{items}}",
"description": "Translation key: storageCleaner_cleanedSummary"
},
"storageCleaner_noDataToClean": {
"message": "该页面没有可清理的存储数据",
"description": "Translation key: storageCleaner_noDataToClean"
},
"storageCleaner_partialFailure": {
"message": "部分清理失败",
"description": "Translation key: storageCleaner_partialFailure"
},
"storageCleaner_options_localStorage": {
"message": "Local Storage",
"description": "Translation key: storageCleaner_options_localStorage"
},
"storageCleaner_options_sessionStorage": {
"message": "Session Storage",
"description": "Translation key: storageCleaner_options_sessionStorage"
},
"storageCleaner_options_indexedDB": {
"message": "站点存储",
"description": "Translation key: storageCleaner_options_indexedDB"
},
"storageCleaner_options_cookies": {
"message": "Cookies",
"description": "Translation key: storageCleaner_options_cookies"
},
"storageCleaner_options_cacheStorage": {
"message": "Cache Storage",
"description": "Translation key: storageCleaner_options_cacheStorage"
},
"storageCleaner_options_serviceWorkers": {
"message": "Service Workers",
"description": "Translation key: storageCleaner_options_serviceWorkers"
},
"textStatistics_pageTitle": {
"message": "文本统计",
"description": "Translation key: textStatistics_pageTitle"
},
"textStatistics_placeholder": {
"message": "在此输入或粘贴文本...",
"description": "Translation key: textStatistics_placeholder"
},
"textStatistics_characters": {
"message": "字符数",
"description": "Translation key: textStatistics_characters"
},
"textStatistics_words": {
"message": "单词数",
"description": "Translation key: textStatistics_words"
},
"textStatistics_lines": {
"message": "行数",
"description": "Translation key: textStatistics_lines"
},
"textStatistics_bytes": {
"message": "字节大小",
"description": "Translation key: textStatistics_bytes"
},
"timestamp_pageTitle": {
"message": "时间戳转换",
"description": "Translation key: timestamp_pageTitle"
},
"timestamp_tsToDate": {
"message": "时间戳 → 日期",
"description": "Translation key: timestamp_tsToDate"
},
"timestamp_dateToTs": {
"message": "日期 → 时间戳",
"description": "Translation key: timestamp_dateToTs"
},
"timestamp_placeholderTs": {
"message": "输入时间戳...",
"description": "Translation key: timestamp_placeholderTs"
},
"timestamp_placeholderDate": {
"message": "YYYY-MM-DD HH:mm:ss",
"description": "Translation key: timestamp_placeholderDate"
},
"timestamp_unitMs": {
"message": "毫秒 (ms)",
"description": "Translation key: timestamp_unitMs"
},
"timestamp_unitS": {
"message": "秒 (s)",
"description": "Translation key: timestamp_unitS"
},
"timestamp_currentTs": {
"message": "当前时间戳",
"description": "Translation key: timestamp_currentTs"
},
"timestamp_useNowTooltip": {
"message": "填充到下方",
"description": "Translation key: timestamp_useNowTooltip"
},
"timestamp_copyTsTooltip": {
"message": "复制时间戳",
"description": "Translation key: timestamp_copyTsTooltip"
},
"timestamp_usedSuccess": {
"message": "已使用当前时间戳",
"description": "Translation key: timestamp_usedSuccess"
},
"timestamp_resultLabel": {
"message": "转换结果",
"description": "Translation key: timestamp_resultLabel"
},
"timestamp_copyResultTooltip": {
"message": "复制结果",
"description": "Translation key: timestamp_copyResultTooltip"
},
"timestamp_relativeTime": {
"message": "相对时间",
"description": "Translation key: timestamp_relativeTime"
},
"timestamp_iso8601": {
"message": "ISO 8601",
"description": "Translation key: timestamp_iso8601"
},
"timestamp_utcTime": {
"message": "UTC 时间",
"description": "Translation key: timestamp_utcTime"
},
"timestamp_copyTooltip": {
"message": "复制",
"description": "Translation key: timestamp_copyTooltip"
},
"timestamp_resultEmpty": {
"message": "请输入并点击转换",
"description": "Translation key: timestamp_resultEmpty"
},
"common_buttons_cancel": {
"message": "取消",
"description": "Translation key: buttons_cancel"
},
"common_buttons_copy": {
"message": "复制",
"description": "Translation key: buttons_copy"
},
"common_buttons_themeMode_light": {
"message": "切换到深色模式",
"description": "Translation key: buttons_themeMode_light"
},
"common_buttons_themeMode_dark": {
"message": "切换到系统模式",
"description": "Translation key: buttons_themeMode_dark"
},
"common_buttons_themeMode_system": {
"message": "切换到浅色模式",
"description": "Translation key: buttons_themeMode_system"
},
"common_buttons_search": {
"message": "搜索工具...",
"description": "Translation key: buttons_search"
},
"common_buttons_back": {
"message": "返回",
"description": "Translation key: buttons_back"
},
"common_buttons_clearSearch": {
"message": "清除搜索",
"description": "Translation key: buttons_clearSearch"
},
"common_buttons_recentSearch": {
"message": "最近搜索",
"description": "Translation key: buttons_recentSearch"
},
"common_buttons_noResults": {
"message": "未找到相关工具",
"description": "Translation key: buttons_noResults"
},
"common_buttons_openInTab": {
"message": "在标签页打开",
"description": "Translation key: buttons_openInTab"
},
"common_errorBoundary_title": {
"message": "糟糕,出了点问题",
"description": "Translation key: errorBoundary_title"
},
"common_errorBoundary_description": {
"message": "应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。",
"description": "Translation key: errorBoundary_description"
},
"common_errorBoundary_refresh": {
"message": "刷新应用",
"description": "Translation key: errorBoundary_refresh"
},
"common_errorBoundary_retry": {
"message": "重新尝试",
"description": "Translation key: errorBoundary_retry"
},
"common_pageErrorBoundary_title": {
"message": "该功能运行异常",
"description": "Translation key: pageErrorBoundary_title"
},
"common_pageErrorBoundary_description": {
"message": "该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。",
"description": "Translation key: pageErrorBoundary_description"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 B

After

Width:  |  Height:  |  Size: 932 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

-56
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

+6 -7
View File
@@ -4,7 +4,6 @@ import { copyTextToClipboard } from '@/utils/clipboard';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { buttonVariants, type ButtonProps } from '@/components/ui/button'; import { buttonVariants, type ButtonProps } from '@/components/ui/button';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useI18n } from '@/utils/chromeI18n';
interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> { interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> {
text: string; text: string;
@@ -19,7 +18,6 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
className, className,
...props ...props
}) => { }) => {
const { t } = useI18n('common');
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -33,18 +31,18 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
e.stopPropagation(); e.stopPropagation();
if (!text) { if (!text) {
toast.error(t('messages.copyEmpty')); toast.error('无内容可复制');
return; return;
} }
const success = await copyTextToClipboard(text); const success = await copyTextToClipboard(text);
if (success) { if (success) {
toast.success(t('messages.copySuccess')); toast.success('已复制到剪贴板');
setCopied(true); setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current); if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500); timerRef.current = setTimeout(() => setCopied(false), 1500);
} else { } else {
toast.error(t('messages.copyError')); toast.error('复制失败');
} }
}; };
@@ -52,7 +50,8 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
<button <button
type="button" type="button"
onClick={handleCopy} onClick={handleCopy}
title={tooltip ?? t('buttons.copy')} title={tooltip ?? '复制'}
aria-label={tooltip ?? '复制'}
className={cn( className={cn(
buttonVariants({ variant, size }), buttonVariants({ variant, size }),
copied && copied &&
@@ -62,7 +61,7 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
{...props} {...props}
> >
{copied ? ( {copied ? (
<Check className="h-[1.2em] w-[1.2em] animate-in fade-in zoom-in-75 duration-200" /> <Check className="h-[1.2em] w-[1.2em]" />
) : ( ) : (
<Copy className="h-[1.2em] w-[1.2em]" /> <Copy className="h-[1.2em] w-[1.2em]" />
)} )}
+3 -6
View File
@@ -1,7 +1,6 @@
import { Component, ErrorInfo, ReactNode } from 'react'; import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react'; import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { getMessage } from '@/utils/chromeI18n';
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@@ -44,11 +43,9 @@ class ErrorBoundary extends Component<Props, State> {
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4"> <div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
<AlertCircle className="h-8 w-8" /> <AlertCircle className="h-8 w-8" />
</div> </div>
<h2 className="text-xl font-extrabold text-destructive mb-2"> <h2 className="text-xl font-extrabold text-destructive mb-2"></h2>
{getMessage('errorBoundary_title')}
</h2>
<p className="text-sm text-muted-foreground mb-6"> <p className="text-sm text-muted-foreground mb-6">
{getMessage('errorBoundary_description')}
</p> </p>
{this.state.error && ( {this.state.error && (
<div className="mb-6 p-4 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-[200px] overflow-auto border border-border/40"> <div className="mb-6 p-4 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-[200px] overflow-auto border border-border/40">
@@ -63,7 +60,7 @@ class ErrorBoundary extends Component<Props, State> {
className="rounded-lg font-bold shadow-sm" className="rounded-lg font-bold shadow-sm"
> >
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
{getMessage('errorBoundary_refresh')}
</Button> </Button>
</div> </div>
</div> </div>
+3 -6
View File
@@ -1,7 +1,6 @@
import { Component, ErrorInfo, ReactNode } from 'react'; import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react'; import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { getMessage } from '@/utils/chromeI18n';
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@@ -46,11 +45,9 @@ class PageErrorBoundary extends Component<Props, State> {
<AlertCircle className="h-6 w-6" /> <AlertCircle className="h-6 w-6" />
</div> </div>
<h3 className="text-base font-semibold text-foreground mb-1.5"> <h3 className="text-base font-semibold text-foreground mb-1.5"></h3>
{getMessage('pageErrorBoundary_title')}
</h3>
<p className="text-xs text-muted-foreground mb-5"> <p className="text-xs text-muted-foreground mb-5">
{getMessage('pageErrorBoundary_description')}
</p> </p>
{this.state.error && ( {this.state.error && (
@@ -68,7 +65,7 @@ class PageErrorBoundary extends Component<Props, State> {
className="font-medium shadow-sm" className="font-medium shadow-sm"
> >
<RefreshCw className="mr-1.5 h-3.5 w-3.5" /> <RefreshCw className="mr-1.5 h-3.5 w-3.5" />
{getMessage('errorBoundary_retry')}
</Button> </Button>
</div> </div>
</div> </div>
+2 -4
View File
@@ -1,7 +1,6 @@
import { FEATURES, getEntryPointType } from '@/config/features'; import { FEATURES, getEntryPointType } from '@/config/features';
import { useRouter } from '@/providers/RouterProvider'; import { useRouter } from '@/providers/RouterProvider';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import PageErrorBoundary from '@/components/PageErrorBoundary'; import PageErrorBoundary from '@/components/PageErrorBoundary';
import PageSkeleton from '@/components/PageSkeleton'; import PageSkeleton from '@/components/PageSkeleton';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -11,7 +10,6 @@ const entryPointType = getEntryPointType();
export default function RouterContainer() { export default function RouterContainer() {
const { currentPage, isLoaded } = useRouter(); const { currentPage, isLoaded } = useRouter();
const { t } = useI18n('common');
const animationClass = const animationClass =
currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter'; currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
@@ -43,9 +41,9 @@ export default function RouterContainer() {
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4"> <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4">
<AlertTriangle className="h-6 w-6" /> <AlertTriangle className="h-6 w-6" />
</div> </div>
<h3 className="text-sm font-semibold text-foreground">{t('router.notFound')}</h3> <h3 className="text-sm font-semibold text-foreground">{'页面未找到'}</h3>
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]"> <p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
{t('router.notFoundDescription', { entryPointType })} {`该功能在当前运行环境(${entryPointType})下不可用或已被移除。`}
</p> </p>
</div> </div>
)} )}
+27 -29
View File
@@ -17,6 +17,16 @@ export interface SwitchButtonGroupProps<T extends string | number = string> exte
buttonClassName?: string; buttonClassName?: string;
} }
const SIZE_CLASSES = {
small: 'text-xs h-8 px-2 py-1 rounded-md',
medium: 'text-sm h-9 px-3 py-1.5 rounded-md',
large: 'text-base h-11 px-4 py-2 rounded-lg',
} as const;
const SELECTED_CLASSES =
'bg-background text-foreground shadow-sm font-semibold animate-in fade-in-50 zoom-in-95 duration-150';
const UNSELECTED_CLASSES = 'hover:bg-background/50 hover:text-foreground/80';
export default function SwitchButtonGroup<T extends string | number = string>({ export default function SwitchButtonGroup<T extends string | number = string>({
value, value,
options, options,
@@ -26,12 +36,6 @@ export default function SwitchButtonGroup<T extends string | number = string>({
buttonClassName, buttonClassName,
...props ...props
}: SwitchButtonGroupProps<T>) { }: SwitchButtonGroupProps<T>) {
const sizeClasses = {
small: 'text-xs h-8 px-2 py-1 rounded-md',
medium: 'text-sm h-9 px-3 py-1.5 rounded-md',
large: 'text-base h-11 px-4 py-2 rounded-lg',
};
return ( return (
<div <div
className={cn( className={cn(
@@ -40,29 +44,23 @@ export default function SwitchButtonGroup<T extends string | number = string>({
)} )}
{...props} {...props}
> >
{options.map((option) => { {options.map((option) => (
const isSelected = value === option.value; <button
key={option.value}
return ( type="button"
<button onClick={() => onChange(option.value)}
key={option.value} className={cn(
type="button" 'flex-1 inline-flex items-center justify-center font-medium whitespace-nowrap transition-all',
onClick={() => onChange(option.value)} 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2',
className={cn( 'disabled:pointer-events-none disabled:opacity-50',
'flex-1 inline-flex items-center justify-center font-medium whitespace-nowrap transition-all', SIZE_CLASSES[size],
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2', value === option.value ? SELECTED_CLASSES : UNSELECTED_CLASSES,
'disabled:pointer-events-none disabled:opacity-50', buttonClassName,
sizeClasses[size], )}
isSelected >
? 'bg-background text-foreground shadow-sm font-semibold animate-in fade-in-50 zoom-in-95 duration-150' {option.label}
: 'hover:bg-background/50 hover:text-foreground/80', </button>
buttonClassName, ))}
)}
>
{option.label}
</button>
);
})}
</div> </div>
); );
} }
+6 -13
View File
@@ -1,6 +1,5 @@
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'; import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
import { CopyButton } from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
@@ -114,8 +113,7 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
const [internalValue, setInternalValue] = useState(defaultValue); const [internalValue, setInternalValue] = useState(defaultValue);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const { t } = useI18n('common'); const placeholder = placeholderProp ?? '请输入文本';
const placeholder = placeholderProp ?? t('textInputArea.placeholder');
const isControlled = controlledValue !== undefined; const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue; const value = isControlled ? controlledValue : internalValue;
@@ -159,7 +157,7 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newVal = e.target.value; const newVal = e.target.value;
if (maxLength && newVal.length > maxLength) { if (maxLength && newVal.length > maxLength) {
const msg = t('charCount', { count: maxLength }); const msg = `内容不能超过 ${maxLength} 个字符`;
setError(msg); setError(msg);
toast.warning(msg); toast.warning(msg);
return; return;
@@ -181,9 +179,9 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
onChange?.(''); onChange?.('');
setError(''); setError('');
internalRef.current?.focus(); internalRef.current?.focus();
toast.success(t('textInputArea.cleared')); toast.success('已清空');
onClear?.(); onClear?.();
}, [isControlled, onChange, onClear, t]); }, [isControlled, onChange, onClear]);
const handleAction = useCallback( const handleAction = useCallback(
(action: ToolbarAction) => { (action: ToolbarAction) => {
@@ -272,18 +270,13 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
{/* 右侧系统按钮组 */} {/* 右侧系统按钮组 */}
<div className="flex items-center gap-1.5 ml-auto shrink-0"> <div className="flex items-center gap-1.5 ml-auto shrink-0">
{allowCopy && value && ( {allowCopy && value && (
<CopyButton <CopyButton text={value} tooltip={'复制内容'} size="sm" className="h-7 w-7 p-1" />
text={value}
tooltip={t('textInputArea.copyContent')}
size="sm"
className="h-7 w-7 p-1"
/>
)} )}
{showClear && value && !disabled && !readOnly && ( {showClear && value && !disabled && !readOnly && (
<button <button
type="button" type="button"
onClick={handleClear} onClick={handleClear}
aria-label={t('textInputArea.clear')} aria-label={'清空'}
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
+23 -21
View File
@@ -5,7 +5,6 @@ import { useThemeMode } from '@/providers/ThemeModeProvider';
import { FeatureConfig, FEATURES } from '@/config/features'; import { FeatureConfig, FEATURES } from '@/config/features';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import { openExtensionPage } from '@/utils/chromeTabs'; import { openExtensionPage } from '@/utils/chromeTabs';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const SEARCH_HISTORY_LIMIT = 10; const SEARCH_HISTORY_LIMIT = 10;
@@ -14,7 +13,6 @@ const SEARCH_HISTORY_DISPLAY = 5;
export default function TopBar() { export default function TopBar() {
const { currentPage, goBack, navigateTo } = useRouter(); const { currentPage, goBack, navigateTo } = useRouter();
const { mode, setMode } = useThemeMode(); const { mode, setMode } = useThemeMode();
const { t } = useI18n(['common', 'features']);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
@@ -66,12 +64,9 @@ export default function TopBar() {
if (!query) return []; if (!query) return [];
return FEATURES.filter((f) => { return FEATURES.filter((f) => {
if (f.key === 'dashboard') return false; if (f.key === 'dashboard') return false;
return ( return f.label.toLowerCase().includes(query) || f.description.toLowerCase().includes(query);
t(f.labelKey).toLowerCase().includes(query) ||
t(f.descriptionKey).toLowerCase().includes(query)
);
}); });
}, [searchQuery, t]); }, [searchQuery]);
const displayedHistory = useMemo(() => { const displayedHistory = useMemo(() => {
if (searchQuery.trim()) return []; if (searchQuery.trim()) return [];
@@ -144,7 +139,7 @@ export default function TopBar() {
<button <button
type="button" type="button"
onClick={goBack} onClick={goBack}
aria-label={t('common_buttons_back')} aria-label={'返回'}
className="flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground" className="flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@@ -159,7 +154,7 @@ export default function TopBar() {
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
placeholder={t('common_buttons_search')} placeholder={'搜索工具...'}
value={searchQuery} value={searchQuery}
onChange={(e) => { onChange={(e) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
@@ -168,7 +163,7 @@ export default function TopBar() {
}} }}
onFocus={() => setShowResults(true)} onFocus={() => setShowResults(true)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
aria-label={t('common_buttons_search')} aria-label={'搜索工具...'}
className="w-full h-9 pl-9 pr-16 text-sm rounded-lg border border-border/60 bg-muted/40 transition-all placeholder:text-muted-foreground/50 focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input" className="w-full h-9 pl-9 pr-16 text-sm rounded-lg border border-border/60 bg-muted/40 transition-all placeholder:text-muted-foreground/50 focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input"
/> />
{!searchQuery && ( {!searchQuery && (
@@ -183,7 +178,7 @@ export default function TopBar() {
setSearchQuery(''); setSearchQuery('');
setSelectedIndex(-1); setSelectedIndex(-1);
}} }}
aria-label={t('common:buttons.clearSearch')} aria-label={'清除搜索'}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:text-foreground transition-colors" className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:text-foreground transition-colors"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
@@ -219,24 +214,22 @@ export default function TopBar() {
{feature.icon && <feature.icon className="h-4 w-4" />} {feature.icon && <feature.icon className="h-4 w-4" />}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate"> <p className="font-medium text-foreground truncate">{feature.label}</p>
{t(feature.labelKey)}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5"> <p className="text-xs text-muted-foreground truncate mt-0.5">
{t(feature.descriptionKey)} {feature.description}
</p> </p>
</div> </div>
</li> </li>
)) ))
) : ( ) : (
<li className="px-4 py-6 text-center text-sm text-muted-foreground"> <li className="px-4 py-6 text-center text-sm text-muted-foreground">
{t('common:buttons.noResults')} {'未找到相关工具'}
</li> </li>
) )
) : ( ) : (
<> <>
<div className="px-3 py-1.5 text-[11px] font-semibold tracking-wider text-muted-foreground/60 uppercase"> <div className="px-3 py-1.5 text-[11px] font-semibold tracking-wider text-muted-foreground/60 uppercase">
{t('common:buttons.recentSearch')} {'最近搜索'}
</div> </div>
{displayedHistory.map((item, index) => ( {displayedHistory.map((item, index) => (
<li <li
@@ -261,10 +254,10 @@ export default function TopBar() {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate"> <p className="font-medium text-foreground truncate">
{item.feature && t(item.feature.labelKey)} {item.feature?.label}
</p> </p>
<p className="text-xs text-muted-foreground truncate mt-0.5"> <p className="text-xs text-muted-foreground truncate mt-0.5">
{item.feature && t(item.feature.descriptionKey)} {item.feature?.description}
</p> </p>
</div> </div>
</li> </li>
@@ -278,10 +271,19 @@ export default function TopBar() {
{/* 右侧:操作区 */} {/* 右侧:操作区 */}
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<IconButton onClick={cycleThemeMode} title={t(`common:buttons.themeMode.${mode}`)}> <IconButton
onClick={cycleThemeMode}
title={
mode === 'light'
? '切换到深色模式'
: mode === 'dark'
? '切换到系统模式'
: '切换到浅色模式'
}
>
<ThemeIcon className="h-4 w-4" /> <ThemeIcon className="h-4 w-4" />
</IconButton> </IconButton>
<IconButton onClick={handleOpenInTab} title={t('common:buttons.openInTab')}> <IconButton onClick={handleOpenInTab} title={'在标签页打开'}>
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</IconButton> </IconButton>
</div> </div>
@@ -0,0 +1,194 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// unmock the globally-mocked component so we test the real implementation
vi.unmock('@/components/CopyButton');
vi.mock('@/utils/clipboard', () => ({
copyTextToClipboard: vi.fn(),
}));
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
import { CopyButton } from '@/components/CopyButton';
import { copyTextToClipboard } from '@/utils/clipboard';
import { toast } from 'sonner';
const mockedCopy = vi.mocked(copyTextToClipboard);
const mockedToast = vi.mocked(toast);
describe('CopyButton', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it('复制成功时调用 copyTextToClipboard 并传入正确 text', async () => {
mockedCopy.mockResolvedValue(true);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<CopyButton text="hello world" />);
await user.click(screen.getByRole('button'));
expect(mockedCopy).toHaveBeenCalledWith('hello world');
expect(mockedToast.success).toHaveBeenCalled();
});
it('复制成功后图标切换为 Check1.5 秒后恢复', async () => {
mockedCopy.mockResolvedValue(true);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<CopyButton text="test" />);
// 点击后复制成功,按钮获得 emerald 样式(说明切到了 Check 状态)
await user.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('button').className).toContain('text-emerald');
});
// 1.5 秒后样式恢复
act(() => {
vi.advanceTimersByTime(1500);
});
await waitFor(() => {
expect(screen.getByRole('button').className).not.toContain('text-emerald');
});
});
it('复制空文本时弹出 error toast', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<CopyButton text="" />);
await user.click(screen.getByRole('button'));
expect(mockedCopy).not.toHaveBeenCalled();
expect(mockedToast.error).toHaveBeenCalled();
});
it('复制失败时弹出 error toast', async () => {
mockedCopy.mockResolvedValue(false);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<CopyButton text="something" />);
await user.click(screen.getByRole('button'));
expect(mockedCopy).toHaveBeenCalledWith('something');
expect(mockedToast.error).toHaveBeenCalled();
});
// ==================== 新增测试 ====================
it('初始渲染时显示 Copy 图标且无 emerald 样式', () => {
render(<CopyButton text="initial" />);
const button = screen.getByRole('button');
expect(button.className).not.toContain('text-emerald');
// 通过 aria-label 确认按钮存在,图标由 lucide 渲染为 svg
expect(button).toHaveAttribute('aria-label');
});
it('自定义 tooltip 会覆盖默认 title 和 aria-label', () => {
render(<CopyButton text="tooltip-test" tooltip="自定义提示" />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('title', '自定义提示');
expect(button).toHaveAttribute('aria-label', '自定义提示');
});
it('className 被正确透传到按钮', () => {
render(<CopyButton text="class-test" className="my-custom-class" />);
const button = screen.getByRole('button');
expect(button.className).toContain('my-custom-class');
});
it('点击事件阻止冒泡', async () => {
mockedCopy.mockResolvedValue(true);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const parentClick = vi.fn();
render(
<div onClick={parentClick}>
<CopyButton text="stop-propagation" />
</div>,
);
await user.click(screen.getByRole('button'));
expect(mockedCopy).toHaveBeenCalled();
expect(parentClick).not.toHaveBeenCalled();
});
it('组件卸载时清除定时器,不触发状态更新警告', async () => {
mockedCopy.mockResolvedValue(true);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const { unmount } = render(<CopyButton text="unmount-test" />);
await user.click(screen.getByRole('button'));
// 在 1.5 秒超时到期前卸载组件
act(() => {
vi.advanceTimersByTime(500);
});
// 卸载不应抛出 "Can't perform a React state update on an unmounted component" 警告
expect(() => unmount()).not.toThrow();
// 前进剩余时间,确认没有异常
act(() => {
vi.advanceTimersByTime(2000);
});
});
it('快速连续点击不会创建多个重叠定时器', async () => {
mockedCopy.mockResolvedValue(true);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<CopyButton text="rapid-click" />);
const button = screen.getByRole('button');
// 快速点击 3 次
await user.click(button);
await user.click(button);
await user.click(button);
// copyTextToClipboard 应该被调用 3 次(每次点击都执行)
expect(mockedCopy).toHaveBeenCalledTimes(3);
// 但 setTimeout 相关的 clearTimeout + setTimeout 组合应正常工作
// advance 1.5 秒后,copied 状态应恢复为 false
act(() => {
vi.advanceTimersByTime(1500);
});
await waitFor(() => {
expect(button.className).not.toContain('text-emerald');
});
});
it('其他 button props 通过 ...props 透传', () => {
render(<CopyButton text="props-test" data-testid="copy-btn" disabled id="copy-button-id" />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('data-testid', 'copy-btn');
expect(button).toBeDisabled();
expect(button).toHaveAttribute('id', 'copy-button-id');
});
});
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { StorageCleanerConfirm } from '@/pages/StorageCleaner/StorageCleanerConfirm'; import { StorageCleanerConfirm } from '@/pages/StorageCleaner/components/StorageCleanerConfirm';
import type { StorageCleanerOptions } from '@/types/storage'; import type { StorageCleanerOptions } from '@/types/storage';
import React from 'react'; import React from 'react';
+12 -13
View File
@@ -18,12 +18,12 @@ describe('TextInputArea 组件', () => {
it('默认显示清空按钮', () => { it('默认显示清空按钮', () => {
render(<TextInputArea value="有内容" onChange={() => {}} />); render(<TextInputArea value="有内容" onChange={() => {}} />);
expect(screen.getByRole('button', { name: 'textInputArea.clear' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '清空' })).toBeInTheDocument();
}); });
it('无内容时清空按钮应隐藏', () => { it('无内容时清空按钮应隐藏', () => {
render(<TextInputArea value="" onChange={() => {}} />); render(<TextInputArea value="" onChange={() => {}} />);
expect(screen.queryByRole('button', { name: 'textInputArea.clear' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '清空' })).not.toBeInTheDocument();
}); });
it('disabled 时清空按钮应隐藏', () => { it('disabled 时清空按钮应隐藏', () => {
@@ -57,7 +57,7 @@ describe('TextInputArea 组件', () => {
const handleChange = vi.fn(); const handleChange = vi.fn();
render(<TextInputArea value="内容" onChange={handleChange} />); render(<TextInputArea value="内容" onChange={handleChange} />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); fireEvent.click(screen.getByRole('button', { name: '清空' }));
expect(handleChange).toHaveBeenCalledWith(''); expect(handleChange).toHaveBeenCalledWith('');
}); });
@@ -81,7 +81,7 @@ describe('TextInputArea 组件', () => {
it('清空按钮应清空内容', () => { it('清空按钮应清空内容', () => {
render(<TextInputArea defaultValue="内容" />); render(<TextInputArea defaultValue="内容" />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); fireEvent.click(screen.getByRole('button', { name: '清空' }));
expect(screen.getByRole('textbox')).toHaveValue(''); expect(screen.getByRole('textbox')).toHaveValue('');
}); });
@@ -90,14 +90,12 @@ describe('TextInputArea 组件', () => {
describe('allowCopy 复制功能', () => { describe('allowCopy 复制功能', () => {
it('allowCopy 且有内容时显示复制按钮', () => { it('allowCopy 且有内容时显示复制按钮', () => {
render(<TextInputArea value="可复制的内容" onChange={() => {}} allowCopy />); render(<TextInputArea value="可复制的内容" onChange={() => {}} allowCopy />);
expect(screen.getByRole('button', { name: 'textInputArea.copyContent' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '复制内容' })).toBeInTheDocument();
}); });
it('allowCopy 但无内容时隐藏复制按钮', () => { it('allowCopy 但无内容时隐藏复制按钮', () => {
render(<TextInputArea value="" onChange={() => {}} allowCopy />); render(<TextInputArea value="" onChange={() => {}} allowCopy />);
expect( expect(screen.queryByRole('button', { name: '复制内容' })).not.toBeInTheDocument();
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
).not.toBeInTheDocument();
}); });
it('allowCopy=false 时不显示复制按钮', () => { it('allowCopy=false 时不显示复制按钮', () => {
@@ -113,7 +111,7 @@ describe('TextInputArea 组件', () => {
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />); render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' })); await user.click(screen.getByRole('button', { name: '复制内容' }));
expect(writeTextSpy).toHaveBeenCalledWith('测试'); expect(writeTextSpy).toHaveBeenCalledWith('测试');
}); });
@@ -126,7 +124,7 @@ describe('TextInputArea 组件', () => {
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />); render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' })); await user.click(screen.getByRole('button', { name: '复制内容' }));
expect(writeTextSpy).toHaveBeenCalledWith('测试'); expect(writeTextSpy).toHaveBeenCalledWith('测试');
}); });
@@ -158,6 +156,7 @@ describe('TextInputArea 组件', () => {
fireEvent.change(textarea, { target: { value: '123456' } }); fireEvent.change(textarea, { target: { value: '123456' } });
expect(handleChange).not.toHaveBeenCalledWith('123456'); expect(handleChange).not.toHaveBeenCalledWith('123456');
expect(screen.getByText('内容不能超过 5 个字符')).toBeInTheDocument();
}); });
it('未超出 maxLength 的输入应正常触发', () => { it('未超出 maxLength 的输入应正常触发', () => {
@@ -370,7 +369,7 @@ describe('TextInputArea 组件', () => {
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />); render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' })); await user.click(screen.getByRole('button', { name: '复制内容' }));
expect(writeTextSpy).toHaveBeenCalledWith('测试'); expect(writeTextSpy).toHaveBeenCalledWith('测试');
}); });
@@ -381,7 +380,7 @@ describe('TextInputArea 组件', () => {
const handleClear = vi.fn(); const handleClear = vi.fn();
render(<TextInputArea value="内容" onChange={() => {}} onClear={handleClear} />); render(<TextInputArea value="内容" onChange={() => {}} onClear={handleClear} />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); fireEvent.click(screen.getByRole('button', { name: '清空' }));
expect(handleClear).toHaveBeenCalledOnce(); expect(handleClear).toHaveBeenCalledOnce();
}); });
@@ -389,7 +388,7 @@ describe('TextInputArea 组件', () => {
it('不传 onClear 时清空按钮应正常工作', () => { it('不传 onClear 时清空按钮应正常工作', () => {
render(<TextInputArea defaultValue="内容" />); render(<TextInputArea defaultValue="内容" />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); fireEvent.click(screen.getByRole('button', { name: '清空' }));
expect(screen.getByRole('textbox')).toHaveValue(''); expect(screen.getByRole('textbox')).toHaveValue('');
}); });
-118
View File
@@ -1,118 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ToolCard from '@/pages/Dashboard/ToolCard';
import { Clock } from 'lucide-react';
describe('ToolCard 组件', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('渲染测试', () => {
it('应渲染标题和描述', () => {
render(
<ToolCard
title="测试工具"
description="这是一个测试工具"
colorKey="primary"
icon={Clock}
onNavigate={() => {}}
/>,
);
expect(screen.getByText('测试工具')).toBeInTheDocument();
expect(screen.getByText('这是一个测试工具')).toBeInTheDocument();
});
it('无描述时仅渲染标题', () => {
render(<ToolCard title="仅标题" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
expect(screen.getByText('仅标题')).toBeInTheDocument();
});
it('应渲染图标', () => {
const { container } = render(
<ToolCard title="带图标" colorKey="primary" icon={Clock} onNavigate={() => {}} />,
);
const svgElement = container.querySelector('svg');
expect(svgElement).toBeInTheDocument();
});
it('提供快照内容时应渲染快照', () => {
render(
<ToolCard
title="带快照"
colorKey="primary"
icon={Clock}
onNavigate={() => {}}
snapshot={<div data-testid="snapshot"></div>}
/>,
);
expect(screen.getByTestId('snapshot')).toBeInTheDocument();
});
it('未提供快照时不渲染快照区域', () => {
const { container } = render(
<ToolCard
title="无快照"
colorKey="primary"
icon={Clock}
onClick={() => {}}
onNavigate={function (): void {
throw new Error('Function not implemented.');
}}
/>,
);
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
});
it('应使用 CardActionArea 渲染,支持键盘聚焦', () => {
render(<ToolCard title="可聚焦" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
const button = screen.getByRole('button', { name: /可聚焦/ });
expect(button).toBeInTheDocument();
});
});
describe('交互测试', () => {
it('点击时应调用 onClick', () => {
const handleClick = vi.fn();
render(<ToolCard title="可点击" colorKey="primary" icon={Clock} onNavigate={handleClick} />);
const button = screen.getByRole('button', { name: /可点击/ });
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('按 Enter 键时应调用 onClick', async () => {
const handleClick = vi.fn();
render(
<ToolCard title="键盘可触发" colorKey="primary" icon={Clock} onNavigate={handleClick} />,
);
const button = screen.getByRole('button', { name: /键盘可触发/ });
await act(async () => {
button.focus();
await userEvent.keyboard('{Enter}');
});
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
describe('样式测试', () => {
it('应应用自定义颜色代码', () => {
const { container } = render(
<ToolCard title="自定义颜色" colorKey="warning" icon={Clock} onNavigate={() => {}} />,
);
const svgElement = container.querySelector('svg');
expect(svgElement).toBeInTheDocument();
});
});
});
+14 -6
View File
@@ -26,10 +26,16 @@ const DialogOverlay = React.forwardRef<
)); ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface DialogContentProps extends React.ComponentPropsWithoutRef<
typeof DialogPrimitive.Content
> {
showCloseButton?: boolean;
}
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> DialogContentProps
>(({ className, children, ...props }, ref) => ( >(({ className, children, showCloseButton = true, ...props }, ref) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
@@ -41,10 +47,12 @@ const DialogContent = React.forwardRef<
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> {showCloseButton && (
<X className="h-4 w-4" /> <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<span className="sr-only">Close</span> <X className="h-4 w-4" />
</DialogPrimitive.Close> <span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)); ));
+26
View File
@@ -0,0 +1,26 @@
import { Toaster as Sonner } from 'sonner';
import { useThemeMode } from '@/providers/ThemeModeProvider';
type ToasterProps = React.ComponentProps<typeof Sonner>;
export function Toaster(props: ToasterProps) {
const { resolvedMode } = useThemeMode();
return (
<Sonner
theme={resolvedMode}
className="toaster group"
position="bottom-center"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
}
+13 -12
View File
@@ -9,20 +9,20 @@ import {
describe('features', () => { describe('features', () => {
describe('FEATURES', () => { describe('FEATURES', () => {
it('should have 9 features defined', () => { it('should have 10 features defined', () => {
expect(FEATURES).toHaveLength(9); expect(FEATURES).toHaveLength(10);
}); });
it('should have all required properties for each feature', () => { it('should have all required properties for each feature', () => {
FEATURES.forEach((feature) => { FEATURES.forEach((feature) => {
expect(feature).toHaveProperty('key'); expect(feature).toHaveProperty('key');
expect(feature).toHaveProperty('labelKey'); expect(feature).toHaveProperty('label');
expect(feature).toHaveProperty('descriptionKey'); expect(feature).toHaveProperty('description');
expect(feature).toHaveProperty('defaultVisible'); expect(feature).toHaveProperty('defaultVisible');
expect(feature).toHaveProperty('components'); expect(feature).toHaveProperty('components');
expect(typeof feature.key).toBe('string'); expect(typeof feature.key).toBe('string');
expect(typeof feature.labelKey).toBe('string'); expect(typeof feature.label).toBe('string');
expect(typeof feature.descriptionKey).toBe('string'); expect(typeof feature.description).toBe('string');
expect(typeof feature.defaultVisible).toBe('boolean'); expect(typeof feature.defaultVisible).toBe('boolean');
expect(typeof feature.components).toBe('object'); expect(typeof feature.components).toBe('object');
expect(feature.components).toHaveProperty('popup'); expect(feature.components).toHaveProperty('popup');
@@ -50,14 +50,14 @@ describe('features', () => {
const feature = getFeatureByKey('dashboard'); const feature = getFeatureByKey('dashboard');
expect(feature).toBeDefined(); expect(feature).toBeDefined();
expect(feature?.key).toBe('dashboard'); expect(feature?.key).toBe('dashboard');
expect(feature?.labelKey).toBe('dashboard_title'); expect(feature?.label).toBe('仪表盘');
}); });
it('should return timestamp feature', () => { it('should return timestamp feature', () => {
const feature = getFeatureByKey('timestamp'); const feature = getFeatureByKey('timestamp');
expect(feature).toBeDefined(); expect(feature).toBeDefined();
expect(feature?.key).toBe('timestamp'); expect(feature?.key).toBe('timestamp');
expect(feature?.labelKey).toBe('timestamp_title'); expect(feature?.label).toBe('时间戳');
expect(feature?.themeColorKey).toBeDefined(); expect(feature?.themeColorKey).toBeDefined();
}); });
@@ -65,7 +65,7 @@ describe('features', () => {
const feature = getFeatureByKey('storageCleaner'); const feature = getFeatureByKey('storageCleaner');
expect(feature).toBeDefined(); expect(feature).toBeDefined();
expect(feature?.key).toBe('storageCleaner'); expect(feature?.key).toBe('storageCleaner');
expect(feature?.labelKey).toBe('storageCleaner_title'); expect(feature?.label).toBe('存储清理');
}); });
it('should return undefined for invalid key', () => { it('should return undefined for invalid key', () => {
@@ -95,7 +95,7 @@ describe('features', () => {
describe('getAllFeatureKeys', () => { describe('getAllFeatureKeys', () => {
it('should return all feature keys', () => { it('should return all feature keys', () => {
const allKeys = getAllFeatureKeys(); const allKeys = getAllFeatureKeys();
expect(allKeys).toHaveLength(9); expect(allKeys).toHaveLength(10);
expect(allKeys).toContain('dashboard'); expect(allKeys).toContain('dashboard');
expect(allKeys).toContain('timestamp'); expect(allKeys).toContain('timestamp');
expect(allKeys).toContain('storageCleaner'); expect(allKeys).toContain('storageCleaner');
@@ -105,6 +105,7 @@ describe('features', () => {
expect(allKeys).toContain('jsonDiff'); expect(allKeys).toContain('jsonDiff');
expect(allKeys).toContain('base64Converter'); expect(allKeys).toContain('base64Converter');
expect(allKeys).toContain('rightClickRestorer'); expect(allKeys).toContain('rightClickRestorer');
expect(allKeys).toContain('testDataGenerator');
}); });
}); });
@@ -121,9 +122,9 @@ describe('features', () => {
expect(pageOrder).toContain('qrCode'); expect(pageOrder).toContain('qrCode');
}); });
it('should have 8 items in page order', () => { it('should have 9 items in page order', () => {
const pageOrder = getDefaultPageOrder(); const pageOrder = getDefaultPageOrder();
expect(pageOrder).toHaveLength(8); expect(pageOrder).toHaveLength(9);
}); });
}); });
}); });
+35 -20
View File
@@ -10,6 +10,7 @@ import {
GitCompareArrows, GitCompareArrows,
ArrowLeftRight, ArrowLeftRight,
MousePointerClick, MousePointerClick,
FileSpreadsheet,
} from 'lucide-react'; } from 'lucide-react';
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info'; export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
@@ -24,11 +25,12 @@ const JwtPage = lazy(() => import('@/pages/Jwt'));
const JsonToolsPage = lazy(() => import('@/pages/JsonTools')); const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter')); const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer')); const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
const TestDataGeneratorPage = lazy(() => import('@/pages/TestDataGenerator'));
export interface FeatureConfig { export interface FeatureConfig {
key: PageType; key: PageType;
labelKey: string; label: string;
descriptionKey: string; description: string;
themeColorKey?: PaletteColorKey; themeColorKey?: PaletteColorKey;
icon?: ComponentType<LucideProps>; icon?: ComponentType<LucideProps>;
defaultVisible: boolean; defaultVisible: boolean;
@@ -42,8 +44,8 @@ export interface FeatureConfig {
export const FEATURES: FeatureConfig[] = [ export const FEATURES: FeatureConfig[] = [
{ {
key: 'dashboard', key: 'dashboard',
labelKey: 'dashboard_title', label: '仪表盘',
descriptionKey: '', description: '',
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: DashboardPage, popup: DashboardPage,
@@ -53,8 +55,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'timestamp', key: 'timestamp',
labelKey: 'timestamp_title', label: '时间戳',
descriptionKey: 'timestamp_description', description: 'Unix 毫秒数转换与格式化',
themeColorKey: 'primary', themeColorKey: 'primary',
icon: Clock, icon: Clock,
defaultVisible: true, defaultVisible: true,
@@ -66,8 +68,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'storageCleaner', key: 'storageCleaner',
labelKey: 'storageCleaner_title', label: '存储清理',
descriptionKey: 'storageCleaner_description', description: '清理缓存、Cookies 及本地存储',
themeColorKey: 'warning', themeColorKey: 'warning',
icon: Database, icon: Database,
defaultVisible: true, defaultVisible: true,
@@ -79,8 +81,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'qrCode', key: 'qrCode',
labelKey: 'qrCode_title', label: '二维码工具',
descriptionKey: 'qrCode_description', description: '生成当前选中的 URL 的二维码',
themeColorKey: 'success', themeColorKey: 'success',
icon: QrCode, icon: QrCode,
defaultVisible: true, defaultVisible: true,
@@ -92,8 +94,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'textStatistics', key: 'textStatistics',
labelKey: 'textStatistics_title', label: '文本统计',
descriptionKey: 'textStatistics_description', description: '实时分析文本字符、单词及字节',
themeColorKey: 'secondary', themeColorKey: 'secondary',
icon: FileText, icon: FileText,
defaultVisible: true, defaultVisible: true,
@@ -105,8 +107,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'jwt', key: 'jwt',
labelKey: 'jwt_title', label: 'JWT 解析',
descriptionKey: 'jwt_description', description: 'JSON Web Token 解码与查看',
themeColorKey: 'info', themeColorKey: 'info',
icon: Key, icon: Key,
defaultVisible: true, defaultVisible: true,
@@ -118,8 +120,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'jsonDiff', key: 'jsonDiff',
labelKey: 'jsonDiff_title', label: 'JSON 工具',
descriptionKey: 'jsonDiff_description', description: '差异比较、格式化、YAML/TOML 转换及压缩',
themeColorKey: 'primary', themeColorKey: 'primary',
icon: GitCompareArrows, icon: GitCompareArrows,
defaultVisible: true, defaultVisible: true,
@@ -131,8 +133,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'base64Converter', key: 'base64Converter',
labelKey: 'base64Converter_title', label: 'Base64 转换器',
descriptionKey: 'base64Converter_description', description: '文本、文件与图像的 Base64 编码转换',
themeColorKey: 'info', themeColorKey: 'info',
icon: ArrowLeftRight, icon: ArrowLeftRight,
defaultVisible: true, defaultVisible: true,
@@ -144,8 +146,8 @@ export const FEATURES: FeatureConfig[] = [
}, },
{ {
key: 'rightClickRestorer', key: 'rightClickRestorer',
labelKey: 'rightClickRestorer_title', label: '右键恢复',
descriptionKey: 'rightClickRestorer_description', description: '检测并恢复被网站禁用的浏览器右键菜单',
themeColorKey: 'success', themeColorKey: 'success',
icon: MousePointerClick, icon: MousePointerClick,
defaultVisible: true, defaultVisible: true,
@@ -155,6 +157,19 @@ export const FEATURES: FeatureConfig[] = [
tab: RightClickRestorerPage, tab: RightClickRestorerPage,
}, },
}, },
{
key: 'testDataGenerator',
label: '测试数据生成器',
description: '自定义规则批量生成测试数据',
themeColorKey: 'warning',
icon: FileSpreadsheet,
defaultVisible: true,
components: {
popup: TestDataGeneratorPage,
sidepanel: TestDataGeneratorPage,
tab: TestDataGeneratorPage,
},
},
]; ];
export function getFeatureByKey(key: PageType): FeatureConfig | undefined { export function getFeatureByKey(key: PageType): FeatureConfig | undefined {
+2 -2
View File
@@ -36,7 +36,7 @@ describe('background 菜单注册与分流', () => {
expect(chrome.contextMenus.create).toHaveBeenCalledWith( expect(chrome.contextMenus.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
id: 'jwt', id: 'jwt',
title: '🔑 解析 JWT', title: '解析 JWT',
parentId: 'testing-tools-parent', parentId: 'testing-tools-parent',
}), }),
); );
@@ -48,7 +48,7 @@ describe('background 菜单注册与分流', () => {
expect(chrome.contextMenus.create).toHaveBeenCalledWith( expect(chrome.contextMenus.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
id: 'qrCode-page', id: 'qrCode-page',
title: '🔗 网页链接转二维码', title: '网页链接转二维码',
contexts: ['page'], contexts: ['page'],
}), }),
); );
@@ -1,11 +1,10 @@
import type { ContextMenuClickedPayload } from '@/utils/messages'; import type { ContextMenuClickedPayload } from '@/utils/messages';
import { MessageAction, onMessage } from '@/utils/messages'; import { MessageAction, onMessage } from '@/utils/messages';
import { getTextStats } from '@/utils/textStatistics'; import { getTextStats } from '@/utils/textStatistics';
import { getMessage } from '@/utils/chromeI18n';
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover'; import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
function convertTimestamp(input: string): string { function convertTimestamp(input: string): string {
const invalidText = getMessage('invalidTimestamp') || 'Invalid Timestamp'; const invalidText = '无效时间戳';
const num = Number(input.trim()); const num = Number(input.trim());
if (isNaN(num)) { if (isNaN(num)) {
+1 -1
View File
@@ -161,7 +161,7 @@ function positionPopover(popover: HTMLElement, x: number, y: number): void {
let hideTimeout: ReturnType<typeof setTimeout> | null = null; let hideTimeout: ReturnType<typeof setTimeout> | null = null;
export function showPopover( function showPopover(
x: number, x: number,
y: number, y: number,
contentHtml: string, contentHtml: string,
+227
View File
@@ -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<T>(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<string, string> = {
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<string, string> = {
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,
];
+561
View File
@@ -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<T>(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<string, string[]> = {
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,
];
+57
View File
@@ -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),
);
}
+465
View File
@@ -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<string, string[]> = {
: ['东城区', '西城区', '朝阳区', '海淀区'],
: ['黄浦区', '徐汇区', '长宁区', '静安区'],
广: ['广州市', '深圳市', '东莞市', '佛山市'],
: ['杭州市', '宁波市', '温州市', '嘉兴市'],
: ['南京市', '苏州市', '无锡市', '常州市'],
: ['成都市', '绵阳市', '德阳市', '宜宾市'],
};
/**
* 生成随机整数
*/
function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* 从数组中随机选择
*/
function randomPick<T>(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,
];
+268
View File
@@ -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<T>(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];
+48
View File
@@ -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;
}
-162
View File
@@ -1,162 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import TextInputArea from '@/components/TextInputArea';
import { useI18n } from '@/utils/chromeI18n';
import CopyButton from '@/components/CopyButton';
import { base64ToText, textToBase64 } from '@/utils/base64Converter';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { useContextMenuData } from '@/utils/useContextMenuData';
import { Button } from '@/components/ui/button';
const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i;
const ERROR_MESSAGE_TO_I18N: Record<string, string> = {
'Invalid Base64 string': 'invalidBase64',
'Input appears to be binary data (e.g. an image). Please use the Image tab instead.':
'binaryDataDetected',
};
interface TextModeProps {
onSwitchToImageMode?: () => void;
}
export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
const { t } = useI18n('base64Converter');
const [input, setInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState('');
const [direction, setDirection] = useState<'encode' | 'decode'>('encode');
useEffect(() => {
const handle = setTimeout(() => {
setDebouncedInput(input);
}, 200);
return () => clearTimeout(handle);
}, [input]);
const handleContextMenuData = useCallback((payload: string) => {
setInput(payload);
setDebouncedInput(payload);
setDirection('decode');
}, []);
useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData });
const conversionPipeline = useMemo(() => {
const trimmed = debouncedInput.trim();
if (!trimmed) return { output: '', error: null };
try {
if (direction === 'encode') {
const result = textToBase64(debouncedInput);
return { output: result.output, error: null };
} else {
const decoded = base64ToText(trimmed);
return { output: decoded, error: null };
}
} catch (e) {
const message = e instanceof Error ? e.message : '';
const i18nKey = ERROR_MESSAGE_TO_I18N[message];
return {
output: '',
error: i18nKey ? t(i18nKey) : message || t('conversionFailed'),
};
}
}, [debouncedInput, direction, t]);
const output = conversionPipeline.output;
const error = conversionPipeline.error;
const placeholder =
direction === 'encode' ? t('textInputPlaceholder') : t('base64InputPlaceholder');
const outputLabel = direction === 'encode' ? t('base64Output') : t('textOutput');
const showImageHint = useMemo(
() => direction === 'decode' && IMAGE_DATA_URI_PATTERN.test(input),
[direction, input],
);
const handleDirectionChange = (value: 'encode' | 'decode') => {
if (value === direction) return;
setDirection(value);
setInput('');
setDebouncedInput('');
};
const handleClear = () => {
setInput('');
setDebouncedInput('');
};
return (
<div className="w-full flex flex-col space-y-4 px-2">
{/* 受控方向切流中枢 */}
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
<SwitchButtonGroup
value={direction}
options={[
{ value: 'encode', label: t('encode') },
{ value: 'decode', label: t('decode') },
]}
onChange={handleDirectionChange}
size="small"
/>
</div>
{/* 高性能受控文本输入端 */}
<TextInputArea
placeholder={placeholder}
value={input}
onChange={setInput}
externalError={error || undefined}
showClear={true}
allowCopy={true}
minRows={5}
maxRows={10}
onClear={handleClear}
/>
{/* 图片 URI 类型劫持警告引导区:
- 💡 修复点:彻底废除原生亮色硬编码 hover:bg-blue-100 类名,
- 完美向全站 shadcn 暗黑生态看齐,采用标准的 bg-primary/10 混合变体。
*/}
{showImageHint && (
<div className="flex items-center justify-between p-3.5 rounded-xl bg-primary/10 border border-primary/20">
<span className="text-xs font-semibold text-primary tracking-tight">
{t('imageDataUriHint')}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onSwitchToImageMode}
className="h-7 rounded-md text-xs font-bold text-primary hover:text-primary hover:bg-primary/20 dark:hover:bg-primary/10 px-2.5"
>
{t('switchToImageMode')}
</Button>
</div>
)}
{/* 5. 编码/解码核心数据承载流卡片 */}
{output && (
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
<div className="flex justify-between items-center select-none">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{outputLabel}
</span>
<CopyButton
text={output}
className="h-6 px-2 rounded-md border text-[10px] font-bold"
/>
</div>
<TextInputArea
readOnly
value={output.length > 2000 ? `${output.substring(0, 2000)}...` : output}
showClear={false}
minRows={4}
/>
</div>
)}
</div>
);
}
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import DecodeResultPaper from '@/components/DecodeResultPaper'; import DecodeResultPaper from '../components/DecodeResultPaper';
describe('DecodeResultPaper 组件', () => { describe('DecodeResultPaper 组件', () => {
const defaultProps = { const defaultProps = {
@@ -27,7 +27,7 @@ describe('DecodeResultPaper 组件', () => {
expect(screen.getByText(/image\/png/)).toBeInTheDocument(); expect(screen.getByText(/image\/png/)).toBeInTheDocument();
}); });
it('应通过 formatFileSize 渲染文件大小', () => { it('应通过 formatBytes 渲染文件大小', () => {
render(<DecodeResultPaper {...{ ...defaultProps, blobSize: 1536 }} />); render(<DecodeResultPaper {...{ ...defaultProps, blobSize: 1536 }} />);
expect(screen.getByText(/1\.5 KB/)).toBeInTheDocument(); expect(screen.getByText(/1\.5 KB/)).toBeInTheDocument();
}); });
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import TextMode from '../TextMode'; import TextMode from '../components/TextMode';
// Mock CopyButton // Mock CopyButton
vi.mock('@/components/CopyButton', () => ({ vi.mock('@/components/CopyButton', () => ({
@@ -76,7 +76,7 @@ describe('TextMode', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Base64 字符串无效')).toBeInTheDocument(); expect(screen.getByText('无效的 Base64 字符串')).toBeInTheDocument();
}); });
}); });
@@ -118,7 +118,7 @@ describe('TextMode', () => {
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
}); });
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); fireEvent.click(screen.getByRole('button', { name: '清空' }));
await waitFor(() => { await waitFor(() => {
expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument();
@@ -182,9 +182,7 @@ describe('TextMode', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect( expect(screen.getByText('检测到二进制数据(如图像),请使用图像选项卡')).toBeInTheDocument();
screen.getByText('输入似乎是二进制数据(如图片)。请切换到「图像」选项卡。'),
).toBeInTheDocument();
}); });
}); });
}); });
@@ -12,7 +12,7 @@ vi.mock('@/config/features', async (importOriginal) => {
}); });
// Mock 子组件 // Mock 子组件
vi.mock('../TextMode', () => ({ vi.mock('../components/TextMode', () => ({
default: ({ onSwitchToImageMode }: { onSwitchToImageMode?: () => void }) => ( default: ({ onSwitchToImageMode }: { onSwitchToImageMode?: () => void }) => (
<div data-testid="text-mode"> <div data-testid="text-mode">
TextMode TextMode
@@ -21,7 +21,7 @@ vi.mock('../TextMode', () => ({
), ),
})); }));
vi.mock('../Base64ConverterSection', () => ({ vi.mock('../components/Base64ConverterSection', () => ({
default: ({ mode }: { mode: string }) => <div data-testid={`${mode}-mode`}>{mode}</div>, default: ({ mode }: { mode: string }) => <div data-testid={`${mode}-mode`}>{mode}</div>,
})); }));
@@ -1,14 +1,14 @@
import { Image as ImageIcon, Trash2, Upload } from 'lucide-react'; import { Image as ImageIcon, Trash2, Upload } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import { useI18n } from '@/utils/chromeI18n'; import { CopyButton } from '@/components/CopyButton';
import CopyButton from '@/components/CopyButton'; import DecodeResultPaper from './DecodeResultPaper';
import DecodeResultPaper from '@/components/DecodeResultPaper';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { downloadBlob, formatFileSize } from '@/utils/base64Converter'; import { downloadBlob } from '@/utils/base64Converter';
import { formatBytes } from '@/utils/format';
import { useStorageState } from '@/utils/useStorageState'; import { useStorageState } from '@/utils/useStorageState';
import type { Base64ConvertDirection } from '@/types/storage'; import type { Base64ConvertDirection } from '@/types/storage';
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { useBase64Converter } from './useBase64Converter'; import { useBase64Converter } from '../useBase64Converter';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const isValidDirection = (val: unknown): val is Base64ConvertDirection => const isValidDirection = (val: unknown): val is Base64ConvertDirection =>
@@ -19,8 +19,6 @@ interface Base64ConverterSectionProps {
} }
export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) { export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) {
const { t } = useI18n('base64Converter');
const [direction, setDirection] = useStorageState( const [direction, setDirection] = useStorageState(
`base64Converter/${mode}Mode/direction`, `base64Converter/${mode}Mode/direction`,
'encode', 'encode',
@@ -43,7 +41,6 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
setCustomFileName, setCustomFileName,
resetAll, resetAll,
safeFileSelect, safeFileSelect,
maxFileSizeStr,
} = useBase64Converter({ mode }); } = useBase64Converter({ mode });
const handleDirectionChange = (next: Base64ConvertDirection) => { const handleDirectionChange = (next: Base64ConvertDirection) => {
@@ -62,8 +59,8 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
<SwitchButtonGroup <SwitchButtonGroup
value={direction} value={direction}
options={[ options={[
{ value: 'encode', label: t('encode') }, { value: 'encode', label: '编码' },
{ value: 'decode', label: t('decode') }, { value: 'decode', label: '解码' },
]} ]}
onChange={handleDirectionChange} onChange={handleDirectionChange}
size="small" size="small"
@@ -122,10 +119,10 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
{info.name} {info.name}
</span> </span>
<span className="text-xs text-muted-foreground/80 font-mono tabular-nums"> <span className="text-xs text-muted-foreground/80 font-mono tabular-nums">
{formatFileSize(info.size)} · {info.type} {formatBytes(info.size)} · {info.type}
</span> </span>
<span className="text-[11px] font-medium text-primary/80 mt-1"> <span className="text-[11px] font-medium text-primary/80 mt-1">
{t('clickOrDropToReplace')} {'点击或拖拽以替换文件'}
</span> </span>
</div> </div>
) : ( ) : (
@@ -136,14 +133,14 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
<Upload className="w-8 h-8 text-muted-foreground/60" /> <Upload className="w-8 h-8 text-muted-foreground/60" />
)} )}
<span className="text-xs font-bold text-foreground/80"> <span className="text-xs font-bold text-foreground/80">
{mode === 'image' ? t('clickOrDropToImage') : t('clickOrDropToFile')} {mode === 'image' ? '点击或拖拽图像到此处' : '点击或拖拽文件到此处'}
</span> </span>
<span className="text-[10px] font-medium text-muted-foreground/60"> <span className="text-[10px] font-medium text-muted-foreground/60">
{t('maxFileSize', { max: maxFileSizeStr })} {'最大文件大小:{{max}}'}
</span> </span>
{mode === 'image' && ( {mode === 'image' && (
<span className="text-[10px] font-medium text-muted-foreground/50"> <span className="text-[10px] font-medium text-muted-foreground/50">
{t('supportedFormats')} {'支持 PNG、JPG、WEBP、GIF、BMP、SVG 等格式'}
</span> </span>
)} )}
</div> </div>
@@ -160,17 +157,17 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3"> <div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
<div className="flex justify-between items-center select-none"> <div className="flex justify-between items-center select-none">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90"> <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{t('base64Output')} {'Base64 编码结果'}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<CopyButton <CopyButton
text={result.rawBase64} text={result.rawBase64}
tooltip={t('copyRaw')} tooltip={'复制纯 Base64'}
className="h-6 px-2 rounded-md border text-[10px] font-bold" className="h-6 px-2 rounded-md border text-[10px] font-bold"
/> />
<CopyButton <CopyButton
text={result.output} text={result.output}
tooltip={t('copyDataUri')} tooltip={'复制 Data URI'}
className="h-6 px-2 rounded-md border text-[10px] font-bold" className="h-6 px-2 rounded-md border text-[10px] font-bold"
/> />
</div> </div>
@@ -188,16 +185,16 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
<div className="flex items-center justify-between font-mono text-[10px] text-muted-foreground/70 select-none pt-1"> <div className="flex items-center justify-between font-mono text-[10px] text-muted-foreground/70 select-none pt-1">
<div className="flex gap-4 items-center tabular-nums"> <div className="flex gap-4 items-center tabular-nums">
<span> <span>
{t('originalSize')}:{' '} {'原始大小'}:{' '}
<span className="font-semibold text-foreground/80"> <span className="font-semibold text-foreground/80">
{formatFileSize(result.originalBytes)} {formatBytes(result.originalBytes)}
</span> </span>
</span> </span>
<span className="text-border/60">|</span> <span className="text-border/60">|</span>
<span> <span>
{t('encodedSize')}:{' '} {'编码大小'}:{' '}
<span className="font-semibold text-foreground/80"> <span className="font-semibold text-foreground/80">
{formatFileSize(result.outputBytes)} {formatBytes(result.outputBytes)}
</span> </span>
</span> </span>
</div> </div>
@@ -208,7 +205,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
className="h-7 rounded-md text-muted-foreground hover:text-destructive text-[11px] gap-1 px-2" className="h-7 rounded-md text-muted-foreground hover:text-destructive text-[11px] gap-1 px-2"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
{t('clear')} {'清空'}
</Button> </Button>
</div> </div>
</div> </div>
@@ -217,7 +214,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
) : ( ) : (
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
<TextInputArea <TextInputArea
placeholder={t('decodeBase64Placeholder')} placeholder={'输入需要解码的 Base64 或 data URI...'}
value={decodeInput} value={decodeInput}
onChange={setDecodeInput} onChange={setDecodeInput}
externalError={decodeError || undefined} externalError={decodeError || undefined}
@@ -228,7 +225,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
/> />
{decoded && ( {decoded && (
<DecodeResultPaper <DecodeResultPaper
title={mode === 'image' ? t('decodedImageOutput') : t('decodedFileOutput')} title={mode === 'image' ? '解码图像' : '解码文件'}
mimeType={decoded.mimeType} mimeType={decoded.mimeType}
blobSize={decoded.blob.size} blobSize={decoded.blob.size}
fileName={decodedFileName} fileName={decodedFileName}
@@ -9,8 +9,9 @@
*/ */
import { Download } from 'lucide-react'; import { Download } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { formatFileSize } from '@/utils/base64Converter'; import { Input } from '@/components/ui/input';
import { useI18n } from '@/utils/chromeI18n'; import { Label } from '@/components/ui/label';
import { formatBytes } from '@/utils/format';
interface DecodeResultPaperProps { interface DecodeResultPaperProps {
/** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput */ /** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput */
@@ -38,8 +39,6 @@ export default function DecodeResultPaper({
onDownload, onDownload,
children, children,
}: DecodeResultPaperProps) { }: DecodeResultPaperProps) {
const { t } = useI18n('base64Converter');
return ( return (
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30"> <div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
{/* 标题 */} {/* 标题 */}
@@ -51,24 +50,19 @@ export default function DecodeResultPaper({
{/* 文件信息 */} {/* 文件信息 */}
<div className="flex gap-4 mb-3"> <div className="flex gap-4 mb-3">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{t('inferredMimeType')}: {mimeType} {'推断的 MIME 类型'}: {mimeType}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{t('decodedSize')}: {formatFileSize(blobSize)} {'解码大小'}: {formatBytes(blobSize)}
</span> </span>
</div> </div>
{/* 文件名输入 */} {/* 文件名输入 */}
<div className="mb-3"> <div className="mb-3">
<label className="block text-xs font-medium text-muted-foreground mb-1"> <Label className="block text-xs font-medium text-muted-foreground mb-1">
{t('decodedFileName')} {'解码后文件名'}
</label> </Label>
<input <Input value={fileName} onChange={(e) => onFileNameChange(e.target.value)} />
type="text"
value={fileName}
onChange={(e) => onFileNameChange(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div> </div>
{/* 下载按钮 */} {/* 下载按钮 */}
@@ -79,7 +73,7 @@ export default function DecodeResultPaper({
className="w-full rounded-lg font-bold" className="w-full rounded-lg font-bold"
> >
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
{t('download')} {'下载'}
</Button> </Button>
</div> </div>
); );
@@ -0,0 +1,90 @@
import TextInputArea from '@/components/TextInputArea';
import { CopyButton } from '@/components/CopyButton';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { Button } from '@/components/ui/button';
import { useTextMode } from '../useTextMode';
interface TextModeProps {
onSwitchToImageMode?: () => void;
}
export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
const {
input,
setInput,
direction,
handleDirectionChange,
placeholder,
output,
outputLabel,
error,
showImageHint,
handleClear,
} = useTextMode();
return (
<div className="w-full flex flex-col space-y-4 px-2">
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
<SwitchButtonGroup
value={direction}
options={[
{ value: 'encode', label: '编码' },
{ value: 'decode', label: '解码' },
]}
onChange={handleDirectionChange}
size="small"
/>
</div>
<TextInputArea
placeholder={placeholder}
value={input}
onChange={setInput}
externalError={error || undefined}
showClear={true}
allowCopy={true}
minRows={5}
maxRows={10}
onClear={handleClear}
/>
{showImageHint && (
<div className="flex items-center justify-between p-3.5 rounded-xl bg-primary/10 border border-primary/20">
<span className="text-xs font-semibold text-primary tracking-tight">
data URI使
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onSwitchToImageMode}
className="h-7 rounded-md text-xs font-bold text-primary hover:text-primary hover:bg-primary/20 dark:hover:bg-primary/10 px-2.5"
>
</Button>
</div>
)}
{output && (
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
<div className="flex justify-between items-center select-none">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{outputLabel}
</span>
<CopyButton
text={output}
className="h-6 px-2 rounded-md border text-[10px] font-bold"
/>
</div>
<TextInputArea
readOnly
value={output.length > 2000 ? `${output.substring(0, 2000)}...` : output}
showClear={false}
minRows={4}
/>
</div>
)}
</div>
);
}
+5 -7
View File
@@ -1,8 +1,7 @@
import { useI18n } from '@/utils/chromeI18n';
import { useStorageState } from '@/utils/useStorageState'; import { useStorageState } from '@/utils/useStorageState';
import type { Base64ConverterPageMode } from '@/types/storage'; import type { Base64ConverterPageMode } from '@/types/storage';
import TextMode from './TextMode'; import TextMode from './components/TextMode';
import Base64ConverterSection from './Base64ConverterSection'; import Base64ConverterSection from './components/Base64ConverterSection';
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
const VALID_PAGE_MODES: readonly Base64ConverterPageMode[] = ['text', 'file', 'image']; const VALID_PAGE_MODES: readonly Base64ConverterPageMode[] = ['text', 'file', 'image'];
@@ -12,7 +11,6 @@ const isValidPageMode = (val: unknown): val is Base64ConverterPageMode =>
type PageMode = Base64ConverterPageMode; type PageMode = Base64ConverterPageMode;
export default function Index() { export default function Index() {
const { t } = useI18n('base64Converter');
const [pageMode, setPageMode] = useStorageState( const [pageMode, setPageMode] = useStorageState(
'base64Converter/pageMode', 'base64Converter/pageMode',
'text', 'text',
@@ -24,9 +22,9 @@ export default function Index() {
<SwitchButtonGroup <SwitchButtonGroup
value={pageMode} value={pageMode}
options={[ options={[
{ value: 'text', label: t('base64Converter:textMode') }, { value: 'text', label: '文本' },
{ value: 'file', label: t('base64Converter:fileMode') }, { value: 'file', label: '文件' },
{ value: 'image', label: t('base64Converter:imageMode') }, { value: 'image', label: '图像' },
]} ]}
onChange={(value: PageMode) => setPageMode(value)} onChange={(value: PageMode) => setPageMode(value)}
size="small" size="small"
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import type { FileToBase64Result } from '@/utils/base64Converter'; import type { FileToBase64Result } from '@/utils/base64Converter';
import { import {
base64ToBlob, base64ToBlob,
@@ -21,8 +20,6 @@ interface UseBase64ConverterProps {
} }
export function useBase64Converter({ mode }: UseBase64ConverterProps) { export function useBase64Converter({ mode }: UseBase64ConverterProps) {
const { t } = useI18n('base64Converter');
const [result, setResult] = useState<FileToBase64Result | null>(null); const [result, setResult] = useState<FileToBase64Result | null>(null);
const [info, setInfo] = useState<FileInfo | null>(null); const [info, setInfo] = useState<FileInfo | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -62,7 +59,7 @@ export function useBase64Converter({ mode }: UseBase64ConverterProps) {
setInfo(null); setInfo(null);
if (!isFileSizeValid(file.size)) { if (!isFileSizeValid(file.size)) {
setEncodeError(t('fileSizeExceeded', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })); setEncodeError(`文件大小超出限制(最大 ${MAX_FILE_SIZE / 1024 / 1024} MB`);
return; return;
} }
@@ -71,7 +68,7 @@ export function useBase64Converter({ mode }: UseBase64ConverterProps) {
!isSupportedImageType(file.type) && !isSupportedImageType(file.type) &&
!isSupportedImageExtension(file.name) !isSupportedImageExtension(file.name)
) { ) {
setEncodeError(t('unsupportedImageType')); setEncodeError('不支持的图像格式');
return; return;
} }
@@ -87,13 +84,13 @@ export function useBase64Converter({ mode }: UseBase64ConverterProps) {
if (!cancelRef.current) setResult(res); if (!cancelRef.current) setResult(res);
} catch (e) { } catch (e) {
if (!cancelRef.current) { if (!cancelRef.current) {
setEncodeError(e instanceof Error ? e.message : t('conversionFailed')); setEncodeError(e instanceof Error ? e.message : '转换失败');
} }
} finally { } finally {
if (!cancelRef.current) setIsLoading(false); if (!cancelRef.current) setIsLoading(false);
} }
}, },
[mode, t], [mode],
); );
const safeFileSelect = useCallback( const safeFileSelect = useCallback(
@@ -116,10 +113,10 @@ export function useBase64Converter({ mode }: UseBase64ConverterProps) {
const message = e instanceof Error ? e.message : ''; const message = e instanceof Error ? e.message : '';
return { return {
decoded: null, decoded: null,
error: message === 'Invalid Base64 string' ? t('invalidBase64') : t('conversionFailed'), error: message === 'Invalid Base64 string' ? 'Base64 字符串无效' : '转换失败',
}; };
} }
}, [debouncedDecodeInput, t]); }, [debouncedDecodeInput]);
const decoded = decodePipeline.decoded; const decoded = decodePipeline.decoded;
const decodeError = decodePipeline.error; const decodeError = decodePipeline.error;
+91
View File
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { base64ToText, textToBase64 } from '@/utils/base64Converter';
import { useContextMenuData } from '@/utils/useContextMenuData';
const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i;
const ERROR_MESSAGE_TO_I18N: Record<string, string> = {
'Invalid Base64 string': '无效的 Base64 字符串',
'Input appears to be binary data (e.g. an image). Please use the Image tab instead.':
'检测到二进制数据(如图像),请使用图像选项卡',
};
export function useTextMode() {
const [input, setInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState('');
const [direction, setDirection] = useState<'encode' | 'decode'>('encode');
useEffect(() => {
const handle = setTimeout(() => {
setDebouncedInput(input);
}, 200);
return () => clearTimeout(handle);
}, [input]);
const handleContextMenuData = useCallback((payload: string) => {
setInput(payload);
setDebouncedInput(payload);
setDirection('decode');
}, []);
useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData });
const conversionPipeline = useMemo(() => {
const trimmed = debouncedInput.trim();
if (!trimmed) return { output: '', error: null };
try {
if (direction === 'encode') {
const result = textToBase64(debouncedInput);
return { output: result.output, error: null };
} else {
const decoded = base64ToText(trimmed);
return { output: decoded, error: null };
}
} catch (e) {
const message = e instanceof Error ? e.message : '';
const i18nKey = ERROR_MESSAGE_TO_I18N[message];
return {
output: '',
error: i18nKey || message || '转换失败',
};
}
}, [debouncedInput, direction]);
const output = conversionPipeline.output;
const error = conversionPipeline.error;
const placeholder =
direction === 'encode' ? '输入需要编码为 Base64 的文本...' : '输入需要解码的 Base64 字符串...';
const outputLabel = direction === 'encode' ? 'Base64 编码结果' : '解码文本结果';
const showImageHint = useMemo(
() => direction === 'decode' && IMAGE_DATA_URI_PATTERN.test(input),
[direction, input],
);
const handleDirectionChange = (value: 'encode' | 'decode') => {
if (value === direction) return;
setDirection(value);
setInput('');
setDebouncedInput('');
};
const handleClear = () => {
setInput('');
setDebouncedInput('');
};
return {
input,
setInput,
direction,
handleDirectionChange,
placeholder,
output,
outputLabel,
error,
showImageHint,
handleClear,
};
}
-107
View File
@@ -1,107 +0,0 @@
import type { ComponentType } from 'react';
import React from 'react';
import type { LucideProps } from 'lucide-react';
import { ChevronRight } from 'lucide-react';
import type { PaletteColorKey } from '@/config/features';
import { cn } from '@/lib/utils';
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
primary: '13, 148, 136', // teal
success: '22, 163, 74', // green
warning: '217, 119, 6', // amber (存储清理的橙色轴)
error: '220, 38, 38', // red
secondary: '147, 51, 232',
info: '37, 99, 235', // blue
};
export interface ToolCardProps extends React.HTMLAttributes<HTMLDivElement> {
title: string;
description?: string;
snapshot?: React.ReactNode;
colorKey: PaletteColorKey;
icon: ComponentType<LucideProps>;
onNavigate: () => void;
}
export default function ToolCard({
title,
description,
snapshot,
colorKey,
icon: IconComponent,
onNavigate,
className,
...props
}: ToolCardProps) {
const rgbValues = PALETTE_COLORS[colorKey];
return (
<div
style={{
['--tool-color' as string]: rgbValues,
}}
/* 💡 核心修复点:
- 坚决不用 h-full 或固定高度,锁死 h-auto(高度自适应流),配合 py-4 px-4 牢牢把内容包裹在卡片体内。
- 废除原先会乱飘的内联 style 属性擦写,全权放权给 Tailwind 的声明式 hover 变体。
*/
className={cn(
'group relative rounded-xl border border-border/70 bg-card text-card-foreground p-4 h-auto flex flex-col items-stretch justify-start gap-3 shadow-sm select-none box-border',
'hover:bg-muted/30',
'hover:border-[rgba(var(--tool-color),0.45)]',
'hover:shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)] dark:hover:shadow-[0_8px_30px_-10px_rgba(var(--tool-color),0.25)]',
className,
)}
{...props}
>
{/* 上半部分:核心信息交互排版轴 */}
<div className="flex items-center justify-between w-full relative min-w-0 min-h-[44px]">
<div className="flex gap-3 items-center min-w-0 flex-1 pr-2">
{/* 左侧圆形图标容器 */}
<div
className={cn(
'flex items-center justify-center w-10 h-10 rounded-xl shrink-0',
'bg-[rgba(var(--tool-color),0.08)] dark:bg-[rgba(var(--tool-color),0.12)]',
'text-[rgb(var(--tool-color))]',
)}
>
<IconComponent className="h-5 w-5 shrink-0" />
</div>
{/* 中间文字描述区:利用 flex-1 min-w-0 防御文本过长发生恶性撑开 */}
<div className="flex-1 min-w-0 flex flex-col">
<h4 className="font-bold text-sm tracking-tight text-foreground leading-snug">
{title}
</h4>
{description && (
<p className="text-[11px] font-medium text-muted-foreground/90 mt-0.5 leading-normal w-full truncate">
{description}
</p>
)}
</div>
</div>
{/* 右侧指示小箭头 */}
<div className="text-muted-foreground/40 group-hover:text-[rgb(var(--tool-color))] p-1 shrink-0 group-hover:translate-x-0.5">
<ChevronRight className="h-4 w-4" />
</div>
{/* 覆盖整个上半部分的绝对定位隐形跳转层(A11y 无障碍标准合规) */}
<button
type="button"
onClick={onNavigate}
aria-label={`进入 ${title}`}
className="absolute inset-0 w-full h-full cursor-pointer bg-transparent border-none opacity-0 focus-visible:outline-none"
/>
</div>
{/* 下半部分:未来的动态预览沙箱独立承载区 */}
{snapshot != null && (
<div className="mt-1 pt-3 border-t border-dashed border-border/80 w-full relative z-10 select-text">
{snapshot}
</div>
)}
</div>
);
}
ToolCard.displayName = 'ToolCard';
+4 -6
View File
@@ -1,12 +1,10 @@
import { useRouter } from '@/providers/RouterProvider'; import { useRouter } from '@/providers/RouterProvider';
import { getFeatureByKey } from '@/config/features'; import { getFeatureByKey } from '@/config/features';
import type { PageType } from '@/types/storage'; import type { PageType } from '@/types/storage';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export default function Index() { export default function Index() {
const { navigateTo, visiblePages, pageOrder, recentlyUsedTools } = useRouter(); const { navigateTo, visiblePages, pageOrder, recentlyUsedTools } = useRouter();
const { t } = useI18n(['features']);
const visibleSet = new Set<string>(visiblePages); const visibleSet = new Set<string>(visiblePages);
@@ -28,7 +26,7 @@ export default function Index() {
{showRecent && ( {showRecent && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider"> <h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
{t('dashboard_recentlyUsed')} {'最近使用'}
</h3> </h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{recentFeatures.map(({ key, feature }) => { {recentFeatures.map(({ key, feature }) => {
@@ -46,7 +44,7 @@ export default function Index() {
)} )}
> >
<IconComponent className="h-3.5 w-3.5 text-muted-foreground/70" /> <IconComponent className="h-3.5 w-3.5 text-muted-foreground/70" />
{t(feature!.labelKey)} {feature!.label}
</button> </button>
); );
})} })}
@@ -57,7 +55,7 @@ export default function Index() {
{/* 全部工具 — 紧凑 Grid */} {/* 全部工具 — 紧凑 Grid */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider"> <h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
{t('dashboard_allTools')} {'全部工具'}
</h3> </h3>
<div className={cn('grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2')}> <div className={cn('grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2')}>
{visibleFeatures.map(({ key, feature }) => { {visibleFeatures.map(({ key, feature }) => {
@@ -82,7 +80,7 @@ export default function Index() {
)} )}
/> />
<span className="text-[11px] font-medium text-muted-foreground/80 group-hover:text-foreground leading-tight text-center truncate w-full transition-colors"> <span className="text-[11px] font-medium text-muted-foreground/80 group-hover:text-foreground leading-tight text-center truncate w-full transition-colors">
{t(feature!.labelKey)} {feature!.label}
</span> </span>
</button> </button>
); );
@@ -0,0 +1,11 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import Index from '../index';
describe('JsonTools 页面', () => {
it('应该渲染模式切换按钮', () => {
render(<Index />);
// 基本渲染测试:页面含多个模式切换按钮,应至少渲染一个
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
});
});
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react'; import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface DiffNavigatorProps extends React.HTMLAttributes<HTMLDivElement> { export interface DiffNavigatorProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -19,8 +18,6 @@ export default function DiffNavigator({
className, className,
...props ...props
}: DiffNavigatorProps) { }: DiffNavigatorProps) {
const { t } = useI18n('jsonDiff');
const isFirst = currentIndex <= 0; const isFirst = currentIndex <= 0;
const isLast = currentIndex >= total - 1; const isLast = currentIndex >= total - 1;
@@ -33,9 +30,7 @@ export default function DiffNavigator({
)} )}
{...props} {...props}
> >
<span className="text-xs font-semibold text-muted-foreground/90"> <span className="text-xs font-semibold text-muted-foreground/90">{'无差异'}</span>
{t('jsonDiff:noDiffs')}
</span>
</div> </div>
); );
} }
@@ -52,7 +47,7 @@ export default function DiffNavigator({
<button <button
type="button" type="button"
disabled={isFirst} disabled={isFirst}
aria-label={t('jsonDiff:previousDiff')} aria-label={'上一个'}
onClick={onPrev} onClick={onPrev}
className={cn( className={cn(
'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground', 'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground',
@@ -72,7 +67,7 @@ export default function DiffNavigator({
<button <button
type="button" type="button"
disabled={isLast} disabled={isLast}
aria-label={t('jsonDiff:nextDiff')} aria-label={'下一个'}
onClick={onNext} onClick={onNext}
className={cn( className={cn(
'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground', 'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground',
@@ -1,8 +1,7 @@
import React from 'react'; import React from 'react';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import JsonTree from './JsonTree'; import JsonTree from './JsonTree';
import type { DiffNode, DiffResult as DiffResultType, DiffType, ViewMode } from './types'; import type { DiffNode, DiffResult as DiffResultType, DiffType, ViewMode } from '../types';
export interface DiffResultProps extends React.HTMLAttributes<HTMLDivElement> { export interface DiffResultProps extends React.HTMLAttributes<HTMLDivElement> {
result: DiffResultType; result: DiffResultType;
@@ -17,8 +16,6 @@ export default function DiffResult({
className, className,
...props ...props
}: DiffResultProps) { }: DiffResultProps) {
const { t } = useI18n('jsonDiff');
if (viewMode === 'sideBySide') { if (viewMode === 'sideBySide') {
return ( return (
<div <div
@@ -26,11 +23,11 @@ export default function DiffResult({
{...props} {...props}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<SectionLabel text={t('jsonDiff:leftLabel')} /> <SectionLabel text={'原始 JSON'} />
<JsonTree node={result.root} side="left" activePath={activePath} /> <JsonTree node={result.root} side="left" activePath={activePath} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<SectionLabel text={t('jsonDiff:rightLabel')} /> <SectionLabel text={'目标 JSON'} />
<JsonTree node={result.root} side="right" activePath={activePath} /> <JsonTree node={result.root} side="right" activePath={activePath} />
</div> </div>
</div> </div>
@@ -1,18 +1,31 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import { formatBytes } from '@/utils/format'; import { formatBytes } from '@/utils/format';
import CopyButton from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import { validateJson } from '@/utils/jsonFormatter'; import { validateJson } from '@/utils/jsonFormatter';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { ConvertFunction, ConvertResult } from '../types';
export interface ConvertResult { const CONVERT_LABELS: Record<
output: string; string,
originalBytes: number; { inputPlaceholder: string; outputLabel: string; emptyHint: string }
outputBytes: number; > = {
} yaml: {
inputPlaceholder: '输入需要转换的 JSON...',
export type ConvertFunction = (text: string) => ConvertResult; outputLabel: 'YAML 结果',
emptyHint: '输入 JSON 后点击转换',
},
toml: {
inputPlaceholder: '输入需要转换的 JSON...',
outputLabel: 'TOML 结果',
emptyHint: '输入 JSON 后点击转换',
},
minify: {
inputPlaceholder: '输入需要压缩的 JSON...',
outputLabel: '压缩结果',
emptyHint: '输入 JSON 后点击压缩',
},
};
interface JsonConvertSectionProps extends React.HTMLAttributes<HTMLDivElement> { interface JsonConvertSectionProps extends React.HTMLAttributes<HTMLDivElement> {
translationPrefix: string; translationPrefix: string;
@@ -25,12 +38,11 @@ export default function JsonConvertSection({
className, className,
...props ...props
}: JsonConvertSectionProps) { }: JsonConvertSectionProps) {
const { t } = useI18n('jsonFormat');
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState(''); const [debouncedInput, setDebouncedInput] = useState('');
const pk = translationPrefix; const pk = translationPrefix;
const labels = CONVERT_LABELS[pk] || CONVERT_LABELS.yaml;
// Debounce input // Debounce input
useEffect(() => { useEffect(() => {
@@ -72,7 +84,7 @@ export default function JsonConvertSection({
<div className={cn('w-full flex flex-col gap-4', className)} {...props}> <div className={cn('w-full flex flex-col gap-4', className)} {...props}>
{/* 输入区 */} {/* 输入区 */}
<TextInputArea <TextInputArea
placeholder={t(`jsonFormat:${pk}InputPlaceholder`)} placeholder={labels.inputPlaceholder}
value={input} value={input}
onChange={setInput} onChange={setInput}
externalError={error || runtimeError || undefined} externalError={error || runtimeError || undefined}
@@ -89,21 +101,21 @@ export default function JsonConvertSection({
<div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none"> <div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none">
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90"> <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{t(`jsonFormat:${pk}OutputLabel`)} {labels.outputLabel}
</span> </span>
<div className="hidden sm:flex gap-3 items-center font-mono text-[10px] text-muted-foreground/70 tabular-nums"> <div className="hidden sm:flex gap-3 items-center font-mono text-[10px] text-muted-foreground/70 tabular-nums">
<span> <span>
{t('jsonFormat:originalSize')}:{' '} {'原始大小'}:{' '}
<span className="font-semibold text-foreground/80"> <span className="font-semibold text-foreground/80">
{formatBytes(result.originalBytes)} {formatBytes(result.originalBytes)}
</span> </span>
</span> </span>
<span className="text-border/60">|</span> <span className="text-border/60">|</span>
<span> <span>
{t('jsonFormat:formattedSize')}:{' '} {'格式化后大小'}:{' '}
<span className="font-semibold text-foreground/80"> <span className="font-semibold text-foreground/80">
{formatByteSize(result.outputBytes)} {formatBytes(result.outputBytes)}
</span> </span>
</span> </span>
</div> </div>
@@ -122,7 +134,7 @@ export default function JsonConvertSection({
) : ( ) : (
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none"> <div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none">
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed"> <p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed">
{error ? t('jsonFormat:fixErrorHint') : t(`jsonFormat:${pk}EmptyHint`)} {error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : labels.emptyHint}
</p> </p>
</div> </div>
)} )}
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import { import {
formatJson, formatJson,
type JsonFormatOptions, type JsonFormatOptions,
@@ -7,15 +6,13 @@ import {
validateJson, validateJson,
} from '@/utils/jsonFormatter'; } from '@/utils/jsonFormatter';
import { formatBytes } from '@/utils/format'; import { formatBytes } from '@/utils/format';
import CopyButton from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
export default function JsonFormatSection() { export default function JsonFormatSection() {
const { t } = useI18n('jsonFormat');
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState(''); const [debouncedInput, setDebouncedInput] = useState('');
const [indentSize, setIndentSize] = useState<number>(2); const [indentSize, setIndentSize] = useState<number>(2);
@@ -66,7 +63,7 @@ export default function JsonFormatSection() {
{/* 缩进配置区 */} {/* 缩进配置区 */}
<div className="flex gap-2 items-center shrink-0 select-none"> <div className="flex gap-2 items-center shrink-0 select-none">
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider"> <span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
{t('jsonFormat:indentSize')} {'缩进'}
</span> </span>
<SwitchButtonGroup <SwitchButtonGroup
value={indentSize} value={indentSize}
@@ -94,7 +91,7 @@ export default function JsonFormatSection() {
htmlFor="sort-keys-checkbox" htmlFor="sort-keys-checkbox"
className="text-xs font-bold text-foreground/80 cursor-pointer tracking-tight group-hover:text-foreground" className="text-xs font-bold text-foreground/80 cursor-pointer tracking-tight group-hover:text-foreground"
> >
{t('jsonFormat:sortKeys')} {'键名排序'}
</Label> </Label>
</div> </div>
</div> </div>
@@ -102,7 +99,7 @@ export default function JsonFormatSection() {
{/* 满血版输入终端 */} {/* 满血版输入终端 */}
<TextInputArea <TextInputArea
placeholder={t('jsonFormat:inputPlaceholder')} placeholder={'输入需要格式化的 JSON...'}
value={input} value={input}
onChange={setInput} onChange={setInput}
externalError={error || runtimeError || undefined} externalError={error || runtimeError || undefined}
@@ -120,21 +117,21 @@ export default function JsonFormatSection() {
<div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none"> <div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none">
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90"> <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{t('jsonFormat:outputLabel')} {'格式化结果'}
</span> </span>
<div className="hidden sm:flex gap-3 items-center font-mono text-[10px] text-muted-foreground/70 tabular-nums"> <div className="hidden sm:flex gap-3 items-center font-mono text-[10px] text-muted-foreground/70 tabular-nums">
<span> <span>
{t('jsonFormat:originalSize')}:{' '} {'原始大小'}:{' '}
<span className="font-semibold text-foreground/80"> <span className="font-semibold text-foreground/80">
{formatBytes(result.originalBytes)} {formatBytes(result.originalBytes)}
</span> </span>
</span> </span>
<span className="text-border/60">|</span> <span className="text-border/60">|</span>
<span> <span>
{t('jsonFormat:formattedSize')}:{' '} {'格式化后大小'}:{' '}
<span className="font-semibold text-foreground/80"> <span className="font-semibold text-foreground/80">
{formatByteSize(result.formattedBytes)} {formatBytes(result.formattedBytes)}
</span> </span>
</span> </span>
</div> </div>
@@ -154,7 +151,7 @@ export default function JsonFormatSection() {
/* 空状态指示引导区 */ /* 空状态指示引导区 */
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none"> <div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none">
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed"> <p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed">
{error ? t('jsonFormat:fixErrorHint') : t('jsonFormat:emptyHint')} {error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : '输入 JSON 后点击格式化'}
</p> </p>
</div> </div>
)} )}
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react'; // 用正统的矢量箭头平替原生的字符 '▾' '▸' import { ChevronDown, ChevronRight } from 'lucide-react'; // 用正统的矢量箭头平替原生的字符 '▾' '▸'
import type { DiffNode, DiffType } from './types'; import type { DiffNode, DiffType } from '../types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export type TreeSide = 'left' | 'right'; export type TreeSide = 'left' | 'right';
+19 -19
View File
@@ -1,9 +1,8 @@
import { useI18n } from '@/utils/chromeI18n'; import JsonDiffInput from './components/JsonDiffInput';
import JsonDiffInput from './JsonDiffInput'; import DiffResult from './components/DiffResult';
import DiffResult from './DiffResult'; import DiffNavigator from './components/DiffNavigator';
import DiffNavigator from './DiffNavigator'; import JsonFormatSection from './components/JsonFormatSection';
import JsonFormatSection from './JsonFormatSection'; import JsonConvertSection from './components/JsonConvertSection';
import JsonConvertSection from './JsonConvertSection';
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { useJsonTools } from './useJsonTools'; import { useJsonTools } from './useJsonTools';
import type { JsonToolsPageMode } from '@/types/storage'; import type { JsonToolsPageMode } from '@/types/storage';
@@ -12,7 +11,6 @@ import type { ViewMode } from './types';
type PageMode = JsonToolsPageMode; type PageMode = JsonToolsPageMode;
export default function Index() { export default function Index() {
const { t } = useI18n(['jsonDiff', 'jsonFormat']);
const { const {
pageMode, pageMode,
setPageMode, setPageMode,
@@ -41,11 +39,11 @@ export default function Index() {
value={pageMode} value={pageMode}
onChange={(v: PageMode) => setPageMode(v)} onChange={(v: PageMode) => setPageMode(v)}
options={[ options={[
{ value: 'diff', label: t('jsonFormat:diffMode') }, { value: 'diff', label: '差异比较' },
{ value: 'format', label: t('jsonFormat:formatMode') }, { value: 'format', label: '格式化' },
{ value: 'yaml', label: t('jsonFormat:yamlMode') }, { value: 'yaml', label: 'YAML' },
{ value: 'toml', label: t('jsonFormat:tomlMode') }, { value: 'toml', label: 'TOML' },
{ value: 'minify', label: t('jsonFormat:minifyMode') }, { value: 'minify', label: '压缩' },
]} ]}
size="small" size="small"
className="w-full sm:w-auto" className="w-full sm:w-auto"
@@ -58,8 +56,8 @@ export default function Index() {
value={viewMode} value={viewMode}
onChange={(v: ViewMode) => setViewMode(v)} onChange={(v: ViewMode) => setViewMode(v)}
options={[ options={[
{ value: 'sideBySide', label: t('jsonDiff:sideBySideMode') }, { value: 'sideBySide', label: '并排' },
{ value: 'unified', label: t('jsonDiff:unifiedMode') }, { value: 'unified', label: '统一' },
]} ]}
size="small" size="small"
/> />
@@ -67,16 +65,16 @@ export default function Index() {
<div className="flex flex-col md:flex-row gap-4 w-full items-stretch"> <div className="flex flex-col md:flex-row gap-4 w-full items-stretch">
<JsonDiffInput <JsonDiffInput
label={t('jsonDiff:leftLabel')} label={'原始 JSON'}
placeholder={t('jsonDiff:leftPlaceholder')} placeholder={'输入原始 JSON...'}
value={leftInput} value={leftInput}
onChange={setLeftInput} onChange={setLeftInput}
error={leftError} error={leftError}
minRows={9} minRows={9}
/> />
<JsonDiffInput <JsonDiffInput
label={t('jsonDiff:rightLabel')} label={'目标 JSON'}
placeholder={t('jsonDiff:rightPlaceholder')} placeholder={'输入目标 JSON...'}
value={rightInput} value={rightInput}
onChange={setRightInput} onChange={setRightInput}
error={rightError} error={rightError}
@@ -99,7 +97,9 @@ export default function Index() {
) : ( ) : (
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[140px]"> <div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[140px]">
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[260px] leading-relaxed"> <p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[260px] leading-relaxed">
{leftError || rightError ? t('jsonDiff:fixErrorHint') : t('jsonDiff:emptyHint')} {leftError || rightError
? '请修正上方 JSON 的语法错误以开启实时流式比对'
: '输入两侧 JSON 后点击比较'}
</p> </p>
</div> </div>
)} )}
+10
View File
@@ -53,3 +53,13 @@ export interface DiffResult {
/** 差异核心总计数(等价于 diffPaths.length),注入 tabular-nums 配合渲染 */ /** 差异核心总计数(等价于 diffPaths.length),注入 tabular-nums 配合渲染 */
diffCount: number; diffCount: number;
} }
/** JSON 转换结果 */
export interface ConvertResult {
output: string;
originalBytes: number;
outputBytes: number;
}
/** JSON 转换函数类型 */
export type ConvertFunction = (text: string) => ConvertResult;
+4 -7
View File
@@ -1,14 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import { useStorageState } from '@/utils/useStorageState'; import { useStorageState } from '@/utils/useStorageState';
import { diffJson } from './diffEngine'; import { diffJson } from '@/utils/diffEngine';
import { jsonToYaml } from '@/utils/jsonToYaml'; import { jsonToYaml } from '@/utils/jsonToYaml';
import { jsonToToml } from '@/utils/jsonToToml'; import { jsonToToml } from '@/utils/jsonToToml';
import { minifyJson } from '@/utils/jsonFormatter'; import { minifyJson } from '@/utils/jsonFormatter';
import { isValidPageMode, tryParse } from './constants'; import { isValidPageMode, tryParse } from './constants';
import type { JsonToolsPageMode } from '@/types/storage'; import type { JsonToolsPageMode } from '@/types/storage';
import type { ConvertFunction } from './JsonConvertSection'; import type { ConvertFunction, ViewMode } from './types';
import type { ViewMode } from './types';
export interface UseJsonToolsReturn { export interface UseJsonToolsReturn {
pageMode: JsonToolsPageMode; pageMode: JsonToolsPageMode;
@@ -35,7 +33,6 @@ export interface UseJsonToolsReturn {
} }
export function useJsonTools(): UseJsonToolsReturn { export function useJsonTools(): UseJsonToolsReturn {
const { t } = useI18n(['jsonDiff', 'jsonFormat']);
const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode); const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode);
// Diff inputs // Diff inputs
@@ -55,12 +52,12 @@ export function useJsonTools(): UseJsonToolsReturn {
// Parse debounced inputs // Parse debounced inputs
const parseState = useMemo(() => { const parseState = useMemo(() => {
const invalidMsg = t('jsonDiff:invalidJson'); const invalidMsg = '无效的 JSON 格式';
return { return {
left: tryParse(debouncedLeft, invalidMsg), left: tryParse(debouncedLeft, invalidMsg),
right: tryParse(debouncedRight, invalidMsg), right: tryParse(debouncedRight, invalidMsg),
}; };
}, [debouncedLeft, debouncedRight, t]); }, [debouncedLeft, debouncedRight]);
const leftError = parseState.left.error; const leftError = parseState.left.error;
const rightError = parseState.right.error; const rightError = parseState.right.error;
+2 -4
View File
@@ -1,5 +1,4 @@
import CopyButton from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
import { useI18n } from '@/utils/chromeI18n';
import { stringifyJson } from '@/utils/jwt'; import { stringifyJson } from '@/utils/jwt';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -18,7 +17,6 @@ export default function JwtSection({
bgClass, bgClass,
borderClass, borderClass,
}: JwtSectionProps) { }: JwtSectionProps) {
const { t } = useI18n('jwt');
return ( return (
<div className={cn('p-4 rounded-xl border border-solid', bgClass, borderClass)}> <div className={cn('p-4 rounded-xl border border-solid', bgClass, borderClass)}>
<div className="flex justify-between items-center mb-2 select-none"> <div className="flex justify-between items-center mb-2 select-none">
@@ -31,7 +29,7 @@ export default function JwtSection({
/> />
</div> </div>
<pre className="m-0 p-3 bg-muted/30 dark:bg-muted/10 rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all border border-border/50 text-foreground/90 leading-relaxed select-text"> <pre className="m-0 p-3 bg-muted/30 dark:bg-muted/10 rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all border border-border/50 text-foreground/90 leading-relaxed select-text">
{content ? stringifyJson(content) : t('jwt:invalidFormat')} {content ? stringifyJson(content) : '无法解析'}
</pre> </pre>
</div> </div>
); );
+7 -11
View File
@@ -1,11 +1,9 @@
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import CopyButton from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
import JwtSection from './JwtSection'; import JwtSection from './JwtSection';
import { useJwt } from './useJwt'; import { useJwt } from './useJwt';
import { useI18n } from '@/utils/chromeI18n';
export default function Index() { export default function Index() {
const { t } = useI18n(['jwt', 'jsonFormat']);
const { jwtInput, result, handleChange, handleClear } = useJwt(); const { jwtInput, result, handleChange, handleClear } = useJwt();
return ( return (
@@ -14,7 +12,7 @@ export default function Index() {
<TextInputArea <TextInputArea
minRows={5} minRows={5}
maxRows={10} maxRows={10}
placeholder={t('jwt_placeholder')} placeholder={'在此粘贴 JWT 令牌 (Encoded JWT)...'}
value={jwtInput} value={jwtInput}
onChange={handleChange} onChange={handleChange}
allowCopy={true} allowCopy={true}
@@ -26,7 +24,7 @@ export default function Index() {
{result && !result.error && ( {result && !result.error && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<JwtSection <JwtSection
title={t('jwt:headerTitle')} title={'HEADER: 算法 & 令牌类型'}
content={result.header} content={result.header}
colorClass="text-[#fb015b] dark:text-rose-400" colorClass="text-[#fb015b] dark:text-rose-400"
borderClass="border-[#fb015b]/20 dark:border-rose-500/20" borderClass="border-[#fb015b]/20 dark:border-rose-500/20"
@@ -34,7 +32,7 @@ export default function Index() {
/> />
<JwtSection <JwtSection
title={t('jwt:payloadTitle')} title={'PAYLOAD: 数据'}
content={result.payload} content={result.payload}
colorClass="text-[#a03aff] dark:text-purple-400" colorClass="text-[#a03aff] dark:text-purple-400"
borderClass="border-[#a03aff]/20 dark:border-purple-500/20" borderClass="border-[#a03aff]/20 dark:border-purple-500/20"
@@ -44,7 +42,7 @@ export default function Index() {
<div className="p-4 rounded-xl border border-border bg-secondary/40 shadow-sm"> <div className="p-4 rounded-xl border border-border bg-secondary/40 shadow-sm">
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<span className="text-xs font-bold tracking-wider text-muted-foreground/90 uppercase"> <span className="text-xs font-bold tracking-wider text-muted-foreground/90 uppercase">
{t('jwt:signatureTitle')} {'签名'}
</span> </span>
<CopyButton <CopyButton
text={result.signature || ''} text={result.signature || ''}
@@ -52,7 +50,7 @@ export default function Index() {
/> />
</div> </div>
<span className="block text-xs font-mono break-all text-foreground/80 bg-muted/30 dark:bg-muted/10 p-3 rounded-lg border border-border/50 leading-relaxed select-text"> <span className="block text-xs font-mono break-all text-foreground/80 bg-muted/30 dark:bg-muted/10 p-3 rounded-lg border border-border/50 leading-relaxed select-text">
{result.signature || t('jwt:noSignature')} {result.signature || '无签名'}
</span> </span>
</div> </div>
</div> </div>
@@ -60,9 +58,7 @@ export default function Index() {
{result?.error && ( {result?.error && (
<div className="p-6 rounded-xl bg-muted/30 border border-dashed border-border text-center"> <div className="p-6 rounded-xl bg-muted/30 border border-dashed border-border text-center">
<p className="text-xs font-semibold text-muted-foreground/80"> <p className="text-xs font-semibold text-muted-foreground/80">{'无效的 JSON 格式'}</p>
{t('jsonFormat:invalidJson')}
</p>
</div> </div>
)} )}
</div> </div>
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react'; import { act, fireEvent, render, screen } from '@testing-library/react';
import ImageUploader from '@/components/ImageUploader'; import ImageUploader from '../components/ImageUploader';
// 配置多端一致性常驻桩(WXT 规范) // 配置多端一致性常驻桩(WXT 规范)
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() }; const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import QrCodePreview from '@/components/QrCodePreview'; import QrCodePreview from '../components/QrCodePreview';
describe('QrCodePreview 组件', () => { describe('QrCodePreview 组件', () => {
const mockOnDownload = vi.fn(); const mockOnDownload = vi.fn();
+142 -19
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import QrCodePage from '../index'; import QrCodePage from '../index';
vi.mock('lucide-react', async (importOriginal) => { vi.mock('lucide-react', async (importOriginal) => {
@@ -7,6 +7,8 @@ vi.mock('lucide-react', async (importOriginal) => {
return { return {
...actual, ...actual,
QrCode: () => <div data-testid="mock-lucide-qrcode">Icon</div>, QrCode: () => <div data-testid="mock-lucide-qrcode">Icon</div>,
Pencil: () => <div data-testid="mock-lucide-pencil">Icon</div>,
Loader2: () => <div data-testid="mock-lucide-loader">Icon</div>,
}; };
}); });
@@ -24,28 +26,37 @@ vi.mock('@/config/features', async (importOriginal) => {
}; };
}); });
vi.mock('@/components/QrCodePreview', () => ({ vi.mock('../components/QrCodePreview', () => ({
default: () => <div data-testid="qr-code-preview">QrCodePreview</div>, default: () => <div data-testid="qr-code-preview">QrCodePreview</div>,
})); }));
vi.mock('@/components/ImageUploader', () => ({ vi.mock('../components/ImageUploader', () => ({
default: () => <div data-testid="image-uploader">ImageUploader</div>, default: () => <div data-testid="image-uploader">ImageUploader</div>,
})); }));
vi.mock('qrious', () => ({ vi.mock('qrious', () => {
default: vi.fn().mockImplementation(() => ({ return {
toDataURL: () => 'data:image/png;base64,mock', default: class QRious {
})), constructor() {
})); return {
toDataURL: () => 'data:image/png;base64,mock',
};
}
},
};
});
describe('QrCodePage', () => { describe('QrCodePage', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('应该默认渲染生成模式', () => { it('应该默认渲染生成模式的输入态', () => {
render(<QrCodePage />); render(<QrCodePage />);
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument(); expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument();
expect(screen.getByText('生成二维码')).toBeInTheDocument();
// 输入态应该隐藏二维码预览
expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument();
}); });
it('应该渲染模式切换按钮', () => { it('应该渲染模式切换按钮', () => {
@@ -58,27 +69,139 @@ describe('QrCodePage', () => {
render(<QrCodePage />); render(<QrCodePage />);
fireEvent.click(screen.getByText('二维码转文本')); fireEvent.click(screen.getByText('二维码转文本'));
expect(screen.getByTestId('image-uploader')).toBeInTheDocument(); expect(screen.getByTestId('image-uploader')).toBeInTheDocument();
expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument();
}); });
it('切换回生成模式应该渲染 QrCodePreview', () => { it('切换回生成模式应该渲染输入态', () => {
render(<QrCodePage />); render(<QrCodePage />);
// 先切换到解析模式 // 先切换到解析模式
fireEvent.click(screen.getByText('二维码转文本')); fireEvent.click(screen.getByText('二维码转文本'));
expect(screen.getByTestId('image-uploader')).toBeInTheDocument();
// 再切换回生成模式 // 再切换回生成模式
fireEvent.click(screen.getByText('文本转二维码')); fireEvent.click(screen.getByText('文本转二维码'));
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument(); expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument();
expect(screen.getByText('生成二维码')).toBeInTheDocument();
}); });
it('应该渲染输入区域的系统标签(对齐新版 Label 机制)', () => { it('应该渲染输入区域的系统标签', () => {
render(<QrCodePage />); render(<QrCodePage />);
expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument(); expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument();
}); });
it('应该渲染双翼响应式卡片网格布局', () => { it('输入态应该显示生成按钮', () => {
const { container } = render(<QrCodePage />); render(<QrCodePage />);
const gridContainer = container.querySelector('.grid'); const generateButton = screen.getByText('生成二维码');
expect(gridContainer).toBeInTheDocument(); expect(generateButton).toBeInTheDocument();
expect(generateButton).toBeDisabled(); // 空输入时按钮应该禁用
});
it('有输入内容时生成按钮应该可用', () => {
render(<QrCodePage />);
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
const generateButton = screen.getByText('生成二维码');
expect(generateButton).not.toBeDisabled();
});
it('点击生成按钮应该切换到预览态', async () => {
render(<QrCodePage />);
// 输入文本
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
// 点击生成按钮
const generateButton = screen.getByText('生成二维码');
fireEvent.click(generateButton);
// 验证切换到预览态
await waitFor(() => {
expect(screen.getByText('原始文本')).toBeInTheDocument();
expect(screen.getByText('编辑')).toBeInTheDocument();
expect(screen.queryByText('生成二维码')).not.toBeInTheDocument();
// 预览态应该显示二维码
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
});
});
it('预览态应该显示截断的文本', async () => {
render(<QrCodePage />);
// 输入长文本
const longText =
'https://example.com/very/long/path/that/should/be/truncated/in/the/preview/because/it/exceeds/the/maximum/length';
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
fireEvent.change(textarea, { target: { value: longText } });
// 点击生成按钮
fireEvent.click(screen.getByText('生成二维码'));
// 验证显示截断的文本(80字符 + "..."
const truncatedText = longText.slice(0, 80) + '...';
await waitFor(() => {
expect(screen.getByText(truncatedText)).toBeInTheDocument();
});
});
it('预览态应该显示完整的短文本', async () => {
render(<QrCodePage />);
// 输入短文本
const shortText = 'https://example.com';
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
fireEvent.change(textarea, { target: { value: shortText } });
// 点击生成按钮
fireEvent.click(screen.getByText('生成二维码'));
// 验证显示完整文本
await waitFor(() => {
expect(screen.getByText(shortText)).toBeInTheDocument();
});
});
it('点击编辑按钮应该返回输入态', async () => {
render(<QrCodePage />);
// 输入文本并生成
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
fireEvent.click(screen.getByText('生成二维码'));
// 等待生成完成
await waitFor(() => {
expect(screen.getByText('编辑')).toBeInTheDocument();
});
// 点击编辑按钮
fireEvent.click(screen.getByText('编辑'));
// 验证返回输入态
expect(screen.getByText('输入 URL 或文本')).toBeInTheDocument();
expect(screen.getByText('生成二维码')).toBeInTheDocument();
expect(screen.queryByText('编辑')).not.toBeInTheDocument();
// 输入态应该隐藏二维码预览
expect(screen.queryByTestId('qr-code-preview')).not.toBeInTheDocument();
});
it('返回编辑态应该保留上次输入的内容', async () => {
render(<QrCodePage />);
// 输入文本并生成
const textarea = screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
fireEvent.change(textarea, { target: { value: 'https://example.com' } });
fireEvent.click(screen.getByText('生成二维码'));
// 等待生成完成
await waitFor(() => {
expect(screen.getByText('编辑')).toBeInTheDocument();
});
// 点击编辑按钮
fireEvent.click(screen.getByText('编辑'));
// 验证输入框保留了上次的内容
const textareaAfterEdit =
screen.getByPlaceholderText('请输入 URL 或文本内容,将自动生成二维码');
expect(textareaAfterEdit).toHaveValue('https://example.com');
}); });
}); });
+86 -25
View File
@@ -1,51 +1,112 @@
import { Loader2, Pencil, QrCode } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import QrCodePreview from '@/components/QrCodePreview'; import QrCodePreview from './QrCodePreview';
import { useI18n } from '@/utils/chromeI18n';
import { useQrCodeContext } from '../contexts/QrCodeContext'; import { useQrCodeContext } from '../contexts/QrCodeContext';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** 文本预览的最大显示字符数 */
const TEXT_PREVIEW_MAX_LENGTH = 80;
export default function GeneratePanel() { export default function GeneratePanel() {
const { t } = useI18n('qrCode'); const {
const { generatorState, setTextToEncode, downloadQrCode, copyQrCode } = useQrCodeContext(); generatorState,
setTextToEncode,
confirmGenerate,
backToEdit,
downloadQrCode,
copyQrCode,
} = useQrCodeContext();
return ( const isInputStep = generatorState.step === 'input';
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch select-none p-0.5"> const hasText = generatorState.textToEncode.trim().length > 0;
<div
className={cn( /** 截断文本用于预览显示 */
'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4', const getTruncatedText = (text: string) => {
'focus-within:ring-1 focus-within:ring-ring focus-within:border-ring', if (text.length <= TEXT_PREVIEW_MAX_LENGTH) return text;
)} return text.slice(0, TEXT_PREVIEW_MAX_LENGTH) + '...';
> };
<div className="flex flex-col space-y-2.5 h-full">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5"> // 输入态:只显示输入框和生成按钮
{t('qrCode:urlInputLabel')} if (isInputStep) {
</Label> return (
<div className="w-full select-none p-0.5">
<div
className={cn(
'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4',
'focus-within:ring-1 focus-within:ring-ring focus-within:border-ring',
)}
>
<div className="flex flex-col space-y-2.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
{'输入 URL 或文本'}
</Label>
<div className="flex-1 min-h-0">
<TextInputArea <TextInputArea
value={generatorState.textToEncode} value={generatorState.textToEncode}
onChange={setTextToEncode} onChange={setTextToEncode}
placeholder={t('qrCode:urlInputPlaceholder')} placeholder={'请输入 URL 或文本内容,将自动生成二维码'}
showCount={true} showCount={true}
showClear={true} showClear={true}
allowCopy={true} allowCopy={false}
minRows={6} minRows={6}
maxRows={12} maxRows={12}
externalError={generatorState.inputError || undefined} externalError={generatorState.inputError || undefined}
onClear={() => setTextToEncode('')} onClear={() => setTextToEncode('')}
/> />
<Button
onClick={confirmGenerate}
disabled={!hasText || generatorState.generating}
className="w-full"
>
{generatorState.generating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{'生成中...'}
</>
) : (
<>
<QrCode className="w-4 h-4 mr-2" />
{'生成二维码'}
</>
)}
</Button>
</div> </div>
</div> </div>
</div> </div>
);
}
<div className="flex flex-col h-full"> // 预览态:显示文本预览 + 二维码(紧凑布局,适配popup窗口)
<QrCodePreview return (
qrCodeDataUrl={generatorState.qrCodeDataUrl} <div className="w-full select-none p-0.5 flex flex-col">
onDownload={downloadQrCode} {/* 文本预览区域(固定高度,单行显示) */}
onCopy={copyQrCode} <div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm p-3 mb-3">
/> <div className="flex items-center justify-between mb-1.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
{'原始文本'}
</Label>
<Button variant="ghost" size="sm" onClick={backToEdit} className="h-6 px-2 text-xs">
<Pencil className="w-3 h-3 mr-1" />
{'编辑'}
</Button>
</div>
<div
className="text-sm text-foreground bg-muted/50 rounded-md px-3 py-2 cursor-default line-clamp-1"
title={generatorState.savedText}
>
{getTruncatedText(generatorState.savedText)}
</div>
</div> </div>
{/* 二维码预览区域(自适应剩余空间) */}
<QrCodePreview
qrCodeDataUrl={generatorState.qrCodeDataUrl}
onDownload={downloadQrCode}
onCopy={copyQrCode}
/>
</div> </div>
); );
} }
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { Image, X } from 'lucide-react'; import { Image, X } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useI18n } from '@/utils/chromeI18n'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
interface ImageUploaderProps { interface ImageUploaderProps {
/** 选中的文件 */ /** 选中的文件 */
@@ -29,7 +30,6 @@ const ImageUploader = ({
dragging, dragging,
onDraggingChange, onDraggingChange,
}: ImageUploaderProps) => { }: ImageUploaderProps) => {
const { t } = useI18n('qrCode');
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = useCallback( const handleFileChange = useCallback(
@@ -45,8 +45,8 @@ const ImageUploader = ({
URL.revokeObjectURL(previewUrl); URL.revokeObjectURL(previewUrl);
} }
onClearFile(); onClearFile();
toast.success(t('qrCode:imageCleared')); toast.success('图片已清除');
}, [previewUrl, onClearFile, t]); }, [previewUrl, onClearFile]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
@@ -86,10 +86,10 @@ const ImageUploader = ({
if (file) { if (file) {
try { try {
handleFileChange(file); handleFileChange(file);
toast.success(t('qrCode:imagePasted')); toast.success('图片粘贴成功,正在解析...');
} catch (error) { } catch (error) {
console.error('处理粘贴图片失败:', error); console.error('处理粘贴图片失败:', error);
toast.error(t('qrCode:imagePasteError')); toast.error('粘贴图片失败,请重试');
} }
} }
break; break;
@@ -102,7 +102,7 @@ const ImageUploader = ({
return () => { return () => {
document.removeEventListener('paste', handlePaste); document.removeEventListener('paste', handlePaste);
}; };
}, [handleFileChange, t]); }, [handleFileChange]);
return ( return (
<div <div
@@ -111,7 +111,7 @@ const ImageUploader = ({
? 'border-green-600 bg-green-50' ? 'border-green-600 bg-green-50'
: selectedFile : selectedFile
? 'border-green-600 bg-green-50/50' ? 'border-green-600 bg-green-50/50'
: 'border-input bg-muted hover:border-green-600 hover:bg-green-500/10/50' : 'border-input bg-muted hover:border-green-600 hover:bg-green-500/10'
}`} }`}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
@@ -125,7 +125,7 @@ const ImageUploader = ({
className="hidden" className="hidden"
id="qr-code-upload" id="qr-code-upload"
/> />
<label htmlFor="qr-code-upload" className="cursor-pointer text-center w-full"> <Label htmlFor="qr-code-upload" className="cursor-pointer text-center w-full">
{selectedFile ? ( {selectedFile ? (
<div className="text-center w-full relative"> <div className="text-center w-full relative">
<div className="relative inline-block"> <div className="relative inline-block">
@@ -134,20 +134,22 @@ const ImageUploader = ({
alt="QR Code Preview" alt="QR Code Preview"
className="max-w-full max-h-40 rounded-lg object-contain" className="max-w-full max-h-40 rounded-lg object-contain"
/> />
<button <Button
type="button" type="button"
variant="destructive"
size="icon"
data-testid="ClearIcon" data-testid="ClearIcon"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleClearFile(); handleClearFile();
}} }}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600 transition-colors" className="absolute -top-2 -right-2 h-6 w-6 rounded-full"
> >
<X className="w-3 h-3" /> <X className="w-3 h-3" />
</button> </Button>
</div> </div>
<span className="block text-sm text-muted-foreground mt-2">{selectedFile.name}</span> <span className="block text-sm text-muted-foreground mt-2">{selectedFile.name}</span>
<span className="block text-xs text-muted-foreground">{t('qrCode:clickToChange')}</span> <span className="block text-xs text-muted-foreground">{'点击更换图片'}</span>
</div> </div>
) : ( ) : (
<> <>
@@ -156,14 +158,14 @@ const ImageUploader = ({
className="w-12 h-12 text-muted-foreground mx-auto mb-2" className="w-12 h-12 text-muted-foreground mx-auto mb-2"
/> />
<span className="block text-sm text-muted-foreground mb-1"> <span className="block text-sm text-muted-foreground mb-1">
{t('qrCode:clickToUpload')} {'点击、拖拽或粘贴上传二维码图片'}
</span> </span>
<span className="block text-xs text-muted-foreground"> <span className="block text-xs text-muted-foreground">
{t('qrCode:supportFormats')} {'支持 PNG、JPG、WEBP、Base64 格式'}
</span> </span>
</> </>
)} )}
</label> </Label>
</div> </div>
); );
}; };
+57 -24
View File
@@ -1,16 +1,18 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import ImageUploader from '@/components/ImageUploader'; import ImageUploader from './ImageUploader';
import { useI18n } from '@/utils/chromeI18n';
import { useQrCodeContext } from '../contexts/QrCodeContext'; import { useQrCodeContext } from '../contexts/QrCodeContext';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export default function ParsePanel() { export default function ParsePanel() {
const { t } = useI18n('qrCode');
const { parserState, setParserState, handleFileChange, handleClearFile } = useQrCodeContext(); const { parserState, setParserState, handleFileChange, handleClearFile } = useQrCodeContext();
const hasFile = parserState.selectedFile !== null;
// 全局粘贴事件监听 // 全局粘贴事件监听
const handlePaste = useCallback( const handlePaste = useCallback(
async (e: ClipboardEvent) => { async (e: ClipboardEvent) => {
@@ -23,7 +25,7 @@ export default function ParsePanel() {
const file = items[i].getAsFile(); const file = items[i].getAsFile();
if (file) { if (file) {
handleFileChange(file); handleFileChange(file);
toast.success(t('qrCode:imagePasted')); toast.success('图片粘贴成功,正在解析...');
} }
return; return;
} }
@@ -37,14 +39,14 @@ export default function ParsePanel() {
const blob = await response.blob(); const blob = await response.blob();
const file = new File([blob], 'pasted-image.png', { type: blob.type }); const file = new File([blob], 'pasted-image.png', { type: blob.type });
handleFileChange(file); handleFileChange(file);
toast.success(t('qrCode:imagePasted')); toast.success('图片粘贴成功,正在解析...');
} catch (error) { } catch (error) {
console.error('处理 Base64 图片失败:', error); console.error('处理 Base64 图片失败:', error);
toast.error(t('qrCode:imagePasteError')); toast.error('粘贴图片失败,请重试');
} }
} }
}, },
[handleFileChange, t], [handleFileChange],
); );
useEffect(() => { useEffect(() => {
@@ -54,9 +56,10 @@ export default function ParsePanel() {
}; };
}, [handlePaste]); }, [handlePaste]);
return ( // 未上传图片:只显示上传区域
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch select-none p-0.5"> if (!hasFile) {
<div className="flex flex-col h-full"> return (
<div className="w-full select-none p-0.5">
<ImageUploader <ImageUploader
selectedFile={parserState.selectedFile} selectedFile={parserState.selectedFile}
onFileChange={handleFileChange} onFileChange={handleFileChange}
@@ -67,30 +70,60 @@ export default function ParsePanel() {
onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))} onDraggingChange={(dragging) => setParserState((prev) => ({ ...prev, dragging }))}
/> />
</div> </div>
);
}
// 已上传图片:显示图片预览 + 解析结果
return (
<div className="w-full select-none p-0.5 flex flex-col">
{/* 图片预览区域(小图预览) */}
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm p-3 mb-3">
<div className="flex items-center justify-between mb-1.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
{'已上传图片'}
</Label>
<Button variant="ghost" size="sm" onClick={handleClearFile} className="h-6 px-2 text-xs">
<RefreshCw className="w-3 h-3 mr-1" />
{'重新上传'}
</Button>
</div>
<div className="flex items-center gap-3 bg-muted/50 rounded-md p-2">
<img
src={parserState.previewUrl}
alt="Uploaded QR Code"
className="w-16 h-16 object-contain rounded"
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground truncate">{parserState.selectedFile?.name}</p>
{parserState.parsing && (
<p className="text-xs text-primary animate-pulse mt-1">{'解析中...'}</p>
)}
</div>
</div>
</div>
{/* 解析结果区域 */}
<div <div
className={cn( className={cn(
'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4', 'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4',
'focus-within:ring-1 focus-within:ring-ring focus-within:border-ring', 'focus-within:ring-1 focus-within:ring-ring focus-within:border-ring',
)} )}
> >
<div className="flex flex-col space-y-2.5 h-full"> <div className="flex flex-col space-y-2.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider pl-0.5"> <Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider pl-0.5">
{t('qrCode:resultLabel')} {'解析结果'}
</Label> </Label>
<div className="flex-1 min-h-0"> <TextInputArea
<TextInputArea value={parserState.decodedResult}
value={parserState.decodedResult} readOnly={true}
readOnly={true} showClear={false}
showClear={false} allowCopy={true}
allowCopy={true} placeholder={parserState.parsing ? '' : '解析结果将显示在此处'}
placeholder="" minRows={4}
minRows={6} maxRows={8}
maxRows={12} externalError={parserState.parseError || undefined}
externalError={parserState.parseError || undefined} />
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { Copy, Download } from 'lucide-react'; import { Copy, Download } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -12,17 +11,18 @@ interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
onDownload: () => void; onDownload: () => void;
/** 复制回调 */ /** 复制回调 */
onCopy: () => void; onCopy: () => void;
/** 自定义占位文本,用于空状态提示 */
placeholderText?: string;
} }
const QrCodePreview = ({ const QrCodePreview = ({
qrCodeDataUrl, qrCodeDataUrl,
onDownload, onDownload,
onCopy, onCopy,
placeholderText,
className, className,
...props ...props
}: QrCodePreviewProps) => { }: QrCodePreviewProps) => {
const { t } = useI18n('qrCode');
// 空状态下的虚线骨架屏 // 空状态下的虚线骨架屏
if (!qrCodeDataUrl) { if (!qrCodeDataUrl) {
return ( return (
@@ -33,7 +33,9 @@ const QrCodePreview = ({
)} )}
{...props} {...props}
> >
<p className="text-sm text-muted-foreground text-center">{t('qrCode:qrCodeWillShow')}</p> <p className="text-sm text-muted-foreground text-center">
{placeholderText || '二维码将显示在这里'}
</p>
</div> </div>
); );
} }
@@ -41,34 +43,34 @@ const QrCodePreview = ({
return ( return (
<div <div
className={cn( className={cn(
'flex flex-col justify-center items-center min-h-[200px] border border-input rounded-xl p-6 bg-muted/40', 'flex flex-col justify-center items-center border border-input rounded-xl p-4 bg-muted/40',
className, className,
)} )}
{...props} {...props}
> >
<div className="flex flex-col items-center w-full max-w-xs"> <div className="flex flex-col items-center w-full max-w-xs">
{/* {/*
2.
bg-white p-3 shadow bg-white p-2 shadow
*/} */}
<div className="p-3 bg-white rounded-lg shadow-sm border border-border/40"> <div className="p-2 bg-white rounded-lg shadow-sm border border-border/40">
<img <img
src={qrCodeDataUrl} src={qrCodeDataUrl}
alt="QR Code Preview" alt="QR Code Preview"
className="w-56 h-56 max-w-full object-contain block animate-in fade-in duration-300" className="w-48 h-48 max-w-full object-contain block animate-in fade-in duration-300"
/> />
</div> </div>
<div className="flex w-full gap-2 mt-5"> <div className="flex w-full gap-2 mt-3">
<Button variant="outline" size="sm" onClick={onDownload} className="flex-1"> <Button variant="outline" size="sm" onClick={onDownload} className="flex-1 h-8">
<Download className="w-4 h-4 text-muted-foreground" /> <Download className="w-3.5 h-3.5 text-muted-foreground" />
<span className="truncate">{t('qrCode:downloadButton')}</span> <span className="truncate text-xs">{'下载二维码'}</span>
</Button> </Button>
<Button variant="default" size="sm" onClick={onCopy} className="flex-1"> <Button variant="default" size="sm" onClick={onCopy} className="flex-1 h-8">
<Copy className="w-4 h-4" /> <Copy className="w-3.5 h-3.5" />
<span className="truncate">{t('qrCode:copyQrButton')}</span> <span className="truncate text-xs">{'复制二维码'}</span>
</Button> </Button>
</div> </div>
</div> </div>
@@ -8,6 +8,10 @@ export interface QrCodeContextValue {
generatorState: QrCodeGeneratorState; generatorState: QrCodeGeneratorState;
setTextToEncode: (text: string) => void; setTextToEncode: (text: string) => void;
/** 显式触发生成二维码并切换到预览态 */
confirmGenerate: () => void;
/** 返回编辑态,保留上次输入内容 */
backToEdit: () => void;
downloadQrCode: () => void; downloadQrCode: () => void;
copyQrCode: () => Promise<void>; copyQrCode: () => Promise<void>;
+280 -75
View File
@@ -1,22 +1,90 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import QRious from 'qrious'; import QRious from 'qrious';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { parseQrCodeFromFile } from '@/utils/qrCodeParser'; import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
import { useContextMenuData } from '@/utils/useContextMenuData'; import { useContextMenuData } from '@/utils/useContextMenuData';
import { useI18n } from '@/utils/chromeI18n';
import { useDebounce } from '@/utils/useDebounce';
import type { QrCodeContextValue } from '../contexts/QrCodeContext'; import type { QrCodeContextValue } from '../contexts/QrCodeContext';
import type { QrCodeGeneratorState, QrCodeMode, QrCodeParserState } from '../types'; import type { QrCodeGeneratorState, QrCodeMode, QrCodeParserState } from '../types';
export function useQrCode(): QrCodeContextValue { /** 防抖延迟时间(毫秒) */
const { t } = useI18n('qrCode'); const DEBOUNCE_DELAY = 500;
/** 检测文本是否为URL格式 */
function isUrl(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) return false;
// 检查是否以 http:// 或 https:// 开头
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return true;
}
// 检查是否为域名格式(包含.且不以特殊字符开头)
const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+([/:].*)?$/;
return domainPattern.test(trimmed);
}
/** 检测URL是否为图片格式 */
function isImageUrl(url: string): boolean {
const trimmedUrl = url.trim().toLowerCase();
// 检查是否为 data:image 格式
if (trimmedUrl.startsWith('data:image/')) {
return true;
}
// 检查是否包含图片扩展名
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico'];
if (imageExtensions.some((ext) => trimmedUrl.includes(ext))) {
return true;
}
// 检查URL路径中是否包含图片相关关键词
const imageKeywords = ['/image/', '/img/', '/photo/', '/pic/', '/upload/'];
if (imageKeywords.some((keyword) => trimmedUrl.includes(keyword))) {
return true;
}
return false;
}
/** 生成二维码的核心逻辑 */
function generateQrCodeDataUrl(text: string): string {
const trimmedText = text.trim();
if (!trimmedText) return '';
try {
let url = trimmedText;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
const isDark = document.documentElement.classList.contains('dark');
const qr = new QRious({
value: url,
size: 260,
level: 'H',
foreground: isDark ? '#f3f4f6' : '#0f172a',
background: isDark ? 'transparent' : '#ffffff',
});
return qr.toDataURL();
} catch (error) {
console.error('QR code generation failed:', error);
return '';
}
}
export function useQrCode(): QrCodeContextValue {
const [mode, setMode] = useState<QrCodeMode>('generate'); const [mode, setMode] = useState<QrCodeMode>('generate');
const [generatorState, setGeneratorState] = useState< const [generatorState, setGeneratorState] = useState<QrCodeGeneratorState>({
Omit<QrCodeGeneratorState, 'generating' | 'qrCodeDataUrl'> step: 'input',
>({
textToEncode: '', textToEncode: '',
savedText: '',
qrCodeDataUrl: '',
generating: false,
inputError: '', inputError: '',
}); });
@@ -29,98 +97,233 @@ export function useQrCode(): QrCodeContextValue {
dragging: false, dragging: false,
}); });
const debouncedTextToEncode = useDebounce(generatorState.textToEncode, 200); const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const qrCodeDataUrl = useMemo(() => { // 清除防抖定时器
const text = debouncedTextToEncode.trim(); useEffect(() => {
if (!text) return ''; return () => {
if (debounceTimerRef.current) {
try { clearTimeout(debounceTimerRef.current);
let url = text;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
} }
};
}, []);
const isDark = document.documentElement.classList.contains('dark'); /** 自动检测URL并生成二维码 */
const autoGenerateIfUrl = useCallback((text: string) => {
const qr = new QRious({ if (debounceTimerRef.current) {
value: url, clearTimeout(debounceTimerRef.current);
size: 260,
level: 'H',
foreground: isDark ? '#f3f4f6' : '#0f172a',
background: isDark ? 'transparent' : '#ffffff',
});
return qr.toDataURL();
} catch (error) {
console.error('QR code generation sync task failed:', error);
return '';
} }
}, [debouncedTextToEncode]);
const fullGeneratorState = useMemo<QrCodeGeneratorState>( if (!isUrl(text)) {
() => ({ return;
...generatorState, }
qrCodeDataUrl,
generating: false, debounceTimerRef.current = setTimeout(() => {
}), setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' }));
[generatorState, qrCodeDataUrl],
const qrCodeDataUrl = generateQrCodeDataUrl(text);
if (qrCodeDataUrl) {
setGeneratorState((prev) => ({
...prev,
step: 'preview',
savedText: text.trim(),
qrCodeDataUrl,
generating: false,
}));
} else {
setGeneratorState((prev) => ({
...prev,
generating: false,
inputError: '生成二维码失败,请重试',
}));
}
}, DEBOUNCE_DELAY);
}, []);
const setTextToEncode = useCallback(
(text: string) => {
setGeneratorState((prev) => ({ ...prev, textToEncode: text, inputError: '' }));
autoGenerateIfUrl(text);
},
[autoGenerateIfUrl],
); );
const setTextToEncode = useCallback((text: string) => { /** 手动触发生成二维码(用于非URL文本) */
setGeneratorState((prev) => ({ ...prev, textToEncode: text, inputError: '' })); const confirmGenerate = useCallback(() => {
const text = generatorState.textToEncode.trim();
if (!text) {
setGeneratorState((prev) => ({ ...prev, inputError: '请输入内容' }));
toast.error('请输入内容');
return;
}
// 先设置 loading 状态,让 React 渲染加载动画
setGeneratorState((prev) => ({ ...prev, generating: true, inputError: '' }));
// 延迟到下一帧生成,确保 loading 状态先被渲染显示
setTimeout(() => {
const qrCodeDataUrl = generateQrCodeDataUrl(text);
if (!qrCodeDataUrl) {
setGeneratorState((prev) => ({
...prev,
generating: false,
inputError: '生成二维码失败,请重试',
}));
toast.error('生成二维码失败,请重试');
return;
}
setGeneratorState((prev) => ({
...prev,
step: 'preview',
savedText: text,
qrCodeDataUrl,
generating: false,
}));
}, 0);
}, [generatorState.textToEncode]);
/** 返回编辑态,保留上次输入内容 */
const backToEdit = useCallback(() => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
setGeneratorState((prev) => ({
...prev,
step: 'input',
textToEncode: prev.savedText,
qrCodeDataUrl: '',
inputError: '',
}));
}, []); }, []);
const handleContextMenuData = useCallback((payload: string) => { /** 从图片URL解析二维码 */
setMode('generate'); const parseQrCodeFromUrl = useCallback(async (imageUrl: string) => {
setGeneratorState((prev) => ({ ...prev, textToEncode: payload, inputError: '' })); try {
setParserState((prev) => ({
...prev,
parsing: true,
parseError: '',
decodedResult: '',
previewUrl: imageUrl,
selectedFile: null,
}));
// 从URL获取图片并转换为File对象
const response = await fetch(imageUrl);
const blob = await response.blob();
const file = new File([blob], 'qrcode-image.png', { type: blob.type });
setParserState((prev) => ({ ...prev, selectedFile: file }));
const result = await parseQrCodeFromFile(file);
if (result.success && result.data) {
setParserState((prev) => ({ ...prev, decodedResult: result.data! }));
toast.success('二维码解析成功');
} else {
const errorMsg = result.error || '未检测到二维码,请确保图片清晰且包含二维码';
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
toast.error(errorMsg);
}
} catch (error) {
console.error('解析图片二维码失败:', error);
const errorMsg = error instanceof Error ? error.message : '解析二维码失败,请重试';
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
toast.error(errorMsg);
} finally {
setParserState((prev) => ({ ...prev, parsing: false }));
}
}, []); }, []);
/** 右键菜单传入URL时,自动生成二维码或解析图片 */
const handleContextMenuData = useCallback(
(payload: string) => {
// 检测是否为图片URL,如果是则切换到解析模式
if (isImageUrl(payload)) {
setMode('parse');
void parseQrCodeFromUrl(payload);
return;
}
// 非图片URL,生成二维码
setMode('generate');
// 直接生成二维码,无需等待
const qrCodeDataUrl = generateQrCodeDataUrl(payload);
if (qrCodeDataUrl) {
// 生成成功,直接跳转到预览态
setGeneratorState((prev) => ({
...prev,
step: 'preview',
textToEncode: payload,
savedText: payload.trim(),
qrCodeDataUrl,
generating: false,
inputError: '',
}));
} else {
// 生成失败,停留在输入态,显示文本供用户编辑
setGeneratorState((prev) => ({
...prev,
step: 'input',
textToEncode: payload,
savedText: '',
qrCodeDataUrl: '',
generating: false,
inputError: '',
}));
}
},
[parseQrCodeFromUrl],
);
useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData }); useContextMenuData({ featureKey: 'qrCode', onData: handleContextMenuData });
// 反向活态解析二维码算法 // 反向活态解析二维码算法
const parseQrCode = useCallback( const parseQrCode = useCallback(async (file: File) => {
async (file: File) => { try {
try { setParserState((prev) => ({ ...prev, parsing: true, parseError: '', decodedResult: '' }));
setParserState((prev) => ({ ...prev, parsing: true, parseError: '', decodedResult: '' }));
const result = await parseQrCodeFromFile(file); const result = await parseQrCodeFromFile(file);
if (result.success && result.data) { if (result.success && result.data) {
setParserState((prev) => ({ ...prev, decodedResult: result.data! })); setParserState((prev) => ({ ...prev, decodedResult: result.data! }));
toast.success(t('qrCode:parseSuccess')); toast.success('二维码解析成功');
} else { } else {
const errorMsg = result.error || t('qrCode:noQrDetected'); const errorMsg = result.error || '未检测到二维码,请确保图片清晰且包含二维码';
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
toast.error(errorMsg);
}
} catch (error) {
console.error('解析二维码失败:', error);
const errorMsg = error instanceof Error ? error.message : t('qrCode:parseError');
setParserState((prev) => ({ ...prev, parseError: errorMsg })); setParserState((prev) => ({ ...prev, parseError: errorMsg }));
toast.error(errorMsg); toast.error(errorMsg);
} finally {
setParserState((prev) => ({ ...prev, parsing: false }));
} }
}, } catch (error) {
[t], console.error('解析二维码失败:', error);
); const errorMsg = error instanceof Error ? error.message : '解析二维码失败,请重试';
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
toast.error(errorMsg);
} finally {
setParserState((prev) => ({ ...prev, parsing: false }));
}
}, []);
const downloadQrCode = useCallback(() => { const downloadQrCode = useCallback(() => {
if (!qrCodeDataUrl) return; if (!generatorState.qrCodeDataUrl) return;
const link = document.createElement('a'); const link = document.createElement('a');
link.href = qrCodeDataUrl; link.href = generatorState.qrCodeDataUrl;
link.download = 'qrcode.png'; link.download = 'qrcode.png';
link.click(); link.click();
toast.success(t('qrCode:qrCodeDownloadSuccess')); toast.success('二维码下载成功');
}, [qrCodeDataUrl, t]); }, [generatorState.qrCodeDataUrl]);
const copyQrCode = useCallback(async () => { const copyQrCode = useCallback(async () => {
if (!qrCodeDataUrl) return; if (!generatorState.qrCodeDataUrl) return;
try { try {
const response = await fetch(qrCodeDataUrl); const response = await fetch(generatorState.qrCodeDataUrl);
const blob = await response.blob(); const blob = await response.blob();
await navigator.clipboard.write([ await navigator.clipboard.write([
@@ -129,12 +332,12 @@ export function useQrCode(): QrCodeContextValue {
}), }),
]); ]);
toast.success(t('qrCode:qrCodeCopySuccess')); toast.success('二维码已复制到剪贴板');
} catch (error) { } catch (error) {
console.error('复制二维码失败:', error); console.error('复制二维码失败:', error);
toast.error(t('qrCode:copyError')); toast.error('复制失败,请重试');
} }
}, [qrCodeDataUrl, t]); }, [generatorState.qrCodeDataUrl]);
const handleFileChange = useCallback( const handleFileChange = useCallback(
(file: File) => { (file: File) => {
@@ -178,8 +381,10 @@ export function useQrCode(): QrCodeContextValue {
return { return {
mode, mode,
setMode, setMode,
generatorState: fullGeneratorState, generatorState,
setTextToEncode, setTextToEncode,
confirmGenerate,
backToEdit,
parseQrCode, parseQrCode,
downloadQrCode, downloadQrCode,
copyQrCode, copyQrCode,
+2 -4
View File
@@ -1,6 +1,5 @@
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { useI18n } from '@/utils/chromeI18n';
import { QrCodeContext } from './contexts/QrCodeContext'; import { QrCodeContext } from './contexts/QrCodeContext';
import { useQrCode } from './hooks/useQrCode'; import { useQrCode } from './hooks/useQrCode';
import GeneratePanel from './components/GeneratePanel'; import GeneratePanel from './components/GeneratePanel';
@@ -8,13 +7,12 @@ import ParsePanel from './components/ParsePanel';
import type { QrCodeMode } from './types'; import type { QrCodeMode } from './types';
export default function Index() { export default function Index() {
const { t } = useI18n('qrCode');
const qrCode = useQrCode(); const qrCode = useQrCode();
// 模式选项驱动骨架 // 模式选项驱动骨架
const modeOptions = [ const modeOptions = [
{ value: 'generate' as QrCodeMode, label: t('qrCode:urlToQr') }, { value: 'generate' as QrCodeMode, label: '文本转二维码' },
{ value: 'parse' as QrCodeMode, label: t('qrCode:qrToUrl') }, { value: 'parse' as QrCodeMode, label: '二维码转文本' },
]; ];
return ( return (
+11 -4
View File
@@ -5,16 +5,23 @@
/** 二维码功能核心主路由模式 */ /** 二维码功能核心主路由模式 */
export type QrCodeMode = 'generate' | 'parse'; export type QrCodeMode = 'generate' | 'parse';
/** 生成器的操作步骤 */
export type GeneratorStep = 'input' | 'preview';
/** * 二维码生成器的状态 /** * 二维码生成器的状态
* 💡 架构优化:保留与全局 Context 骨架契合的形态 * 💡 架构优化:采用两步操作模式(输入态 → 预览态)
* 外部依然可以流畅读取这些状态,但在新架构下运行效率和稳定性大幅提升 * 用户显式点击生成按钮后才生成二维码,体验更清晰
*/ */
export interface QrCodeGeneratorState { export interface QrCodeGeneratorState {
/** 当前操作步骤:输入态或预览态 */
step: GeneratorStep;
/** 受控的输入源文本(支持 URL 或任意文本快照) */ /** 受控的输入源文本(支持 URL 或任意文本快照) */
textToEncode: string; textToEncode: string;
/** 由防抖源文本流在单次渲染内存中同步派生出的二维码 Base64 Data URL */ /** 保存的文本快照,用于预览态展示和编辑态回填 */
savedText: string;
/** 由用户显式触发生成的二维码 Base64 Data URL */
qrCodeDataUrl: string; qrCodeDataUrl: string;
/** 是否正在生成(流式架构下已默认为恒定 false 的非阻塞快照,保留作为 UI 骨架兼容) */ /** 是否正在生成 */
generating: boolean; generating: boolean;
/** 输入文本校验或底层画布崩溃的错误提示信息 */ /** 输入文本校验或底层画布崩溃的错误提示信息 */
inputError: string; inputError: string;
+11 -13
View File
@@ -3,17 +3,15 @@ import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Shield, ShieldCheck, MousePointerClick, AlertTriangle } from 'lucide-react'; import { Shield, ShieldCheck, MousePointerClick, AlertTriangle } from 'lucide-react';
import { useRightClickRestorer } from './useRightClickRestorer'; import { useRightClickRestorer } from './useRightClickRestorer';
import { useI18n } from '@/utils/chromeI18n';
export default function Index() { export default function Index() {
const { t } = useI18n('rightClickRestorer');
const { domain, isLoading, isUnlocked, isUnsupported, unlock } = useRightClickRestorer(); const { domain, isLoading, isUnlocked, isUnsupported, unlock } = useRightClickRestorer();
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full"> <div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full">
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide"> <span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
{t('rightClickRestorer:loading')} {'正在加载...'}
</span> </span>
</div> </div>
); );
@@ -24,7 +22,7 @@ export default function Index() {
{/* Current Domain */} {/* Current Domain */}
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden"> <div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
<div className="p-4"> <div className="p-4">
<Label className="text-sm font-medium">{t('rightClickRestorer:currentDomain')}</Label> <Label className="text-sm font-medium">{'当前域名'}</Label>
<div className="mt-2 flex items-center justify-between gap-2"> <div className="mt-2 flex items-center justify-between gap-2">
<code className="text-sm bg-muted px-2 py-1 rounded truncate min-w-0 flex-1"> <code className="text-sm bg-muted px-2 py-1 rounded truncate min-w-0 flex-1">
{domain || '—'} {domain || '—'}
@@ -32,17 +30,17 @@ export default function Index() {
{isUnsupported ? ( {isUnsupported ? (
<Badge variant="destructive" className="gap-1 shrink-0"> <Badge variant="destructive" className="gap-1 shrink-0">
<AlertTriangle className="h-3 w-3" /> <AlertTriangle className="h-3 w-3" />
{t('rightClickRestorer:unsupported')} {'不支持'}
</Badge> </Badge>
) : isUnlocked ? ( ) : isUnlocked ? (
<Badge variant="default" className="gap-1 bg-green-600 hover:bg-green-700 shrink-0"> <Badge variant="default" className="gap-1 bg-green-600 hover:bg-green-700 shrink-0">
<ShieldCheck className="h-3 w-3" /> <ShieldCheck className="h-3 w-3" />
{t('rightClickRestorer:statusUnlocked')} {'已解锁'}
</Badge> </Badge>
) : ( ) : (
<Badge variant="secondary" className="gap-1 shrink-0"> <Badge variant="secondary" className="gap-1 shrink-0">
<Shield className="h-3 w-3" /> <Shield className="h-3 w-3" />
{t('rightClickRestorer:statusLocked')} {'未解锁'}
</Badge> </Badge>
)} )}
</div> </div>
@@ -55,16 +53,18 @@ export default function Index() {
{isUnsupported ? ( {isUnsupported ? (
<> <>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('rightClickRestorer:unsupportedDesc')} {'当前页面为浏览器内部页面或扩展页面,无法解锁右键功能。请切换到普通网页后重试。'}
</p> </p>
<Button className="w-full gap-2" disabled variant="secondary"> <Button className="w-full gap-2" disabled variant="secondary">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
{t('rightClickRestorer:unsupported')} {'不支持'}
</Button> </Button>
</> </>
) : ( ) : (
<> <>
<p className="text-xs text-muted-foreground">{t('rightClickRestorer:unlockDesc')}</p> <p className="text-xs text-muted-foreground">
{'点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。'}
</p>
<Button <Button
className="w-full gap-2" className="w-full gap-2"
onClick={() => void unlock()} onClick={() => void unlock()}
@@ -72,9 +72,7 @@ export default function Index() {
variant={isUnlocked ? 'secondary' : 'default'} variant={isUnlocked ? 'secondary' : 'default'}
> >
<MousePointerClick className="h-4 w-4" /> <MousePointerClick className="h-4 w-4" />
{isUnlocked {isUnlocked ? '右键已解锁' : '解锁当前网站右键'}
? t('rightClickRestorer:alreadyUnlocked')
: t('rightClickRestorer:unlockBtn')}
</Button> </Button>
</> </>
)} )}
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import Index from '../index';
// Mock the chrome APIs
vi.mock('@/utils/chromeStorage', () => ({
storageUtil: {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock('@/utils/storageCleaner', () => ({
getCurrentTab: vi.fn().mockResolvedValue({ id: 1, url: 'https://example.com' }),
isRestrictedUrl: vi.fn().mockReturnValue(false),
getCookieSize: vi.fn().mockResolvedValue(0),
getLocalStorageSize: vi.fn().mockResolvedValue(0),
getSessionStorageSize: vi.fn().mockResolvedValue(0),
getOriginStorageEstimate: vi.fn().mockResolvedValue(0),
getCacheStorageSize: vi.fn().mockResolvedValue(0),
getServiceWorkerCount: vi.fn().mockResolvedValue(0),
clearStorage: vi.fn().mockResolvedValue({ overallSuccess: true }),
formatCleaningResult: vi.fn().mockReturnValue('Cleaned successfully'),
}));
describe('StorageCleaner 页面', () => {
it('应该渲染初始化加载状态', () => {
// storageCleaner:initializing 的中文文案为「正在读取站点数据...」
render(<Index />);
expect(screen.getByText(/正在读取站点数据/)).toBeInTheDocument();
});
});
@@ -14,7 +14,6 @@
*/ */
import React from 'react'; import React from 'react';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -43,8 +42,6 @@ export default function AutoRefreshToggle({
className, className,
...props ...props
}: AutoRefreshToggleProps) { }: AutoRefreshToggleProps) {
const { t } = useI18n('storageCleaner');
return ( return (
<div <div
className={cn( className={cn(
@@ -57,7 +54,7 @@ export default function AutoRefreshToggle({
htmlFor="auto-refresh-switch" htmlFor="auto-refresh-switch"
className="text-xs font-bold text-muted-foreground/90 cursor-pointer select-none tracking-wide uppercase" className="text-xs font-bold text-muted-foreground/90 cursor-pointer select-none tracking-wide uppercase"
> >
{t('storageCleaner:autoRefresh')} {'清理后自动刷新页面'}
</Label> </Label>
<Switch id="auto-refresh-switch" checked={reloadAfterClean} onCheckedChange={onChange} /> <Switch id="auto-refresh-switch" checked={reloadAfterClean} onCheckedChange={onChange} />
@@ -2,7 +2,6 @@ import React from 'react';
import { CheckCircle, XCircle } from 'lucide-react'; import { CheckCircle, XCircle } from 'lucide-react';
import type { CleaningResult as CleaningResultType } from '@/types/storage'; import type { CleaningResult as CleaningResultType } from '@/types/storage';
import { formatCleaningResult } from '@/utils/storageCleaner'; import { formatCleaningResult } from '@/utils/storageCleaner';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface CleaningResultProps extends React.HTMLAttributes<HTMLDivElement> { interface CleaningResultProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -10,8 +9,6 @@ interface CleaningResultProps extends React.HTMLAttributes<HTMLDivElement> {
} }
export default function CleaningResult({ result, className, ...props }: CleaningResultProps) { export default function CleaningResult({ result, className, ...props }: CleaningResultProps) {
const { t } = useI18n('storageCleaner');
if (!result) return null; if (!result) return null;
const isSuccess = result.overallSuccess; const isSuccess = result.overallSuccess;
@@ -33,9 +30,7 @@ export default function CleaningResult({ result, className, ...props }: Cleaning
)} )}
<span className="text-xs sm:text-sm font-semibold leading-relaxed break-all"> <span className="text-xs sm:text-sm font-semibold leading-relaxed break-all">
{isSuccess {isSuccess ? formatCleaningResult(result) : result.error || '部分清理失败'}
? formatCleaningResult(result, t)
: result.error || t('storageCleaner:partialFailure')}
</span> </span>
</div> </div>
</div> </div>
@@ -1,5 +1,4 @@
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface ErrorDisplayProps extends React.HTMLAttributes<HTMLDivElement> { interface ErrorDisplayProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -7,8 +6,6 @@ interface ErrorDisplayProps extends React.HTMLAttributes<HTMLDivElement> {
} }
export default function ErrorDisplay({ error, className, ...props }: ErrorDisplayProps) { export default function ErrorDisplay({ error, className, ...props }: ErrorDisplayProps) {
const { t } = useI18n('storageCleaner');
return ( return (
<div <div
className={cn( className={cn(
@@ -27,7 +24,7 @@ export default function ErrorDisplay({ error, className, ...props }: ErrorDispla
</p> </p>
<p className="text-xs font-medium leading-relaxed text-muted-foreground/90 px-2"> <p className="text-xs font-medium leading-relaxed text-muted-foreground/90 px-2">
{t('storageCleaner:errorStandardOnly')} {'存储清理功能仅适用于标准网页'}
</p> </p>
</div> </div>
</div> </div>
@@ -1,10 +1,18 @@
import React from 'react'; import React from 'react';
import { formatBytes } from '@/utils/format'; import { formatBytes } from '@/utils/format';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { StorageSizeInfo } from './useStorageCleaner'; import type { StorageSizeInfo } from '../useStorageCleaner';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
const OPTION_LABELS: Record<string, string> = {
localStorage: 'Local Storage',
sessionStorage: 'Session Storage',
indexedDB: '站点存储',
cookies: 'Cookies',
cacheStorage: 'Cache Storage',
serviceWorkers: 'Service Workers',
};
interface OptionItemProps extends React.HTMLAttributes<HTMLDivElement> { interface OptionItemProps extends React.HTMLAttributes<HTMLDivElement> {
labelKey: string; labelKey: string;
checked: boolean; checked: boolean;
@@ -20,10 +28,9 @@ export default function OptionItem({
className, className,
...props ...props
}: OptionItemProps) { }: OptionItemProps) {
const { t } = useI18n('storageCleaner');
const sizeValue = sizeInfo?.value; const sizeValue = sizeInfo?.value;
const isCount = sizeInfo?.displayType === 'count'; const isCount = sizeInfo?.displayType === 'count';
const label = OPTION_LABELS[labelKey] || labelKey;
return ( return (
<div <div
@@ -44,7 +51,7 @@ export default function OptionItem({
checked ? 'text-foreground' : 'text-foreground/75', checked ? 'text-foreground' : 'text-foreground/75',
)} )}
> >
{t(labelKey)} {label}
</span> </span>
{sizeValue !== undefined && sizeValue > 0 ? ( {sizeValue !== undefined && sizeValue > 0 ? (
@@ -54,11 +61,11 @@ export default function OptionItem({
checked ? 'text-primary/70' : 'text-muted-foreground/70', checked ? 'text-primary/70' : 'text-muted-foreground/70',
)} )}
> >
{isCount ? `${sizeValue} ${t('storageCleaner:countUnit')}` : formatBytes(sizeValue)} {isCount ? `${sizeValue} ` : formatBytes(sizeValue)}
</span> </span>
) : ( ) : (
<span className="block text-[10px] font-medium text-muted-foreground/50 mt-0.5 italic"> <span className="block text-[10px] font-medium text-muted-foreground/50 mt-0.5 italic">
{t('storageCleaner:noData')} {'无数据'}
</span> </span>
)} )}
</div> </div>
@@ -10,7 +10,6 @@ import {
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import type { StorageCleanerOptions } from '@/types/storage'; import type { StorageCleanerOptions } from '@/types/storage';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface StorageCleanerConfirmProps { export interface StorageCleanerConfirmProps {
@@ -20,17 +19,24 @@ export interface StorageCleanerConfirmProps {
options: StorageCleanerOptions; options: StorageCleanerOptions;
} }
const OPTION_LABELS: Record<string, string> = {
localStorage: 'Local Storage',
sessionStorage: 'Session Storage',
indexedDB: '站点存储',
cookies: 'Cookies',
cacheStorage: 'Cache Storage',
serviceWorkers: 'Service Workers',
};
export function StorageCleanerConfirm({ export function StorageCleanerConfirm({
open, open,
onClose, onClose,
onConfirm, onConfirm,
options, options,
}: StorageCleanerConfirmProps) { }: StorageCleanerConfirmProps) {
const { t } = useI18n('storageCleaner');
const selectedOptions = Object.entries(options) const selectedOptions = Object.entries(options)
.filter(([_, value]) => value) .filter(([_, value]) => value)
.map(([key, _]) => t(`storageCleaner:options.${key as keyof StorageCleanerOptions}`)); .map(([key, _]) => OPTION_LABELS[key] || key);
return ( return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}> <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
@@ -42,14 +48,14 @@ export function StorageCleanerConfirm({
{/* 头部标题区域 */} {/* 头部标题区域 */}
<DialogHeader className="pt-1"> <DialogHeader className="pt-1">
<DialogTitle className="text-center text-lg font-bold tracking-tight text-foreground"> <DialogTitle className="text-center text-lg font-bold tracking-tight text-foreground">
{t('storageCleaner:confirmTitle')} {'确认清理数据?'}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
{/* 内容主体:限制最大宽度,防止内部元素在大分辨率下被横向拉得太松散 */} {/* 内容主体:限制最大宽度,防止内部元素在大分辨率下被横向拉得太松散 */}
<div className="text-center py-4 flex flex-col items-center w-full max-w-[280px] mx-auto"> <div className="text-center py-4 flex flex-col items-center w-full max-w-[280px] mx-auto">
<DialogDescription className="mb-4 text-xs font-medium text-muted-foreground/90 leading-relaxed"> <DialogDescription className="mb-4 text-xs font-medium text-muted-foreground/90 leading-relaxed">
{t('storageCleaner:confirmDesc')} {'您将永久删除当前页面的以下选定存储项。'}
</DialogDescription> </DialogDescription>
{/* 待清理项目徽章群 */} {/* 待清理项目徽章群 */}
@@ -69,7 +75,7 @@ export function StorageCleanerConfirm({
<div className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-lg bg-destructive/5 border border-dashed border-destructive/20 w-full max-w-[240px]"> <div className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-lg bg-destructive/5 border border-dashed border-destructive/20 w-full max-w-[240px]">
<AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" /> <AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" />
<span className="text-[11px] font-bold text-destructive leading-none tracking-tight"> <span className="text-[11px] font-bold text-destructive leading-none tracking-tight">
{t('storageCleaner:irreversible')} {'此操作不可撤销'}
</span> </span>
</div> </div>
</div> </div>
@@ -81,7 +87,7 @@ export function StorageCleanerConfirm({
onClick={onConfirm} onClick={onConfirm}
className="w-full text-xs font-bold shadow-sm h-9" className="w-full text-xs font-bold shadow-sm h-9"
> >
{t('storageCleaner:confirmAction')} {'确认清理'}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -89,7 +95,7 @@ export function StorageCleanerConfirm({
onClick={onClose} onClick={onClose}
className="w-full text-xs font-semibold shadow-sm h-9 text-muted-foreground hover:text-foreground" className="w-full text-xs font-semibold shadow-sm h-9 text-muted-foreground hover:text-foreground"
> >
{t('common_buttons_cancel')} {'取消'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,8 +1,7 @@
import React from 'react'; import React from 'react';
import type { StorageCleanerOptions } from '@/types/storage'; import type { StorageCleanerOptions } from '@/types/storage';
import type { StorageSizeInfo } from './useStorageCleaner'; import type { StorageSizeInfo } from '../useStorageCleaner';
import OptionItem from './OptionItem'; import OptionItem from './OptionItem';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -26,8 +25,6 @@ export default function StorageOptionsGrid({
className, className,
...props ...props
}: StorageOptionsGridProps) { }: StorageOptionsGridProps) {
const { t } = useI18n('storageCleaner');
const optionKeys: (keyof StorageCleanerOptions)[] = [ const optionKeys: (keyof StorageCleanerOptions)[] = [
'localStorage', 'localStorage',
'sessionStorage', 'sessionStorage',
@@ -48,7 +45,7 @@ export default function StorageOptionsGrid({
{optionKeys.map((key) => ( {optionKeys.map((key) => (
<OptionItem <OptionItem
key={key} key={key}
labelKey={`storageCleaner:options.${key}`} labelKey={key}
checked={options[key]} checked={options[key]}
sizeInfo={sizes[key]} sizeInfo={sizes[key]}
onChange={() => onOptionChange(key)} onChange={() => onOptionChange(key)}
@@ -62,7 +59,7 @@ export default function StorageOptionsGrid({
className="border-t border-border flex justify-between items-center pl-3.5 pr-7 py-2.5 bg-muted/20 hover:bg-muted/40 cursor-pointer select-none transition-colors" className="border-t border-border flex justify-between items-center pl-3.5 pr-7 py-2.5 bg-muted/20 hover:bg-muted/40 cursor-pointer select-none transition-colors"
> >
<Label className="text-xs font-bold text-muted-foreground/90 cursor-pointer tracking-wide uppercase"> <Label className="text-xs font-bold text-muted-foreground/90 cursor-pointer tracking-wide uppercase">
{t('storageCleaner:selectAll')} {'全选所有项'}
</Label> </Label>
<Checkbox <Checkbox
+8 -11
View File
@@ -1,16 +1,13 @@
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import StorageCleanerConfirm from './StorageCleanerConfirm'; import StorageCleanerConfirm from './components/StorageCleanerConfirm';
import { useStorageCleaner } from './useStorageCleaner'; import { useStorageCleaner } from './useStorageCleaner';
import StorageOptionsGrid from './StorageOptionsGrid'; import StorageOptionsGrid from './components/StorageOptionsGrid';
import AutoRefreshToggle from './AutoRefreshToggle'; import AutoRefreshToggle from './components/AutoRefreshToggle';
import ErrorDisplay from './ErrorDisplay'; import ErrorDisplay from './components/ErrorDisplay';
import CleaningResult from './CleaningResult'; import CleaningResult from './components/CleaningResult';
import { useI18n } from '@/utils/chromeI18n';
export default function Index() { export default function Index() {
const { t } = useI18n('storageCleaner');
const { const {
error, error,
isInitializing, isInitializing,
@@ -36,7 +33,7 @@ export default function Index() {
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full"> <div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full">
<Loader2 className="h-6 w-6 text-muted-foreground/80" /> <Loader2 className="h-6 w-6 text-muted-foreground/80" />
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide"> <span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
{t('storageCleaner:initializing')} {'正在读取站点数据...'}
</span> </span>
</div> </div>
); );
@@ -75,10 +72,10 @@ export default function Index() {
{loading ? ( {loading ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('storageCleaner:cleaning')} {'正在清理...'}
</> </>
) : ( ) : (
t('storageCleaner:cleanNow') '立即清理'
)} )}
</Button> </Button>
</div> </div>
+8 -13
View File
@@ -16,7 +16,6 @@ import {
getSessionStorageSize, getSessionStorageSize,
isRestrictedUrl, isRestrictedUrl,
} from '@/utils/storageCleaner'; } from '@/utils/storageCleaner';
import { useI18n } from '@/utils/chromeI18n';
import { toast } from 'sonner'; import { toast } from 'sonner';
const DEFAULT_OPTIONS: StorageCleanerOptions = { const DEFAULT_OPTIONS: StorageCleanerOptions = {
@@ -59,7 +58,6 @@ export interface UseStorageCleanerReturn {
} }
export function useStorageCleaner(): UseStorageCleanerReturn { export function useStorageCleaner(): UseStorageCleanerReturn {
const { t } = useI18n(['storageCleaner', 'common']);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [isInitializing, setIsInitializing] = useState<boolean>(true); const [isInitializing, setIsInitializing] = useState<boolean>(true);
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS); const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
@@ -93,11 +91,11 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
if (currentRequestId !== requestIdRef.current) return; if (currentRequestId !== requestIdRef.current) return;
if (!tab || !tab.url) { if (!tab || !tab.url) {
setError(t('storageCleaner:errorNoTab')); setError('无法获取当前标签页');
return; return;
} }
if (isRestrictedUrl(tab.url)) { if (isRestrictedUrl(tab.url)) {
setError(t('storageCleaner:errorRestricted')); setError('存储清理功能不支持此页面');
return; return;
} }
@@ -135,7 +133,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
setIsInitializing(false); setIsInitializing(false);
} }
} }
}, [t]); }, []);
const loadInfoRef = useRef(loadInfo); const loadInfoRef = useRef(loadInfo);
useEffect(() => { useEffect(() => {
@@ -177,10 +175,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
if (storageTimerRef.current) clearTimeout(storageTimerRef.current); if (storageTimerRef.current) clearTimeout(storageTimerRef.current);
storageTimerRef.current = setTimeout(async () => { storageTimerRef.current = setTimeout(async () => {
await storageUtil await storageUtil
.set('storageCleaner/preferences', { .set('storageCleaner/preferences', { reloadAfterClean, selectedTypes: options })
reloadAfterClean,
selectedTypes: options,
})
.catch(console.error); .catch(console.error);
}, 500); }, 500);
}, [options, reloadAfterClean, isInitializing]); }, [options, reloadAfterClean, isInitializing]);
@@ -209,7 +204,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
const tab = await getCurrentTab(); const tab = await getCurrentTab();
if (!tab || !tab.id || !tab.url) { if (!tab || !tab.id || !tab.url) {
toast.warning(t('storageCleaner:errorNoTab')); toast.warning('无法获取当前标签页');
return; return;
} }
@@ -219,18 +214,18 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
setResult(cleaningResult); setResult(cleaningResult);
if (reloadAfterClean && cleaningResult.overallSuccess) { if (reloadAfterClean && cleaningResult.overallSuccess) {
toast.success(t('storageCleaner:cleanSuccessReload')); toast.success('清理成功,即将刷新页面');
await chrome.tabs.reload(tab.id); await chrome.tabs.reload(tab.id);
} else { } else {
await loadInfo(); await loadInfo();
} }
} catch (err) { } catch (err) {
toast.error(`${t('storageCleaner:cleanError')}: ${String(err)}`); toast.error(`清理失败: ${String(err)}`);
} finally { } finally {
setLoading(false); setLoading(false);
setShowConfirm(false); setShowConfirm(false);
} }
}, [options, reloadAfterClean, loadInfo, t]); }, [options, reloadAfterClean, loadInfo]);
const totalBytes = useMemo(() => { const totalBytes = useMemo(() => {
return Object.values(sizes).reduce((acc, s) => { return Object.values(sizes).reduce((acc, s) => {
@@ -0,0 +1,109 @@
/**
* 数据预览组件
* 展示一条示例数据,展示数据结构
*/
import { useMemo } from 'react';
import { FileJson, FileText } from 'lucide-react';
import { getGeneratorById } from '@/lib/generators';
import type { FieldConfig } from '@/types/testDataGenerator';
interface DataPreviewProps {
fields: FieldConfig[];
}
/** JSON 语法高亮渲染 */
function JsonHighlight({ data }: { data: Record<string, unknown> }) {
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 (
<pre className="text-sm font-mono leading-relaxed">
<span className="text-muted-foreground">{'{'}</span>
{formatted.map((line, i) => (
<div key={i} className="flex">
<span className="text-muted-foreground">{line.indent}</span>
<span className="text-blue-500 dark:text-blue-400">&quot;{line.key}&quot;</span>
<span className="text-muted-foreground">{': '}</span>
<span className={getValueColor(line.value)}>{line.value}</span>
{!line.isLast && <span className="text-muted-foreground">,</span>}
</div>
))}
<span className="text-muted-foreground">{'}'}</span>
</pre>
);
}
/** 根据值类型返回颜色类名 */
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 sampleData = useMemo(() => {
if (fields.length === 0) return null;
const record: Record<string, unknown> = {};
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 (
<div className="flex flex-col items-center justify-center h-full text-center">
<FileJson className="h-8 w-8 text-muted-foreground/30 mb-2" />
<p className="text-xs text-muted-foreground/50">{'配置字段后点击「生成数据」按钮'}</p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* 示例标签 */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded bg-primary/10 flex items-center justify-center">
<FileText className="h-3 w-3 text-primary" />
</div>
<span className="text-xs font-medium text-muted-foreground">{'示例数据'}</span>
</div>
<span className="text-[10px] text-muted-foreground/50 bg-muted px-1.5 py-0.5 rounded">
{Object.keys(sampleData).length} {'字段'}
</span>
</div>
{/* 示例数据展示 */}
<div className="flex-1 min-h-0 overflow-auto rounded-lg bg-zinc-950 dark:bg-zinc-900 p-4">
<JsonHighlight data={sampleData} />
</div>
</div>
);
}
@@ -0,0 +1,78 @@
/**
* 导出面板组件
* 提供复制、下载 JSON、下载 CSV 选项
*/
import { Copy, Download } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { DataExporter } from '@/utils/dataExporter';
import type { GenerateResult } from '@/types/testDataGenerator';
interface ExportPanelProps {
result: GenerateResult | null;
}
export default function ExportPanel({ result }: ExportPanelProps) {
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('已复制到剪贴板');
} else {
toast.error('复制失败');
}
};
const handleCopyCSV = async () => {
const content = DataExporter.toCSV(result.data!);
const success = await DataExporter.copyToClipboard(content);
if (success) {
toast.success('已复制到剪贴板');
} else {
toast.error('复制失败');
}
};
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 (
<div className="space-y-2">
<h4 className="text-sm font-medium text-foreground">{'导出数据'}</h4>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" size="sm" onClick={handleCopyJSON} className="h-9 gap-1.5">
<Copy className="h-4 w-4" />
{'复制 JSON'}
</Button>
<Button variant="outline" size="sm" onClick={handleCopyCSV} className="h-9 gap-1.5">
<Copy className="h-4 w-4" />
{'复制 CSV'}
</Button>
<Button variant="outline" size="sm" onClick={handleDownloadJSON} className="h-9 gap-1.5">
<Download className="h-4 w-4" />
{'下载 JSON'}
</Button>
<Button variant="outline" size="sm" onClick={handleDownloadCSV} className="h-9 gap-1.5">
<Download className="h-4 w-4" />
{'下载 CSV'}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,215 @@
/**
* 字段编辑器组件
* 编辑单个字段的详细配置
*/
import { useState, useCallback } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
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 generator = getGeneratorById(field.generatorId);
const [nameError, setNameError] = useState<string | null>(null);
const validateFieldName = useCallback(
(name: string): string | null => {
const trimmed = name.trim();
if (!trimmed) {
return '字段名称不能为空';
}
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) {
return '字段名称只能包含字母、数字和下划线';
}
const isDuplicate = allFieldNames.some(
(n, i) => n === trimmed && i !== allFieldNames.indexOf(field.name),
);
if (isDuplicate) {
return '字段名称已存在';
}
return null;
},
[allFieldNames, field.name],
);
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<string, unknown> = {};
generator?.params.forEach((p) => {
defaultParams[p.key] = p.defaultValue;
});
onChange({
...field,
generatorId,
params: defaultParams,
});
};
const handleParamsChange = (params: Record<string, unknown>) => {
onChange({ ...field, params });
};
const nullRatePresets = [
{ label: '5%', value: 5 },
{ label: '20%', value: 20 },
{ label: '50%', value: 50 },
{ label: '75%', value: 75 },
];
return (
<div className="space-y-5">
{/* 基础配置 */}
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">{'字段名称'}</Label>
<span className="text-xs text-muted-foreground">{field.name.length}/20</span>
</div>
<Input
value={field.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder={'请输入字段名称'}
maxLength={20}
className={`h-9 ${nameError ? 'border-destructive' : ''}`}
/>
{nameError && <p className="text-xs text-destructive mt-1">{nameError}</p>}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">{'字段描述'}</Label>
<span className="text-xs text-muted-foreground">
{(field.description || '').length}/50
</span>
</div>
<Input
value={field.description || ''}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder={'可选,添加字段说明'}
maxLength={50}
className="h-9"
/>
</div>
</div>
{/* 必填/选填配置 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">{'必填'}</Label>
<Switch checked={field.required} onCheckedChange={handleRequiredChange} />
</div>
{!field.required && (
<div className="space-y-2 pl-1">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{'空值率'}</span>
<Badge variant="secondary" className="text-xs">
{field.nullRate}%
</Badge>
</div>
<Input
type="number"
value={field.nullRate}
onChange={(e) => {
const value = Number(e.target.value);
if (value >= 0 && value <= 100) {
handleNullRateChange(value);
}
}}
min={0}
max={100}
step={5}
className="h-9 w-full"
/>
<div className="flex gap-2">
{nullRatePresets.map((preset) => (
<button
key={preset.value}
onClick={() => handleNullRateChange(preset.value)}
className={`px-3 py-1.5 text-xs rounded-md transition-colors ${
field.nullRate === preset.value
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
{preset.label}
</button>
))}
</div>
</div>
)}
</div>
{/* 唯一性约束 */}
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">{'唯一性约束'}</Label>
<Switch checked={field.unique} onCheckedChange={handleUniqueChange} />
</div>
{/* 生成器选择 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">{'数据生成器'}</Label>
<GeneratorSelector selectedId={field.generatorId} onChange={handleGeneratorChange} />
</div>
{/* 生成器参数配置 */}
{generator && (
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">{'生成器参数'}</Label>
<GeneratorConfig
generator={generator}
params={field.params}
onChange={handleParamsChange}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,57 @@
/**
* 字段项组件
* 展示单个字段的基本信息,适配固定高度卡片
*/
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 generator = getGeneratorById(field.generatorId);
return (
<div
className="flex-1 min-w-0 cursor-pointer rounded-md transition-all hover:bg-muted/50"
onClick={onClick}
>
<div
className={`h-full ${field.description ? 'flex flex-col justify-center' : 'flex items-center'}`}
>
{/* 第一行:字段名 + 标签 */}
<div className="flex items-center gap-1.5 overflow-hidden">
<span className="font-medium text-sm text-foreground truncate shrink-0 max-w-[140px]">
{field.name}
</span>
<Badge variant="secondary" className="text-[10px] shrink-0 px-1 py-0">
{generator?.name || field.generatorId}
</Badge>
{!field.required && (
<Badge
variant="outline"
className="text-[10px] shrink-0 px-1 py-0 text-muted-foreground"
>
{field.nullRate}%
</Badge>
)}
{field.unique && (
<Badge variant="outline" className="text-[10px] shrink-0 px-1 py-0 text-blue-500">
{'唯一'}
</Badge>
)}
</div>
{/* 第二行:描述 */}
{field.description && (
<p className="text-[11px] text-muted-foreground mt-0.5 truncate leading-tight">
{field.description}
</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,460 @@
/**
* 字段列表组件
* 展示所有字段配置,支持添加、删除、拖拽排序
* 超过 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 * 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 (
<div
ref={setNodeRef}
style={style}
className={`group relative rounded-lg border transition-all ${
isDragging
? 'border-primary shadow-lg'
: 'border-border hover:border-muted-foreground/30 hover:shadow-sm'
}`}
>
<div className="flex items-center gap-2 h-full px-3 py-2">
{/* 拖拽手柄 */}
<button
className="cursor-grab active:cursor-grabbing p-1 rounded hover:bg-muted/50 touch-none shrink-0"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
<div className="flex-1 min-w-0">
<FieldItem field={field} onClick={onEdit} />
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive"
onClick={onRemove}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
}
export default function FieldList({
fields,
onUpdate: _onUpdate,
onRemove,
onAdd,
onEdit,
onReorder,
editingRule,
onRuleSaved,
}: FieldListProps) {
const [scrollTop, setScrollTop] = useState(0);
const [activeId, setActiveId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(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('规则已更新');
onRuleSaved?.();
}
}, [editingRule, fields, 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('规则已保存');
onRuleSaved?.();
}
},
[ruleName, ruleDescription, fields, onRuleSaved],
);
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-foreground">
{'字段'} ({fields.length}/{MAX_FIELDS})
</h3>
<div className="flex items-center gap-2">
{editingRule ? (
<>
<Button
variant="outline"
size="sm"
onClick={handleUpdateRule}
disabled={fields.length === 0}
className="h-8 gap-1.5 px-2.5"
title={`${'编辑中'}: ${editingRule.name}`}
>
<Save className="h-3.5 w-3.5" />
{'更新规则'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={fields.length === 0}
className="h-8 px-2"
>
{'另存为'}
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={fields.length === 0}
className="h-8 gap-1.5 px-2.5"
>
<Save className="h-3.5 w-3.5" />
{'保存规则'}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={onAdd}
disabled={isMaxReached}
className="h-8 gap-1.5 px-2.5"
>
<Plus className="h-4 w-4" />
{'添加字段'}
</Button>
</div>
</div>
<div className="flex-1 min-h-0">
{fields.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<GripVertical className="h-10 w-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">{'暂无字段'}</p>
<p className="text-xs text-muted-foreground/70 mt-1">{'点击上方按钮添加第一个字段'}</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
<div
ref={containerRef}
className="overflow-y-auto"
style={containerStyle}
onScroll={handleScroll}
>
{isVirtual ? (
/* 虚拟滚动模式 */
<div style={{ height: totalHeight, position: 'relative' }}>
<div
style={{
position: 'absolute',
top: visibleStart * ROW_HEIGHT,
left: 0,
right: 0,
}}
>
{visibleFields.map((field, vi) => {
const realIndex = fields.findIndex((f) => f.id === field.id);
const isLast = visibleStart + vi === fields.length - 1;
return (
<div
key={field.id}
style={{
height: ROW_HEIGHT,
paddingBottom: isLast ? 0 : ROW_GAP,
}}
>
<SortableFieldItem
field={field}
onEdit={() => onEdit(realIndex)}
onRemove={() => onRemove(realIndex)}
/>
</div>
);
})}
</div>
</div>
) : (
/* 普通模式(≤5 条) */
<div className="space-y-2">
{fields.map((field, index) => (
<SortableFieldItem
key={field.id}
field={field}
onEdit={() => onEdit(index)}
onRemove={() => onRemove(index)}
/>
))}
</div>
)}
</div>
</SortableContext>
<DragOverlay>
{activeField ? (
<div className="rounded-lg border border-primary bg-background shadow-lg opacity-90">
<div className="flex items-center gap-2 h-full px-3 py-2">
<div className="p-1">
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<FieldItem field={activeField} onClick={() => {}} />
</div>
<div className="h-8 w-8 shrink-0" />
</div>
</div>
) : null}
</DragOverlay>
</DndContext>
)}
</div>
{/* 保存规则对话框 */}
<Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<DialogContent
showCloseButton={false}
className="w-[calc(100vw-4rem)] max-w-[420px] p-0 pt-6 flex flex-col"
>
<div className="flex-1 overflow-y-auto px-6 pt-2 pb-4 space-y-4">
<Input
value={ruleName}
onChange={(e) => setRuleName(e.target.value)}
placeholder={'规则名称'}
className="h-9"
/>
<Input
value={ruleDescription}
onChange={(e) => setRuleDescription(e.target.value)}
placeholder={'规则描述(可选)'}
className="h-9"
/>
</div>
<div className="flex justify-end gap-2 px-6 py-2 border-t shrink-0">
<Button variant="ghost" size="sm" onClick={() => setShowSaveDialog(false)}>
{'取消'}
</Button>
<Button size="sm" onClick={() => handleSave()} disabled={!ruleName.trim()}>
{'确认'}
</Button>
</div>
</DialogContent>
</Dialog>
{/* 覆盖确认对话框 */}
<Dialog open={showConfirmOverwrite} onOpenChange={setShowConfirmOverwrite}>
<DialogContent
showCloseButton={false}
className="w-[calc(100vw-4rem)] max-w-[420px] p-0 pt-6 flex flex-col"
>
<div className="flex-1 overflow-y-auto px-6 pb-4">
<p className="text-sm text-muted-foreground">{'已存在同名规则,是否覆盖保存?'}</p>
</div>
<div className="flex justify-end gap-2 px-6 py-2 border-t shrink-0">
<Button variant="ghost" size="sm" onClick={() => setShowConfirmOverwrite(false)}>
{'取消'}
</Button>
<Button size="sm" onClick={() => handleSave(true)}>
{'覆盖'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,63 @@
/**
* 生成按钮组件
* 显示生成按钮、加载状态和进度条
*/
import { Play, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
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) {
return (
<div className="space-y-3">
{isGenerating ? (
<>
<Button variant="destructive" onClick={onCancel} className="w-full h-11 gap-2">
<Square className="h-5 w-5" />
{'取消'}
</Button>
{/* 进度条 */}
{progress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{'已生成 {{current}} / {{total}} 条'}</span>
<span>{progress.progress}%</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progress.progress}%` }}
/>
</div>
{progress.estimatedTimeLeft !== undefined && (
<p className="text-xs text-muted-foreground text-center">
{'预计剩余 {{time}} 秒'}
</p>
)}
</div>
)}
</>
) : (
<Button onClick={onClick} disabled={disabled} className="w-full h-11 gap-2">
<Play className="h-5 w-5" />
{'生成数据'}
</Button>
)}
</div>
);
}
@@ -0,0 +1,85 @@
/**
* 生成选项组件
* 配置生成数量、数据格式等选项
*/
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
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) {
return (
<div className="space-y-4">
{/* 生成数量 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">{'生成数量'}</Label>
<div className="flex flex-wrap gap-2">
{COUNT_PRESETS.map((preset) => (
<button
key={preset}
onClick={() => onCountChange(preset)}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
count === preset
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
{preset.toLocaleString()}
</button>
))}
</div>
<Input
type="number"
value={count}
onChange={(e) => {
const value = Number(e.target.value);
if (value > 0 && value <= 100000) {
onCountChange(value);
}
}}
min={1}
max={100000}
className="h-9"
/>
</div>
{/* 数据格式 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">{'数据格式'}</Label>
<div className="flex gap-2">
{FORMAT_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => onFormatChange(option.value)}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
format === option.value
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
{option.label}
</button>
))}
</div>
</div>
</div>
);
}

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