Compare commits

..

132 Commits

Author SHA1 Message Date
Ubuntu ee0408a944 fix(ci): sync manifest version from tag before build
The release workflow was always building with the hardcoded version
from package.json (1.0.0), causing Chrome Web Store to reject uploads
because 1.0.0 equals the already-published 1.0 (trailing zeros ignored).

Added a step to extract the version from the git tag and update
package.json before the build, so the zip contains the correct version.
2026-05-23 18:56:23 +08:00
LingandRX a0d6e76cad Merge pull request #48 from LingandRX/hotfix/fix-release-workflow
fix(ci): fix release workflow artifact upload
2026-05-23 14:46:05 +08:00
Ubuntu cc6d414d10 fix(ci): fix release workflow artifact upload
- Copy zip files to fixed directory before upload to avoid glob pattern issues
- Add debug steps to verify output and downloaded artifacts
- Fix Node.js 20 compatibility issue with upload-artifact@v4
2026-05-23 14:43:36 +08:00
LingandRX 5daa151b5e Refactor GitHub Actions workflow for clarity and efficiency (#46) 2026-05-23 14:27:57 +08:00
LingandRX e93b5c817f Merge pull request #45 from LingandRX/develop
chore: release v1.0.4
2026-05-23 14:15:07 +08:00
雨霖铃 d678f14549 ci(release): fix typecheck command and add missing WXT prepare steps
- Replace non-existent
pm run compile with correct
pm run typecheck
- Add
px wxt prepare before typecheck and test steps (aligned with ci.yml)
- Update AGENTS.md to remove fixed issue note
2026-05-23 14:13:27 +08:00
LingandRX c95e028482 Merge pull request #44 from LingandRX/develop
chore: release v1.0.4
2026-05-23 14:07:13 +08:00
雨霖铃 8ec36ac3b3 test: fix i18n namespace assertions in Base64Converter and QrCode tests
Align test selectors with useLazyTranslation mock which prefixes keys
with namespace (e.g. base64Converter:encode). Also remove obsolete
page title assertions for components that no longer render titles.
2026-05-23 14:04:10 +08:00
雨霖铃 262c5e9255 Merge remote-tracking branch 'origin/main' into develop 2026-05-23 13:39:28 +08:00
雨霖铃 e4993cb0ce docs: update README with current project state
- Add Right Click Restorer feature description
- Remove non-existent pageTheme.ts from project structure
- Update UI components to mention shadcn/ui
- Add missing permissions: contextMenus, alarms, unlimitedStorage
- Clarify permission descriptions
2026-05-23 13:23:53 +08:00
雨霖铃 0d60961a82 docs: update AGENTS.md outdated info
- Remove non-existent config/pageTheme.ts and config/theme.ts
- Update style guide from MUI sx to Tailwind CSS + shadcn/ui
- Replace MUI with Tailwind CSS and shadcn/ui in tech stack
2026-05-23 13:21:50 +08:00
雨霖铃 df3eee8749 refactor: 移除所有页面的 PageHeader 组件
删除 PageHeader 组件及其测试文件,清理相关不再使用的导入和变量。
2026-05-23 13:15:25 +08:00
雨霖铃 cf05df7022 fix(i18n): replace useTranslation with useLazyTranslation in Base64Converter
Fix missing translations by ensuring base64Converter namespace is dynamically loaded.
2026-05-23 13:07:31 +08:00
雨霖铃 e0888c7fb3 refactor: remove remaining config/pageTheme dependencies
- Replace base64ConverterPageStyles.primaryColor with inline text-indigo-800
- Replace qrCodePageStyles.primaryColor with inline text-green-700
- Remove obsolete page-error-boundary design doc
2026-05-23 12:58:35 +08:00
雨霖铃 8b2bca9eef refactor(timestamp): migrate pageTheme constants to Timestamp feature
Remove config/pageTheme.ts and move DATE_FORMAT, ZONES, UnitType, ZoneType
to pages/Timestamp/constants.ts for better feature cohesion.
2026-05-23 12:57:51 +08:00
雨霖铃 6bac88fbc4 feat(right-click-restorer): add unsupported page detection
- Detect and block chrome://, chrome-extension://, about://, edge://, brave:// pages
- Show 'Unsupported' badge with warning icon and disabled button
- Add 'unsupported' and 'unsupportedDesc' i18n keys (zh/en)
- Update useRightClickRestorer hook with isUnsupported state
- Add unit tests for unsupported page behavior
- Update page UI to render conditional unsupported layout
2026-05-23 12:46:20 +08:00
雨霖铃 4c08e17b19 fix(right-click-restorer): prevent domain overflow in domain card
- Add truncate/min-w-0/flex-1 to domain code block for ellipsis
- Add shrink-0 to Badge to prevent compression
- Add gap-2 for consistent spacing
2026-05-23 12:41:42 +08:00
雨霖铃 fa0f1bdcad feat: add right-click restorer for current site
- Add RightClickRestorer page with active unlock trigger
- Content script with dual-world strategy (isolated + main world)
- Monkey patch preventDefault/stopPropagation via background executeScript
- Overlay penetration for pointer-events blocking layers
- Delegated main-world injection to avoid CSP restrictions
- Async message handling for reliable state updates
- i18n translations (zh/en) and unit tests
2026-05-23 12:39:14 +08:00
雨霖铃 2d7bb8fc2c refactor: update background.ts comments for clarity and enhance alarm handling in reload logic; add alarms permission in wxt.config.ts 2026-05-23 10:08:12 +08:00
雨霖铃 a0e6871b16 refactor: rename autoRefresh to reloadAfterClean for clarity and consistency across components 2026-05-23 10:02:02 +08:00
雨霖铃 3adfe95234 refactor: update docs with new features, revised tech stack, and CI changes 2026-05-23 00:17:24 +08:00
LingandRX efbed7cf53 feat: Tailwind CSS/shadcn/ui 迁移 & 右键菜单功能 (#42)
* feat: 添加 contextMenus 权限和消息类型定义

* feat: 实现右键菜单注册与点击分流逻辑

- 新增 utils/contextMenu.ts 封装菜单配置和解析函数
- 在 background.ts 监听 onInstalled 初始化菜单
- 实现 contextMenus.onClicked 点击事件处理
- 分流逻辑:优先发送到侧边栏,否则打开 options 页面
- 添加 contextMenu 单元测试(15个测试用例)
- 更新 vitest.setup.ts 添加 contextMenus mock

* feat: 实现 Content Script 原位轻量提示 UI

- 新增 uiPopover.ts 实现原位弹窗组件(深色主题、自动定位、自动隐藏)
- 新增 contextMenuHandler.ts 处理右键菜单消息
- 时间戳转换:在点击位置显示转换结果
- 文本统计:显示字符/单词/行数/字节统计
- 更新 messageHandler.ts 挂载右键菜单消息监听

* feat: 实现 React 页面层右键菜单数据联动

- 新增 useContextMenuData Hook 处理右键菜单数据传递
- 重构 Jwt/Base64Converter/TextStatistics/QrCode 页面接收右键数据
- RouterProvider 支持从 URL 参数解析右键菜单数据
- 添加 contextMenu/pendingData 存储键到 StorageSchema
- 添加 useContextMenuData 单元测试(12个测试用例)

* test: 添加 background 单元测试与边界情况处理

- 新增 entrypoints/__tests__/background.test.ts (12个测试用例)
- 超长文本截断限制 (>10000字符)
- Base64 内联图片拦截并返回错误提示
- 更新 parseContextMenuClick 返回 ParseResult 格式
- 更新测试匹配新的返回格式
- lint/typecheck/test 全部通过 (579个测试)

* fix: 修复右键菜单跳转到错误页面的问题

- 将 options 页面路径从 '/entrypoints/options/index.html' 修正为 '/options.html'
- WXT 构建后 entrypoints/options/index.html 会输出为根目录的 options.html

* fix: 修复右键菜单跳转目标为 popup 页面

- 将 fallback 页面从 options.html 改为 popup.html
- popup 页面使用 RouterProvider,可以正确处理 URL 参数并跳转到对应功能页面
- options 页面是独立设置页,不支持 URL 参数路由

* fix: 使用 openPopup() 替代 tabs.create() 打开弹窗

- background.ts: 使用 browser.action.openPopup() 打开 popup 弹窗
- 先保存数据到 storage,然后打开 popup
- RouterProvider.tsx: 初始化时检查 storage 中的 pendingData 并跳转到对应页面
- 移除之前错误的 tabs.create() 方式

* fix: 修复右键菜单数据未传递到目标页面的问题

- RouterProvider 不再提前清除 pendingData
- 让目标页面的 useContextMenuData 来消费和清除数据
- 这样确保 TextStatistics 等页面能正确接收选中的文本

* fix: 修复 popup 已打开时右键菜单不生效的问题

- 在 RouterProvider 中添加对 contextMenu/pendingData 的 storage 变化监听
- 当 pendingData 变化时,自动跳转到对应功能页面
- 解决了 openPopup() 只聚焦已有窗口而不触发重新挂载的问题

* feat: 支持右键菜单图片二维码识别

- 在 qrCodeParser.ts 中添加 parseQrCodeFromUrl 函数,支持从图片 URL 解析二维码
- 在 QrCodeToUrlSection 中使用 useContextMenuData 接收右键菜单传递的图片 URL
- 自动下载图片并解析二维码,显示解析结果

* refactor: 暂时移除图片二维码识别功能

- 删除 qrCode-image 右键菜单配置
- 删除 parseQrCodeFromUrl 函数
- 删除 QrCodeToUrlSection 中的 useContextMenuData 相关代码
- 更新相关测试用例

* feat: 支持右键菜单时间戳转换

- 在 useTimestampConverter 中添加 useContextMenuData hook
- 智能识别输入是时间戳还是日期时间字符串
- 自动切换到对应模式并执行转换
- 时间戳自动识别秒/毫秒单位

* fix: 修复右键菜单功能的多个逻辑漏洞

1. 修复时区硬编码问题 - 使用用户选择的时区而非固定 Asia/Shanghai
2. 修复 openPopup() 失败后数据残留 - 失败时清除 storage 中的待处理数据
3. 统一数据过期时间常量 - 导出 CONTEXT_MENU_DATA_EXPIRY_MS 并统一使用
4. 修复 featureKey 类型断言不安全 - 使用映射表处理非常规菜单 ID

* refactor(qrcode): 提取独立组件并添加单元测试

- 创建 pages/QrCode/types.ts 定义状态类型接口
- 提取 QrCodePreview 组件用于二维码预览和操作
- 提取 ImageUploader 组件封装图片上传、拖拽、粘贴逻辑
- 重构 UrlToQrCodeSection 状态提升到父组件
- 重构 QrCodeToUrlSection 通过回调传递解析结果
- 更新主页面 index.tsx 集中管理所有状态
- 为 QrCodePreview 和 ImageUploader 添加单元测试 (23 个用例)

* refactor(qrcode): 引入通用组件并重构双栏布局

- 引入 SwitchButtonGroup 用于模式切换
- 引入 TextInputArea 用于文本输入和结果展示
- 使用 MUI Grid 构建响应式双栏布局
- 实现 generate/parse 模式下的左右面板内容切换
- 修复 iconColor 颜色格式错误,使用 qrCodePageStyles.primaryColor

* refactor(qrcode): 国际化补充、质量保障与错误处理优化

- 补充 i18n 翻译键(generateMode, parseMode, pasteHint 等)
- 创建 useDebounce Hook 实现输入防抖 (200ms)
- 使用 useRef 解决 useEffect 无限循环问题
- 编写 pages/QrCode 单元测试 (7 个用例)
- 优化右键菜单错误处理,改进用户提示
- 运行 603 个测试全部通过

* refactor(qrcode): 组件化重构,采用 Context + Hook 模式

- 创建 QrCodeContext 和 QrCodeProvider 管理共享状态
- 提取 useQrCode Hook 封装所有状态和业务逻辑
- 创建 GeneratePanel 组件处理二维码生成模式
- 创建 ParsePanel 组件处理二维码解析模式
- 简化 index.tsx 为容器组件 (340行 → 47行)
- 删除未使用的旧组件文件 (QrCodeToUrlSection, UrlToQrCodeSection)
- 清理 types.ts 中未使用的类型定义
- 603 个测试全部通过

* fix(qrcode): 固定二维码预览图片尺寸,避免布局抖动

- 设置 QR_PREVIEW_IMAGE 固定尺寸 250x250
- 与 QRious 生成的二维码大小保持一致
- 解决输入内容变化时预览窗口大小跳动问题

* fix(qrcode): 修复切换 tab 后二维码图片失效问题

- 移除 ImageUploader 组件卸载时的预览 URL 释放逻辑
- 在 useQrCode hook 中统一管理预览 URL 生命周期
- 创建新预览 URL 前释放旧的,避免内存泄漏

* fix(qrcode): 固定图片上传区域高度,避免布局抖动

- 将 DROPZONE 的 minHeight: 200 改为 height: 250
- 与二维码预览图片高度保持一致
- 解决上传图片时 div 高度变化问题

* refactor(qrCode): 优化二维码解析功能

- 将'二维码转 URL'改为'二维码转文本'
- 将解析结果的文本预览框的 placeholder 置为空
- 去除手动解析二维码功能,只保留自动解析

* fix(pageHeader): 使用 MUI 主题色替代硬编码颜色,支持暗色模式

* feat(pageHeader): popup 模式下隐藏 PageHeader 组件

* fix(i18n): 统一使用 useLazyTranslation 避免翻译 key 闪烁

* perf: 配置 manualChunks 拆分 vendor chunk,优化打包体积

- 使用 Vite 插件 manualChunksForHtmlOnly 仅对 HTML 多入口构建生效
- 跳过 background/content-script 的 IIFE 构建(不支持 manualChunks)
- 拆分 vendor-react (193KB)、vendor-mui (326KB)、vendor-i18n (55KB)
- 按需加载 vendor-qr (78KB)、vendor-dnd (45KB)、vendor-markdown (41KB)
- PageErrorBoundary chunk 从 454KB 降至 36KB

* chore: upgrade wxt to v0.20.26 and @wxt-dev/module-react to v1.2.2

* chore: upgrade low-risk dependencies

- react: 19.2.3 → 19.2.6
- react-dom: 19.2.3 → 19.2.6
- dayjs: 1.11.19 → 1.11.20
- marked: 18.0.3 → 18.0.4
- prettier: 3.8.1 → 3.8.3
- terser: 5.46.0 → 5.47.1
- i18next: 26.0.8 → 26.2.0
- react-i18next: 17.0.6 → 17.0.8
- globals: 17.2.0 → 17.6.0
- eslint-plugin-react-hooks: 7.0.1 → 7.1.1
- @typescript-eslint/*: 8.54.0 → 8.59.4
- typescript-eslint: 8.54.0 → 8.59.4
- @testing-library/jest-dom: 6.6.0 → 6.9.1
- @testing-library/react: 16.0.0 → 16.3.2
- @testing-library/user-event: 14.5.2 → 14.6.1
- @testing-library/dom: added as peer dependency
- @types/react: 19.2.7 → 19.2.15
- @types/chrome: 0.1.36 → 0.1.42
- @types/webextension-polyfill: 0.12.4 → 0.12.5

* chore: upgrade medium-risk dependencies

- lint-staged: 16.2.7 → 17.0.5
- jsdom: 25.0.0 → 29.1.1
- @vitejs/plugin-react: 4.3.4 → 6.0.2
- vitest: 2.0.0 → 4.1.7
- @vitest/coverage-v8: upgraded to match vitest
- @webext-core/messaging: 2.3.0 → 3.0.1
- eslint: 9.39.2 → 10.4.0
- @eslint/js: added as new dependency
- @types/marked: removed (marked now provides its own types)

Fixes:
- Fix ref update during render in useQrCode.ts
- Fix ref update during render in useStorageCleaner.ts
- Add error cause in jwt.ts decode function
- Add type assertion for mock functions in htmlToMarkdown.test.ts

* chore(deps): 降级 eslint 版本并移除 @eslint/js

* docs: 精简 AGENTS.md,聚焦高信号信息

* test: 修复测试中的 act() 警告和 clipboard mock 问题

- TextInputArea: 使用 userEvent 替代 fireEvent,用 vi.spyOn 替代 Object.assign mock clipboard
- ImageMode/FileMode: 添加 waitForStorageReady() 等待 useStorageState 异步初始化

* perf: 优化首屏加载,快照有效时跳过骨架屏

- isLoaded 初始值根据 localStorage 快照是否存在决定
- 后续访问直接渲染真实 DOM,无需等待 chrome.storage 异步加载
- loadInitialData 仍在后台静默执行确保数据同步

* 引入 Tailwind CSS 和 shadcn/ui

* 替换 Timestamp 页面图标为 lucide-react 的 Clock 图标

* 用 Tailwind CSS 重写 Timestamp 页面的 MUI 基础排版组件

* 用 Tailwind CSS 重构 ResultView 和 LiveClock 组件

* 用 Tailwind CSS 重写 Dashboard/index.tsx 和 ToolCard.tsx

* 用 Tailwind CSS 重写 TextStatistics 页面

* 用 Tailwind CSS 重写 Base64Converter 页面

* 用 Tailwind CSS 重写 QrCode 页面及相关组件

* 用 Tailwind CSS 重写 Jwt 页面

* 用 Tailwind CSS 重写 StorageCleaner 页面,创建 shadcn/ui Dialog 组件

* 用 Tailwind CSS 重写 JsonTools 页面

* 用 Tailwind CSS 重构所有共享组件

* 修复 TypeScript 类型错误:将 MUI sx 语法改成标准 CSS 属性

* 重写基础骨架组件:用纯 Tailwind CSS 替换 MUI Box 组件

* 重写所有页面和组件:用纯 Tailwind CSS 替换 MUI 组件和图标

* 移除 MUI 依赖,简化配置文件,重写 ThemeModeProvider

* 移除 wxt.config.ts 中残留的 MUI chunk 配置

* 为 Dialog 组件添加暗黑模式背景色支持

* fix: 修复暗黑模式样式兼容性

- 在 tailwind.config.js 中启用 darkMode: 'class' 并扩展语义化颜色变量
- 将全项目硬编码颜色(bg-white、text-gray-*、border-gray-* 等)替换为语义化类名
- 修复 popup/index.html 硬编码浅色背景色
- 同步更新相关单元测试中的类名断言

* fix: 修复输入框和按钮在暗色模式下的样式问题

- 为 :root 和 .dark 添加 color-scheme,使浏览器表单控件默认样式适配暗色模式
- 将 bg-primary 搭配 text-white 的按钮改为 text-primary-foreground
- 替换残留的 hover:bg-blue-700、text-blue-500、bg-gray-200 等硬编码颜色
- 同步更新 TextInputArea 单元测试

* fix: 增强 SwitchButtonGroup 和立即转换按钮的视觉层级

- SwitchButtonGroup 选中按钮阴影从 shadow-sm 升级到 shadow-md
- 同步更新单元测试中断言
- Timestamp 立即转换按钮添加 shadow-md 增强边界感

* style: 隐藏所有窗口的滚动条

- 在全局 CSS 中为 html 添加 scrollbar-width: none(Firefox)
- 添加 ::-webkit-scrollbar { display: none }(Chrome/Safari/Opera)

* feat: add sonner dependency for toast notifications

* refactor: remove DomainHeader component and its usage

* refactor: 使用 className 替换 sx/buttonSx 并优化 SwitchButtonGroup 选中动画

* refactor: 简化 TextInputArea,移除 showMessage 和 MUI 样式,改用 shadcn 样式和 sonner toast

* refactor: 简化 TextInputArea,移除 showMessage 和 MUI 样式,改用 shadcn 样式和 sonner toast

* refactor: 重构 QrCodePreview 组件,继承 HTMLDiv 属性并统一 shadcn 样式

- 组件改为继承 `React.HTMLAttributes<HTMLDivElement>`,支持外部传入 className 和事件
- 使用 `cn()` 函数合并外部类名
- 空白状态改用 `border border-dashed` 和更浅的 `bg-muted/40` 背景
- 正常状态改为纯白背景柔边圆角容器包裹二维码,确保暗黑模式下黑白对比度
- 下载/复制按钮完全对齐 shadcn 的 Outline 与 Default 样式规范
- 更新测试用例中的 alt 文本为 "QR Code Preview"

* refactor: 将 PageHeader 组件样式从 MUI sx 迁移至 Tailwind 类名,并更新测试

* refactor: 更新PageErrorBoundary的文案和样式以对齐shadcn主题

- 错误提示从“该页面加载失败”改为“该功能运行异常”
- 重试按钮文字从“重试”改为“重新尝试”
- 使用border-destructive, bg-destructive等语义化类名替换硬编码颜色
- 更新对应测试断言

* refactor: 重构 CopyButton 与 RouterContainer 组件,移除 GlobalSnackbar 并统一使用 sonner toast 和 cn 工具函数

* refactor: 移除 MUI sx 样式和未使用的 GlobalSnackbar 引用

* feat: add @radix-ui/react-select dependency

* feat: add Input and Select shadcn UI components

* refactor: 重构 Timestamp 页面使用响应式 Hook 统一状态并全面迁移至 shadcn 样式

* refactor: 将 TextStatistics 页面和 TextInputArea 组件全面迁移至 shadcn 样式并优化布局

* feat: add Badge, Checkbox, Label, and Switch shadcn UI components

* refactor: 全面迁移 StorageCleaner 页面至 shadcn 样式并移除 GlobalSnackbar 依赖

* refactor: 全面重构 JsonTools 页面,采用声明式响应架构并统一 shadcn 样式

* refactor: 全面重构 Jwt 页面,采用防抖输入并统一 shadcn 样式

* refactor: 重构 Dashboard 页面和 ToolCard 组件,全面迁移至 shadcn 样式并优化布局

* refactor: 重构 HtmlToMarkdown 和 MarkdownToHtml 页面,统一 shadcn 样式并优化打印与暗色模式

* refactor: 抽离 useBase64Converter hook 并新增 Base64ConverterSection 组件,统一 Base64 转换页面的 shadcn 样式

* refactor: 重构 QrCode 页面为声明式响应架构并统一 shadcn 样式

* refactor: 优化 Dashboard 布局及 ToolCard 描述文本截断处理

* refactor: 优化 MarkdownToHtml 预览样式,剥离 CSS 变量回退值并增强 iframe 暗色模式自适应

* refactor: 优化 Popover 安全性和交互体验,修复 Context Menu 数据持久化问题并添加 i18n 支持

* refactor: strengthen i18n type safety and fix async Promise handling in language detection

* refactor: extend vitest setup with full chrome/browser mocks and i18n utility mock

* refactor: migrate storage listeners to wxt/browser and refactor provider state management

* refactor: restructure CI/CD pipelines with cached dependencies, separate setup job, and multi-browser builds

* refactor: remove tsc check from lint-staged and reorder lint commands

* refactor: migrate eslint config to tseslint.config with projectService and upgrade React rules

* refactor: use __MSG_ placeholders for i18n and remove postcss config

* refactor: replace __MSG_ placeholders with static strings for extension name and description

* refactor: add React version detection and disable prop-types in eslint; refactor lazy translation tests with proper mock isolation and Chrome/browser stubs

* refactor: use regex matchers in StorageCleanerConfirm tests and add chrome/browser mocks

* refactor: use regex matchers for i18n text and add uniform mocks in test files

* ci: add wxt prepare step to generate .wxt types for typecheck and tests

---------

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
2026-05-23 00:02:43 +08:00
Ubuntu 364672918d ci: add wxt prepare step to generate .wxt types for typecheck and tests 2026-05-22 23:59:56 +08:00
雨霖铃 5e23b29a25 refactor: use regex matchers for i18n text and add uniform mocks in test files 2026-05-22 23:51:16 +08:00
雨霖铃 cc9162f601 refactor: use regex matchers in StorageCleanerConfirm tests and add chrome/browser mocks 2026-05-22 23:45:41 +08:00
雨霖铃 67bafe53d5 refactor: add React version detection and disable prop-types in eslint; refactor lazy translation tests with proper mock isolation and Chrome/browser stubs 2026-05-22 23:43:08 +08:00
雨霖铃 ba57c070f9 refactor: replace __MSG_ placeholders with static strings for extension name and description 2026-05-22 23:22:28 +08:00
雨霖铃 34030e5f6a refactor: use __MSG_ placeholders for i18n and remove postcss config 2026-05-22 23:18:42 +08:00
雨霖铃 8b11dcd0ce refactor: migrate eslint config to tseslint.config with projectService and upgrade React rules 2026-05-22 23:13:47 +08:00
雨霖铃 67c0c30a7a refactor: remove tsc check from lint-staged and reorder lint commands 2026-05-22 23:12:32 +08:00
雨霖铃 a3b2bfb43a refactor: restructure CI/CD pipelines with cached dependencies, separate setup job, and multi-browser builds 2026-05-22 23:10:55 +08:00
雨霖铃 81a2e793ba refactor: migrate storage listeners to wxt/browser and refactor provider state management 2026-05-22 23:06:55 +08:00
雨霖铃 756ece31d3 refactor: extend vitest setup with full chrome/browser mocks and i18n utility mock 2026-05-22 23:02:41 +08:00
雨霖铃 7efed8673a refactor: strengthen i18n type safety and fix async Promise handling in language detection 2026-05-22 22:58:27 +08:00
雨霖铃 88bebfa1b8 refactor: 优化 Popover 安全性和交互体验,修复 Context Menu 数据持久化问题并添加 i18n 支持 2026-05-22 22:56:25 +08:00
雨霖铃 272bcae904 refactor: 优化 MarkdownToHtml 预览样式,剥离 CSS 变量回退值并增强 iframe 暗色模式自适应 2026-05-22 22:46:13 +08:00
雨霖铃 539f9648b1 refactor: 优化 Dashboard 布局及 ToolCard 描述文本截断处理 2026-05-22 22:37:45 +08:00
雨霖铃 7e58b5688f refactor: 重构 QrCode 页面为声明式响应架构并统一 shadcn 样式 2026-05-22 22:32:11 +08:00
雨霖铃 89e33f6273 refactor: 抽离 useBase64Converter hook 并新增 Base64ConverterSection 组件,统一 Base64 转换页面的 shadcn 样式 2026-05-22 22:26:35 +08:00
雨霖铃 9601afdd49 refactor: 重构 HtmlToMarkdown 和 MarkdownToHtml 页面,统一 shadcn 样式并优化打印与暗色模式 2026-05-22 22:06:34 +08:00
雨霖铃 ae0b923e11 refactor: 重构 Dashboard 页面和 ToolCard 组件,全面迁移至 shadcn 样式并优化布局 2026-05-22 21:56:09 +08:00
雨霖铃 917fca5947 refactor: 全面重构 Jwt 页面,采用防抖输入并统一 shadcn 样式 2026-05-22 21:46:45 +08:00
雨霖铃 d2e5e6b45d refactor: 全面重构 JsonTools 页面,采用声明式响应架构并统一 shadcn 样式 2026-05-22 21:45:13 +08:00
雨霖铃 336b62256d refactor: 全面迁移 StorageCleaner 页面至 shadcn 样式并移除 GlobalSnackbar 依赖 2026-05-22 21:26:02 +08:00
雨霖铃 7ddc761df4 feat: add Badge, Checkbox, Label, and Switch shadcn UI components 2026-05-22 21:25:29 +08:00
雨霖铃 67553afe9c refactor: 将 TextStatistics 页面和 TextInputArea 组件全面迁移至 shadcn 样式并优化布局 2026-05-22 21:10:52 +08:00
雨霖铃 02aef0c7b1 refactor: 重构 Timestamp 页面使用响应式 Hook 统一状态并全面迁移至 shadcn 样式 2026-05-22 21:03:48 +08:00
雨霖铃 94158f016b feat: add Input and Select shadcn UI components 2026-05-22 21:03:06 +08:00
雨霖铃 dd03d9391f feat: add @radix-ui/react-select dependency 2026-05-22 21:02:48 +08:00
雨霖铃 21e44cb889 refactor: 移除 MUI sx 样式和未使用的 GlobalSnackbar 引用 2026-05-22 20:51:57 +08:00
雨霖铃 b3dd7146ad refactor: 重构 CopyButton 与 RouterContainer 组件,移除 GlobalSnackbar 并统一使用 sonner toast 和 cn 工具函数 2026-05-22 20:51:13 +08:00
雨霖铃 2ced38769b refactor: 更新PageErrorBoundary的文案和样式以对齐shadcn主题
- 错误提示从“该页面加载失败”改为“该功能运行异常”
- 重试按钮文字从“重试”改为“重新尝试”
- 使用border-destructive, bg-destructive等语义化类名替换硬编码颜色
- 更新对应测试断言
2026-05-22 20:49:46 +08:00
雨霖铃 8406b03dd6 refactor: 将 PageHeader 组件样式从 MUI sx 迁移至 Tailwind 类名,并更新测试 2026-05-22 20:49:07 +08:00
雨霖铃 da667ccb13 refactor: 重构 QrCodePreview 组件,继承 HTMLDiv 属性并统一 shadcn 样式
- 组件改为继承 `React.HTMLAttributes<HTMLDivElement>`,支持外部传入 className 和事件
- 使用 `cn()` 函数合并外部类名
- 空白状态改用 `border border-dashed` 和更浅的 `bg-muted/40` 背景
- 正常状态改为纯白背景柔边圆角容器包裹二维码,确保暗黑模式下黑白对比度
- 下载/复制按钮完全对齐 shadcn 的 Outline 与 Default 样式规范
- 更新测试用例中的 alt 文本为 "QR Code Preview"
2026-05-22 20:48:14 +08:00
雨霖铃 1bfb7d4e6d refactor: 简化 TextInputArea,移除 showMessage 和 MUI 样式,改用 shadcn 样式和 sonner toast 2026-05-22 20:47:04 +08:00
雨霖铃 b3c2fbccf5 refactor: 简化 TextInputArea,移除 showMessage 和 MUI 样式,改用 shadcn 样式和 sonner toast 2026-05-22 20:46:43 +08:00
雨霖铃 b29e9417cc refactor: 使用 className 替换 sx/buttonSx 并优化 SwitchButtonGroup 选中动画 2026-05-22 20:45:31 +08:00
雨霖铃 cf0d19914c refactor: remove DomainHeader component and its usage 2026-05-22 20:44:59 +08:00
雨霖铃 6b2ac33b41 feat: add sonner dependency for toast notifications 2026-05-22 20:44:24 +08:00
雨霖铃 02a611e49b style: 隐藏所有窗口的滚动条
- 在全局 CSS 中为 html 添加 scrollbar-width: none(Firefox)
- 添加 ::-webkit-scrollbar { display: none }(Chrome/Safari/Opera)
2026-05-22 20:03:09 +08:00
雨霖铃 72883b5d1d fix: 增强 SwitchButtonGroup 和立即转换按钮的视觉层级
- SwitchButtonGroup 选中按钮阴影从 shadow-sm 升级到 shadow-md
- 同步更新单元测试中断言
- Timestamp 立即转换按钮添加 shadow-md 增强边界感
2026-05-22 18:08:44 +08:00
雨霖铃 c7beebf6e0 fix: 修复输入框和按钮在暗色模式下的样式问题
- 为 :root 和 .dark 添加 color-scheme,使浏览器表单控件默认样式适配暗色模式
- 将 bg-primary 搭配 text-white 的按钮改为 text-primary-foreground
- 替换残留的 hover:bg-blue-700、text-blue-500、bg-gray-200 等硬编码颜色
- 同步更新 TextInputArea 单元测试
2026-05-22 17:36:37 +08:00
雨霖铃 de7da9f1db fix: 修复暗黑模式样式兼容性
- 在 tailwind.config.js 中启用 darkMode: 'class' 并扩展语义化颜色变量
- 将全项目硬编码颜色(bg-white、text-gray-*、border-gray-* 等)替换为语义化类名
- 修复 popup/index.html 硬编码浅色背景色
- 同步更新相关单元测试中的类名断言
2026-05-22 16:04:50 +08:00
雨霖铃 5b9a0f59d3 为 Dialog 组件添加暗黑模式背景色支持 2026-05-22 08:55:23 +08:00
雨霖铃 3e1753da23 移除 wxt.config.ts 中残留的 MUI chunk 配置 2026-05-22 00:57:01 +08:00
雨霖铃 6763cd7862 移除 MUI 依赖,简化配置文件,重写 ThemeModeProvider 2026-05-22 00:53:12 +08:00
雨霖铃 e2dd5f5915 重写所有页面和组件:用纯 Tailwind CSS 替换 MUI 组件和图标 2026-05-22 00:51:11 +08:00
雨霖铃 1bc3ee59a0 重写基础骨架组件:用纯 Tailwind CSS 替换 MUI Box 组件 2026-05-22 00:44:32 +08:00
雨霖铃 6711783975 修复 TypeScript 类型错误:将 MUI sx 语法改成标准 CSS 属性 2026-05-22 00:38:48 +08:00
雨霖铃 427e668e7c 用 Tailwind CSS 重构所有共享组件 2026-05-22 00:31:52 +08:00
雨霖铃 37618eb71f 用 Tailwind CSS 重写 JsonTools 页面 2026-05-22 00:07:56 +08:00
雨霖铃 d2334c5e95 用 Tailwind CSS 重写 StorageCleaner 页面,创建 shadcn/ui Dialog 组件 2026-05-22 00:00:04 +08:00
雨霖铃 a4533be2c1 用 Tailwind CSS 重写 Jwt 页面 2026-05-21 23:52:20 +08:00
雨霖铃 83bf9dd342 用 Tailwind CSS 重写 QrCode 页面及相关组件 2026-05-21 23:46:44 +08:00
雨霖铃 8902bc0f10 用 Tailwind CSS 重写 Base64Converter 页面 2026-05-21 23:40:52 +08:00
雨霖铃 4753a7b54d 用 Tailwind CSS 重写 TextStatistics 页面 2026-05-21 23:35:25 +08:00
雨霖铃 7f9bf2334c 用 Tailwind CSS 重写 Dashboard/index.tsx 和 ToolCard.tsx 2026-05-21 23:32:19 +08:00
雨霖铃 6bd05aa6f1 用 Tailwind CSS 重构 ResultView 和 LiveClock 组件 2026-05-21 23:23:13 +08:00
雨霖铃 bdb40343b1 用 Tailwind CSS 重写 Timestamp 页面的 MUI 基础排版组件 2026-05-21 23:13:29 +08:00
雨霖铃 9a9336aea8 替换 Timestamp 页面图标为 lucide-react 的 Clock 图标 2026-05-21 23:00:44 +08:00
雨霖铃 3eb00d8a29 引入 Tailwind CSS 和 shadcn/ui 2026-05-21 22:57:23 +08:00
雨霖铃 bb27577278 perf: 优化首屏加载,快照有效时跳过骨架屏
- isLoaded 初始值根据 localStorage 快照是否存在决定
- 后续访问直接渲染真实 DOM,无需等待 chrome.storage 异步加载
- loadInitialData 仍在后台静默执行确保数据同步
2026-05-21 22:46:05 +08:00
雨霖铃 141e206b62 Merge branch 'main' into develop 2026-05-21 22:33:03 +08:00
LingandRX 689c3faf16 chore: upgrade dependencies (WXT v0.20.26 + low/medium risk deps) (#41)
* feat: 添加 contextMenus 权限和消息类型定义

* feat: 实现右键菜单注册与点击分流逻辑

- 新增 utils/contextMenu.ts 封装菜单配置和解析函数
- 在 background.ts 监听 onInstalled 初始化菜单
- 实现 contextMenus.onClicked 点击事件处理
- 分流逻辑:优先发送到侧边栏,否则打开 options 页面
- 添加 contextMenu 单元测试(15个测试用例)
- 更新 vitest.setup.ts 添加 contextMenus mock

* feat: 实现 Content Script 原位轻量提示 UI

- 新增 uiPopover.ts 实现原位弹窗组件(深色主题、自动定位、自动隐藏)
- 新增 contextMenuHandler.ts 处理右键菜单消息
- 时间戳转换:在点击位置显示转换结果
- 文本统计:显示字符/单词/行数/字节统计
- 更新 messageHandler.ts 挂载右键菜单消息监听

* feat: 实现 React 页面层右键菜单数据联动

- 新增 useContextMenuData Hook 处理右键菜单数据传递
- 重构 Jwt/Base64Converter/TextStatistics/QrCode 页面接收右键数据
- RouterProvider 支持从 URL 参数解析右键菜单数据
- 添加 contextMenu/pendingData 存储键到 StorageSchema
- 添加 useContextMenuData 单元测试(12个测试用例)

* test: 添加 background 单元测试与边界情况处理

- 新增 entrypoints/__tests__/background.test.ts (12个测试用例)
- 超长文本截断限制 (>10000字符)
- Base64 内联图片拦截并返回错误提示
- 更新 parseContextMenuClick 返回 ParseResult 格式
- 更新测试匹配新的返回格式
- lint/typecheck/test 全部通过 (579个测试)

* fix: 修复右键菜单跳转到错误页面的问题

- 将 options 页面路径从 '/entrypoints/options/index.html' 修正为 '/options.html'
- WXT 构建后 entrypoints/options/index.html 会输出为根目录的 options.html

* fix: 修复右键菜单跳转目标为 popup 页面

- 将 fallback 页面从 options.html 改为 popup.html
- popup 页面使用 RouterProvider,可以正确处理 URL 参数并跳转到对应功能页面
- options 页面是独立设置页,不支持 URL 参数路由

* fix: 使用 openPopup() 替代 tabs.create() 打开弹窗

- background.ts: 使用 browser.action.openPopup() 打开 popup 弹窗
- 先保存数据到 storage,然后打开 popup
- RouterProvider.tsx: 初始化时检查 storage 中的 pendingData 并跳转到对应页面
- 移除之前错误的 tabs.create() 方式

* fix: 修复右键菜单数据未传递到目标页面的问题

- RouterProvider 不再提前清除 pendingData
- 让目标页面的 useContextMenuData 来消费和清除数据
- 这样确保 TextStatistics 等页面能正确接收选中的文本

* fix: 修复 popup 已打开时右键菜单不生效的问题

- 在 RouterProvider 中添加对 contextMenu/pendingData 的 storage 变化监听
- 当 pendingData 变化时,自动跳转到对应功能页面
- 解决了 openPopup() 只聚焦已有窗口而不触发重新挂载的问题

* feat: 支持右键菜单图片二维码识别

- 在 qrCodeParser.ts 中添加 parseQrCodeFromUrl 函数,支持从图片 URL 解析二维码
- 在 QrCodeToUrlSection 中使用 useContextMenuData 接收右键菜单传递的图片 URL
- 自动下载图片并解析二维码,显示解析结果

* refactor: 暂时移除图片二维码识别功能

- 删除 qrCode-image 右键菜单配置
- 删除 parseQrCodeFromUrl 函数
- 删除 QrCodeToUrlSection 中的 useContextMenuData 相关代码
- 更新相关测试用例

* feat: 支持右键菜单时间戳转换

- 在 useTimestampConverter 中添加 useContextMenuData hook
- 智能识别输入是时间戳还是日期时间字符串
- 自动切换到对应模式并执行转换
- 时间戳自动识别秒/毫秒单位

* fix: 修复右键菜单功能的多个逻辑漏洞

1. 修复时区硬编码问题 - 使用用户选择的时区而非固定 Asia/Shanghai
2. 修复 openPopup() 失败后数据残留 - 失败时清除 storage 中的待处理数据
3. 统一数据过期时间常量 - 导出 CONTEXT_MENU_DATA_EXPIRY_MS 并统一使用
4. 修复 featureKey 类型断言不安全 - 使用映射表处理非常规菜单 ID

* refactor(qrcode): 提取独立组件并添加单元测试

- 创建 pages/QrCode/types.ts 定义状态类型接口
- 提取 QrCodePreview 组件用于二维码预览和操作
- 提取 ImageUploader 组件封装图片上传、拖拽、粘贴逻辑
- 重构 UrlToQrCodeSection 状态提升到父组件
- 重构 QrCodeToUrlSection 通过回调传递解析结果
- 更新主页面 index.tsx 集中管理所有状态
- 为 QrCodePreview 和 ImageUploader 添加单元测试 (23 个用例)

* refactor(qrcode): 引入通用组件并重构双栏布局

- 引入 SwitchButtonGroup 用于模式切换
- 引入 TextInputArea 用于文本输入和结果展示
- 使用 MUI Grid 构建响应式双栏布局
- 实现 generate/parse 模式下的左右面板内容切换
- 修复 iconColor 颜色格式错误,使用 qrCodePageStyles.primaryColor

* refactor(qrcode): 国际化补充、质量保障与错误处理优化

- 补充 i18n 翻译键(generateMode, parseMode, pasteHint 等)
- 创建 useDebounce Hook 实现输入防抖 (200ms)
- 使用 useRef 解决 useEffect 无限循环问题
- 编写 pages/QrCode 单元测试 (7 个用例)
- 优化右键菜单错误处理,改进用户提示
- 运行 603 个测试全部通过

* refactor(qrcode): 组件化重构,采用 Context + Hook 模式

- 创建 QrCodeContext 和 QrCodeProvider 管理共享状态
- 提取 useQrCode Hook 封装所有状态和业务逻辑
- 创建 GeneratePanel 组件处理二维码生成模式
- 创建 ParsePanel 组件处理二维码解析模式
- 简化 index.tsx 为容器组件 (340行 → 47行)
- 删除未使用的旧组件文件 (QrCodeToUrlSection, UrlToQrCodeSection)
- 清理 types.ts 中未使用的类型定义
- 603 个测试全部通过

* fix(qrcode): 固定二维码预览图片尺寸,避免布局抖动

- 设置 QR_PREVIEW_IMAGE 固定尺寸 250x250
- 与 QRious 生成的二维码大小保持一致
- 解决输入内容变化时预览窗口大小跳动问题

* fix(qrcode): 修复切换 tab 后二维码图片失效问题

- 移除 ImageUploader 组件卸载时的预览 URL 释放逻辑
- 在 useQrCode hook 中统一管理预览 URL 生命周期
- 创建新预览 URL 前释放旧的,避免内存泄漏

* fix(qrcode): 固定图片上传区域高度,避免布局抖动

- 将 DROPZONE 的 minHeight: 200 改为 height: 250
- 与二维码预览图片高度保持一致
- 解决上传图片时 div 高度变化问题

* refactor(qrCode): 优化二维码解析功能

- 将'二维码转 URL'改为'二维码转文本'
- 将解析结果的文本预览框的 placeholder 置为空
- 去除手动解析二维码功能,只保留自动解析

* fix(pageHeader): 使用 MUI 主题色替代硬编码颜色,支持暗色模式

* feat(pageHeader): popup 模式下隐藏 PageHeader 组件

* fix(i18n): 统一使用 useLazyTranslation 避免翻译 key 闪烁

* perf: 配置 manualChunks 拆分 vendor chunk,优化打包体积

- 使用 Vite 插件 manualChunksForHtmlOnly 仅对 HTML 多入口构建生效
- 跳过 background/content-script 的 IIFE 构建(不支持 manualChunks)
- 拆分 vendor-react (193KB)、vendor-mui (326KB)、vendor-i18n (55KB)
- 按需加载 vendor-qr (78KB)、vendor-dnd (45KB)、vendor-markdown (41KB)
- PageErrorBoundary chunk 从 454KB 降至 36KB

* chore: upgrade wxt to v0.20.26 and @wxt-dev/module-react to v1.2.2

* chore: upgrade low-risk dependencies

- react: 19.2.3 → 19.2.6
- react-dom: 19.2.3 → 19.2.6
- dayjs: 1.11.19 → 1.11.20
- marked: 18.0.3 → 18.0.4
- prettier: 3.8.1 → 3.8.3
- terser: 5.46.0 → 5.47.1
- i18next: 26.0.8 → 26.2.0
- react-i18next: 17.0.6 → 17.0.8
- globals: 17.2.0 → 17.6.0
- eslint-plugin-react-hooks: 7.0.1 → 7.1.1
- @typescript-eslint/*: 8.54.0 → 8.59.4
- typescript-eslint: 8.54.0 → 8.59.4
- @testing-library/jest-dom: 6.6.0 → 6.9.1
- @testing-library/react: 16.0.0 → 16.3.2
- @testing-library/user-event: 14.5.2 → 14.6.1
- @testing-library/dom: added as peer dependency
- @types/react: 19.2.7 → 19.2.15
- @types/chrome: 0.1.36 → 0.1.42
- @types/webextension-polyfill: 0.12.4 → 0.12.5

* chore: upgrade medium-risk dependencies

- lint-staged: 16.2.7 → 17.0.5
- jsdom: 25.0.0 → 29.1.1
- @vitejs/plugin-react: 4.3.4 → 6.0.2
- vitest: 2.0.0 → 4.1.7
- @vitest/coverage-v8: upgraded to match vitest
- @webext-core/messaging: 2.3.0 → 3.0.1
- eslint: 9.39.2 → 10.4.0
- @eslint/js: added as new dependency
- @types/marked: removed (marked now provides its own types)

Fixes:
- Fix ref update during render in useQrCode.ts
- Fix ref update during render in useStorageCleaner.ts
- Add error cause in jwt.ts decode function
- Add type assertion for mock functions in htmlToMarkdown.test.ts

* chore(deps): 降级 eslint 版本并移除 @eslint/js

* docs: 精简 AGENTS.md,聚焦高信号信息

* test: 修复测试中的 act() 警告和 clipboard mock 问题

- TextInputArea: 使用 userEvent 替代 fireEvent,用 vi.spyOn 替代 Object.assign mock clipboard
- ImageMode/FileMode: 添加 waitForStorageReady() 等待 useStorageState 异步初始化
2026-05-21 22:12:11 +08:00
LingandRX f5e8e96830 Merge branch 'main' into develop 2026-05-21 22:11:06 +08:00
雨霖铃 f1f0047928 test: 修复测试中的 act() 警告和 clipboard mock 问题
- TextInputArea: 使用 userEvent 替代 fireEvent,用 vi.spyOn 替代 Object.assign mock clipboard
- ImageMode/FileMode: 添加 waitForStorageReady() 等待 useStorageState 异步初始化
2026-05-21 22:06:09 +08:00
雨霖铃 f76fc3c140 docs: 精简 AGENTS.md,聚焦高信号信息 2026-05-21 21:54:07 +08:00
雨霖铃 9ed84382ea chore(deps): 降级 eslint 版本并移除 @eslint/js 2026-05-21 21:47:21 +08:00
Ubuntu 4a382144d1 chore: upgrade medium-risk dependencies
- lint-staged: 16.2.7 → 17.0.5
- jsdom: 25.0.0 → 29.1.1
- @vitejs/plugin-react: 4.3.4 → 6.0.2
- vitest: 2.0.0 → 4.1.7
- @vitest/coverage-v8: upgraded to match vitest
- @webext-core/messaging: 2.3.0 → 3.0.1
- eslint: 9.39.2 → 10.4.0
- @eslint/js: added as new dependency
- @types/marked: removed (marked now provides its own types)

Fixes:
- Fix ref update during render in useQrCode.ts
- Fix ref update during render in useStorageCleaner.ts
- Add error cause in jwt.ts decode function
- Add type assertion for mock functions in htmlToMarkdown.test.ts
2026-05-21 20:44:40 +08:00
Ubuntu a654e64fe0 chore: upgrade low-risk dependencies
- react: 19.2.3 → 19.2.6
- react-dom: 19.2.3 → 19.2.6
- dayjs: 1.11.19 → 1.11.20
- marked: 18.0.3 → 18.0.4
- prettier: 3.8.1 → 3.8.3
- terser: 5.46.0 → 5.47.1
- i18next: 26.0.8 → 26.2.0
- react-i18next: 17.0.6 → 17.0.8
- globals: 17.2.0 → 17.6.0
- eslint-plugin-react-hooks: 7.0.1 → 7.1.1
- @typescript-eslint/*: 8.54.0 → 8.59.4
- typescript-eslint: 8.54.0 → 8.59.4
- @testing-library/jest-dom: 6.6.0 → 6.9.1
- @testing-library/react: 16.0.0 → 16.3.2
- @testing-library/user-event: 14.5.2 → 14.6.1
- @testing-library/dom: added as peer dependency
- @types/react: 19.2.7 → 19.2.15
- @types/chrome: 0.1.36 → 0.1.42
- @types/webextension-polyfill: 0.12.4 → 0.12.5
2026-05-21 20:25:00 +08:00
Ubuntu ed8720e63e chore: upgrade wxt to v0.20.26 and @wxt-dev/module-react to v1.2.2 2026-05-21 20:13:34 +08:00
Ubuntu 179a3d31f4 Revert "fix(ci): 修复 CI/CD 工作流问题"
This reverts commit 1df53323d0.
2026-05-21 19:52:44 +08:00
Ubuntu 7cd99876d7 Revert "fix(ci): 修复 CI/CD 工作流问题"
This reverts commit 6b0de96b18.
2026-05-21 19:52:44 +08:00
Ubuntu 6b0de96b18 fix(ci): 修复 CI/CD 工作流问题
- fix: release.yml typecheck 脚本名 compile → typecheck(发版阻断 bug)
- perf: CI 测试改用 test:coverage 生成覆盖率报告
- refactor: Release 复用 CI 工作流(workflow_call),消除重复 Job
- feat: Release 新增 Firefox/Chrome zip 产物验证步骤
- fix: 补全 lint-staged 配置,修复 pre-commit hook 失效问题
- feat: 添加 Dependabot 自动依赖更新配置
- fix: 添加 @vitest/coverage-v8 依赖(覆盖率所需)
- fix: 改用 npx 直接调用命令,规避 npm PATH 问题
2026-05-21 18:33:22 +08:00
Ubuntu 1df53323d0 fix(ci): 修复 CI/CD 工作流问题
- fix: release.yml typecheck 脚本名 compile → typecheck(发版阻断 bug)
- perf: CI 测试改用 test:coverage 生成覆盖率报告
- refactor: Release 复用 CI 工作流(workflow_call),消除重复 Job
- feat: Release 新增 Firefox/Chrome zip 产物验证步骤
- fix: 补全 lint-staged 配置,修复 pre-commit hook 失效问题
- feat: 添加 Dependabot 自动依赖更新配置
2026-05-21 18:01:55 +08:00
LingandRX a5c34e5428 Merge develop into main (#27)
* feat: 添加 contextMenus 权限和消息类型定义

* feat: 实现右键菜单注册与点击分流逻辑

- 新增 utils/contextMenu.ts 封装菜单配置和解析函数
- 在 background.ts 监听 onInstalled 初始化菜单
- 实现 contextMenus.onClicked 点击事件处理
- 分流逻辑:优先发送到侧边栏,否则打开 options 页面
- 添加 contextMenu 单元测试(15个测试用例)
- 更新 vitest.setup.ts 添加 contextMenus mock

* feat: 实现 Content Script 原位轻量提示 UI

- 新增 uiPopover.ts 实现原位弹窗组件(深色主题、自动定位、自动隐藏)
- 新增 contextMenuHandler.ts 处理右键菜单消息
- 时间戳转换:在点击位置显示转换结果
- 文本统计:显示字符/单词/行数/字节统计
- 更新 messageHandler.ts 挂载右键菜单消息监听

* feat: 实现 React 页面层右键菜单数据联动

- 新增 useContextMenuData Hook 处理右键菜单数据传递
- 重构 Jwt/Base64Converter/TextStatistics/QrCode 页面接收右键数据
- RouterProvider 支持从 URL 参数解析右键菜单数据
- 添加 contextMenu/pendingData 存储键到 StorageSchema
- 添加 useContextMenuData 单元测试(12个测试用例)

* test: 添加 background 单元测试与边界情况处理

- 新增 entrypoints/__tests__/background.test.ts (12个测试用例)
- 超长文本截断限制 (>10000字符)
- Base64 内联图片拦截并返回错误提示
- 更新 parseContextMenuClick 返回 ParseResult 格式
- 更新测试匹配新的返回格式
- lint/typecheck/test 全部通过 (579个测试)

* fix: 修复右键菜单跳转到错误页面的问题

- 将 options 页面路径从 '/entrypoints/options/index.html' 修正为 '/options.html'
- WXT 构建后 entrypoints/options/index.html 会输出为根目录的 options.html

* fix: 修复右键菜单跳转目标为 popup 页面

- 将 fallback 页面从 options.html 改为 popup.html
- popup 页面使用 RouterProvider,可以正确处理 URL 参数并跳转到对应功能页面
- options 页面是独立设置页,不支持 URL 参数路由

* fix: 使用 openPopup() 替代 tabs.create() 打开弹窗

- background.ts: 使用 browser.action.openPopup() 打开 popup 弹窗
- 先保存数据到 storage,然后打开 popup
- RouterProvider.tsx: 初始化时检查 storage 中的 pendingData 并跳转到对应页面
- 移除之前错误的 tabs.create() 方式

* fix: 修复右键菜单数据未传递到目标页面的问题

- RouterProvider 不再提前清除 pendingData
- 让目标页面的 useContextMenuData 来消费和清除数据
- 这样确保 TextStatistics 等页面能正确接收选中的文本

* fix: 修复 popup 已打开时右键菜单不生效的问题

- 在 RouterProvider 中添加对 contextMenu/pendingData 的 storage 变化监听
- 当 pendingData 变化时,自动跳转到对应功能页面
- 解决了 openPopup() 只聚焦已有窗口而不触发重新挂载的问题

* feat: 支持右键菜单图片二维码识别

- 在 qrCodeParser.ts 中添加 parseQrCodeFromUrl 函数,支持从图片 URL 解析二维码
- 在 QrCodeToUrlSection 中使用 useContextMenuData 接收右键菜单传递的图片 URL
- 自动下载图片并解析二维码,显示解析结果

* refactor: 暂时移除图片二维码识别功能

- 删除 qrCode-image 右键菜单配置
- 删除 parseQrCodeFromUrl 函数
- 删除 QrCodeToUrlSection 中的 useContextMenuData 相关代码
- 更新相关测试用例

* feat: 支持右键菜单时间戳转换

- 在 useTimestampConverter 中添加 useContextMenuData hook
- 智能识别输入是时间戳还是日期时间字符串
- 自动切换到对应模式并执行转换
- 时间戳自动识别秒/毫秒单位

* fix: 修复右键菜单功能的多个逻辑漏洞

1. 修复时区硬编码问题 - 使用用户选择的时区而非固定 Asia/Shanghai
2. 修复 openPopup() 失败后数据残留 - 失败时清除 storage 中的待处理数据
3. 统一数据过期时间常量 - 导出 CONTEXT_MENU_DATA_EXPIRY_MS 并统一使用
4. 修复 featureKey 类型断言不安全 - 使用映射表处理非常规菜单 ID

* refactor(qrcode): 提取独立组件并添加单元测试

- 创建 pages/QrCode/types.ts 定义状态类型接口
- 提取 QrCodePreview 组件用于二维码预览和操作
- 提取 ImageUploader 组件封装图片上传、拖拽、粘贴逻辑
- 重构 UrlToQrCodeSection 状态提升到父组件
- 重构 QrCodeToUrlSection 通过回调传递解析结果
- 更新主页面 index.tsx 集中管理所有状态
- 为 QrCodePreview 和 ImageUploader 添加单元测试 (23 个用例)

* refactor(qrcode): 引入通用组件并重构双栏布局

- 引入 SwitchButtonGroup 用于模式切换
- 引入 TextInputArea 用于文本输入和结果展示
- 使用 MUI Grid 构建响应式双栏布局
- 实现 generate/parse 模式下的左右面板内容切换
- 修复 iconColor 颜色格式错误,使用 qrCodePageStyles.primaryColor

* refactor(qrcode): 国际化补充、质量保障与错误处理优化

- 补充 i18n 翻译键(generateMode, parseMode, pasteHint 等)
- 创建 useDebounce Hook 实现输入防抖 (200ms)
- 使用 useRef 解决 useEffect 无限循环问题
- 编写 pages/QrCode 单元测试 (7 个用例)
- 优化右键菜单错误处理,改进用户提示
- 运行 603 个测试全部通过

* refactor(qrcode): 组件化重构,采用 Context + Hook 模式

- 创建 QrCodeContext 和 QrCodeProvider 管理共享状态
- 提取 useQrCode Hook 封装所有状态和业务逻辑
- 创建 GeneratePanel 组件处理二维码生成模式
- 创建 ParsePanel 组件处理二维码解析模式
- 简化 index.tsx 为容器组件 (340行 → 47行)
- 删除未使用的旧组件文件 (QrCodeToUrlSection, UrlToQrCodeSection)
- 清理 types.ts 中未使用的类型定义
- 603 个测试全部通过

* fix(qrcode): 固定二维码预览图片尺寸,避免布局抖动

- 设置 QR_PREVIEW_IMAGE 固定尺寸 250x250
- 与 QRious 生成的二维码大小保持一致
- 解决输入内容变化时预览窗口大小跳动问题

* fix(qrcode): 修复切换 tab 后二维码图片失效问题

- 移除 ImageUploader 组件卸载时的预览 URL 释放逻辑
- 在 useQrCode hook 中统一管理预览 URL 生命周期
- 创建新预览 URL 前释放旧的,避免内存泄漏

* fix(qrcode): 固定图片上传区域高度,避免布局抖动

- 将 DROPZONE 的 minHeight: 200 改为 height: 250
- 与二维码预览图片高度保持一致
- 解决上传图片时 div 高度变化问题

* refactor(qrCode): 优化二维码解析功能

- 将'二维码转 URL'改为'二维码转文本'
- 将解析结果的文本预览框的 placeholder 置为空
- 去除手动解析二维码功能,只保留自动解析

* fix(pageHeader): 使用 MUI 主题色替代硬编码颜色,支持暗色模式

* feat(pageHeader): popup 模式下隐藏 PageHeader 组件

* fix(i18n): 统一使用 useLazyTranslation 避免翻译 key 闪烁

* perf: 配置 manualChunks 拆分 vendor chunk,优化打包体积

- 使用 Vite 插件 manualChunksForHtmlOnly 仅对 HTML 多入口构建生效
- 跳过 background/content-script 的 IIFE 构建(不支持 manualChunks)
- 拆分 vendor-react (193KB)、vendor-mui (326KB)、vendor-i18n (55KB)
- 按需加载 vendor-qr (78KB)、vendor-dnd (45KB)、vendor-markdown (41KB)
- PageErrorBoundary chunk 从 454KB 降至 36KB
2026-05-21 01:34:35 +08:00
雨霖铃 72f899477c perf: 配置 manualChunks 拆分 vendor chunk,优化打包体积
- 使用 Vite 插件 manualChunksForHtmlOnly 仅对 HTML 多入口构建生效
- 跳过 background/content-script 的 IIFE 构建(不支持 manualChunks)
- 拆分 vendor-react (193KB)、vendor-mui (326KB)、vendor-i18n (55KB)
- 按需加载 vendor-qr (78KB)、vendor-dnd (45KB)、vendor-markdown (41KB)
- PageErrorBoundary chunk 从 454KB 降至 36KB
2026-05-21 01:25:19 +08:00
雨霖铃 8e2ac5200d fix(i18n): 统一使用 useLazyTranslation 避免翻译 key 闪烁 2026-05-21 00:50:01 +08:00
雨霖铃 604fbb4d1f feat(pageHeader): popup 模式下隐藏 PageHeader 组件 2026-05-21 00:38:00 +08:00
雨霖铃 704ec7289d fix(pageHeader): 使用 MUI 主题色替代硬编码颜色,支持暗色模式 2026-05-21 00:34:02 +08:00
雨霖铃 e4fac241f1 refactor(qrCode): 优化二维码解析功能
- 将'二维码转 URL'改为'二维码转文本'
- 将解析结果的文本预览框的 placeholder 置为空
- 去除手动解析二维码功能,只保留自动解析
2026-05-21 00:22:47 +08:00
雨霖铃 4b339fc628 fix(qrcode): 固定图片上传区域高度,避免布局抖动
- 将 DROPZONE 的 minHeight: 200 改为 height: 250
- 与二维码预览图片高度保持一致
- 解决上传图片时 div 高度变化问题
2026-05-21 00:13:56 +08:00
雨霖铃 70580a9a63 fix(qrcode): 修复切换 tab 后二维码图片失效问题
- 移除 ImageUploader 组件卸载时的预览 URL 释放逻辑
- 在 useQrCode hook 中统一管理预览 URL 生命周期
- 创建新预览 URL 前释放旧的,避免内存泄漏
2026-05-21 00:12:06 +08:00
雨霖铃 99a0400db3 fix(qrcode): 固定二维码预览图片尺寸,避免布局抖动
- 设置 QR_PREVIEW_IMAGE 固定尺寸 250x250
- 与 QRious 生成的二维码大小保持一致
- 解决输入内容变化时预览窗口大小跳动问题
2026-05-21 00:09:17 +08:00
雨霖铃 df0b9440ca refactor(qrcode): 组件化重构,采用 Context + Hook 模式
- 创建 QrCodeContext 和 QrCodeProvider 管理共享状态
- 提取 useQrCode Hook 封装所有状态和业务逻辑
- 创建 GeneratePanel 组件处理二维码生成模式
- 创建 ParsePanel 组件处理二维码解析模式
- 简化 index.tsx 为容器组件 (340行 → 47行)
- 删除未使用的旧组件文件 (QrCodeToUrlSection, UrlToQrCodeSection)
- 清理 types.ts 中未使用的类型定义
- 603 个测试全部通过
2026-05-21 00:07:42 +08:00
雨霖铃 c5b51a9b33 refactor(qrcode): 国际化补充、质量保障与错误处理优化
- 补充 i18n 翻译键(generateMode, parseMode, pasteHint 等)
- 创建 useDebounce Hook 实现输入防抖 (200ms)
- 使用 useRef 解决 useEffect 无限循环问题
- 编写 pages/QrCode 单元测试 (7 个用例)
- 优化右键菜单错误处理,改进用户提示
- 运行 603 个测试全部通过
2026-05-20 22:42:33 +08:00
雨霖铃 bc560cc453 refactor(qrcode): 引入通用组件并重构双栏布局
- 引入 SwitchButtonGroup 用于模式切换
- 引入 TextInputArea 用于文本输入和结果展示
- 使用 MUI Grid 构建响应式双栏布局
- 实现 generate/parse 模式下的左右面板内容切换
- 修复 iconColor 颜色格式错误,使用 qrCodePageStyles.primaryColor
2026-05-20 22:12:48 +08:00
雨霖铃 2870e01610 refactor(qrcode): 提取独立组件并添加单元测试
- 创建 pages/QrCode/types.ts 定义状态类型接口
- 提取 QrCodePreview 组件用于二维码预览和操作
- 提取 ImageUploader 组件封装图片上传、拖拽、粘贴逻辑
- 重构 UrlToQrCodeSection 状态提升到父组件
- 重构 QrCodeToUrlSection 通过回调传递解析结果
- 更新主页面 index.tsx 集中管理所有状态
- 为 QrCodePreview 和 ImageUploader 添加单元测试 (23 个用例)
2026-05-20 21:59:57 +08:00
雨霖铃 9c03c585a8 merge: 合并 feature-context-menu 分支
功能特性:
- 右键菜单支持选中文本/图片触发对应工具
- 支持 JWT 解析、Base64 解码、文本统计、时间戳转换
- 网页链接转二维码
- popup/sidepanel 双入口支持
- 智能识别输入类型自动转换

修复:
- 时区硬编码问题
- openPopup 失败后数据残留
- 统一数据过期时间常量
- 类型断言安全性改进
2026-05-20 21:40:21 +08:00
雨霖铃 0427f16cc9 fix: 修复右键菜单功能的多个逻辑漏洞
1. 修复时区硬编码问题 - 使用用户选择的时区而非固定 Asia/Shanghai
2. 修复 openPopup() 失败后数据残留 - 失败时清除 storage 中的待处理数据
3. 统一数据过期时间常量 - 导出 CONTEXT_MENU_DATA_EXPIRY_MS 并统一使用
4. 修复 featureKey 类型断言不安全 - 使用映射表处理非常规菜单 ID
2026-05-20 21:39:33 +08:00
雨霖铃 e9a6fa3dd8 feat: 支持右键菜单时间戳转换
- 在 useTimestampConverter 中添加 useContextMenuData hook
- 智能识别输入是时间戳还是日期时间字符串
- 自动切换到对应模式并执行转换
- 时间戳自动识别秒/毫秒单位
2026-05-20 21:32:35 +08:00
雨霖铃 00fe3eddd9 refactor: 暂时移除图片二维码识别功能
- 删除 qrCode-image 右键菜单配置
- 删除 parseQrCodeFromUrl 函数
- 删除 QrCodeToUrlSection 中的 useContextMenuData 相关代码
- 更新相关测试用例
2026-05-20 21:27:36 +08:00
雨霖铃 8e920c2a94 feat: 支持右键菜单图片二维码识别
- 在 qrCodeParser.ts 中添加 parseQrCodeFromUrl 函数,支持从图片 URL 解析二维码
- 在 QrCodeToUrlSection 中使用 useContextMenuData 接收右键菜单传递的图片 URL
- 自动下载图片并解析二维码,显示解析结果
2026-05-20 21:19:39 +08:00
雨霖铃 387a95d6a6 fix: 修复 popup 已打开时右键菜单不生效的问题
- 在 RouterProvider 中添加对 contextMenu/pendingData 的 storage 变化监听
- 当 pendingData 变化时,自动跳转到对应功能页面
- 解决了 openPopup() 只聚焦已有窗口而不触发重新挂载的问题
2026-05-20 21:15:43 +08:00
雨霖铃 71995037dd fix: 修复右键菜单数据未传递到目标页面的问题
- RouterProvider 不再提前清除 pendingData
- 让目标页面的 useContextMenuData 来消费和清除数据
- 这样确保 TextStatistics 等页面能正确接收选中的文本
2026-05-20 21:09:47 +08:00
雨霖铃 8e56ebff6e fix: 使用 openPopup() 替代 tabs.create() 打开弹窗
- background.ts: 使用 browser.action.openPopup() 打开 popup 弹窗
- 先保存数据到 storage,然后打开 popup
- RouterProvider.tsx: 初始化时检查 storage 中的 pendingData 并跳转到对应页面
- 移除之前错误的 tabs.create() 方式
2026-05-20 21:07:06 +08:00
雨霖铃 5868316c3a fix: 修复右键菜单跳转目标为 popup 页面
- 将 fallback 页面从 options.html 改为 popup.html
- popup 页面使用 RouterProvider,可以正确处理 URL 参数并跳转到对应功能页面
- options 页面是独立设置页,不支持 URL 参数路由
2026-05-20 21:03:55 +08:00
雨霖铃 8ec584c85f fix: 修复右键菜单跳转到错误页面的问题
- 将 options 页面路径从 '/entrypoints/options/index.html' 修正为 '/options.html'
- WXT 构建后 entrypoints/options/index.html 会输出为根目录的 options.html
2026-05-20 21:00:11 +08:00
雨霖铃 c1890960ea test: 添加 background 单元测试与边界情况处理
- 新增 entrypoints/__tests__/background.test.ts (12个测试用例)
- 超长文本截断限制 (>10000字符)
- Base64 内联图片拦截并返回错误提示
- 更新 parseContextMenuClick 返回 ParseResult 格式
- 更新测试匹配新的返回格式
- lint/typecheck/test 全部通过 (579个测试)
2026-05-20 20:54:51 +08:00
雨霖铃 3b98c9438e feat: 实现 React 页面层右键菜单数据联动
- 新增 useContextMenuData Hook 处理右键菜单数据传递
- 重构 Jwt/Base64Converter/TextStatistics/QrCode 页面接收右键数据
- RouterProvider 支持从 URL 参数解析右键菜单数据
- 添加 contextMenu/pendingData 存储键到 StorageSchema
- 添加 useContextMenuData 单元测试(12个测试用例)
2026-05-20 20:51:11 +08:00
雨霖铃 471e17eedf feat: 实现 Content Script 原位轻量提示 UI
- 新增 uiPopover.ts 实现原位弹窗组件(深色主题、自动定位、自动隐藏)
- 新增 contextMenuHandler.ts 处理右键菜单消息
- 时间戳转换:在点击位置显示转换结果
- 文本统计:显示字符/单词/行数/字节统计
- 更新 messageHandler.ts 挂载右键菜单消息监听
2026-05-20 20:44:00 +08:00
雨霖铃 3fb99ea49f feat: 实现右键菜单注册与点击分流逻辑
- 新增 utils/contextMenu.ts 封装菜单配置和解析函数
- 在 background.ts 监听 onInstalled 初始化菜单
- 实现 contextMenus.onClicked 点击事件处理
- 分流逻辑:优先发送到侧边栏,否则打开 options 页面
- 添加 contextMenu 单元测试(15个测试用例)
- 更新 vitest.setup.ts 添加 contextMenus mock
2026-05-20 20:41:24 +08:00
雨霖铃 3d375f78f3 feat: 添加 contextMenus 权限和消息类型定义 2026-05-20 20:35:55 +08:00
LingandRX 373fc0496c Develop (#26)
统一简化多个页面里的CopyButton调用,删除不再需要的空回调参数

* feat: 添加clearCookies函数的单元测试并修复cookie域名处理逻辑

* feat: 增强 data URI 处理,支持带参数的前缀并更新相关测试

* fix: 修正 AGENTS.md 中 TypeScript 类型检查命令的描述

* feat: 添加 settings.local.json 文件以配置 Bash 权限

* perf: 预设背景色避免 Popup 弹窗白屏闪烁

* perf: 避免图标过早实例化,传递组件引用而非 JSX 节点

* feat: 添加 useLazyTranslation 和 preloadNamespaces 函数以支持动态加载 i18n 命名空间

* feat: 使用 useLazyTranslation 替换 useTranslation 以支持懒加载翻译

* feat: 添加 PageSkeleton 组件及其测试用例以支持页面加载骨架屏

* feat: 使用骨架屏替换加载状态指示器,优化用户体验

* feat: 优化 CopyButton 组件的复制功能,添加定时器管理复制状态

* feat: 调整 chunk 大小警告阈值以优化构建性能
2026-05-20 20:07:44 +08:00
LingandRX 7167a43763 Develop (#25)
核心功能新增
工具套件扩展
JSON 工具(差异比较、格式化、转 YAML/TOML、压缩)
Base64 转换器(文本/文件/图像编解码)
Markdown ↔ HTML 双向转换
JWT 解析器
文本统计工具
二维码生成/解析(替换库以减小体积)
表单工具(已移除)
表单映射、智能填充、高亮定位功能已移除
架构与工程改进
技术栈升级
引入 @webext-core/messaging 重构消息通信
添加 @dnd-kit 支持功能列表拖拽排序
引入 i18next 实现中英文国际化
使用 MUI 主题系统替代硬编码样式,支持暗色模式
代码质量
统一组件导出为默认导出
提取公共组件(TextInputArea、SwitchButtonGroup、PageHeader、DecodeResultPaper)
使用 useStorageState 钩子统一管理存储状态
添加 PageErrorBoundary 页面级错误边界
实现组件懒加载优化首屏性能
关键重构
路由系统重构 — 支持独立标签页模式,合并路由与功能配置
状态管理优化 — 存储状态增加快照机制、验证器和防抖处理
剪贴板与打印工具 — 统一为 async/await 形式,修复重复触发问题
存储清理器 — 使用 TextEncoder 准确计算 UTF-8 字节数,支持 TB 单位
测试与 CI
测试覆盖 — 为新增工具、公共组件、边界场景补充单元测试
CI 精简 — 移除 Firefox 测试,仅保留 Chrome;修正类型检查命令
2026-05-20 19:55:49 +08:00
LingandRX 0cf8bae9f1 Develop (#24)
* docs: 添加组件文档注释和类型导入

refactor: 统一使用 SnackbarOptions 类型
style: 优化导入语句顺序和格式

* refactor: 简化假数据生成器中的faker导入和使用

Co-authored-by: Copilot <copilot@github.com>

* feat(form-recognizer): 增强表单识别功能并优化UI交互

- 新增字段类型偏好设置功能,支持按域名保存字段类型
- 重构FieldList组件,改进字段选择和类型修改体验
- 添加字段定位闪烁功能,便于在页面上快速找到对应字段
- 优化表单填充逻辑,支持单个字段覆盖默认填充模式
- 移除独立的侧边栏页面,统一使用主页面组件
- 改进useStorageState钩子,增加加载状态管理和防抖处理

* refactor: 移除未使用的组件文件

* refactor(页面头部): 提取通用 PageHeader 组件并替换各页面头部实现

重构各页面头部为统一的 PageHeader 组件,提高代码复用性和维护性

* style(组件): 调整自动刷新开关和存储选项网格的样式

优化自动刷新开关的文本内边距,重构存储选项网格的布局结构,调整间距和边框样式

* style(ui): 调整时间戳页面和结果视图的样式

- 为时区选择器添加圆角
- 优化结果视图的布局和对齐方式
- 调整结果项的内边距和文本样式

* ci(workflow): 移除Firefox测试以简化CI流程

仅保留Chrome浏览器的构建步骤,减少CI运行时间和资源消耗

* refactor(qrcode): 重构二维码解析功能并提取为独立模块

* style(页面样式): 统一页面头部图标颜色并优化表单样式

更新各页面头部组件的图标颜色配置,移除冗余的表单标签属性,优化输入框样式和按钮悬停效果

* feat(formMapping): 添加表单映射功能

- 创建 FormMappingPage 页面组件
- 实现表单扫描器 scanner.ts
- 实现高亮器 highlighter.ts
- 更新路由配置 routes.ts
- 更新内容脚本 content.ts
- 更新类型定义 storage.d.ts
- 添加 formMappingPageStyles 样式配置

* feat(表单映射): 添加配置导出功能及状态提示

添加配置导出为JSON文件的功能,包含导出按钮和错误处理。新增Snackbar组件用于显示导出成功和错误状态。优化页面布局结构,将状态提示移至全局容器外。

* feat(表单填充): 新增智能表单填充功能

添加智能表单填充功能,包括:
1. 新增表单填充页面和路由配置
2. 实现模糊匹配引擎和智能注入引擎
3. 添加Mock数据生成器和视觉反馈渲染器
4. 扩展消息接口支持填充操作

* ci: 精简触发CI的工作流分支

移除对develop分支及其变体的触发,仅保留main分支的触发

* test: 更新路由测试以匹配新增的路由数量

* test: 将测试文件中的描述和断言翻译为中文

* test(PageHeader): 添加组件测试用例验证渲染逻辑和样式

* refactor(theme): 重构主题样式并优化仪表盘卡片组件

将页面样式配置集中管理,移除各页面中硬编码的背景色
新增 DashboardCard 组件封装通用卡片逻辑
添加 dashboardCards 配置文件统一管理卡片数据

* refactor(Button): 统一按钮样式并移除重复样式定义

将按钮样式统一封装到 Button 组件中,移除各页面重复的样式定义
更新组件文档说明,提供更清晰的用法示例

* docs: 更新 README 文件中的目录结构说明

添加新组件和配置文件的说明,保持文档与代码同步

* refactor: 优化代码类型声明和UI布局

修复类型声明从any改为never以提高类型安全性
调整TimestampPage页面布局间距
移除DashboardPage中不必要的实时时钟状态
更新ESLint配置以使用推荐配置
添加选项页面打开的错误处理

* refactor(消息通信): 使用 @webext-core/messaging 重构消息处理逻辑

将原有的 chrome.runtime.onMessage 和 chrome.tabs.sendMessage 替换为类型安全的 @webext-core/messaging 实现
添加 ProtocolMap 类型定义确保消息类型安全
更新相关页面和后台脚本使用新的消息通信方式

* fix: 修复依赖项缺失导致的潜在问题

修复 useStorageState 中缺少 defaultValue 依赖的问题
重构 RouterProvider 的初始化逻辑,使用 useCallback 优化性能

* refactor: 重构表单识别和消息处理逻辑

将表单映射UI逻辑提取到独立文件
将消息处理器提取到独立文件
将表单识别页面逻辑提取到自定义hook
优化代码结构和可维护性

* feat(侧边栏): 添加侧边栏状态变化通知功能

- 在消息类型中新增侧边栏状态变化枚举和字段
- 侧边栏打开和关闭时发送状态通知
- 替换轮询检查方式为消息监听机制

* refactor: 优化高亮组件渲染逻辑并添加防抖处理

重构高亮组件渲染逻辑,使用 requestAnimationFrame 进行节流优化
在 useStorageCleaner 中添加防抖处理以避免频繁加载

* refactor: 统一组件导出方式为默认导出

* refactor: 重构样式系统并迁移至 MUI 主题

删除冗余的 CSS 文件,统一使用 MUI 主题管理样式
新增 useActiveTabDomain 和 useSidePanelState 自定义 Hook
优化各入口点的主题集成和布局处理

* docs: 更新项目文档以反映新增功能和技术栈细节

更新 AGENTS.md 文档,详细描述新增的功能模块(存储管理、URL 管理、二维码生成、表单工具套件等)和更新的技术栈信息

* feat: 添加错误边界组件以捕获子组件错误

在 popup、sidepanel 和 options 入口点添加 ErrorBoundary 组件,用于捕获并处理子组件中的 JavaScript 错误。当错误发生时,显示友好的错误界面并提供刷新功能。

* refactor(ui): 使用全局 snackbar 替换本地通知组件

重构表单页面中的通知系统,移除本地 Snackbar 和 Alert 组件,改用全局 GlobalSnackbar 组件统一管理通知
标准化主题配置中的 CSS 属性命名,移除带前缀的样式属性

* refactor(snackbar): 重构全局消息提示为 SnackbarProvider 组件

将原本分散在各页面的 GlobalSnackbar 组件重构为集中管理的 SnackbarProvider,通过 Context 提供统一的消息提示功能。主要变更包括:

1. 创建新的 SnackbarProvider 组件作为全局消息提示容器
2. 提供 useSnackbar hook 供子组件调用
3. 移除各页面中独立的 GlobalSnackbar 实例
4. 在 App 根组件中统一集成 SnackbarProvider
5. 优化消息提示样式和交互行为

* refactor(Snackbar): 重构消息提示组件并优化样式

重构 Snackbar 组件,将状态管理逻辑提取到 GlobalSnackbar 中复用
优化消息提示样式,调整阴影效果和最小尺寸
统一各页面使用 Snackbar 的方式,移除冗余配置

* refactor: 重构路由和功能配置系统

- 将路由配置和仪表盘卡片配置合并为统一的 features 配置
- 重构 RouterProvider 增加数据校验和本地快照功能
- 优化表单字段识别和填充逻辑,提取公共方法
- 更新相关组件和文档以适配新的配置系统
- 删除废弃的 dashboardCards 和 routes 配置文件

* refactor(GlobalSnackbar): 合并SnackbarProvider到GlobalSnackbar组件

重构Snackbar相关代码,将SnackbarProvider合并到GlobalSnackbar组件中,简化代码结构
更新相关组件引用路径,从SnackbarProvider改为GlobalSnackbar
修复StorageCleanerPage禁用状态逻辑问题

* feat(表单映射): 增强高亮器功能并优化元素定位

- 新增高亮器闪烁功能,支持自定义持续时间
- 改进元素定位算法,支持 Shadow DOM 穿透
- 优化拾取模式,支持连续拾取和悬停预览
- 增强错误处理和信息反馈
- 添加测试用例确保核心功能稳定性

* refactor: 移除表单映射相关功能

删除表单映射相关的UI、逻辑和测试代码,包括表单识别、智能填充和高亮功能
简化消息处理逻辑和配置文件,保留核心功能

* feat: 实现页面组件懒加载并优化加载状态显示

refactor: 移除未使用的数据生成和验证工具类

chore: 更新应用图标文件

* refactor(qrcode): 替换二维码生成和解析库以减小体积

使用 qr-scanner 替代 jsqr 进行二维码解析,使用 qrious 替代 qrcode 进行二维码生成
新的库体积更小且性能更好,同时移除了不再使用的 @faker-js/faker 依赖

* feat: 添加在标签页打开功能并优化错误处理

修复测试中的加载状态检查
将控制台日志改为错误捕获
添加全局消息提示组件

* feat: 支持独立标签页模式并重构路由配置

重构路由系统以支持独立标签页模式,替换原有的独立窗口模式
- 新增标签页路由、可见页面列表和页面顺序的存储键
- 修改 RouterProvider 以支持不同窗口类型的独立配置
- 更新选项页面以支持为不同窗口类型单独配置
- 添加相关单元测试

* feat(ui): 优化响应式布局和样式设计

重构选项页面布局,增强移动端适配性
更新主题配置和组件样式,提升用户体验

* refactor: 移除URL工具相关代码

移除不再需要的URL工具功能,包括组件、页面、样式和类型定义

* refactor: 简化页面标题和存储选项网格的响应式布局

* refactor: 优化样式和错误处理

- 简化 chrome.runtime.openOptionsPage 的错误处理
- 拆分 html, body, #root 的样式定义,使用 minHeight 替代 height

* refactor: 重构页面组件结构
- 将页面组件从 entrypoints/popup/pages 移动到 pages 目录
- 更新文档中的路径引用

* style(DashboardPage): 优化仪表盘布局为响应式网格

refactor(ToolCard): 重构工具卡片组件并添加详细注释

* feat: 新增文本统计功能

添加文本统计工具页面及相关功能,包括:
- 文本统计工具页面组件
- 文本统计计算工具函数
- 相关类型定义和样式配置
- 单元测试覆盖核心功能

* feat(jwt): 添加 JWT 解析工具功能

新增 JWT 解析工具页面,包含以下内容:
- 添加 JWT 类型定义和页面样式配置
- 实现 JWT 解析核心功能及测试用例
- 创建 JWT 解析页面组件
- 在功能配置中注册 JWT 工具

* docs: 更新 README 以更清晰地描述项目功能和结构

- 重构功能描述部分,使用更简洁的列表形式展示
- 更新技术栈和项目结构说明
- 优化开发流程部分的描述
- 移除冗余的权限和依赖说明

* docs: 更新AGENTS.md文档以反映项目最新状态

- 重写项目概述和核心命令部分,使其更简洁准确
- 重构目录结构描述,采用更清晰的树状格式
- 移除过时的功能模块描述,聚焦当前核心功能
- 更新AI代理开发准则和权限管理说明
- 优化文档整体结构和可读性

* test: 更新特征计数测试以匹配新增 textStatistics 和 jwt 工具

* style: 统一导入语句顺序

* refactor(pages): 重构工具页面组件结构并优化功能实现

- 将各工具页面组件迁移至独立目录并重构为模块化结构
- 新增存储清理工具相关组件及状态管理逻辑
- 实现时间戳转换工具的自定义hook和UI组件
- 优化JWT解析工具的代码结构和样式
- 更新文本统计工具的性能计算逻辑
- 调整配置文件中的页面引用路径
- 删除无用文件和过时文档

* feat(顶部栏): 添加搜索功能和历史记录

实现顶部栏搜索框功能,支持模糊搜索工具并显示搜索结果列表
添加搜索历史记录功能,自动保存最近搜索并显示
支持键盘导航和选择搜索结果

* feat: 添加多语言支持(i18n)

实现应用的多语言功能,支持中英文切换
添加i18next和相关依赖
创建语言资源文件
更新组件以使用翻译功能
重构文本统计工具页面
完善测试和构建配置

* feat(顶部栏): 添加语言切换功能并优化国际化支持
feat(i18n): 增加语言归一化和同步功能
fix(剪贴板): 使用国际化文本替换剪贴板消息
test(顶部栏): 更新测试以匹配国际化标题

* refactor: 重构组件目录结构,将组件移动到对应功能页面目录

将多个组件从通用组件目录移动到各自功能页面目录下,包括 ToolCard、StorageCleanerConfirm、UrlToQrCodeSection 和 QrCodeToUrlSection。同时新增 Dashboard 页面相关组件 DashboardCard 和 ToolCard,并优化了组件导入路径。

重构旨在提高代码组织性,使组件与其所属功能页面更紧密关联,便于维护和理解代码结构。所有组件功能保持不变,仅调整文件位置和导入路径。

* refactor(Dashboard): 优化卡片组件结构并移除无用属性

重构 DashboardCard 组件接口,直接传递属性而非配置对象
移除 ToolCard 中无用的 hasAI 属性
使用 useMemo 优化可见性判断性能
统一使用 alpha 函数处理颜色透明度

* refactor(timestamp): 合并输入处理函数并优化样式结构

重构时间戳转换器的输入处理逻辑,将 setTsInput 和 setDtInput 合并为统一的 setInput 函数
将组件样式提取到 pageTheme 配置中统一管理
使用 ToggleButtonGroup 替换自定义模式切换器实现
移除不必要的 showMessage 属性传递,改用 useSnackbar 钩子

* refactor(dashboard): 重构仪表盘卡片组件并优化布局

- 删除冗余的 DashboardCard 组件,直接使用 ToolCard
- 将网格布局样式移至主题配置
- 使用 CardActionArea 增强可访问性
- 简化点击事件处理逻辑
- 更新相关测试用例

* refactor(storageCleaner): 重构样式代码并优化国际化处理

将分散的样式代码集中到pageTheme.ts中统一管理
优化组件中的国际化文本引用
修复useStorageCleaner中的状态同步问题
更新测试用例以匹配国际化文本

* refactor(StorageCleaner): 优化条件判断和清理未使用的代码

简化禁用按钮的条件判断逻辑
移除未使用的 resultTimeoutRef 及相关清理代码

* refactor(QrCode): 重构二维码页面样式和组件结构

将内联样式提取到主题配置文件中
移除未使用的全局消息提示依赖
优化组件结构和代码复用
删除未使用的QrCodeUploader组件

* refactor(ErrorBoundary): 移除冗余状态更新并调整错误背景色

移除 componentDidCatch 中的冗余 setState 调用
将错误背景色从 'error.shortest' 改为 rgba 格式

* feat(TopBar): 优化顶部导航栏功能并添加样式配置

- 新增 openExtensionPage 工具函数用于打开扩展页面
- 将顶部导航栏样式配置提取到 pageTheme 中统一管理
- 改进搜索历史功能,增加错误处理和配置化限制
- 优化键盘导航和搜索结果的交互逻辑

* chore: 在.gitignore中添加docs目录

避免将文档生成目录提交到版本控制

* feat(JsonDiff): add type definitions for diff engine

* feat: 新增 JSON 差异比较工具功能

添加 JSON 差异比较工具页面,支持并排和统一视图模式,包含以下功能:
- 左右两侧 JSON 输入框及校验
- 差异高亮显示及导航
- 支持对象和数组的深度比较
- 国际化支持
- 单元测试覆盖核心差异算法

* feat(主题): 添加暗色模式支持并重构主题系统

- 新增 ThemeModeProvider 提供亮色/暗色/系统模式切换功能
- 重构所有组件使用 MUI 主题变量替代硬编码颜色
- 更新测试用例以适配新的主题系统
- 添加 i18n 翻译支持主题切换相关文本
- 统一入口点使用 AppRoot 组件封装主题和路由提供者

* feat(jsonDiff): 新增 JSON 格式化功能模块

- 新增 jsonFormat 语言资源,支持中英文文本
- 新增 JsonFormatSection 组件,提供 JSON 格式化输入与结果展示
- 在 JsonDiff 页面加入模式切换,支持差异比较与格式化双模式
- 实现 formatJson 和 validateJson 工具方法,支持缩进大小和键名排序选项
- 增加格式化结果显示大小统计和一键复制功能
- 写入大量单元测试,覆盖格式化和校验功能边界
- 优化页面布局与交互,提升用户体验和功能集成度

* feat(jsonDiff): 新增 JSON 转 YAML/TOML 及压缩功能

- 在语言文件(en/zh)中添加 YAML、TOML 和 JSON 压缩模式相关文案
- 扩展 JsonDiff 页面,新增 yaml、toml、minify 三种页面模式
- 新增 JsonConvertSection 组件,提供输入校验、转换操作及结果展示
- 实现 jsonToYaml 工具函数,将 JSON 转换为符合规范的 YAML 格式
- 实现 jsonToToml 工具函数,将 JSON 转换为符合规范且支持嵌套的 TOML 格式
- 实现 minifyJson 工具函数,实现 JSON 字符串压缩为单行格式
- 为所有新功能增加单元测试,覆盖正常及异常场景,保证转换正确性和稳定性
- 在页面中添加对应的切换按钮和图标,提升用户体验和操作便捷性

* refactor(jsonTools): 重命名 JSON 差异比较为 JSON 工具

- 将 JsonDiff 页面组件重命名为 JsonTools 并更新相关引用
- 修改功能配置中对应组件的名称以匹配新命名
- 更新英文和中文语言包中该功能的标题及描述
- 调整测试文件中导入路径以使用新的 JsonTools 文件夹名

* feat(storage): 优化本地存储状态管理和 JSON 工具页模式持久化

- 添加 useStorageState 钩子,支持同步快照和异步加载存储状态
- 实现快照机制,防止页面首屏闪烁现象
- 增加状态值验证器功能,保证恢复数据合法性
- JSON 工具页面模式改用受控存储状态持久化
- 新增 JsonToolsPageMode 类型定义,统一页面模式类型
- 为 useStorageState 编写完整单元测试,覆盖快照、校验及存储同步场景

* feat(base64Converter): 新增 Base64 转换器功能模块

- 添加 Base64 转换器页面及相关样式配置
- 支持文本、文件及图像三种转换模式,含编码和解码功能
- 实现拖拽、文件选择及图像预览功能
- 增加中文与英文多语言资源文件支持
- 补充相关工具函数,支持编码解码及格式校验
- 扩展路由提供者逻辑,合并已保存列表与默认功能集合
- 更新测试用例,覆盖合并新功能的场景验证
- 在特性配置中加入 Base64 转换器信息,支持图标和默认显示配置

* feat(markdown): 新增 Markdown 转 HTML 功能模块

- 添加 marked 依赖及类型定义支持 Markdown 解析
- 新增 MarkdownToHtml 页面,支持实时编辑、预览及 HTML 输出三种模式切换
- 实现 markdownToHtml 工具函数进行 Markdown 转 HTML 操作,含错误处理
- 包装完整 HTML 文档功能,支持打印和下载导出
- 配置全局主题样式,优化 Markdown 渲染视觉效果
- 完善 i18n 国际化资源,支持多语言页面展示
- 统一类型定义,新增 MarkdownToHtml 相关存储状态类型
- 添加相关单元测试,覆盖转换逻辑及功能组件
- 在功能配置中注册 Markdown 转 HTML 工具,支持界面调用和路由访问

* feat(htmlToMarkdown): 添加 HTML 转 Markdown 功能页面及转换工具

- 新增 HtmlToMarkdownPage 组件,支持实时转换 HTML 为 Markdown
- 增加 htmlToMarkdown 工具函数,实现基于 DOM 解析的转换逻辑
- 新增下载 Markdown 文件功能,支持一键导出转换结果
- 配置文件增加 htmlToMarkdown 相关功能项和页面样式
- 国际化资源添加对应中英文文案
- 更新类型定义,新增 HTML 转 Markdown 相关类型和存储键
- 补充对应的单元测试,覆盖转换逻辑和导出功能
- 修正路由提供器测试,确保新增页面被正确识别和排序

* refactor(jwt): 替换 JSON 格式化函数名称及优化代码表达

- 将 jwt 解析工具中的 formatJson 函数重命名为 stringifyJson
- 更新 Jwt 页面中相关的格式化函数调用,保持命名一致
- 统一注释风格,增强代码可读性
- 保持功能不变,改进代码语义表达

* refactor(timestamp): 优化时间戳页面布局与样式,简化组件结构

- 调整模式切换器和单位切换器样式,增加统一转换工作卡片和转换按钮样式
- 精简LiveClock组件,移除单位切换功能,瘦身为单行参考条
- 优化Timestamp页面布局,输入区、单位选择、时区选择及结果区并入统一卡片中
- 移除ResultView中多余的额外时间格式展示,仅保留转换结果显示
- 精简useTimestampConverter,删除自动转换的防抖useEffect逻辑
- 更新多处样式细节以提升界面紧凑度和一致性
- 删除国际化文件中未使用的时间相关文案键值

* feat(layout): 优化二维码与时间戳页面响应式布局

- 新增桌面端左右分栏布局样式,实现 md 断点后等宽分栏二卡片等高
- 调整二维码页面,移动端折叠,桌面端两个面板强制展开且隐藏折叠交互
- 时间戳页面新增左右分栏样式,左侧为转换工作台,右侧为结果展示卡片
- 结果展示新增空状态占位提示,支持显示额外时间格式信息及复制按钮
- 国际化资源补充时间戳转换相关文本,包括相对时间、ISO8601、UTC时间等标签
- 统一各布局区容器高度及内联样式,强化桌面端用户体验与视觉一致性

* fix(style): 优化页面布局与文字溢出显示

- 为页面主题配置添加 gridAutoRows 属性,统一自动行高
- 修改 ToolCard 中描述文本样式,实现单行溢出省略号显示
- 解决文本换行和截断,避免内容超出指定范围
- 改善视觉效果和布局一致性,提高用户体验

* feat(options): 支持功能列表拖拽排序

- 新增 @dnd-kit 相关依赖包,实现拖拽排序功能
- 将原先的上下移动按钮替换为拖拽交互
- 封装 SortableFeatureRow 组件,支持拖拽排序与开关切换
- 使用 DndContext 和 SortableContext 管理拖拽逻辑与排序状态
- 拖拽时高亮显示拖动项,优化用户体验
- 调整恢复默认按钮位置,与 Tab 同行展示
- 优化 UI 样式及相关交互提示信息
- 保存排序结果至存储,保证配置持久化

* fix(router): 防止组件卸载后更新状态导致竞态条件

- 修改 loadInitialData 调用逻辑,添加 cancelled 标志防止卸载后更新状态
- 确保组件卸载时取消状态更新,避免 React 警告和无效操作
- 新增测试覆盖组件卸载时不应更新 isLoaded 状态的竞态条件防护
- 修复加载初始路由数据失败时的错误处理代码逻辑

* refactor(router): 移除不再使用的本地导航相关代码

- 删除 RouterProvider 中的 navigateLocal 和 syncNavigation 方法及相关注释
- 清理对应的类型定义,移除不再使用的接口成员
- 更新测试文件,移除对 navigateLocal 的模拟函数调用
- 精简导航逻辑,保持代码整洁和一致性

* refactor(clipboard): 优化剪贴板复制函数并添加测试用例

- 将复制文本和复制图片的函数改为 async/await 形式,简化代码结构
- 删除 Promise 构造函数中多余的 then/catch,改用 try/catch 返回布尔值表示成功与否
- 修改 CopyButton 组件使用新的异步复制函数写法,提升代码可读性
- 添加剪贴板复制功能的单元测试,覆盖成功与失败两种场景
- 在测试环境中模拟 ClipboardItem 以支持图片复制测试

* fix(options): 验证配置数据有效性并恢复默认值

- 添加 isValidPage 和 isValidPageList 函数验证 visiblePages 和 pageOrder 的数据有效性
- 当读取到非法数据时,自动恢复为默认配置,避免异常状态
- 更新异步加载配置逻辑,确保界面状态稳定
- 新增单元测试,覆盖合法与非法数据的加载场景
- 修正 .gitignore 文件

* refactor(storage): 替换 IndexedDB 大小计算为通用存储估算

- 将获取 IndexedDB 大小的方法更名为获取源存储估算
- 修改调用处使用新的通用存储估算函数
- 更新错误日志信息以反映新的统计方法
- 统一存储空间估算接口,提升扩展能力

* fix(utils): 解决打印重复触发问题

- 添加 printed 标志防止多次执行打印
- 在 onload 和 setTimeout 中判断 printed 状态
- 防止部分浏览器 onload 不触发导致重复打印
- 增强打印逻辑的稳定性和兼容性

* fix(ErrorBoundary): 修复错误状态未随子组件变化重置的问题

- 增加 componentDidUpdate 钩子,子组件变化时重置错误状态
- 添加完整的单元测试覆盖正常渲染、错误捕获、状态重置和刷新按钮功能
- 修改 background.ts,调用 reload 时添加异常捕获并打印错误
- 修改 sidepanel/App.tsx,调用 openOptionsPage 时添加异常捕获并打印错误
- tsconfig.json 开启 noImplicitAny 检查,提高代码类型安全
- 添加 qrious 模块类型声明文件,完善类型定义

* fix(utils): 修复存储状态和主题模式加载的竞态条件问题

- 在 ThemeModeProvider 中取消异步操作防止内存泄漏
- 在 useStorageState 钩子中添加取消标志,避免状态更新竞态
- 修正 TopBar 搜索历史持久化,添加 useEffect 副作用同步存储
- 扩展 storageCleaner 支持 TB 单位,防止格式化错误
- GlobalSnackbar 组件支持自定义 anchorOrigin 属性,增强弹窗位置灵活性
- 优化 jsonToToml 和 jsonToYaml 中多余的字符判断逻辑,提升序列化准确性

* fix(Base64Converter): 修复切换模式时文件状态未及时重置问题

- 添加取消标志避免文件选择异步状态错乱
- 切换页面模式时清空文件和加载状态
- 切换文本编码方向时清空输出和错误状态
- 阻止重复选择同一模式导致不必要状态更新
- 确保异步转换过程中若取消选择不更新状态

* refactor(Base64Converter): 拆分文本、文件和图像转换模式为独立组件

- 将文本编码/解码逻辑提取至 TextMode 组件,简化主页面代码
- 将文件转Base64相关处理封装到 FileMode 组件,提升代码模块化
- 将图像转Base64相关处理封装到 ImageMode 组件,增强功能分离
- 主页面根据当前模式动态渲染对应组件,移除大量重复状态及逻辑
- 优化模式切换逻辑,取消不必要的状态重置,简化事件处理
- 清理未使用的导入和废弃代码,减少页面体积与复杂度

* test(toolcard): 调整键盘触发事件模拟

- 替换点击事件为键盘按下和释放事件模拟
- 修正测试用例以更真实地模拟键盘交互
- 确保事件处理函数被正确调用
- 移除eslint配置文件中对测试文件的忽略设置

* test(ToolCard): 优化键盘事件测试逻辑

- 引入 userEvent 以模拟更真实的用户键盘输入
- 使用 act 包裹异步操作确保测试稳定性
- 修改按 Enter 键测试为异步函数以支持 await
- 替换 fireEvent 键盘事件为 userEvent.keyboard 模拟Enter键
- 在触发键盘事件前手动聚焦目标按钮,确保事件正确触发

* docs: 添加页面级 ErrorBoundary 设计文档

为懒加载页面组件添加独立 ErrorBoundary 保护的设计方案。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(PageErrorBoundary): 添加页面级错误边界组件

轻量内嵌错误卡片,支持 resetKey 自动重置和重试按钮。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(PageErrorBoundary): 添加页面级错误边界单元测试

覆盖正常渲染、错误捕获、重试恢复、resetKey 自动重置等场景。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(RouterContainer): 在 Suspense 内层添加 PageErrorBoundary

以 currentPage 为 resetKey,页面切换时自动重置错误状态。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(options): 使用 PageErrorBoundary 替换全局 ErrorBoundary

options 页面统一使用页面级错误卡片,与 RouterContainer 体验一致。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(utils): add unified formatBytes function

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(utils): add formatBytes unit tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(utils): delegate formatByteSize to formatBytes

- Add unified formatBytes to utils/format.ts
- Update textStatistics.ts to re-export
- Update test expectations for consistent decimal formatting

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(utils): delegate formatFileSize and formatSize to formatBytes

- Update base64Converter.ts to re-export formatBytes as formatFileSize
- Update storageCleaner.ts to re-export formatBytes as formatSize

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(base64Converter): 支持 data URI 解码并优化错误提示

- base64ToText 支持自动剥离 data:<mime>;base64, 前缀进行正确解码
- 解码非 UTF-8 数据(如图片)时抛出友好错误,提示切换至图像模式
- TextMode 组件增加基于输入内容智能提示,解码图片类型时显示切换图像模式按钮
- 添加切换图像模式的回调支持及对应 UI 提示
- 增加对应单元测试覆盖新增功能和错误处理
- 更新中英文国际化文案,增加新的错误和提示信息
- eslint 配置调整,允许测试文件使用 any 类型警告关闭

* feat(base64Converter): 添加文件与图像模式的Base64解码及下载功能

- 增加文件模式的Base64解码功能,支持手动输入Base64内容进行解码
- 添加解码后的文件名编辑与下载按钮,提升用户操作便捷性
- 实现文件模式中编码与解码的切换按钮,并保留用户选择状态
- 图像模式同步新增解码功能,支持Base64字符串或数据URI的转换
- 图像解码结果显示预览图像、推断MIME类型、解码大小信息
- 增设统一错误处理机制,提示无效Base64字符串错误
- 更新i18n翻译词条,新增解码相关UI文字描述
- 扩展单元测试覆盖编码/解码切换及解码异常处理场景

* feat(eslint): 在测试文件规则中添加对未使用变量的警告配置

* fix(storageCleaner): 更新错误消息以反映清理操作的上下文

* feat(RouterProvider): 使用 ref 持有最新 currentPage,优化 chrome.storage 监听器注册

* fix(StorageSchema): 更新 RouterProvider 相关路由键的注释以反映默认行为

* feat(base64Converter): 添加对 BMP 和 WebP 文件类型的支持,更新魔数签名匹配规则

* fix(LiveClock): 修复定时器变量命名以提高代码可读性

* fix(storageCleaner): 使用 TextEncoder 准确计算 UTF-8 字节数

将 getCookieSize、getLocalStorageSize 和 getSessionStorageSize
中的字符串 .length 替换为 TextEncoder.encode().length,
避免 Unicode 字符导致的大小低估问题。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor: 移除不必要的样式定义,简化模式切换按钮的样式

* feat(SwitchButtonGroup): 添加 SwitchButtonGroup 组件及其测试用例,替换现有的 ToggleButtonGroup

* feat(SwitchButtonGroup): 添加 size 和 buttonSx 属性支持,并更新测试用例

* feat(MarkdownToHtml): 替换 ToggleButtonGroup 为 SwitchButtonGroup,简化模式切换逻辑

* feat: 替换 ToggleButtonGroup 为 SwitchButtonGroup,简化模式切换逻辑

* chore: 移除不必要的 compile 脚本

* feat: 更新 SwitchButtonGroup 组件,支持 number 类型和自定义样式,替换多个 ToggleButtonGroup 实现

* feat(Timestamp): 更新单位和时区选择样式,支持自适应宽度和换行

* feat: 更新比较按钮样式,添加 nowrap 属性以防止换行

* style(HTML转Markdown页): 替换旧版InputProps为新版slotProps

将Material UI组件的InputProps属性更新为slotProps语法,适配最新组件API

* feat: 添加 TextInputArea 多行文本输入组件及配套测试

新增功能完整的 TextInputArea 组件,支持受控/非受控模式、字符计数、复制清空、表单验证、自定义操作按钮等特性,同时编写了全面的单元测试覆盖所有核心功能。

* refactor(TopBar,JWT): 移动常量定义并重构JWT页面

1. 将TopBar的常量从pageTheme.ts移至组件内
2. 移除pageTheme.ts中废弃的topBarStyles定义
3. 重构JWT页面:替换TextField为TextInputArea组件
4. 简化JWT页面的输入处理逻辑

* feat(TextInputArea): 添加 sx 样式透传支持

新增 TextInputArea 组件的 sx 属性,支持透传样式到外层容器,新增对应测试用例验证样式生效

* refactor(TextStatistics): 抽离文本输入区域为公共组件

将原页面内的TextField文本输入框抽离为TextInputArea公共组件,简化当前页面代码,复用组件样式与逻辑

* feat(TextInputArea): 添加外部错误属性,支持父组件控制错误展示

- 新增externalError属性,优先级高于内部验证错误
- 新增配套测试用例验证外部错误的展示和优先级逻辑

* feat(textInputArea): 实现国际化适配并完善组件功能

1. 为文本输入区域组件添加多语言支持,新增中英文字段
2. 重构清空和复制按钮,使用国际化文案替代硬编码
3. 新增onClear回调属性,新增对应测试用例
4. 调整底部操作栏布局,合并操作按钮显示逻辑
5. 优化字符计数提示文案为国际化形式

* refactor(utils/jwt): 替换硬编码错误提示为国际化文案

1. 新增中英文多语言错误提示文案
2. 将decodeBase64Url和parseJwt中的硬编码错误信息替换为i18n翻译内容
3. 更新JWT格式错误的校验提示文案
4. 调整测试用例适配新的错误信息校验逻辑

* feat(components): 添加 DecodeResultPaper 通用结果展示组件及测试用例

提取 FileMode 和 ImageMode 共用的解码结果展示Paper结构,包含标题、预览区、文件信息、文件名输入和下载按钮,同时补充完整的单元测试覆盖渲染和交互逻辑

* refactor(Base64Converter): 重构页面组件,统一使用TextInputArea组件

1.  替换原有TextField为公共TextInputArea组件,减少重复代码
2.  抽离工具栏操作逻辑,使用useMemo缓存动作配置
3.  统一错误提示和输出区域的渲染逻辑,复用DecodeResultPaper组件
4.  更新测试用例,适配新的元素选择方式

* refactor(JsonTools): 替换原生TextField为复用的TextInputArea组件

统一JSON工具页面的输入框组件,移除冗余的样式和错误提示代码,简化页面代码结构

* refactor(Jwt页面): 优化错误提示实现方式

将手动实现的错误提示组件替换为通过externalError属性传递的方式,移除未使用的ErrorOutlineIcon导入,简化代码结构

* ci(github workflow): 修正CI脚本中的类型检查命令

将原本执行编译的npm run compile替换为正确的类型检查命令npm run typecheck
2026-05-17 18:42:01 +08:00
LingandRX a6dcdddf66 Develop (#22)
* docs: 添加组件文档注释和类型导入

refactor: 统一使用 SnackbarOptions 类型
style: 优化导入语句顺序和格式

* refactor: 简化假数据生成器中的faker导入和使用

Co-authored-by: Copilot <copilot@github.com>

* feat(form-recognizer): 增强表单识别功能并优化UI交互

- 新增字段类型偏好设置功能,支持按域名保存字段类型
- 重构FieldList组件,改进字段选择和类型修改体验
- 添加字段定位闪烁功能,便于在页面上快速找到对应字段
- 优化表单填充逻辑,支持单个字段覆盖默认填充模式
- 移除独立的侧边栏页面,统一使用主页面组件
- 改进useStorageState钩子,增加加载状态管理和防抖处理

* refactor: 移除未使用的组件文件

* refactor(页面头部): 提取通用 PageHeader 组件并替换各页面头部实现

重构各页面头部为统一的 PageHeader 组件,提高代码复用性和维护性

* style(组件): 调整自动刷新开关和存储选项网格的样式

优化自动刷新开关的文本内边距,重构存储选项网格的布局结构,调整间距和边框样式

* style(ui): 调整时间戳页面和结果视图的样式

- 为时区选择器添加圆角
- 优化结果视图的布局和对齐方式
- 调整结果项的内边距和文本样式

* ci(workflow): 移除Firefox测试以简化CI流程

仅保留Chrome浏览器的构建步骤,减少CI运行时间和资源消耗

* refactor(qrcode): 重构二维码解析功能并提取为独立模块

* style(页面样式): 统一页面头部图标颜色并优化表单样式

更新各页面头部组件的图标颜色配置,移除冗余的表单标签属性,优化输入框样式和按钮悬停效果

* feat(formMapping): 添加表单映射功能

- 创建 FormMappingPage 页面组件
- 实现表单扫描器 scanner.ts
- 实现高亮器 highlighter.ts
- 更新路由配置 routes.ts
- 更新内容脚本 content.ts
- 更新类型定义 storage.d.ts
- 添加 formMappingPageStyles 样式配置

* feat(表单映射): 添加配置导出功能及状态提示

添加配置导出为JSON文件的功能,包含导出按钮和错误处理。新增Snackbar组件用于显示导出成功和错误状态。优化页面布局结构,将状态提示移至全局容器外。

* feat(表单填充): 新增智能表单填充功能

添加智能表单填充功能,包括:
1. 新增表单填充页面和路由配置
2. 实现模糊匹配引擎和智能注入引擎
3. 添加Mock数据生成器和视觉反馈渲染器
4. 扩展消息接口支持填充操作

* ci: 精简触发CI的工作流分支

移除对develop分支及其变体的触发,仅保留main分支的触发

* test: 更新路由测试以匹配新增的路由数量

* test: 将测试文件中的描述和断言翻译为中文

* test(PageHeader): 添加组件测试用例验证渲染逻辑和样式

* refactor(theme): 重构主题样式并优化仪表盘卡片组件

将页面样式配置集中管理,移除各页面中硬编码的背景色
新增 DashboardCard 组件封装通用卡片逻辑
添加 dashboardCards 配置文件统一管理卡片数据

* refactor(Button): 统一按钮样式并移除重复样式定义

将按钮样式统一封装到 Button 组件中,移除各页面重复的样式定义
更新组件文档说明,提供更清晰的用法示例

* docs: 更新 README 文件中的目录结构说明

添加新组件和配置文件的说明,保持文档与代码同步

* refactor: 优化代码类型声明和UI布局

修复类型声明从any改为never以提高类型安全性
调整TimestampPage页面布局间距
移除DashboardPage中不必要的实时时钟状态
更新ESLint配置以使用推荐配置
添加选项页面打开的错误处理

* refactor(消息通信): 使用 @webext-core/messaging 重构消息处理逻辑

将原有的 chrome.runtime.onMessage 和 chrome.tabs.sendMessage 替换为类型安全的 @webext-core/messaging 实现
添加 ProtocolMap 类型定义确保消息类型安全
更新相关页面和后台脚本使用新的消息通信方式

* fix: 修复依赖项缺失导致的潜在问题

修复 useStorageState 中缺少 defaultValue 依赖的问题
重构 RouterProvider 的初始化逻辑,使用 useCallback 优化性能

* refactor: 重构表单识别和消息处理逻辑

将表单映射UI逻辑提取到独立文件
将消息处理器提取到独立文件
将表单识别页面逻辑提取到自定义hook
优化代码结构和可维护性

* feat(侧边栏): 添加侧边栏状态变化通知功能

- 在消息类型中新增侧边栏状态变化枚举和字段
- 侧边栏打开和关闭时发送状态通知
- 替换轮询检查方式为消息监听机制

* refactor: 优化高亮组件渲染逻辑并添加防抖处理

重构高亮组件渲染逻辑,使用 requestAnimationFrame 进行节流优化
在 useStorageCleaner 中添加防抖处理以避免频繁加载

* refactor: 统一组件导出方式为默认导出

* refactor: 重构样式系统并迁移至 MUI 主题

删除冗余的 CSS 文件,统一使用 MUI 主题管理样式
新增 useActiveTabDomain 和 useSidePanelState 自定义 Hook
优化各入口点的主题集成和布局处理

* docs: 更新项目文档以反映新增功能和技术栈细节

更新 AGENTS.md 文档,详细描述新增的功能模块(存储管理、URL 管理、二维码生成、表单工具套件等)和更新的技术栈信息

* feat: 添加错误边界组件以捕获子组件错误

在 popup、sidepanel 和 options 入口点添加 ErrorBoundary 组件,用于捕获并处理子组件中的 JavaScript 错误。当错误发生时,显示友好的错误界面并提供刷新功能。

* refactor(ui): 使用全局 snackbar 替换本地通知组件

重构表单页面中的通知系统,移除本地 Snackbar 和 Alert 组件,改用全局 GlobalSnackbar 组件统一管理通知
标准化主题配置中的 CSS 属性命名,移除带前缀的样式属性

* refactor(snackbar): 重构全局消息提示为 SnackbarProvider 组件

将原本分散在各页面的 GlobalSnackbar 组件重构为集中管理的 SnackbarProvider,通过 Context 提供统一的消息提示功能。主要变更包括:

1. 创建新的 SnackbarProvider 组件作为全局消息提示容器
2. 提供 useSnackbar hook 供子组件调用
3. 移除各页面中独立的 GlobalSnackbar 实例
4. 在 App 根组件中统一集成 SnackbarProvider
5. 优化消息提示样式和交互行为

* refactor(Snackbar): 重构消息提示组件并优化样式

重构 Snackbar 组件,将状态管理逻辑提取到 GlobalSnackbar 中复用
优化消息提示样式,调整阴影效果和最小尺寸
统一各页面使用 Snackbar 的方式,移除冗余配置

* refactor: 重构路由和功能配置系统

- 将路由配置和仪表盘卡片配置合并为统一的 features 配置
- 重构 RouterProvider 增加数据校验和本地快照功能
- 优化表单字段识别和填充逻辑,提取公共方法
- 更新相关组件和文档以适配新的配置系统
- 删除废弃的 dashboardCards 和 routes 配置文件

* refactor(GlobalSnackbar): 合并SnackbarProvider到GlobalSnackbar组件

重构Snackbar相关代码,将SnackbarProvider合并到GlobalSnackbar组件中,简化代码结构
更新相关组件引用路径,从SnackbarProvider改为GlobalSnackbar
修复StorageCleanerPage禁用状态逻辑问题

* feat(表单映射): 增强高亮器功能并优化元素定位

- 新增高亮器闪烁功能,支持自定义持续时间
- 改进元素定位算法,支持 Shadow DOM 穿透
- 优化拾取模式,支持连续拾取和悬停预览
- 增强错误处理和信息反馈
- 添加测试用例确保核心功能稳定性

* refactor: 移除表单映射相关功能

删除表单映射相关的UI、逻辑和测试代码,包括表单识别、智能填充和高亮功能
简化消息处理逻辑和配置文件,保留核心功能

* feat: 实现页面组件懒加载并优化加载状态显示

refactor: 移除未使用的数据生成和验证工具类

chore: 更新应用图标文件

* refactor(qrcode): 替换二维码生成和解析库以减小体积

使用 qr-scanner 替代 jsqr 进行二维码解析,使用 qrious 替代 qrcode 进行二维码生成
新的库体积更小且性能更好,同时移除了不再使用的 @faker-js/faker 依赖

* feat: 添加在标签页打开功能并优化错误处理

修复测试中的加载状态检查
将控制台日志改为错误捕获
添加全局消息提示组件

* feat: 支持独立标签页模式并重构路由配置

重构路由系统以支持独立标签页模式,替换原有的独立窗口模式
- 新增标签页路由、可见页面列表和页面顺序的存储键
- 修改 RouterProvider 以支持不同窗口类型的独立配置
- 更新选项页面以支持为不同窗口类型单独配置
- 添加相关单元测试

* feat(ui): 优化响应式布局和样式设计

重构选项页面布局,增强移动端适配性
更新主题配置和组件样式,提升用户体验

* refactor: 移除URL工具相关代码

移除不再需要的URL工具功能,包括组件、页面、样式和类型定义

* refactor: 简化页面标题和存储选项网格的响应式布局

* refactor: 优化样式和错误处理

- 简化 chrome.runtime.openOptionsPage 的错误处理
- 拆分 html, body, #root 的样式定义,使用 minHeight 替代 height

* refactor: 重构页面组件结构
- 将页面组件从 entrypoints/popup/pages 移动到 pages 目录
- 更新文档中的路径引用

* style(DashboardPage): 优化仪表盘布局为响应式网格

refactor(ToolCard): 重构工具卡片组件并添加详细注释

* feat: 新增文本统计功能

添加文本统计工具页面及相关功能,包括:
- 文本统计工具页面组件
- 文本统计计算工具函数
- 相关类型定义和样式配置
- 单元测试覆盖核心功能

* feat(jwt): 添加 JWT 解析工具功能

新增 JWT 解析工具页面,包含以下内容:
- 添加 JWT 类型定义和页面样式配置
- 实现 JWT 解析核心功能及测试用例
- 创建 JWT 解析页面组件
- 在功能配置中注册 JWT 工具

* docs: 更新 README 以更清晰地描述项目功能和结构

- 重构功能描述部分,使用更简洁的列表形式展示
- 更新技术栈和项目结构说明
- 优化开发流程部分的描述
- 移除冗余的权限和依赖说明

* docs: 更新AGENTS.md文档以反映项目最新状态

- 重写项目概述和核心命令部分,使其更简洁准确
- 重构目录结构描述,采用更清晰的树状格式
- 移除过时的功能模块描述,聚焦当前核心功能
- 更新AI代理开发准则和权限管理说明
- 优化文档整体结构和可读性

* test: 更新特征计数测试以匹配新增 textStatistics 和 jwt 工具

* style: 统一导入语句顺序

* refactor(pages): 重构工具页面组件结构并优化功能实现

- 将各工具页面组件迁移至独立目录并重构为模块化结构
- 新增存储清理工具相关组件及状态管理逻辑
- 实现时间戳转换工具的自定义hook和UI组件
- 优化JWT解析工具的代码结构和样式
- 更新文本统计工具的性能计算逻辑
- 调整配置文件中的页面引用路径
- 删除无用文件和过时文档

* feat(顶部栏): 添加搜索功能和历史记录

实现顶部栏搜索框功能,支持模糊搜索工具并显示搜索结果列表
添加搜索历史记录功能,自动保存最近搜索并显示
支持键盘导航和选择搜索结果

* feat: 添加多语言支持(i18n)

实现应用的多语言功能,支持中英文切换
添加i18next和相关依赖
创建语言资源文件
更新组件以使用翻译功能
重构文本统计工具页面
完善测试和构建配置

* feat(顶部栏): 添加语言切换功能并优化国际化支持
feat(i18n): 增加语言归一化和同步功能
fix(剪贴板): 使用国际化文本替换剪贴板消息
test(顶部栏): 更新测试以匹配国际化标题

* refactor: 重构组件目录结构,将组件移动到对应功能页面目录

将多个组件从通用组件目录移动到各自功能页面目录下,包括 ToolCard、StorageCleanerConfirm、UrlToQrCodeSection 和 QrCodeToUrlSection。同时新增 Dashboard 页面相关组件 DashboardCard 和 ToolCard,并优化了组件导入路径。

重构旨在提高代码组织性,使组件与其所属功能页面更紧密关联,便于维护和理解代码结构。所有组件功能保持不变,仅调整文件位置和导入路径。

* refactor(Dashboard): 优化卡片组件结构并移除无用属性

重构 DashboardCard 组件接口,直接传递属性而非配置对象
移除 ToolCard 中无用的 hasAI 属性
使用 useMemo 优化可见性判断性能
统一使用 alpha 函数处理颜色透明度

* refactor(timestamp): 合并输入处理函数并优化样式结构

重构时间戳转换器的输入处理逻辑,将 setTsInput 和 setDtInput 合并为统一的 setInput 函数
将组件样式提取到 pageTheme 配置中统一管理
使用 ToggleButtonGroup 替换自定义模式切换器实现
移除不必要的 showMessage 属性传递,改用 useSnackbar 钩子

* refactor(dashboard): 重构仪表盘卡片组件并优化布局

- 删除冗余的 DashboardCard 组件,直接使用 ToolCard
- 将网格布局样式移至主题配置
- 使用 CardActionArea 增强可访问性
- 简化点击事件处理逻辑
- 更新相关测试用例

* refactor(storageCleaner): 重构样式代码并优化国际化处理

将分散的样式代码集中到pageTheme.ts中统一管理
优化组件中的国际化文本引用
修复useStorageCleaner中的状态同步问题
更新测试用例以匹配国际化文本

* refactor(StorageCleaner): 优化条件判断和清理未使用的代码

简化禁用按钮的条件判断逻辑
移除未使用的 resultTimeoutRef 及相关清理代码

* refactor(QrCode): 重构二维码页面样式和组件结构

将内联样式提取到主题配置文件中
移除未使用的全局消息提示依赖
优化组件结构和代码复用
删除未使用的QrCodeUploader组件

* refactor(ErrorBoundary): 移除冗余状态更新并调整错误背景色

移除 componentDidCatch 中的冗余 setState 调用
将错误背景色从 'error.shortest' 改为 rgba 格式

* feat(TopBar): 优化顶部导航栏功能并添加样式配置

- 新增 openExtensionPage 工具函数用于打开扩展页面
- 将顶部导航栏样式配置提取到 pageTheme 中统一管理
- 改进搜索历史功能,增加错误处理和配置化限制
- 优化键盘导航和搜索结果的交互逻辑

* chore: 在.gitignore中添加docs目录

避免将文档生成目录提交到版本控制

* feat(JsonDiff): add type definitions for diff engine

* feat: 新增 JSON 差异比较工具功能

添加 JSON 差异比较工具页面,支持并排和统一视图模式,包含以下功能:
- 左右两侧 JSON 输入框及校验
- 差异高亮显示及导航
- 支持对象和数组的深度比较
- 国际化支持
- 单元测试覆盖核心差异算法

* feat(主题): 添加暗色模式支持并重构主题系统

- 新增 ThemeModeProvider 提供亮色/暗色/系统模式切换功能
- 重构所有组件使用 MUI 主题变量替代硬编码颜色
- 更新测试用例以适配新的主题系统
- 添加 i18n 翻译支持主题切换相关文本
- 统一入口点使用 AppRoot 组件封装主题和路由提供者

* feat(jsonDiff): 新增 JSON 格式化功能模块

- 新增 jsonFormat 语言资源,支持中英文文本
- 新增 JsonFormatSection 组件,提供 JSON 格式化输入与结果展示
- 在 JsonDiff 页面加入模式切换,支持差异比较与格式化双模式
- 实现 formatJson 和 validateJson 工具方法,支持缩进大小和键名排序选项
- 增加格式化结果显示大小统计和一键复制功能
- 写入大量单元测试,覆盖格式化和校验功能边界
- 优化页面布局与交互,提升用户体验和功能集成度

* feat(jsonDiff): 新增 JSON 转 YAML/TOML 及压缩功能

- 在语言文件(en/zh)中添加 YAML、TOML 和 JSON 压缩模式相关文案
- 扩展 JsonDiff 页面,新增 yaml、toml、minify 三种页面模式
- 新增 JsonConvertSection 组件,提供输入校验、转换操作及结果展示
- 实现 jsonToYaml 工具函数,将 JSON 转换为符合规范的 YAML 格式
- 实现 jsonToToml 工具函数,将 JSON 转换为符合规范且支持嵌套的 TOML 格式
- 实现 minifyJson 工具函数,实现 JSON 字符串压缩为单行格式
- 为所有新功能增加单元测试,覆盖正常及异常场景,保证转换正确性和稳定性
- 在页面中添加对应的切换按钮和图标,提升用户体验和操作便捷性

* refactor(jsonTools): 重命名 JSON 差异比较为 JSON 工具

- 将 JsonDiff 页面组件重命名为 JsonTools 并更新相关引用
- 修改功能配置中对应组件的名称以匹配新命名
- 更新英文和中文语言包中该功能的标题及描述
- 调整测试文件中导入路径以使用新的 JsonTools 文件夹名

* feat(storage): 优化本地存储状态管理和 JSON 工具页模式持久化

- 添加 useStorageState 钩子,支持同步快照和异步加载存储状态
- 实现快照机制,防止页面首屏闪烁现象
- 增加状态值验证器功能,保证恢复数据合法性
- JSON 工具页面模式改用受控存储状态持久化
- 新增 JsonToolsPageMode 类型定义,统一页面模式类型
- 为 useStorageState 编写完整单元测试,覆盖快照、校验及存储同步场景

* feat(base64Converter): 新增 Base64 转换器功能模块

- 添加 Base64 转换器页面及相关样式配置
- 支持文本、文件及图像三种转换模式,含编码和解码功能
- 实现拖拽、文件选择及图像预览功能
- 增加中文与英文多语言资源文件支持
- 补充相关工具函数,支持编码解码及格式校验
- 扩展路由提供者逻辑,合并已保存列表与默认功能集合
- 更新测试用例,覆盖合并新功能的场景验证
- 在特性配置中加入 Base64 转换器信息,支持图标和默认显示配置

* feat(markdown): 新增 Markdown 转 HTML 功能模块

- 添加 marked 依赖及类型定义支持 Markdown 解析
- 新增 MarkdownToHtml 页面,支持实时编辑、预览及 HTML 输出三种模式切换
- 实现 markdownToHtml 工具函数进行 Markdown 转 HTML 操作,含错误处理
- 包装完整 HTML 文档功能,支持打印和下载导出
- 配置全局主题样式,优化 Markdown 渲染视觉效果
- 完善 i18n 国际化资源,支持多语言页面展示
- 统一类型定义,新增 MarkdownToHtml 相关存储状态类型
- 添加相关单元测试,覆盖转换逻辑及功能组件
- 在功能配置中注册 Markdown 转 HTML 工具,支持界面调用和路由访问

* feat(htmlToMarkdown): 添加 HTML 转 Markdown 功能页面及转换工具

- 新增 HtmlToMarkdownPage 组件,支持实时转换 HTML 为 Markdown
- 增加 htmlToMarkdown 工具函数,实现基于 DOM 解析的转换逻辑
- 新增下载 Markdown 文件功能,支持一键导出转换结果
- 配置文件增加 htmlToMarkdown 相关功能项和页面样式
- 国际化资源添加对应中英文文案
- 更新类型定义,新增 HTML 转 Markdown 相关类型和存储键
- 补充对应的单元测试,覆盖转换逻辑和导出功能
- 修正路由提供器测试,确保新增页面被正确识别和排序

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: yll <mtmtchb@outlook.com>
2026-05-10 23:44:45 +08:00
LingandRX 3c9c9e740e Develop (#21)
docs: 添加组件文档注释和类型导入
refactor: 统一使用 SnackbarOptions 类型
style: 优化导入语句顺序和格式
refactor: 简化假数据生成器中的faker导入和使用
feat(form-recognizer): 增强表单识别功能并优化UI交互
refactor: 移除未使用的组件文件
refactor(页面头部): 提取通用 PageHeader 组件
style(组件): 调整自动刷新开关和存储选项网格的样式
style(ui): 调整时间戳页面和结果视图的样式
ci(workflow): 移除Firefox测试以简化CI流程
refactor(qrcode): 重构二维码解析功能并提取为独立模块
style(页面样式): 统一页面头部图标颜色并优化表单样式
feat(formMapping): 添加表单映射功能
feat(表单映射): 添加配置导出功能及状态提示
feat(表单填充): 新增智能表单填充功能
ci: 精简触发CI的工作流分支
test: 更新路由测试以匹配新增的路由数量
test: 将测试文件中的描述和断言翻译为中文
test(PageHeader): 添加组件测试用例
refactor(theme): 重构主题样式并优化仪表盘卡片组件
refactor(Button): 统一按钮样式并移除重复样式定义
docs: 更新 README 文件中的目录结构说明
refactor: 优化代码类型声明和UI布局
refactor(消息通信): 使用 @webext-core/messaging 重构消息处理逻辑
fix: 修复依赖项缺失导致的潜在问题
refactor: 重构表单识别和消息处理逻辑
feat(侧边栏): 添加侧边栏状态变化通知功能
refactor: 优化高亮组件渲染逻辑并添加防抖处理
refactor: 统一组件导出方式为默认导出
refactor: 重构样式系统并迁移至 MUI 主题
docs: 更新项目文档以反映新增功能和技术栈细节
feat: 添加错误边界组件以捕获子组件错误
refactor(ui): 使用全局 snackbar 替换本地通知组件
refactor(snackbar): 重构全局消息提示为 SnackbarProvider 组件
refactor(Snackbar): 重构消息提示组件并优化样式
refactor: 重构路由和功能配置系统
refactor(GlobalSnackbar): 合并SnackbarProvider到GlobalSnackbar组件
feat(表单映射): 增强高亮器功能并优化元素定位
refactor: 移除表单映射相关功能
feat: 实现页面组件懒加载并优化加载状态显示
refactor: 移除未使用的数据生成和验证工具类
chore: 更新应用图标文件
refactor(qrcode): 替换二维码生成和解析库以减小体积
feat: 添加在标签页打开功能并优化错误处理
feat: 支持独立标签页模式并重构路由配置
feat(ui): 优化响应式布局和样式设计
refactor: 移除URL工具相关代码
refactor: 简化页面标题和存储选项网格的响应式布局
refactor: 优化样式和错误处理
refactor: 重构页面组件结构
style(DashboardPage): 优化仪表盘布局为响应式网格
refactor(ToolCard): 重构工具卡片组件并添加详细注释
feat: 新增文本统计功能
feat(jwt): 添加 JWT 解析工具功能
docs: 更新 README 以更清晰地描述项目功能和结构
docs: 更新AGENTS.md文档以反映项目最新状态
test: 更新特征计数测试以匹配新增工具
style: 统一导入语句顺序
refactor(pages): 重构工具页面组件结构并优化功能实现
feat(顶部栏): 添加搜索功能和历史记录
feat: 添加多语言支持(i18n)
feat(顶部栏): 添加语言切换功能并优化国际化支持
feat(i18n): 增加语言归一化和同步功能
fix(剪贴板): 使用国际化文本替换剪贴板消息
test(顶部栏): 更新测试以匹配国际化标题
refactor: 重构组件目录结构
refactor(Dashboard): 优化卡片组件结构并移除无用属性
refactor(timestamp): 合并输入处理函数并优化样式结构
refactor(dashboard): 重构仪表盘卡片组件并优化布局
refactor(storageCleaner): 重构样式代码并优化国际化处理
refactor(StorageCleaner): 优化条件判断和清理未使用的代码
refactor(QrCode): 重构二维码页面样式和组件结构
refactor(ErrorBoundary): 移除冗余状态更新并调整错误背景色
feat(TopBar): 优化顶部导航栏功能并添加样式配置
chore: 在.gitignore中添加docs目录
feat(JsonDiff): 添加差异引擎类型定义
feat: 新增 JSON 差异比较工具功能
feat(主题): 添加暗色模式支持并重构主题系统
2026-05-09 00:18:29 +08:00
LingandRX ff964e3608 Develop (#20)
* docs: 添加组件文档注释和类型导入

refactor: 统一使用 SnackbarOptions 类型
style: 优化导入语句顺序和格式

* refactor: 简化假数据生成器中的faker导入和使用

Co-authored-by: Copilot <copilot@github.com>

* feat(form-recognizer): 增强表单识别功能并优化UI交互

- 新增字段类型偏好设置功能,支持按域名保存字段类型
- 重构FieldList组件,改进字段选择和类型修改体验
- 添加字段定位闪烁功能,便于在页面上快速找到对应字段
- 优化表单填充逻辑,支持单个字段覆盖默认填充模式
- 移除独立的侧边栏页面,统一使用主页面组件
- 改进useStorageState钩子,增加加载状态管理和防抖处理

* refactor: 移除未使用的组件文件

* refactor(页面头部): 提取通用 PageHeader 组件并替换各页面头部实现

重构各页面头部为统一的 PageHeader 组件,提高代码复用性和维护性

* style(组件): 调整自动刷新开关和存储选项网格的样式

优化自动刷新开关的文本内边距,重构存储选项网格的布局结构,调整间距和边框样式

* style(ui): 调整时间戳页面和结果视图的样式

- 为时区选择器添加圆角
- 优化结果视图的布局和对齐方式
- 调整结果项的内边距和文本样式

* ci(workflow): 移除Firefox测试以简化CI流程

仅保留Chrome浏览器的构建步骤,减少CI运行时间和资源消耗

* refactor(qrcode): 重构二维码解析功能并提取为独立模块

* style(页面样式): 统一页面头部图标颜色并优化表单样式

更新各页面头部组件的图标颜色配置,移除冗余的表单标签属性,优化输入框样式和按钮悬停效果

* feat(formMapping): 添加表单映射功能

- 创建 FormMappingPage 页面组件
- 实现表单扫描器 scanner.ts
- 实现高亮器 highlighter.ts
- 更新路由配置 routes.ts
- 更新内容脚本 content.ts
- 更新类型定义 storage.d.ts
- 添加 formMappingPageStyles 样式配置

* feat(表单映射): 添加配置导出功能及状态提示

添加配置导出为JSON文件的功能,包含导出按钮和错误处理。新增Snackbar组件用于显示导出成功和错误状态。优化页面布局结构,将状态提示移至全局容器外。

* feat(表单填充): 新增智能表单填充功能

添加智能表单填充功能,包括:
1. 新增表单填充页面和路由配置
2. 实现模糊匹配引擎和智能注入引擎
3. 添加Mock数据生成器和视觉反馈渲染器
4. 扩展消息接口支持填充操作

* ci: 精简触发CI的工作流分支

移除对develop分支及其变体的触发,仅保留main分支的触发

* test: 更新路由测试以匹配新增的路由数量

* test: 将测试文件中的描述和断言翻译为中文

* test(PageHeader): 添加组件测试用例验证渲染逻辑和样式

* refactor(theme): 重构主题样式并优化仪表盘卡片组件

将页面样式配置集中管理,移除各页面中硬编码的背景色
新增 DashboardCard 组件封装通用卡片逻辑
添加 dashboardCards 配置文件统一管理卡片数据

* refactor(Button): 统一按钮样式并移除重复样式定义

将按钮样式统一封装到 Button 组件中,移除各页面重复的样式定义
更新组件文档说明,提供更清晰的用法示例

* docs: 更新 README 文件中的目录结构说明

添加新组件和配置文件的说明,保持文档与代码同步

* refactor: 优化代码类型声明和UI布局

修复类型声明从any改为never以提高类型安全性
调整TimestampPage页面布局间距
移除DashboardPage中不必要的实时时钟状态
更新ESLint配置以使用推荐配置
添加选项页面打开的错误处理

* refactor(消息通信): 使用 @webext-core/messaging 重构消息处理逻辑

将原有的 chrome.runtime.onMessage 和 chrome.tabs.sendMessage 替换为类型安全的 @webext-core/messaging 实现
添加 ProtocolMap 类型定义确保消息类型安全
更新相关页面和后台脚本使用新的消息通信方式

* fix: 修复依赖项缺失导致的潜在问题

修复 useStorageState 中缺少 defaultValue 依赖的问题
重构 RouterProvider 的初始化逻辑,使用 useCallback 优化性能

* refactor: 重构表单识别和消息处理逻辑

将表单映射UI逻辑提取到独立文件
将消息处理器提取到独立文件
将表单识别页面逻辑提取到自定义hook
优化代码结构和可维护性

* feat(侧边栏): 添加侧边栏状态变化通知功能

- 在消息类型中新增侧边栏状态变化枚举和字段
- 侧边栏打开和关闭时发送状态通知
- 替换轮询检查方式为消息监听机制

* refactor: 优化高亮组件渲染逻辑并添加防抖处理

重构高亮组件渲染逻辑,使用 requestAnimationFrame 进行节流优化
在 useStorageCleaner 中添加防抖处理以避免频繁加载

* refactor: 统一组件导出方式为默认导出

* refactor: 重构样式系统并迁移至 MUI 主题

删除冗余的 CSS 文件,统一使用 MUI 主题管理样式
新增 useActiveTabDomain 和 useSidePanelState 自定义 Hook
优化各入口点的主题集成和布局处理

* docs: 更新项目文档以反映新增功能和技术栈细节

更新 AGENTS.md 文档,详细描述新增的功能模块(存储管理、URL 管理、二维码生成、表单工具套件等)和更新的技术栈信息

* feat: 添加错误边界组件以捕获子组件错误

在 popup、sidepanel 和 options 入口点添加 ErrorBoundary 组件,用于捕获并处理子组件中的 JavaScript 错误。当错误发生时,显示友好的错误界面并提供刷新功能。

* refactor(ui): 使用全局 snackbar 替换本地通知组件

重构表单页面中的通知系统,移除本地 Snackbar 和 Alert 组件,改用全局 GlobalSnackbar 组件统一管理通知
标准化主题配置中的 CSS 属性命名,移除带前缀的样式属性

* refactor(snackbar): 重构全局消息提示为 SnackbarProvider 组件

将原本分散在各页面的 GlobalSnackbar 组件重构为集中管理的 SnackbarProvider,通过 Context 提供统一的消息提示功能。主要变更包括:

1. 创建新的 SnackbarProvider 组件作为全局消息提示容器
2. 提供 useSnackbar hook 供子组件调用
3. 移除各页面中独立的 GlobalSnackbar 实例
4. 在 App 根组件中统一集成 SnackbarProvider
5. 优化消息提示样式和交互行为

* refactor(Snackbar): 重构消息提示组件并优化样式

重构 Snackbar 组件,将状态管理逻辑提取到 GlobalSnackbar 中复用
优化消息提示样式,调整阴影效果和最小尺寸
统一各页面使用 Snackbar 的方式,移除冗余配置

* refactor: 重构路由和功能配置系统

- 将路由配置和仪表盘卡片配置合并为统一的 features 配置
- 重构 RouterProvider 增加数据校验和本地快照功能
- 优化表单字段识别和填充逻辑,提取公共方法
- 更新相关组件和文档以适配新的配置系统
- 删除废弃的 dashboardCards 和 routes 配置文件

* refactor(GlobalSnackbar): 合并SnackbarProvider到GlobalSnackbar组件

重构Snackbar相关代码,将SnackbarProvider合并到GlobalSnackbar组件中,简化代码结构
更新相关组件引用路径,从SnackbarProvider改为GlobalSnackbar
修复StorageCleanerPage禁用状态逻辑问题

* feat(表单映射): 增强高亮器功能并优化元素定位

- 新增高亮器闪烁功能,支持自定义持续时间
- 改进元素定位算法,支持 Shadow DOM 穿透
- 优化拾取模式,支持连续拾取和悬停预览
- 增强错误处理和信息反馈
- 添加测试用例确保核心功能稳定性

* refactor: 移除表单映射相关功能

删除表单映射相关的UI、逻辑和测试代码,包括表单识别、智能填充和高亮功能
简化消息处理逻辑和配置文件,保留核心功能

* feat: 实现页面组件懒加载并优化加载状态显示

refactor: 移除未使用的数据生成和验证工具类

chore: 更新应用图标文件

* refactor(qrcode): 替换二维码生成和解析库以减小体积

使用 qr-scanner 替代 jsqr 进行二维码解析,使用 qrious 替代 qrcode 进行二维码生成
新的库体积更小且性能更好,同时移除了不再使用的 @faker-js/faker 依赖

* feat: 添加在标签页打开功能并优化错误处理

修复测试中的加载状态检查
将控制台日志改为错误捕获
添加全局消息提示组件

* feat: 支持独立标签页模式并重构路由配置

重构路由系统以支持独立标签页模式,替换原有的独立窗口模式
- 新增标签页路由、可见页面列表和页面顺序的存储键
- 修改 RouterProvider 以支持不同窗口类型的独立配置
- 更新选项页面以支持为不同窗口类型单独配置
- 添加相关单元测试

* feat(ui): 优化响应式布局和样式设计

重构选项页面布局,增强移动端适配性
更新主题配置和组件样式,提升用户体验

* refactor: 移除URL工具相关代码

移除不再需要的URL工具功能,包括组件、页面、样式和类型定义

* refactor: 简化页面标题和存储选项网格的响应式布局

* refactor: 优化样式和错误处理

- 简化 chrome.runtime.openOptionsPage 的错误处理
- 拆分 html, body, #root 的样式定义,使用 minHeight 替代 height

* refactor: 重构页面组件结构
- 将页面组件从 entrypoints/popup/pages 移动到 pages 目录
- 更新文档中的路径引用

* style(DashboardPage): 优化仪表盘布局为响应式网格

refactor(ToolCard): 重构工具卡片组件并添加详细注释

* feat: 新增文本统计功能

添加文本统计工具页面及相关功能,包括:
- 文本统计工具页面组件
- 文本统计计算工具函数
- 相关类型定义和样式配置
- 单元测试覆盖核心功能

* feat(jwt): 添加 JWT 解析工具功能

新增 JWT 解析工具页面,包含以下内容:
- 添加 JWT 类型定义和页面样式配置
- 实现 JWT 解析核心功能及测试用例
- 创建 JWT 解析页面组件
- 在功能配置中注册 JWT 工具

* docs: 更新 README 以更清晰地描述项目功能和结构

- 重构功能描述部分,使用更简洁的列表形式展示
- 更新技术栈和项目结构说明
- 优化开发流程部分的描述
- 移除冗余的权限和依赖说明

* docs: 更新AGENTS.md文档以反映项目最新状态

- 重写项目概述和核心命令部分,使其更简洁准确
- 重构目录结构描述,采用更清晰的树状格式
- 移除过时的功能模块描述,聚焦当前核心功能
- 更新AI代理开发准则和权限管理说明
- 优化文档整体结构和可读性

* test: 更新特征计数测试以匹配新增 textStatistics 和 jwt 工具

* style: 统一导入语句顺序

* refactor(pages): 重构工具页面组件结构并优化功能实现

- 将各工具页面组件迁移至独立目录并重构为模块化结构
- 新增存储清理工具相关组件及状态管理逻辑
- 实现时间戳转换工具的自定义hook和UI组件
- 优化JWT解析工具的代码结构和样式
- 更新文本统计工具的性能计算逻辑
- 调整配置文件中的页面引用路径
- 删除无用文件和过时文档

* feat(顶部栏): 添加搜索功能和历史记录

实现顶部栏搜索框功能,支持模糊搜索工具并显示搜索结果列表
添加搜索历史记录功能,自动保存最近搜索并显示
支持键盘导航和选择搜索结果

* feat: 添加多语言支持(i18n)

实现应用的多语言功能,支持中英文切换
添加i18next和相关依赖
创建语言资源文件
更新组件以使用翻译功能
重构文本统计工具页面
完善测试和构建配置

* feat(顶部栏): 添加语言切换功能并优化国际化支持
feat(i18n): 增加语言归一化和同步功能
fix(剪贴板): 使用国际化文本替换剪贴板消息
test(顶部栏): 更新测试以匹配国际化标题

* refactor: 重构组件目录结构,将组件移动到对应功能页面目录

将多个组件从通用组件目录移动到各自功能页面目录下,包括 ToolCard、StorageCleanerConfirm、UrlToQrCodeSection 和 QrCodeToUrlSection。同时新增 Dashboard 页面相关组件 DashboardCard 和 ToolCard,并优化了组件导入路径。

重构旨在提高代码组织性,使组件与其所属功能页面更紧密关联,便于维护和理解代码结构。所有组件功能保持不变,仅调整文件位置和导入路径。

* refactor(Dashboard): 优化卡片组件结构并移除无用属性

重构 DashboardCard 组件接口,直接传递属性而非配置对象
移除 ToolCard 中无用的 hasAI 属性
使用 useMemo 优化可见性判断性能
统一使用 alpha 函数处理颜色透明度

* refactor(timestamp): 合并输入处理函数并优化样式结构

重构时间戳转换器的输入处理逻辑,将 setTsInput 和 setDtInput 合并为统一的 setInput 函数
将组件样式提取到 pageTheme 配置中统一管理
使用 ToggleButtonGroup 替换自定义模式切换器实现
移除不必要的 showMessage 属性传递,改用 useSnackbar 钩子

* refactor(dashboard): 重构仪表盘卡片组件并优化布局

- 删除冗余的 DashboardCard 组件,直接使用 ToolCard
- 将网格布局样式移至主题配置
- 使用 CardActionArea 增强可访问性
- 简化点击事件处理逻辑
- 更新相关测试用例

* refactor(storageCleaner): 重构样式代码并优化国际化处理

将分散的样式代码集中到pageTheme.ts中统一管理
优化组件中的国际化文本引用
修复useStorageCleaner中的状态同步问题
更新测试用例以匹配国际化文本

* refactor(StorageCleaner): 优化条件判断和清理未使用的代码

简化禁用按钮的条件判断逻辑
移除未使用的 resultTimeoutRef 及相关清理代码

* refactor(QrCode): 重构二维码页面样式和组件结构

将内联样式提取到主题配置文件中
移除未使用的全局消息提示依赖
优化组件结构和代码复用
删除未使用的QrCodeUploader组件

* refactor(ErrorBoundary): 移除冗余状态更新并调整错误背景色

移除 componentDidCatch 中的冗余 setState 调用
将错误背景色从 'error.shortest' 改为 rgba 格式

* feat(TopBar): 优化顶部导航栏功能并添加样式配置

- 新增 openExtensionPage 工具函数用于打开扩展页面
- 将顶部导航栏样式配置提取到 pageTheme 中统一管理
- 改进搜索历史功能,增加错误处理和配置化限制
- 优化键盘导航和搜索结果的交互逻辑

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: yll <mtmtchb@outlook.com>
2026-05-06 23:11:27 +08:00
雨霖铃 9d1d8a362a fix(剪贴板): 改进剪贴板操作的错误处理
在复制文本和图片到剪贴板时,捕获并返回更详细的错误信息
同时更新GlobalSnackbar组件,优化类型导入和消息显示
2026-05-04 13:26:33 +08:00
雨霖铃 2a0a5658b5 refactor(clipboard): 重构剪贴板工具函数并优化相关组件
将 copyToClipboard 拆分为 copyTextToClipboard 和 copyImageToClipboard
移除 useClipboard hook 并将消息处理移至调用方
优化组件中的剪贴板操作错误处理逻辑
2026-05-03 20:43:40 +08:00
LingandRX 57ea4d9858 Enhance form recognition, optimize UI, and unify components (#19)
- **docs**: 完善组件注释、README 目录结构及 AGENTS.md 文档。
- **refactor**:
  - 提取通用 `PageHeader`、`Button`、`DashboardCard` 及 `ErrorBoundary` 组件。
  - 重构消息通信机制,采用 `@webext-core/messaging` 实现类型安全。
  - 将全局通知系统重构为 `SnackbarProvider` (后合并至 `GlobalSnackbar`)。
  - 迁移样式系统至 MUI 主题,移除冗余 CSS。
  - 优化路由配置,支持独立标签页模式及页面懒加载。
  - 移除未使用文件、URL 工具及表单映射相关功能。
- **feat**:
  - 新增配置导出功能(JSON)及状态提示。
  - 新增侧边栏状态变化通知机制。
  - 新增文本统计及 JWT 解析工具。
  - 优化二维码生成与解析逻辑,换用更轻量的 `qrious` 和 `qr-scanner`。
  - 增强高亮器功能,支持闪烁效果及 Shadow DOM 穿透。
- **style**: 优化仪表盘响应式网格布局及 UI 细节。
- **fix**: 修复 `useStorageState` 依赖缺失及路由初始化性能问题。
- **test**: 更新单元测试以覆盖新增的工具函数及功能特性。
2026-05-03 17:06:32 +08:00
246 changed files with 23516 additions and 23287 deletions
+19
View File
@@ -0,0 +1,19 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(npx vitest:*)",
"Bash(git rm:*)",
"Bash(git stash:*)",
"Bash(git reset:*)",
"Bash(git checkout:*)",
"Bash(npx tsc:*)",
"Bash(npx eslint:*)",
"Bash(npm run:*)",
"Bash(npm test:*)",
"Bash(pnpm typecheck *)",
"Bash(pnpm test *)"
]
}
}
+83 -17
View File
@@ -13,9 +13,13 @@ concurrency:
cancel-in-progress: true
jobs:
lint:
name: Lint
# 💡 1. 提速核心:前置基建节点(Infrastructure Initialization
# 专门负责锁死环境、同步下载并缓存 node_modules,下游节点直接满血复用!
setup:
name: Prepare Dependencies
runs-on: ubuntu-latest
outputs:
cache-key: ${{ steps.cache-info.outputs.key }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -24,17 +28,53 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
# 建立基于 package-lock.json 唯一哈希的缓存大闸
- name: Cache Node Modules
id: cache-nodemodules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-v22-
- name: Install dependencies
if: steps.cache-nodemodules.outputs.cache-hit != 'true'
run: npm ci
- name: Output Cache Key
id: cache-info
run: echo "key=${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}" >> $GITHUB_OUTPUT
# 💡 2. 静态语法质检节点(依赖前置节点完成)
lint:
name: Lint
runs-on: ubuntu-latest
needs: setup
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Restore Node Modules Instantantly
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
- name: Run ESLint
run: npm run lint
# 💡 3. 强类型守卫节点(2秒瞬时恢复,开箱即查)
typecheck:
name: TypeScript Check
runs-on: ubuntu-latest
needs: setup
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -43,17 +83,24 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Restore Node Modules Instantantly
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
- name: Generate WXT types
run: npx wxt prepare
- name: Run TypeScript type check
run: npm run compile
run: npm run typecheck
# 💡 4. 单元测试节点(无缝运行你刚刚修复完的 setupTests.ts 套件)
test:
name: Unit Tests
runs-on: ubuntu-latest
needs: setup
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -62,21 +109,30 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Restore Node Modules Instantantly
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
- name: Generate WXT types
run: npx wxt prepare
- name: Run tests
run: npm run test
# 💡 5. 多端分布式最终编译节点(Production Matrix Compliance
build:
name: Build (${{ matrix.browser }})
runs-on: ubuntu-latest
# 只有当 Linter、类型大闸、Vitest 单元测试全数满分通过,才放行最终打包编译
needs: [ lint, typecheck, test ]
strategy:
matrix:
browser: [chrome]
# 💡 完美对齐 WXT 跨端架构:将 firefox 同步纳入生产编译大矩阵,
# 如果 firefox 编译因任何多端不兼容挂掉,CI 会立刻拉起警报,防护力拉满!
browser: [ chrome, firefox ]
fail-fast: false
steps:
- name: Checkout
@@ -86,11 +142,21 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Restore Node Modules Instantantly
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
- name: Build (Chrome)
if: matrix.browser == 'chrome'
run: npm run build
# 💡 动态代理编译指令:完美匹配 WXT / 各类多端打包器的标准构建命令
- name: Generate WXT types
run: npx wxt prepare
- name: Build Extension (${{ matrix.browser }})
run: |
if npm run | grep -q "build:${{ matrix.browser }}"; then
npm run build:${{ matrix.browser }}
else
npm run build -- --browser ${{ matrix.browser }}
fi
+109 -65
View File
@@ -9,97 +9,142 @@ permissions:
contents: write
jobs:
# ── Phase 1: 全量 CI 检查 ────────────────────────────────────────────
# ── Phase 1: 依赖准备 ───────────────────
setup:
name: Prepare Dependencies
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: actions/cache@v4
id: cache-nodemodules
with:
path: node_modules
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-release-v22-
- if: steps.cache-nodemodules.outputs.cache-hit != 'true'
run: npm ci
# ── Phase 2: 质量检查 ──────────────────
lint:
name: Lint
runs-on: ubuntu-latest
needs: setup
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
- run: npm run lint
typecheck:
name: TypeScript Check
runs-on: ubuntu-latest
needs: setup
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run TypeScript type check
run: npm run compile
- uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
- run: npx wxt prepare
- run: npm run typecheck
test:
name: Unit Tests
runs-on: ubuntu-latest
needs: setup
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
- run: npx wxt prepare
- run: npm run test
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test
# ── Phase 2: 打包 & 发布 ─────────────────────────────────────────────
release:
name: Package & Release
# ── Phase 3: 打包 ───────────────────────
build-extension:
name: Package Extension
runs-on: ubuntu-latest
needs: [lint, typecheck, test]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
run: npm ci
- name: Package Chrome extension
run: npm run zip
- name: Package Firefox extension
run: npm run zip:firefox
- name: Find zip artifacts
id: find_zips
- name: Sync version from tag
run: |
CHROME_ZIP=$(find .output -name "*.zip" | grep -v firefox | head -1)
FIREFOX_ZIP=$(find .output -name "*.zip" | grep firefox | head -1)
echo "chrome_zip=$CHROME_ZIP" >> "$GITHUB_OUTPUT"
echo "firefox_zip=$FIREFOX_ZIP" >> "$GITHUB_OUTPUT"
echo "Found Chrome zip: $CHROME_ZIP"
echo "Found Firefox zip: $FIREFOX_ZIP"
VERSION="${GITHUB_REF_NAME#v}"
echo "Setting version to $VERSION"
npm version "$VERSION" --no-git-tag-version
- name: Build and Zip Extension
run: |
npm run zip
npm run zip:firefox
# 🔍 调试:确认 zip 文件位置
- name: Verify output files
run: |
echo "=== .output/ contents ==="
ls -la .output/ || echo "Not found"
echo "=== All zip files ==="
find . -name "*.zip" -type f
# 💡 关键修复:将 zip 文件复制到固定目录,避免 glob 问题
- name: Prepare release artifacts
run: |
mkdir -p release-archives
cp .output/*.zip release-archives/ 2>/dev/null || true
echo "Files in release-archives:"
ls -la release-archives/
- name: Upload Extension Artifacts
uses: actions/upload-artifact@v4
with:
name: extension-zips
path: release-archives/
if-no-files-found: error
retention-days: 7
# ── Phase 4: 发布 ───────────────────────
release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: build-extension
steps:
- uses: actions/checkout@v4
- name: Download Extension Artifacts
uses: actions/download-artifact@v4
with:
name: extension-zips
path: release-artifacts
# 🔍 调试:确认下载成功
- name: Verify downloaded artifacts
run: |
echo "=== release-artifacts/ ==="
ls -la release-artifacts/
- name: Extract version from tag
id: version
@@ -114,5 +159,4 @@ jobs:
prerelease: ${{ contains(github.ref_name, '-') }}
generate_release_notes: true
files: |
${{ steps.find_zips.outputs.chrome_zip }}
${{ steps.find_zips.outputs.firefox_zip }}
release-artifacts/*.zip
+4 -1
View File
@@ -13,7 +13,6 @@ stats.html
stats-*.json
.wxt
.vitest
.claude
# Editor directories and files
.vscode/*
@@ -28,3 +27,7 @@ stats-*.json
.trae/*
.workbuddy/*
.qoder/*
dev/*
docs/*
+77 -281
View File
@@ -1,315 +1,111 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## 项目概述
这是一个基于 WXT 框架的浏览器扩展项目,提供多种测试工具功能,包括时间戳转换、存储管理、URL 管理、二维码生成、表单识别与填充等。
WXT 浏览器扩展项目 (React 19 + TypeScript)。
## 核心命令
### 开发相关
- `npm run dev` - 启动 Chrome 浏览器的开发模式
- `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式
- `npm run build` - 构建 Chrome 浏览器的生产版本
- `npm run build:firefox` - 构建 Firefox 浏览器的生产版本
- `npm run zip` - 打包 Chrome 扩展
- `npm run zip:firefox` - 打包 Firefox 扩展
- `npm run compile` - TypeScript 类型检查(不生成文件)
- `npm run lint` - 运行 ESLint 检查
### 测试相关
- `npm run test` - 运行所有测试(单次执行)
- `npm run test:watch` - 运行测试并监听文件变化
- `npm run test:coverage` - 运行测试并生成覆盖率报告
**运行单个测试文件:**
```bash
npx vitest run components/__tests__/CopyButton.test.tsx
npm run dev # Chrome 开发模式 (HMR)
npm run dev:firefox # Firefox 开发模式
npm run build # Chrome 生产构建
npm run build:firefox # Firefox 生产构建
npm run zip # 打包 Chrome 扩展 (.output/*.zip)
npm run zip:firefox # 打包 Firefox 扩展
npm run lint # ESLint (--max-warnings=0)
npm run typecheck # tsc --noEmit
npm run test # vitest run (单次执行)
npm run test:watch # vitest 监视模式
npm run test:coverage # 带覆盖率的测试
```
**测试技术栈:**
运行单个测试: `npx vitest run path/to/file.test.ts`
- Vitest v2 - 测试框架
- @testing-library/react v16 - React 组件测试
- @testing-library/user-event v14 - 用户交互模拟
- jsdom v25 - 浏览器环境模拟
## 验证流程
### 依赖与准备
CI 执行顺序: `setup → lint/typecheck/test(并行) → build` (build 依赖前三者)。
- `npm install` - 安装依赖
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
- `prepare` 钩子会初始化 Husky Git 钩子
Pre-commit hook (`.husky/pre-commit` 调用 `lint-staged`):
## 项目架构
- 代码文件 (`*.{ts,tsx,js,jsx,mjs}`): `eslint --fix --max-warnings=0 --no-warn-ignored``prettier --write`
- 其他文件 (`*.{json,css,scss,md}`): `prettier --write`
### 技术栈
提交前确保 `lint``typecheck` 通过。
- **框架**: WXT v0.20.6 (Web Extension Toolkit) - 浏览器扩展开发框架
- **前端**: React 19 + TypeScript 5
- **UI 库**: Material UI (MUI) v7 + Emotion
- **状态管理**: React Hooks + 自定义 Hooks
- **路由**: 自定义路由系统(支持 popup/sidepanel/detached 三种模式)
- **测试**: Vitest + Testing Library
- **代码质量**: ESLint v9 + Prettier + Husky + lint-staged
## WXT 生成文件
### 目录结构
- `.wxt/` 目录由 `postinstall` 自动执行 `wxt prepare` 生成,包含 TypeScript 类型声明和扩展的 tsconfig。
- 生产构建输出到 `.output/` 目录。
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`
## 项目结构
```
├── components/ # 可复用 UI 组件
│ ├── __tests__/ # 组件测试文件
│ ├── Button.tsx # 按钮组件
│ ├── CopyButton.tsx # 复制按钮组件
│ ├── DashboardCard.tsx # 仪表盘卡片组件
│ ├── FieldList.tsx # 字段列表组件
│ ├── GlobalSnackbar.tsx # 全局提示消息组件
│ ├── PageHeader.tsx # 页面头部组件
│ ├── QrCodeToUrlSection.tsx # 二维码解析为 URL 组件
│ ├── QrCodeUploader.tsx # 二维码上传组件
│ ├── RouterContainer.tsx # 路由容器组件
│ ├── StorageCleanerConfirm.tsx # 存储清理确认组件
│ ├── ToolCard.tsx # 工具卡片组件
│ ├── TopBar.tsx # 顶部导航栏组件
│ ├── UrlEntryForm.tsx # URL 录入表单组件
│ ├── UrlEntryItem.tsx # URL 条目组件
│ ├── UrlEntryList.tsx # URL 列表组件
│ └── UrlToQrCodeSection.tsx # URL 转二维码组件
├── config/ # 配置文件
│ ├── __tests__/ # 配置测试文件
│ ├── dashboardCards.tsx # 仪表盘卡片配置
│ ├── pageTheme.ts # 页面主题配置
│ ├── routes.ts # 路由配置
│ └── theme.ts # 全局主题配置
├── entrypoints/ # 浏览器扩展入口点
│ ├── background.ts # 后台脚本(主进程)
│ ├── content.ts # 内容脚本(注入到页面)
│ ├── content/
│ │ └── messageHandler.ts # 消息处理器
│ ├── options/ # 选项页面
│ │ ├── App.tsx # 选项应用
│ │ ├── index.html # 选项页面 HTML
│ │ └── main.tsx # 选项页面入口
│ ├── popup/ # 扩展弹窗界面
│ │ ├── App.tsx # 弹窗主应用
│ │ ├── main.tsx # 弹窗入口
│ │ ├── index.html # 弹窗 HTML
│ │ ├── pages/ # 弹窗页面
│ │ │ ├── components/ # 页面级组件
│ │ │ │ ├── AutoRefreshToggle.tsx # 自动刷新开关
│ │ │ │ ├── CleaningResult.tsx # 清理结果展示
│ │ │ │ ├── DomainHeader.tsx # 域名头部
│ │ │ │ ├── ErrorDisplay.tsx # 错误显示
│ │ │ │ ├── LiveClock.tsx # 实时时钟
│ │ │ │ ├── OptionItem.tsx # 选项条目
│ │ │ │ ├── ResultView.tsx # 结果视图
│ │ │ │ └── StorageOptionsGrid.tsx # 存储选项网格
│ │ │ ├── hooks/ # 自定义 Hooks
│ │ │ │ ├── useActiveTabDomain.ts # 当前标签页域名
│ │ │ │ ├── useFormRecognizer.ts # 表单识别
│ │ │ │ ├── useSidePanelState.ts # 侧边栏状态
│ │ │ │ └── useTimestampConverter.ts # 时间戳转换
│ │ │ ├── DashboardPage.tsx # 仪表盘页面
│ │ │ ├── FormFillPage.tsx # 表单填充页面
│ │ │ ├── FormMappingPage.tsx # 表单映射页面
│ │ │ ├── FormRecognizerPage.tsx # 表单识别页面
│ │ │ ├── OpenUrlPage.tsx # 打开 URL 页面
│ │ │ ├── OpenUrlViewerPage.tsx # URL 查看页面
│ │ │ ├── QrCodePage.tsx # 二维码页面
│ │ │ ├── StorageCleanerPage.tsx # 存储清理页面
│ │ │ ├── TimestampPage.tsx # 时间戳页面
│ │ │ └── useStorageCleaner.ts # 存储清理 Hook
│ └── sidepanel/ # 侧边栏界面
│ ├── App.tsx # 侧边栏应用
│ ├── index.html # 侧边栏 HTML
│ └── main.tsx # 侧边栏入口
├── providers/ # React Providers
│ └── RouterProvider.tsx # 路由 Provider
├── utils/ # 工具函数
│ ├── __tests__/ # 工具测试文件
│ ├── formMapping/ # 表单映射工具
│ │ ├── highlighter.ts # 表单高亮器
│ │ ├── scanner.ts # 表单扫描器
│ │ ├── smartInjector.ts # 智能注入器
│ │ └── ui.ts # UI 工具
│ ├── chromeStorage.ts # Chrome 存储工具
│ ├── chromeTabs.ts # Chrome 标签页工具
│ ├── clipboard.ts # 剪贴板工具
│ ├── dataTemplate.ts # 数据模板
│ ├── dataValidator.ts # 数据验证器
│ ├── dayjs.ts # 日期处理工具
│ ├── dummyDataGenerator.ts # 虚拟数据生成器(基于 Faker)
│ ├── messages.ts # 消息通信工具
│ ├── qrCodeParser.ts # 二维码解析器
│ ├── storageCleaner.ts # 存储清理工具
│ ├── useStorageState.ts # 存储状态 Hook
│ └── useUrlPreferences.ts # URL 偏好设置 Hook
├── types/ # 类型定义
│ └── storage.d.ts # 存储相关类型
├── docs/ # 文档
│ └── plans/ # 计划文档
├── public/ # 静态资源
│ └── icon/ # 扩展图标
└── .github/ # GitHub 配置
└── workflows/ # CI/CD 工作流
├── ci.yml # 持续集成
└── release.yml # 发布流程
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
entrypoints/ # 扩展入口点 (popup/, options/, sidepanel/, background.ts, content.ts)
pages/ # 功能页面组件 (懒加载)
components/ # 可复用 UI 组件
providers/ # React Context (Router, Theme 等)
hooks/ # 自定义 React Hooks
utils/ # 工具函数与服务抽象
types/ # TypeScript 类型声明
i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json 及各功能独立 JSON)
```
### 核心功能模块
## 关键架构决策
#### 1. 时间戳转换工具
**路由**: 不使用 React Router。通过 `config/features.tsx``FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
渲染对应组件。支持三种渲染模式: popup / sidepanel / tab。
- 位置: `entrypoints/popup/pages/TimestampPage.tsx`
- Hook: `entrypoints/popup/pages/hooks/useTimestampConverter.ts`
- 依赖: dayjs 库进行日期处理
- 功能: 支持日期与时间戳的双向转换,支持多种格式,实时时钟显示
**存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts``StorageSchema` 中定义。使用 `utils/chromeStorage.ts` 及其
Hook。Router 同时使用 `chrome.storage.local``localStorage` 做快照以消除首屏闪烁。
#### 2. 存储清理工具
**通信**: 使用 `@webext-core/messaging`,协议定义在 `utils/messages.ts`
- 位置: `entrypoints/popup/pages/StorageCleanerPage.tsx`
- Hook: `entrypoints/popup/pages/useStorageCleaner.ts`
- 工具: `utils/storageCleaner.ts`
- 功能: 清理缓存、Cookies、本地存储,支持按域名筛选,自动刷新功能
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。
#### 3. URL 管理工具
**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
- 打开 URL: `entrypoints/popup/pages/OpenUrlPage.tsx`
- 查看 URL: `entrypoints/popup/pages/OpenUrlViewerPage.tsx`
- 组件: `components/UrlEntryForm.tsx`, `components/UrlEntryList.tsx`
- 功能: 批量打开多个 URL,URL 列表管理
## 测试环境
#### 4. 二维码工具
- 环境: jsdom
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
- Setup 文件: `vitest.setup.ts` 自动 mock:
- `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等)
- `react-i18next` (返回 key 作为翻译)
- `@/utils/useLazyTranslation` (返回 `ns:key` 格式翻译)
- `window.matchMedia`
- 测试文件命名: `__tests__/*.test.{ts,tsx}``*.test.{ts,tsx}`
- 位置: `entrypoints/popup/pages/QrCodePage.tsx`
- 组件: `components/QrCodeUploader.tsx`, `components/QrCodeToUrlSection.tsx`, `components/UrlToQrCodeSection.tsx`
- 工具: `utils/qrCodeParser.ts`
- 依赖: qrcode, jsqr 库
- 功能: URL 转二维码生成,二维码图片解析为 URL
## i18n
#### 5. 表单工具套件
- 命名空间: `common` (默认), `features`
- 翻译键格式: `namespace:key` (如 `features:timestamp.title`)
- 语言: `zh` (默认), `en`
- 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json` 及对应功能独立 JSON
**表单识别 (Form Recognizer)**
## 新功能开发清单
- 位置: `entrypoints/popup/pages/FormRecognizerPage.tsx`
- Hook: `entrypoints/popup/pages/hooks/useFormRecognizer.ts`
- 功能: 智能识别页面表单指纹
1.`types/storage.d.ts` 添加 `PageType` 联合类型
2.`config/features.tsx``FEATURES` 数组添加配置
3.`pages/` 创建页面组件 (懒加载)
4.`i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON)
5. 如需新权限,更新 `wxt.config.ts``manifest.permissions`
6. 添加对应的单元测试
**表单映射 (Form Mapping)**
## 代码规范
- 位置: `entrypoints/popup/pages/FormMappingPage.tsx`
- 工具: `utils/formMapping/` 目录
- `scanner.ts` - 表单扫描器
- `highlighter.ts` - 表单高亮器
- `smartInjector.ts` - 智能注入器
- `ui.ts` - UI 工具
- 功能: 表单指纹识别与自定义映射规则配置
- 禁止使用 `any` (测试文件除外)
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
- 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className``cn()` 工具)
- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
- ESLint 使用 `typescript-eslint``projectService: true`(无需手动维护 project 路径)
**表单填充 (Form Fill)**
## 技术栈版本
- 位置: `entrypoints/popup/pages/FormFillPage.tsx`
- 工具: `utils/dummyDataGenerator.ts` (基于 @faker-js/faker)
- 功能: 根据表单指纹智能填充表单数据
#### 6. 仪表盘系统
- 位置: `entrypoints/popup/pages/DashboardPage.tsx`
- 配置: `config/features.tsx`
- 组件: `components/DashboardCard.tsx`, `components/ToolCard.tsx`
- 功能: 统一工具入口,可自定义显示的工具卡片
#### 7. 多模式显示系统
- 支持三种显示模式:
- **popup** - 扩展弹窗(点击图标显示)
- **sidepanel** - 浏览器侧边栏
- **detached** - 独立窗口模式
- 路由配置: `config/features.tsx`
- 路由容器: `components/RouterContainer.tsx`
- Provider: `providers/RouterProvider.tsx`
#### 8. 通信系统
- 位置: `utils/messages.ts`
- 机制: 使用 `@webext-core/messaging` 库实现
- 内容脚本消息处理: `entrypoints/content/messageHandler.ts`
- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗/侧边栏
#### 9. 数据存储
- Chrome Storage API: `utils/chromeStorage.ts`
- 存储状态 Hook: `utils/useStorageState.ts`
- URL 偏好设置: `utils/useUrlPreferences.ts`
- 类型定义: `types/storage.d.ts`
### 关键配置文件
#### wxt.config.ts
- 配置 WXT 框架参数
- 启用 React 模块
- 配置浏览器扩展权限(storage, unlimitedStorage, clipboardWrite, activeTab, scripting, tabs, cookies, sidePanel
- Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码)
- 配置侧边栏和选项页面
#### manifest 权限
```typescript
permissions: [
'storage', // 存储权限
'unlimitedStorage', // 无限制存储
'clipboardWrite', // 剪贴板写入
'activeTab', // 当前标签页
'scripting', // 脚本注入
'tabs', // 标签页管理
'cookies', // Cookies 管理
'sidePanel', // 侧边栏
],
host_permissions:['<all_urls>'] // 访问所有网站
```
#### CI/CD 配置
- `.github/workflows/ci.yml` - 持续集成工作流
- `.github/workflows/release.yml` - 发布工作流
## 开发注意事项
### 扩展入口点
- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务
- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互
- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示
- **侧边栏**: `entrypoints/sidepanel/main.tsx` - 浏览器侧边栏界面
- **选项页面**: `entrypoints/options/main.tsx` - 扩展设置页面
### 路由系统
- 使用自定义路由系统,支持多种显示模式
- 路由配置在 `config/features.tsx`
- 通过 `getEntryPointType()` 判断当前入口点类型
- 支持页面可见性配置(`defaultVisible`
### 浏览器兼容性
- 支持 Chrome 和 Firefox 浏览器
- 使用 WXT 框架抽象浏览器差异
- 使用 `@types/chrome``@types/webextension-polyfill` 提供类型支持
### 代码质量
- 使用 ESLint v9 进行代码检查(基于 typescript-eslint
- Prettier 进行代码格式化
- Husky v9 用于 Git 钩子管理
- Lint-staged 确保暂存文件符合规范
- GitHub Actions CI/CD 自动化测试和构建
### 测试策略
- 组件测试: `components/__tests__/` 目录
- 工具函数测试: `utils/__tests__/` 目录
- 配置测试: `config/__tests__/` 目录
- 使用 Vitest 作为测试框架
- 使用 Testing Library 进行 React 组件测试
- WXT: ^0.20.26
- React: ^19.2.6
- Tailwind CSS: ^3.4.19
- shadcn/ui (基于 Radix UI + class-variance-authority)
- TypeScript: ^5.9.3
- Vitest: ^4.1.7
- i18next: ^26.2.0
-83
View File
@@ -1,83 +0,0 @@
# Testing Tools Browser Extension - Gemini Instructions
This document provides essential context and instructions for AI agents working on the Testing Tools browser extension project.
## Project Overview
**Testing Tools** is a lightweight, feature-rich browser extension built with the [WXT (Web Extension Toolkit)](https://wxt.dev/) framework. It provides a suite of utilities for developers and testers, including timestamp conversion, storage management, URL shortcuts, and QR code tools.
### Tech Stack
- **Framework:** WXT (Web Extension Toolkit)
- **Frontend:** React 19 + TypeScript
- **UI Library:** Material UI (MUI) @7.x
- **Date Handling:** dayjs (with UTC and timezone plugins)
- **Messaging:** @webext-core/messaging
- **Storage:** Type-safe Chrome Storage API wrapper
- **Testing:** Vitest + Testing Library (jsdom)
### Architecture & Directory Structure
- `entrypoints/`: Extension entry points (popup, options, sidepanel, background, content).
- `popup/`: Main UI shown when clicking the extension icon.
- `options/`: Extension settings page.
- `sidepanel/`: Browser side panel integration.
- `background.ts`: Background script for lifecycle management and background tasks.
- `content.ts`: Content script injected into web pages.
- `components/`: Reusable React components.
- `config/`: Application configuration, including routes and themes.
- `providers/`: React Context providers (e.g., `RouterProvider`).
- `utils/`: Utility functions and service abstractions.
- `chromeStorage.ts`: Type-safe storage utility.
- `types/`: Global TypeScript type definitions.
- `public/`: Static assets (icons, etc.).
## Building and Running
### Development
- `npm run dev`: Start Chrome development mode with HMR.
- `npm run dev:firefox`: Start Firefox development mode.
- `npm run compile`: Run TypeScript type checking (`tsc --noEmit`).
### Production
- `npm run build`: Build production version for Chrome.
- `npm run build:firefox`: Build production version for Firefox.
- `npm run zip`: Package the extension for Chrome Web Store.
- `npm run zip:firefox`: Package the extension for Firefox Add-ons.
### Testing & Linting
- `npm run test`: Run all tests once.
- `npm run test:watch`: Run tests in watch mode.
- `npm run test:coverage`: Run tests and generate coverage report.
- `npm run lint`: Run ESLint checks.
## Development Conventions
### Coding Style
- **TypeScript:** Use strict typing. Prefer interfaces for object structures and types for unions/aliases.
- **Components:** Functional components with Hooks. Use MUI components for consistent UI.
- **Storage:** Always use `storageUtil` from `@/utils/chromeStorage.ts` for accessing `chrome.storage.local`. Ensure keys are defined in `StorageSchema` in `@/types/storage.d.ts`.
- **Messaging:** Use `@webext-core/messaging` for communication between entry points. Define message types in `@/utils/messages.ts`.
### Testing Practices
- **Framework:** Vitest with `jsdom` environment.
- **Location:** Place tests in `__tests__` directories adjacent to the files being tested.
- **Naming:** Follow `*.test.ts` or `*.test.tsx` naming convention.
- **Patterns:** Use `@testing-library/react` for component testing. Prefer `user-event` (v14+) for simulating interactions.
### CI/CD
- **GitHub Actions:** CI runs on push/PR to `main` and `develop` branches (lint, compile, test, build).
- **Releases:** Automatic release to GitHub on pushing a `v*` tag.
## Key Considerations for AI Agents
- **Manifest Permissions:** When adding features that require new browser APIs, update `wxt.config.ts`.
- **Browser Compatibility:** Ensure features work in both Chrome and Firefox.
- **React 19:** Be aware of React 19 specific features and deprecations.
- **WXT Modules:** The project uses `@wxt-dev/module-react`.
+112 -211
View File
@@ -1,258 +1,159 @@
# Testing Tools Browser Extension
这是一个基于 WXT 框架的浏览器扩展项目,提供实用的测试工具功能。
这是一个基于 WXT 框架的浏览器扩展项目,为开发者和测试人员提供实用的效率工具.
## 项目概述
Testing Tools 是一个轻量级的浏览器扩展,提供多种实用的测试工具功能。项目采用现代化的技术栈,包括 React 19、TypeScript 和 Material UI,并利用 WXT 框架简化浏览器扩展的开发流程。
**Testing Tools** 是一个轻量级、功能丰富的浏览器扩展,采用现代化的技术栈构建. 它旨在简化日常开发和测试任务,如时间戳转换、存储管理、JWT 解析等. 项目利用 [WXT (Web Extension Toolkit)](https://wxt.dev/) 框架,提供了卓越的开发体验和跨浏览器支持.
## 功能特性
### Dashboard 首页
### 🚀 Dashboard 首页
- 卡片式工具展示
- 支持自定义工具排序和可见性
- 实时数据预览(时间戳
- **工具导航**: 快速访问所有可用工具.
- **个性化定制**: 支持自定义工具排序和可见性.
- **实时预览**: 在卡片上直接查看实时数据(如当前时间戳).
### 时间戳转换工具
### 时间戳转换工具
- 实时显示当前时间戳(毫秒/秒可切换)
- 日期与时间戳之间的双向转换
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
- 一键复制转换结果
- 输入验证和错误提示
- **实时显示**: 毫秒级精度显示当前系统时间.
- **双向转换**: 日期字符串与 Unix 时间戳(秒/毫秒)之间的无缝转换.
- **多时区支持**: 预设常用时区(亚洲/上海、美洲/纽约、欧洲/伦敦),支持快速切换.
- **快捷操作**: 一键复制转换结果,支持多种格式.
### 存储清理工具
### 🧹 存储清理工具
- 自动读取当前域名
- 支持清理多种存储类型:
- localStorage
- sessionStorage
- IndexedDB
- Cookies
- Cache Storage
- Service Workers
- 可选择的清理类型(默认全选)
- 确认对话框防止误操作
- 清理结果统计
- 自动刷新页面选项
- **智能识别**: 自动检测并显示当前活动标签页的域名.
- **全面清理**: 支持一键清理 localStorage、sessionStorage、IndexedDB、Cookies、Cache Storage 和 Service Workers.
- **细粒度控制**: 可根据需要选择特定的清理项.
- **自动刷新**: 提供清理后自动刷新页面的选项,确保状态同步.
### URL 工具
### 📝 文本统计工具
- 保存常用 URL 列表
- 快速打开保存的 URL
- 支持 URL 验证和安全检查
- 内置 URL 查看器(iframe 沙箱模式)
- **实时分析**: 键入即统计,无需额外操作.
- **多维指标**: 统计字符数、单词数、行数以及精确的字节大小.
- **性能优化**: 采用高性能分词算法,支持大文本处理.
### 二维码工具
### 🔑 JWT 解析工具
- URL 转二维码(生成器)
- 二维码转 URL(解析器)
- 支持上传二维码图片解析
- 生成的二维码可下载
- 一键复制转换结果
- 卡片式布局,节省空间
- **快速解码**: 自动解析 JSON Web Token 的 Header 和 Payload.
- **格式化显示**: 以着色和格式化的 JSON 视图展示数据,方便阅读.
- **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示.
- **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性.
### 🖱️ 右键恢复工具
- **当前页面检测**: 自动识别当前活动标签页的域名.
- **一键恢复**: 解除网站对右键菜单的限制,恢复复制、粘贴等基础操作.
- **状态可视化**: 通过 Badge 组件直观展示当前页面的锁定/解锁状态.
### 🖼️ 二维码工具
- **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载.
- **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容.
### 🔄 JSON 工具
- **差异比较**: 对比两段 JSON 数据,高亮展示差异.
- **格式化**: 支持 JSON 美化、压缩、转 YAML / TOML.
### 🧰 Base64 转换器
- **文本编解码**: 支持文本内容的 Base64 编码与解码.
- **文件转换**: 支持文件与 Base64 字符串互转.
- **图像预览**: 支持图片 Base64 编码与实时预览.
### 📝 Markdown ↔ HTML 转换
- **Markdown 转 HTML**: 实时预览 Markdown 渲染结果,支持导出 HTML 源码.
- **HTML 转 Markdown**: 将 HTML 内容转换为 Markdown 格式.
## 技术栈
- **框架**: WXT (Web Extension Toolkit)
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
- **前端**: React 19 + TypeScript
- **UI **: Material UI
- **日期处理**: dayjs (含 UTC 和时区插件)
- **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库)
- **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
- **国际化**: i18next + react-i18next
- **通信**: @webext-core/messaging
- **存储**: Chrome Storage API (类型安全封装)
- **二维码**: qrcode (生成) + jsqr (解析)
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
- **测试**: Vitest + Testing Library
## 项目结构
```
├── components/ # 可复用 UI 组件
│ ├── Button.tsx
── CopyButton.tsx
│ ├── DashboardCard.tsx # 仪表盘卡片组件(React.memo 优化)
│ ├── GlobalSnackbar.tsx
│ ├── PageHeader.tsx # 页面标题栏组件
│ ├── RouterContainer.tsx
│ ├── StorageCleanerConfirm.tsx
── ToolCard.tsx
│ └── TopBar.tsx
├── config/ # 配置文件
│ ├── dashboardCards.tsx # 仪表盘卡片配置数据
│ └── routes.ts # 页面路由定义
├── entrypoints/ # 浏览器扩展入口点
│ ├── popup/ # 扩展弹窗界面
│ │ ├── App.tsx
│ │ ├── main.tsx
│ │ └── pages/ # 页面组件
│ │ ├── DashboardPage.tsx
│ │ ├── OpenUrlPage.tsx
│ │ ├── OpenUrlViewerPage.tsx
│ │ ├── QrCodePage.tsx
│ │ ├── StorageCleanerPage.tsx
│ │ └── TimestampPage.tsx
│ ├── options/ # 选项页面
│ ├── sidepanel/ # 侧边栏
│ ├── background.ts # 后台脚本
│ └── content.ts # 内容脚本
├── providers/ # React Context providers
│ └── RouterProvider.tsx # 路由状态管理
├── types/ # TypeScript 类型定义
│ └── storage.d.ts
├── utils/ # 工具函数
│ ├── chromeStorage.ts
│ ├── clipboard.ts
│ ├── dayjs.ts
│ ├── messages.tsx
│ └── storageCleaner.ts
├── public/ # 静态资源
├── wxt.config.ts # WXT 配置文件
├── package.json
└── README.md
```text
├── components/ # 可复用 React 组件
├── config/ # 应用配置
── features.tsx # 功能定义与路由映射
├── entrypoints/ # 扩展程序入口点
│ ├── popup/ # 点击图标弹出的主界面
│ ├── options/ # 扩展程序设置页面
│ ├── sidepanel/ # 浏览器侧边栏集成
│ ├── background.ts # 后台 Service Worker
── content.ts # 网页注入脚本
├── pages/ # 各功能模块的页面组件
├── providers/ # 全局状态提供者 (Router, Theme 等)
├── hooks/ # 自定义 React Hooks
├── utils/ # 工具函数与服务抽象
├── types/ # TypeScript 类型声明
├── lib/ # 通用工具函数 (cn, utils 等)
├── i18n/ # 国际化资源
├── public/ # 静态资源 (图标、manifest 资源等)
├── wxt.config.ts # WXT 框架核心配置
└── package.json # 项目元数据与依赖管理
```
## 路由系统
## 开发与部署
项目实现了灵活的路由系统,支持:
### 开发环境要求
- **页面导航**: 在不同工具页面之间切换
- **路由同步**: 通过 Chrome Storage 同步路由状态
- **可见性控制**: 可配置显示哪些页面
- **页面排序**: 自定义工具卡片的显示顺序
- Node.js >= 18.x
- npm 或 pnpm
### 页面类型 (PageType)
### 常用命令
| 页面 | 说明 | 默认可见 |
| ---------------- | ---------- | -------- |
| `dashboard` | 首页 | |
| `timestamp` | 时间戳转换 | ✓ |
| `storageCleaner` | 存储清理 | ✓ |
| `openUrl` | URL 工具 | ✓ |
| `qrCode` | 二维码工具 | ✓ |
| `openUrlViewer` | URL 查看器 | ✗ |
| 命令 | 说明 |
| ----------------------- | --------------------------------- |
| `npm run dev` | 启动 Chrome 开发模式(支持 HMR) |
| `npm run dev:firefox` | 启动 Firefox 开发模式 |
| `npm run build` | 构建 Chrome 生产版本 |
| `npm run build:firefox` | 构建 Firefox 生产版本 |
| `npm run zip` | 打包 Chrome 扩展 (.output/\*.zip) |
| `npm run zip:firefox` | 打包 Firefox 扩展 |
| `npm run typecheck` | 执行 TypeScript 类型检查 |
| `npm run lint` | 执行 ESLint 代码规范检查 |
| `npm run test` | 运行单元测试 |
| `npm run test:coverage` | 生成测试覆盖率报告 |
## 扩展入口点
运行单个测试: `npx vitest run path/to/file.test.ts`
| 入口点 | 说明 |
| -------------- | ------------------------ |
| **popup** | 点击扩展图标弹出的界面 |
| **options** | 扩展选项页面 |
| **sidepanel** | 浏览器侧边栏 |
| **background** | 后台脚本(生命周期管理) |
| **content** | 内容脚本(注入到网页) |
### 自动化流程
## 开发环境要求
项目通过 GitHub Actions 实现了完善的 CI/CD 流程:
- Node.js >= 18
- npm 或 yarn
## 安装与运行
### 1. 安装依赖
```bash
npm install
```
### 2. 开发模式
```bash
# Chrome 浏览器
npm run dev
# Firefox 浏览器
npm run dev:firefox
```
### 3. 构建生产版本
```bash
# Chrome 浏览器
npm run build
# Firefox 浏览器
npm run build:firefox
```
### 4. 打包分发
```bash
# Chrome 浏览器
npm run zip
# Firefox 浏览器
npm run zip:firefox
```
### 5. 代码质量
```bash
npm run compile # TypeScript 类型检查
npm run lint # ESLint 代码检查
```
### 6. 测试
```bash
npm run test # 运行所有测试
npm run test:watch # 运行测试并监听文件变化
npm run test:coverage # 运行测试并生成覆盖率报告
```
## 持续集成与发布
项目使用 GitHub Actions 实现自动化 CI/CD,无需手动操作。
### CI — 持续集成
在以下场景自动触发:
- push 到 `main` / `develop` / `develop-*` 分支
- 所有 PR(合并到 `main``develop`
自动执行:ESLint 检查 → TypeScript 类型检查 → 单元测试 → Chrome & Firefox 构建验证。
### 发布版本
只需推送符合 `v*` 格式的 Git tag,即可自动完成全量 CI 检查、打包并发布到 GitHub Release
```bash
git tag v1.0.0
git push origin v1.0.0
```
> 含 `-` 的 tag(如 `v1.0.0-beta.1`)会自动标记为预发布版本(prerelease)。
工作流文件位于 `.github/workflows/`
- `ci.yml` — 持续集成
- `release.yml` — 自动发布
- **CI**: 每次推送或 PR 都会自动执行 Lint、类型检查、测试和构建验证.
- **Release**: 推送以 `v*` 开头的 Tag 会自动打包并创建 GitHub Release.
## 权限说明
扩展请求以下权限:
扩展根据功能需要申请了以下权限:
- `storage` `unlimitedStorage` - 本地数据存储
- `clipboardWrite` - 剪贴板写入(复制功能)
- `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入
- `cookies` - Cookie 访问
- `sidePanel` - 侧边栏支持
- `<all_urls>` - 访问所有网站内容(内容脚本注入)
- `storage` & `unlimitedStorage`: 存储用户设置、工具配置及大量数据.
- `activeTab` & `tabs`: 获取当前页面 URL 及其元数据.
- `scripting`: 在网页中执行清理和右键恢复脚本.
- `cookies`: 管理和清理网站 Cookie.
- `sidePanel`: 支持在浏览器侧边栏中运行.
- `clipboardWrite`: 提供一键复制功能.
- `contextMenus`: 注册右键菜单,支持快捷操作.
- `alarms`: 管理后台定时任务(如清理后自动刷新).
## 主要依赖
## 浏览器支持
- `react`, `react-dom` - 前端框架
- `@mui/material` - UI 组件库
- `dayjs` - 日期处理
- `@webext-core/messaging` - 扩展消息通信
- `vitest` - 测试框架
- `@testing-library/react` - React 组件测试
## 浏览器兼容性
- Chrome (推荐)
- Chrome (及其它 Chromium 内核浏览器)
- Firefox
## 许可证
此项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。
基于 [MIT License](LICENSE) 开源.
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
-57
View File
@@ -1,57 +0,0 @@
import { Button as MuiButton, ButtonProps as MuiButtonProps } from '@mui/material';
/**
* 按钮属性类型
* 继承自 MUI ButtonProps,支持所有 MUI Button 的属性
*/
export type ButtonProps = MuiButtonProps;
/**
* Button - 自定义按钮组件
*
* 基于 MUI Button 的二次封装,提供统一的项目风格:
* - 禁用阴影和涟漪效果
* - 圆角设计 (borderRadius: 4)
* - 固定高度和字体大小
* - hover 时轻微上浮效果
* - 支持 sx 数组合并
*
* @example
* ```tsx
* <Button variant="contained" color="primary">
* 提交
* </Button>
* ```
*
* @param sx - 自定义样式,支持数组或单个样式对象
* @param props - 其他 MUI Button 属性
* @returns 按钮组件
*/
export function Button({ sx = [], ...props }: ButtonProps) {
return (
<MuiButton
disableElevation
disableRipple
{...props}
sx={[
{
py: 1.6,
borderRadius: 4,
fontSize: '1rem',
fontWeight: 600,
textTransform: 'none',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
transform: 'translateY(-1px)',
},
'&:active': {
transform: 'translateY(0)',
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
/>
);
}
export default Button;
+65 -60
View File
@@ -1,87 +1,92 @@
import React, { useState } from 'react';
import { IconButton, Tooltip } from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CheckIcon from '@mui/icons-material/Check';
import { copyToClipboard } from '@/utils/clipboard';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
import React, { useEffect, useRef, useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { copyTextToClipboard } from '@/utils/clipboard';
import { cn } from '@/lib/utils'; // 1. 必须使用 cn 工具函数
import { toast } from 'sonner'; // 2. 推荐使用 shadcn 默认的全局 toast
/**
* 复制按钮组件属性
* @param text 要复制的文本
* @param tooltip 提示信息
* @param size 按钮大小
* @param color 按钮颜色
* @param style 自定义样式
* @param showMessage 消息提示函数,用于显示复制成功或失败的消息
*/
interface CopyButtonProps {
// 3. 继承原生按钮属性,允许外部自由扩展 className、variant 等
interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
text: string;
tooltip?: string;
size?: 'small' | 'medium' | 'large';
color?: 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning' | string;
style?: React.CSSProperties;
showMessage?: (message: string, options?: SnackbarOptions) => void;
// 移除复杂的自定义颜色变体,交由 Tailwind 类名或 shadcn 的 variant 解决
variant?: 'default' | 'secondary' | 'ghost' | 'outline';
}
/**
* 复制按钮组件
* @param text 要复制的文本
* @param tooltip 提示信息
* @param size 按钮大小
* @param color 按钮颜色
* @param style 自定义样式
* @param showMessage 消息提示函数,用于显示复制成功或失败的消息
* @returns 复制按钮组件
*/
export const CopyButton: React.FC<CopyButtonProps> = ({
text,
tooltip = '复制',
size = 'small',
color = 'primary',
style,
showMessage,
variant = 'ghost',
className,
...props
}) => {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCopy = async () => {
if (text) {
const success = await copyToClipboard(text, showMessage);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); // 基础组件防冒泡,避免触发父级点击事件
if (!text) {
toast.error('无内容可复制');
return;
}
const success = await copyTextToClipboard(text);
if (success) {
toast.success('复制成功');
setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);
} else {
showMessage?.('无内容可复制', { severity: 'error' });
toast.error('复制失败');
}
};
// 4. 将控制尺寸的类名标准化
const sizeClasses = {
small: 'h-8 w-8 text-xs',
medium: 'h-10 w-10 text-sm',
large: 'h-12 w-12 text-base',
};
// 5. 映射 shadcn 的底层通用 Variant 类名
const variantClasses = {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
};
return (
<Tooltip title={tooltip}>
<IconButton
size={size}
<button
type="button"
onClick={handleCopy}
style={style}
sx={{
color: copied ? 'success.main' : color,
bgcolor: '#fff',
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
'&:hover': {
bgcolor: copied
? 'success.main'
: !['primary', 'secondary', 'success', 'error', 'info', 'warning'].includes(color)
? color
: `${color}.main`,
color: '#fff',
},
}}
title={tooltip}
// 6. 使用 cn() 合并类名,并完美支持暗黑模式的语义化变量 (destructive/muted等)
className={cn(
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
sizeClasses[size],
copied
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' // 兼顾暗黑模式的成功色
: variantClasses[variant],
className, // 允许外部直接传入 text-red-500 等覆盖样式
)}
{...props}
>
{copied ? (
<CheckIcon fontSize={size === 'small' ? 'small' : 'medium'} />
<Check className="h-[1.2em] w-[1.2em] animate-in fade-in zoom-in-75 duration-200" />
) : (
<ContentCopyIcon fontSize={size === 'small' ? 'small' : 'medium'} />
<Copy className="h-[1.2em] w-[1.2em]" />
)}
</IconButton>
</Tooltip>
</button>
);
};
-46
View File
@@ -1,46 +0,0 @@
import React from 'react';
import ToolCard from './ToolCard';
export interface DashboardCardConfig {
/** 卡片标题 */
title: string;
/** 卡片描述文字 */
description: string;
/** 主题颜色代码 */
colorCode: string;
/** 图标组件 */
icon: React.ReactNode;
}
interface DashboardCardProps {
/** 卡片配置数据 */
config: DashboardCardConfig;
/** 点击卡片时的回调函数 */
onClick: () => void;
/** 卡片右侧的实时预览内容(如时间戳显示) */
snapshot?: React.ReactNode;
/** 卡片背景色,默认使用主题色 */
cardBackgroundColor?: string;
}
/**
* DashboardCard - 仪表盘卡片组件
*
* 基于 ToolCard 的封装,专门用于仪表盘页面
* 使用 React.memo 避免不必要的重渲染
*/
const DashboardCard = React.memo(
({ config, onClick, snapshot, cardBackgroundColor }: DashboardCardProps) => (
<ToolCard
title={config.title}
description={config.description}
colorCode={config.colorCode}
icon={config.icon}
onClick={onClick}
cardBackgroundColor={cardBackgroundColor}
snapshot={snapshot}
/>
),
);
export default DashboardCard;
+86
View File
@@ -0,0 +1,86 @@
/**
* DecodeResultPaper
*
* FileMode 与 ImageMode 通用的 decode 结果展示组件。
* 提取了二者 decode 输出区完全一致的结构:
* 标题 → 可选预览(children)→ 文件信息 → 文件名输入 → 下载按钮
*
* FileMode 直接使用,ImageMode 通过 children 传入图片预览。
*/
import { Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { formatFileSize } from '@/utils/base64Converter';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface DecodeResultPaperProps {
/** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput */
title: string;
/** 解码后推断的 MIME 类型 */
mimeType: string;
/** 解码后 Blob 的大小(字节) */
blobSize: number;
/** 当前文件名 */
fileName: string;
/** 文件名变更回调 */
onFileNameChange: (name: string) => void;
/** 下载按钮点击回调 */
onDownload: () => void;
/** 可选的预览内容,ImageMode 用于渲染图片预览 */
children?: React.ReactNode;
}
export default function DecodeResultPaper({
title,
mimeType,
blobSize,
fileName,
onFileNameChange,
onDownload,
children,
}: DecodeResultPaperProps) {
const { t } = useLazyTranslation('base64Converter');
return (
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
{/* 标题 */}
<span className="block mb-2 text-xs font-bold text-muted-foreground">{title}</span>
{/* 可选预览内容(ImageMode 的图片) */}
{children}
{/* 文件信息 */}
<div className="flex gap-4 mb-3">
<span className="text-xs text-muted-foreground">
{t('inferredMimeType')}: {mimeType}
</span>
<span className="text-xs text-muted-foreground">
{t('decodedSize')}: {formatFileSize(blobSize)}
</span>
</div>
{/* 文件名输入 */}
<div className="mb-3">
<label className="block text-xs font-medium text-muted-foreground mb-1">
{t('decodedFileName')}
</label>
<input
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>
{/* 下载按钮 */}
<Button
variant="default"
onClick={onDownload}
disabled={!fileName.trim()}
className="w-full rounded-lg font-bold"
>
<Download className="mr-2 h-4 w-4" />
{t('download')}
</Button>
</div>
);
}
+27 -55
View File
@@ -1,7 +1,6 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { Box, Typography, Button, Paper, Container } from '@mui/material';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import RefreshIcon from '@mui/icons-material/Refresh';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface Props {
children: ReactNode;
@@ -16,83 +15,56 @@ interface State {
* 错误边界组件:捕获子组件树中的 JavaScript 错误
*/
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
state: State = {
hasError: false,
error: null,
};
public static getDerivedStateFromError(error: Error): State {
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
private handleReset = () => {
componentDidUpdate(prevProps: Props) {
if (this.state.hasError && prevProps.children !== this.props.children) {
this.setState({ hasError: false, error: null });
}
}
private handleReset = () => {
window.location.reload();
};
public render() {
render() {
if (this.state.hasError) {
return (
<Container sx={{ mt: 8 }}>
<Paper
elevation={0}
sx={{
p: 4,
textAlign: 'center',
borderRadius: 4,
border: '1px solid',
borderColor: 'error.light',
bgcolor: 'error.shortest',
}}
>
<ErrorOutlineIcon color="error" sx={{ fontSize: 64, mb: 2 }} />
<Typography variant="h5" fontWeight={800} gutterBottom color="error.main">
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
<div className="mt-16 mx-auto max-w-md">
<div className="p-6 text-center rounded-xl border border-red-200 bg-red-50">
<AlertCircle className="h-16 w-16 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-extrabold text-red-600 mb-2"></h2>
<p className="text-sm text-muted-foreground mb-6">
</Typography>
</p>
{this.state.error && (
<Box
sx={{
mb: 3,
p: 2,
bgcolor: 'grey.100',
borderRadius: 2,
textAlign: 'left',
maxHeight: '200px',
overflow: 'auto',
}}
>
<Typography
variant="caption"
component="pre"
sx={{
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
color: 'error.dark',
}}
>
<div className="mb-6 p-4 bg-muted rounded-lg text-left max-h-[200px] overflow-auto">
<pre className="font-mono text-xs whitespace-pre-wrap break-all text-red-700">
{this.state.error.toString()}
</Typography>
</Box>
</pre>
</div>
)}
<Button
variant="contained"
color="error"
startIcon={<RefreshIcon />}
variant="default"
onClick={this.handleReset}
sx={{ borderRadius: 2, fontWeight: 700 }}
className="rounded-lg font-bold bg-red-600 hover:bg-red-700 text-white"
>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</Paper>
</Container>
</div>
</div>
);
}
-200
View File
@@ -1,200 +0,0 @@
import React from 'react';
import {
Box,
Typography,
Paper,
List,
ListItem,
ListItemIcon,
Collapse,
FormControl,
InputLabel,
Select,
MenuItem,
SelectChangeEvent,
Checkbox,
Button,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { FieldType } from '@/utils/dummyDataGenerator';
// 字段数据接口
interface FieldData {
id: string;
fieldType: string;
label: string | null;
placeholder: string;
name: string;
value: string;
isSelected: boolean;
generatedValue: string;
useInvalidData?: boolean;
}
// 字段类型显示名称映射
const FIELD_TYPE_NAMES: Record<string, string> = {
[FieldType.TEXT]: '文本',
[FieldType.EMAIL]: '邮箱',
[FieldType.PHONE]: '手机号',
[FieldType.NUMBER]: '数字',
[FieldType.DATE]: '日期',
[FieldType.TEXTarea]: '文本域',
[FieldType.RADIO]: '单选框',
[FieldType.CHECKBOX]: '复选框',
[FieldType.SELECT]: '下拉框',
[FieldType.PASSWORD]: '密码',
[FieldType.NAME]: '姓名',
[FieldType.ID_CARD]: '身份证号',
[FieldType.UNKNOWN]: '未知',
};
interface FieldListProps {
fields: FieldData[];
showFields: boolean;
onToggleShowFields: () => void;
onFieldTypeChange: (fieldId: string, newType: string) => void;
onLocateField: (fieldId: string) => void;
onHoverField: (fieldId: string | null) => void;
onToggleFieldSelection: (fieldId: string) => void;
onToggleAllFields: () => void;
hoveredFieldId: string | null;
}
const FieldList: React.FC<FieldListProps> = ({
fields,
showFields,
onToggleShowFields,
onFieldTypeChange,
onHoverField,
onToggleFieldSelection,
onToggleAllFields,
hoveredFieldId,
}) => {
if (fields.length === 0) return null;
const handleTypeChange = (fieldId: string, event: SelectChangeEvent<string>) => {
onFieldTypeChange(fieldId, event.target.value);
};
const allSelected = fields.every((f) => f.isSelected);
const selectedCount = fields.filter((f) => f.isSelected).length;
return (
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 2 }}>
<Box
sx={{
borderBottom: 1,
borderColor: 'divider',
px: 2,
py: 1.5,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
}}
onClick={onToggleShowFields}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
({fields.length})
</Typography>
<Typography
variant="caption"
sx={{
bgcolor: selectedCount > 0 ? 'primary.main' : 'grey.300',
color: selectedCount > 0 ? 'white' : 'text.secondary',
px: 1,
py: 0.25,
borderRadius: 1,
}}
>
{selectedCount}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
size="small"
onClick={(e) => {
e.stopPropagation();
onToggleAllFields();
}}
>
{allSelected ? '取消全选' : '全选'}
</Button>
{showFields ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</Box>
</Box>
<Collapse in={showFields}>
<List dense sx={{ maxHeight: 400, overflow: 'auto' }}>
{fields.map((field, index) => (
<ListItem
key={field.id}
sx={{
py: 1,
px: 2,
bgcolor: hoveredFieldId === field.id ? '#e3f2fd' : 'transparent',
transition: 'background-color 0.2s ease',
}}
onMouseEnter={() => onHoverField(field.id)}
onMouseLeave={() => onHoverField(null)}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<Checkbox
size="small"
checked={field.isSelected}
onChange={(e) => {
e.stopPropagation();
onToggleFieldSelection(field.id);
}}
/>
</ListItemIcon>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
opacity: field.isSelected ? 1 : 0.5,
}}
>
{field.label || field.name || field.placeholder || `字段 ${index + 1}`}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FormControl size="small" sx={{ flex: 1, minWidth: 120 }}>
<InputLabel></InputLabel>
<Select
value={field.fieldType}
label="类型"
onChange={(e) => handleTypeChange(field.id, e)}
>
{Object.values(FieldType).map((type) => (
<MenuItem key={type} value={type}>
{FIELD_TYPE_NAMES[type] || type}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
{field.placeholder && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
: {field.placeholder}
</Typography>
)}
</Box>
</ListItem>
))}
</List>
</Collapse>
</Paper>
);
};
export default FieldList;
+141 -105
View File
@@ -1,12 +1,13 @@
/**
* GlobalSnackbar - 全局 Snackbar 消息提示组件
* GlobalSnackbar - 全局 Snackbar 消息提示组件及 Provider
*
* 提供可复用的 Toast 消息提示功能,支持种使用方式:
* 提供可复用的 Toast 消息提示功能,支持种使用方式:
* 1. 作为受控组件使用:通过 props 控制显示状态
* 2. 通过 useSnackbarState Hook 使用:自动管理状态
* 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态
* 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式
*
* @module GlobalSnackbar
* @version 1.0.0
* @version 1.1.0
*
* @example
* ```tsx
@@ -18,14 +19,33 @@
* severity="success"
* />
*
* // 方式二:Hook 方式
* // 方式二:Hook 方式 (局部状态)
* const { snackbarProps, showMessage } = useSnackbarState();
* showMessage('Hello!', { severity: 'info' });
*
* // 方式三:Context 方式 (全局状态)
* // 在根组件包裹 Provider
* <SnackbarProvider>
* <App />
* </SnackbarProvider>
*
* // 在子组件中使用
* const { showMessage } = useSnackbar();
* showMessage('Global Message');
* ```
*/
import React, { JSX, useState } from 'react';
import { Snackbar, Alert, type SxProps, type Theme, alpha, Portal } from '@mui/material';
import {
JSX,
useState,
useRef,
createContext,
useContext,
useEffect,
type ReactNode,
type SyntheticEvent,
} from 'react';
import { CheckCircle, Info, AlertTriangle, XCircle } from 'lucide-react';
/**
* Snackbar 消息严重程度类型
@@ -37,12 +57,6 @@ import { Snackbar, Alert, type SxProps, type Theme, alpha, Portal } from '@mui/m
*/
export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
/**
* 重新导出 SnackbarProvider 组件
* @description 提供 Context 方式的全局 Snackbar 功能
*/
export { SnackbarProvider } from './SnackbarProvider';
/**
* GlobalSnackbar 组件的属性接口
* @interface GlobalSnackbarProps
@@ -68,9 +82,9 @@ export interface GlobalSnackbarProps {
/** 是否隐藏 Alert 图标,默认 false */
hideIcon?: boolean;
/** 自定义样式,透传给外层 Snackbar 组件 */
sx?: SxProps<Theme>;
sx?: React.CSSProperties;
/** 自定义样式,透传给内层 Alert 组件(仅 showAlert=true 时生效) */
alertSx?: SxProps<Theme>;
alertSx?: React.CSSProperties;
}
/**
@@ -118,31 +132,23 @@ const defaultProps: Required<
hideIcon: false,
};
const severityConfig: Record<
SnackbarSeverity,
{ icon: React.ElementType; bgClass: string; textClass: string }
> = {
success: { icon: CheckCircle, bgClass: 'bg-green-500', textClass: 'text-white' },
info: { icon: Info, bgClass: 'bg-primary/100', textClass: 'text-white' },
warning: { icon: AlertTriangle, bgClass: 'bg-amber-500', textClass: 'text-white' },
error: { icon: XCircle, bgClass: 'bg-red-500', textClass: 'text-white' },
};
/**
* GlobalSnackbar 组件
*
* 全局消息提示的展示组件,支持受控和非受控两种使用模式。
* 使用 MUI Snackbar 和 Alert 组件实现消息提示功能。
*
* @param {GlobalSnackbarProps} props - 组件属性
* @returns {JSX.Element}
*
* @remarks
* - 使用 Portal 组件将 Snackbar 渲染到 body 末尾,避免 z-index 问题
* - 默认位置在屏幕底部居中
* - 自动设置高 z-index 确保显示在其他内容之上
*
* @example
* ```tsx
* // 受控模式
* const [open, setOpen] = useState(false);
* <GlobalSnackbar
* message="保存成功"
* open={open}
* onClose={() => setOpen(false)}
* severity="success"
* />
* ```
*/
export function GlobalSnackbar({
message,
@@ -152,68 +158,40 @@ export function GlobalSnackbar({
autoHideDuration = defaultProps.autoHideDuration,
showAlert = defaultProps.showAlert,
hideIcon = defaultProps.hideIcon,
}: GlobalSnackbarProps): JSX.Element {
/**
* 使用 Portal 将 Snackbar 传送到 DOM 顶层 (body 标签下)
*
* @description
* Portal 的优势:
* - 避免父容器 overflow、z-index 等样式影响
* - 确保 Snackbar 始终显示在最顶层
* - 避免与其他组件的样式冲突
*/
}: GlobalSnackbarProps): JSX.Element | null {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (open && autoHideDuration > 0) {
timerRef.current = setTimeout(() => {
onClose();
}, autoHideDuration);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}
return undefined;
}, [open, autoHideDuration, onClose]);
if (!open) return null;
const config = severityConfig[severity];
const IconComponent = config.icon;
return (
<Portal>
<Snackbar
open={open}
autoHideDuration={autoHideDuration}
onClose={onClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
disableWindowBlurListener
sx={{
zIndex: 999999,
// 确保距离底部的间距,响应式设计适配不同屏幕
bottom: { xs: '24px', sm: '24px' },
// 固定宽度时使用 transform 实现真正的居中
left: '50%',
transform: 'translateX(-50%)',
minWidth: '140px',
}}
>
<div className="fixed z-[999999] bottom-6 left-1/2 -translate-x-1/2 animate-in fade-in slide-in-from-bottom-2 duration-300">
{showAlert ? (
<Alert
severity={severity}
variant="filled"
icon={hideIcon ? false : undefined}
sx={{
// 胶囊形状,现代化的设计风格
borderRadius: '50px',
px: 2.5,
py: 0.2,
minWidth: '140px',
// 居中内容
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
// 粗体小字
fontWeight: 800,
fontSize: '0.75rem',
// 移除默认渐变背景
backgroundImage: 'none',
// 添加阴影效果,颜色根据 severity 自动匹配主题色
boxShadow: (theme: Theme) =>
`0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`,
// 图标样式:白色、稍大
'& .MuiAlert-icon': { mr: 0.5, fontSize: '1.1rem', color: '#fff' },
// 消息文字样式:白色、适当内边距
'& .MuiAlert-message': { color: '#fff', padding: '6px 0' },
}}
<div
className={`flex items-center gap-2 px-5 py-1.5 rounded-full shadow-lg ${config.bgClass} ${config.textClass}`}
style={{ minWidth: '140px' }}
>
{message}
</Alert>
) : undefined}
</Snackbar>
</Portal>
{!hideIcon && <IconComponent className="h-4 w-4 flex-shrink-0" />}
<span className="text-xs font-bold">{message}</span>
</div>
) : (
<div className="px-4 py-2 rounded-lg bg-gray-800 text-white text-sm">{message}</div>
)}
</div>
);
}
@@ -287,17 +265,7 @@ export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarS
setOpen(false);
};
/**
* 处理 Snackbar 关闭事件
*
* @param {React.SyntheticEvent | Event} [_event] - 关闭事件
* @param {string} [reason] - 关闭原因:timeout | clickaway | escapeKeyDown
*
* @description
* - 忽略 clickaway 原因(用户点击其他区域),防止误关闭
* - 其他情况调用 closeMessage 关闭
*/
const handleClose = (_event?: React.SyntheticEvent | Event, reason?: string) => {
const handleClose = (_event?: SyntheticEvent | Event, reason?: string) => {
if (reason === 'clickaway') return;
closeMessage();
};
@@ -325,8 +293,76 @@ export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarS
};
}
// --- Context & Provider ---
/**
* GlobalSnackbar 组件的默认导出
* @description 方便使用 `import GlobalSnackbar from './GlobalSnackbar'` 方式导入
* Snackbar Context 的值类型定义
*/
interface SnackbarContextValue {
showMessage: (message: string, options?: SnackbarOptions) => void;
closeMessage: () => void;
}
const SnackbarContext = createContext<SnackbarContextValue | null>(null);
/**
* SnackbarProvider 组件的 props 类型
*/
interface SnackbarProviderProps {
children: ReactNode;
initialOptions?: SnackbarOptions;
}
/**
* SnackbarProvider 组件
*
* 全局消息提示的 Provider 组件,需要包裹在应用根组件外层。
*/
export function SnackbarProvider({ children, initialOptions }: SnackbarProviderProps): JSX.Element {
const { snackbarProps, showMessage, closeMessage } = useSnackbarState(initialOptions);
return (
<SnackbarContext.Provider value={{ showMessage, closeMessage }}>
{children}
<GlobalSnackbar {...snackbarProps} />
</SnackbarContext.Provider>
);
}
/**
* useSnackbar - 在子组件中获取 Snackbar 上下文的 Hook
*
* @param {SnackbarOptions} [options] - 钩子级别的默认配置(如 autoHideDuration
* @returns {SnackbarContextValue} - 包含 showMessage 和 closeMessage 的对象
* @throws {Error} - 如果不在 SnackbarProvider 内部调用,抛出错误
*
* @description
* 选项合并策略:
* 1. 调用 showMessage 时传入的 callOptions 优先级最高
* 2. useSnackbar(options) 传入的 Hook 级别配置次之
* 3. SnackbarProvider(initialOptions) 传入的全局配置优先级最低
*/
export function useSnackbar(options?: SnackbarOptions): SnackbarContextValue {
const context = useContext(SnackbarContext);
if (!context) {
throw new Error('useSnackbar must be used within SnackbarProvider');
}
// 包装 showMessage 以支持 Hook 级别的 initialOptions
const wrappedShowMessage = (message: string, callOptions?: SnackbarOptions) => {
// 采用防御性编程,确保 options 和 callOptions 为空时也能正常工作
// 优先级:callOptions > options
const mergedOptions: SnackbarOptions = {
...(options || {}),
...(callOptions || {}),
};
context.showMessage(message, mergedOptions);
};
return {
...context,
showMessage: wrappedShowMessage,
};
}
export default GlobalSnackbar;
+178
View File
@@ -0,0 +1,178 @@
import { useCallback, useEffect, useRef } from 'react';
import { Image, X } from 'lucide-react';
import { useSnackbar } from '@/components/GlobalSnackbar';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface ImageUploaderProps {
/** 选中的文件 */
selectedFile: File | null;
/** 文件变更回调 */
onFileChange: (file: File) => void;
/** 清除文件回调 */
onClearFile: () => void;
/** 文件预览 URL */
previewUrl: string;
/** 预览 URL 变更回调 */
onPreviewUrlChange: (url: string) => void;
/** 是否正在拖拽 */
dragging: boolean;
/** 拖拽状态变更回调 */
onDraggingChange: (dragging: boolean) => void;
}
const ImageUploader = ({
selectedFile,
onFileChange,
onClearFile,
previewUrl,
onPreviewUrlChange,
dragging,
onDraggingChange,
}: ImageUploaderProps) => {
const { t } = useLazyTranslation('qrCode');
const { showMessage } = useSnackbar();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = useCallback(
(file: File) => {
onFileChange(file);
onPreviewUrlChange(URL.createObjectURL(file));
},
[onFileChange, onPreviewUrlChange],
);
const handleClearFile = () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
onClearFile();
showMessage(t('qrCode:imageCleared'), {
severity: 'success',
autoHideDuration: 1000,
});
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleFileChange(e.target.files[0]);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
onDraggingChange(true);
};
const handleDragLeave = () => {
onDraggingChange(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
onDraggingChange(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) {
handleFileChange(droppedFile);
}
};
// 监听粘贴事件
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) {
try {
handleFileChange(file);
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('处理粘贴图片失败:', error);
showMessage(t('qrCode:imagePasteError'), {
severity: 'error',
autoHideDuration: 3000,
});
}
}
break;
}
}
};
document.addEventListener('paste', handlePaste);
return () => {
document.removeEventListener('paste', handlePaste);
};
}, [showMessage, handleFileChange, t]);
return (
<div
className={`flex flex-col items-center justify-center h-[250px] border-2 border-dashed rounded-xl p-4 cursor-pointer transition-all duration-200 ${
dragging
? 'border-green-600 bg-green-50'
: selectedFile
? 'border-green-600 bg-green-50/50'
: 'border-input bg-muted hover:border-green-600 hover:bg-green-500/10/50'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleInputChange}
className="hidden"
id="qr-code-upload"
/>
<label htmlFor="qr-code-upload" className="cursor-pointer text-center w-full">
{selectedFile ? (
<div className="text-center w-full relative">
<div className="relative inline-block">
<img
src={previewUrl}
alt="QR Code Preview"
className="max-w-full max-h-40 rounded-lg object-contain"
/>
<button
type="button"
data-testid="ClearIcon"
onClick={(e) => {
e.stopPropagation();
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"
>
<X className="w-3 h-3" />
</button>
</div>
<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>
</div>
) : (
<>
<Image
data-testid="ImageIcon"
className="w-12 h-12 text-muted-foreground mx-auto mb-2"
/>
<span className="block text-sm text-muted-foreground mb-1">
{t('qrCode:clickToUpload')}
</span>
<span className="block text-xs text-muted-foreground">
{t('qrCode:supportFormats')}
</span>
</>
)}
</label>
</div>
);
};
export default ImageUploader;
+95
View File
@@ -0,0 +1,95 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface Props {
children: ReactNode;
resetKey?: string | number;
}
interface State {
hasError: boolean;
error: Error | null;
}
/**
* 页面级错误边界组件:捕获子组件树中的 JavaScript 错误
* 完美适配 shadcn/ui 语义化主题与暗黑模式
*/
export class PageErrorBoundary extends Component<Props, State> {
state: State = {
hasError: false,
error: null,
};
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error in page:', error, errorInfo);
}
componentDidUpdate(prevProps: Props) {
if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) {
this.setState({ hasError: false, error: null });
}
}
private handleRetry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
return (
<div className="flex flex-col items-center justify-center flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200">
{/*
1. 适配暗黑模式的容器设计:
不再使用 border-red-200 / bg-red-50,改用标准的 border-destructive/20 和 bg-destructive/5
并在黑夜模式下会自动转为深红底色,绝不刺眼。
*/}
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 max-w-md w-full shadow-sm">
{/* 2. 状态符号改用标准的 text-destructive 语义色 */}
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
<AlertCircle className="h-6 w-6" />
</div>
<h3 className="text-base font-semibold text-foreground mb-1.5"></h3>
<p className="text-xs text-muted-foreground mb-5">
</p>
{/* 3. 错误日志展示:使用与 shadcn 贴合的深色代码块包裹 */}
{this.state.error && (
<div className="mb-5 p-3 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-40 overflow-y-auto border border-border/40">
<pre className="font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
{this.state.error.stack || this.state.error.toString()}
</pre>
</div>
)}
{/*
4. 严谨调用 shadcn 原子 Button
去掉全部手动指定的红底白字类名,直接启用 variant="destructive"。
它会自动处理 hover 颜色变化、暗黑模式切换以及无障碍高亮边框。
*/}
<Button
variant="destructive"
size="sm"
onClick={this.handleRetry}
className="font-medium shadow-sm"
>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
return this.props.children;
}
}
export default PageErrorBoundary;
-106
View File
@@ -1,106 +0,0 @@
import { Stack, Typography, Box, alpha, SxProps, Theme } from '@mui/material';
import { ReactNode } from 'react';
/**
* PageHeader 组件属性接口
*/
export interface PageHeaderProps {
/** 要显示的图标组件 */
icon: ReactNode;
/** 图标的颜色,默认为 '#1976d2'(蓝色) */
iconColor?: string;
/** 主标题文本 */
title: string;
/** 副标题文本(可选) */
subtitle?: string;
/** 在标题右侧显示的徽章/标签组件(可选) */
badge?: ReactNode;
/** 图标容器的自定义样式 */
iconSx?: SxProps<Theme>;
/** 标题文本的自定义样式 */
titleSx?: SxProps<Theme>;
/** 副标题文本的自定义样式 */
subtitleSx?: SxProps<Theme>;
/** 整个组件的自定义样式 */
sx?: SxProps<Theme>;
}
/**
* PageHeader - 通用页面标题栏组件
*
* 用于显示带图标的页面标题,支持自定义颜色、副标题、徽章等功能
*
* @example
* ```tsx
* <PageHeader
* icon={<AccessTimeIcon />}
* iconColor="#1976d2"
* title="时间戳转换"
* subtitle="Unix 毫秒数转换与格式化"
* />
* ```
*
* @example
* ```tsx
* <PageHeader
* icon={<StorageIcon />}
* iconColor={storageCleanerPageStyles.warningColor}
* title="存储清理"
* subtitle={domain}
* badge={<Badge>已占用 {size}</Badge>}
* />
* ```
*/
export default function PageHeader({
icon,
iconColor = '#1976d2',
title,
subtitle,
badge,
iconSx,
titleSx,
subtitleSx,
sx,
}: PageHeaderProps) {
return (
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5, ...sx }}>
{/* 图标容器 */}
<Box
sx={{
p: 1,
borderRadius: 2.5,
bgcolor: alpha(iconColor, 0.1),
color: iconColor,
display: 'flex',
...iconSx,
}}
>
{icon}
</Box>
{/* 标题区域 */}
<Box sx={{ flex: 1 }}>
{/* 标题行(含徽章) */}
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography
variant="subtitle1"
fontWeight={900}
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2, ...titleSx }}
>
{title}
</Typography>
{badge}
</Stack>
{/* 副标题 */}
{subtitle && (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontWeight: 600, ...subtitleSx }}
>
{subtitle}
</Typography>
)}
</Box>
</Stack>
);
}
+78
View File
@@ -0,0 +1,78 @@
/**
* PageSkeleton 组件 - 页面加载骨架屏
*
* 用于 Suspense fallback 和初始加载状态,提供平滑的视觉过渡
* 避免白屏闪烁,减少布局偏移
*/
interface PageSkeletonProps {
/** 骨架屏类型 */
variant?: 'dashboard' | 'tool';
}
/**
* 仪表盘卡片骨架屏
*/
function DashboardCardSkeleton() {
return (
<div className="rounded-xl border border-border p-5 h-[100px]">
<div className="flex justify-between items-start">
<div className="flex gap-3 items-center">
<div className="w-10 h-10 rounded-lg bg-muted animate-pulse" />
<div>
<div className="w-24 h-5 bg-muted rounded animate-pulse" />
<div className="w-32 h-3.5 bg-muted rounded animate-pulse mt-1.5" />
</div>
</div>
<div className="w-3 h-3 rounded-full bg-muted animate-pulse" />
</div>
</div>
);
}
/**
* 工具页面骨架屏
*/
function ToolPageSkeleton() {
return (
<div className="p-5">
{/* 标题区域 */}
<div className="w-44 h-7 bg-muted rounded animate-pulse mb-4" />
{/* 输入区域 */}
<div className="w-full h-[120px] bg-muted rounded-xl animate-pulse mb-4" />
{/* 控制栏 */}
<div className="flex gap-2 mb-4">
<div className="w-24 h-9 bg-muted rounded-lg animate-pulse" />
<div className="w-20 h-9 bg-muted rounded-lg animate-pulse" />
<div className="flex-1" />
<div className="w-22 h-9 bg-muted rounded-lg animate-pulse" />
</div>
{/* 结果区域 */}
<div className="w-full h-[160px] bg-muted rounded-xl animate-pulse" />
</div>
);
}
/**
* 页面加载骨架屏
*
* @param props - PageSkeletonProps
* @returns 骨架屏 JSX 元素
*/
export default function PageSkeleton({ variant = 'dashboard' }: PageSkeletonProps) {
if (variant === 'tool') {
return <ToolPageSkeleton />;
}
return (
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(300px,1fr))] auto-rows-fr gap-4 p-4">
{Array.from({ length: 6 }).map((_, index) => (
<DashboardCardSkeleton key={index} />
))}
</div>
);
}
PageSkeleton.displayName = 'PageSkeleton';
+89
View File
@@ -0,0 +1,89 @@
import React from 'react';
import { Copy, Download } from 'lucide-react';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
// 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件
interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
/** 二维码 Data URL */
qrCodeDataUrl: string;
/** 下载回调 */
onDownload: () => void;
/** 复制回调 */
onCopy: () => void;
}
const QrCodePreview = ({
qrCodeDataUrl,
onDownload,
onCopy,
className,
...props
}: QrCodePreviewProps) => {
const { t } = useLazyTranslation('qrCode');
// 空状态下的虚线骨架屏
if (!qrCodeDataUrl) {
return (
<div
className={cn(
'flex flex-col justify-center items-center min-h-[200px] border border-dashed border-input rounded-xl p-4 bg-muted/40',
className,
)}
{...props}
>
<p className="text-sm text-muted-foreground text-center">{t('qrCode:qrCodeWillShow')}</p>
</div>
);
}
return (
<div
className={cn(
'flex flex-col justify-center items-center min-h-[200px] border border-input rounded-xl p-6 bg-muted/40',
className,
)}
{...props}
>
<div className="flex flex-col items-center w-full max-w-xs">
{/*
2. 二维码容器适配:
在暗黑模式下,纯黑白的二维码如果直接暴露在暗色背景下,会导致手机摄像头极难识别。
通过裹一层 bg-white 和 p-3,确保黑白对比度绝对安全,同时加入 shadow 增强卡片感。
*/}
<div className="p-3 bg-white rounded-lg shadow-sm border border-border/40">
<img
src={qrCodeDataUrl}
alt="QR Code Preview"
className="w-56 h-56 max-w-full object-contain block animate-in fade-in duration-300"
/>
</div>
{/* 3. 按钮群全面向 shadcn 官方 Button 视觉规范对齐 */}
<div className="flex w-full gap-2 mt-5">
{/* 下载按钮:使用标准的次要按钮风格 (Outline) */}
<button
type="button"
onClick={onDownload}
className="flex-1 inline-flex h-9 items-center justify-center gap-2 px-3 text-sm font-medium rounded-md border border-input bg-background shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<Download className="w-4 h-4 text-muted-foreground" />
<span className="truncate">{t('qrCode:downloadButton')}</span>
</button>
{/* 复制按钮:使用标准的主要行动按钮风格 (Default) */}
<button
type="button"
onClick={onCopy}
className="flex-1 inline-flex h-9 items-center justify-center gap-2 px-3 text-sm font-medium rounded-md bg-primary text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<Copy className="w-4 h-4" />
<span className="truncate">{t('qrCode:copyQrButton')}</span>
</button>
</div>
</div>
</div>
);
};
export default QrCodePreview;
-323
View File
@@ -1,323 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Box,
Typography,
TextField,
Button,
Stack,
Alert,
Accordion,
AccordionSummary,
AccordionDetails,
CircularProgress,
InputAdornment,
IconButton,
} from '@mui/material';
import LinkIcon from '@mui/icons-material/Link';
import ImageIcon from '@mui/icons-material/Image';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ClearIcon from '@mui/icons-material/Clear';
import CopyButton from '@/components/CopyButton';
import { qrCodePageStyles } from '@/config/pageTheme';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
interface QrCodeToUrlSectionProps {
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
showMessage: (message: string, options?: SnackbarOptions) => void;
}
const QrCodeToUrlSection = ({
expanded,
onExpandedChange,
showMessage,
}: QrCodeToUrlSectionProps) => {
const [qrCodeFile, setQrCodeFile] = useState<File | null>(null);
const [parsedUrl, setParsedUrl] = useState('');
const [parseError, setParseError] = useState('');
const [parsing, setParsing] = useState(false);
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = useCallback((file: File) => {
setQrCodeFile(file);
setParseError('');
setParsedUrl('');
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleFileChange(e.target.files[0]);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragging(true);
};
const handleDragLeave = () => {
setDragging(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragging(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) {
handleFileChange(droppedFile);
}
};
const parseQrCode = async () => {
if (!qrCodeFile) {
showMessage('请选择二维码图片', { severity: 'error', autoHideDuration: 300 });
return;
}
try {
setParsing(true);
setParseError('');
setParsedUrl('');
const result = await parseQrCodeFromFile(qrCodeFile);
if (result.success && result.data) {
setParsedUrl(result.data);
showMessage('二维码解析成功', { severity: 'success', autoHideDuration: 1000 });
} else {
showMessage(result.error || '未检测到二维码', {
severity: 'error',
autoHideDuration: 1000,
});
}
} catch (error) {
console.error('解析二维码失败:', error);
showMessage('解析二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
} finally {
setParsing(false);
}
};
// 监听粘贴事件
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
if (!expanded) return;
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) {
try {
handleFileChange(file);
showMessage('图片粘贴成功', { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('处理粘贴图片失败:', error);
showMessage('粘贴图片失败,请重试', { severity: 'error', autoHideDuration: 3000 });
}
}
break;
}
}
};
document.addEventListener('paste', handlePaste);
return () => {
document.removeEventListener('paste', handlePaste);
};
}, [expanded, showMessage, handleFileChange]);
return (
<Accordion
expanded={expanded}
onChange={(_, isExpanded) => onExpandedChange(isExpanded)}
sx={{
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
'&:before': { display: 'none' },
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ borderBottom: 'none' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<LinkIcon color="success" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
URL
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={3}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 200,
border: '2px dashed',
borderColor: dragging
? qrCodePageStyles.successColor
: qrCodeFile
? qrCodePageStyles.successColor
: 'grey.200',
borderRadius: 3,
p: 4,
bgcolor: dragging
? 'rgba(76, 175, 80, 0.1)'
: qrCodeFile
? 'rgba(76, 175, 80, 0.05)'
: 'grey.50',
cursor: 'pointer',
transition: 'all 0.2s',
'&:hover': {
borderColor: qrCodePageStyles.successColor,
bgcolor: 'rgba(76, 175, 80, 0.05)',
},
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleInputChange}
style={{
display: 'none',
}}
id="qr-code-upload"
/>
<label
htmlFor="qr-code-upload"
style={{ cursor: 'pointer', textAlign: 'center', width: '100%' }}
>
{qrCodeFile ? (
<Box sx={{ textAlign: 'center', width: '100%', position: 'relative' }}>
<Box sx={{ position: 'relative', display: 'inline-block' }}>
<img
src={URL.createObjectURL(qrCodeFile)}
alt="QR Code Preview"
style={{
maxWidth: '100%',
maxHeight: 160,
borderRadius: 8,
objectFit: 'contain',
}}
/>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setQrCodeFile(null);
setParsedUrl('');
setParseError('');
showMessage('图片已清除', {
severity: 'success',
autoHideDuration: 1000,
});
}}
sx={{
position: 'absolute',
top: -8,
right: -8,
bgcolor: 'rgba(244, 67, 54, 0.9)',
color: 'white',
'&:hover': {
bgcolor: 'rgba(211, 47, 47, 0.95)',
},
}}
>
<ClearIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
{qrCodeFile.name}
</Typography>
<Typography variant="caption" color="text.secondary">
</Typography>
</Box>
) : (
<>
<ImageIcon sx={{ fontSize: 48, color: 'grey.300', mb: 2 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
</Typography>
<Typography variant="caption" color="text.secondary">
PNGJPGWEBP
</Typography>
</>
)}
</label>
</Box>
<Button
variant="contained"
startIcon={parsing ? <CircularProgress size={16} color="inherit" /> : <LinkIcon />}
onClick={parseQrCode}
disabled={parsing}
sx={{
py: 1.2,
borderRadius: 3,
bgcolor: qrCodePageStyles.successColor,
fontWeight: 700,
'&:hover': {
bgcolor: qrCodePageStyles.successDark,
},
}}
>
{parsing ? '解析中...' : '解析二维码'}
</Button>
<Box
sx={{
position: 'relative',
mt: 2,
}}
>
<TextField
label="解析结果"
value={parsedUrl}
fullWidth
variant="outlined"
slotProps={{
input: {
readOnly: true,
endAdornment: (
<InputAdornment position="end">
<CopyButton
text={parsedUrl}
tooltip="复制"
size="small"
color={qrCodePageStyles.primaryColor}
showMessage={showMessage}
/>
</InputAdornment>
),
},
}}
sx={qrCodePageStyles.INPUT_STYLE}
/>
</Box>
{parseError && (
<Alert severity="error" sx={{ borderRadius: 3 }}>
{parseError}
</Alert>
)}
</Stack>
</AccordionDetails>
</Accordion>
);
};
export default QrCodeToUrlSection;
-382
View File
@@ -1,382 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Alert,
Box,
CircularProgress,
IconButton,
Paper,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import ImageIcon from '@mui/icons-material/Image';
import ClearIcon from '@mui/icons-material/Clear';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import GlobalSnackbar, { useSnackbarState } from './GlobalSnackbar';
import CopyButton from './CopyButton';
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
interface QrCodeUploaderProps {
onQrCodeDetected?: (data: string) => void;
supportedFormats?: string[];
maxFileSize?: number; // in bytes
timeout?: number; // in milliseconds
showPreview?: boolean;
showProgress?: boolean;
className?: string;
}
const QrCodeUploader: React.FC<QrCodeUploaderProps> = ({
onQrCodeDetected,
supportedFormats = ['image/png', 'image/jpeg', 'image/webp'],
maxFileSize = 5 * 1024 * 1024, // 5MB
timeout = 10000, // 10 seconds
showPreview = true,
showProgress = true,
className,
}) => {
const { snackbarProps, showMessage } = useSnackbarState({ autoHideDuration: 3000 });
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [result, setResult] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadAreaRef = useRef<HTMLDivElement>(null);
// 清理预览 URL
useEffect(() => {
return () => {
if (preview) {
URL.revokeObjectURL(preview);
}
};
}, [preview]);
// 处理文件
const processFile = useCallback(
async (file: File) => {
setUploading(true);
setProgress(0);
try {
const progressInterval = setInterval(() => {
setProgress((prev) => {
if (prev >= 90) {
clearInterval(progressInterval);
return prev;
}
return prev + 10;
});
}, 200);
const result = await parseQrCodeFromFile(file, timeout);
clearInterval(progressInterval);
setProgress(100);
if (result.success && result.data) {
setResult(result.data);
showMessage('二维码解析成功', { severity: 'success' });
if (onQrCodeDetected) {
onQrCodeDetected(result.data);
}
} else {
setError(result.error || '未检测到二维码');
showMessage(result.error || '未检测到二维码', { severity: 'error' });
}
} catch (err) {
setError(err instanceof Error ? err.message : '解析失败');
showMessage('解析失败: ' + (err instanceof Error ? err.message : '未知错误'), {
severity: 'error',
});
} finally {
setUploading(false);
setTimeout(() => setProgress(0), 500);
}
},
[timeout, showMessage, onQrCodeDetected],
);
// 处理文件
const handleFile = useCallback(
(selectedFile: File) => {
// 检查文件格式
if (!supportedFormats.includes(selectedFile.type)) {
setError(
`不支持的文件格式。支持的格式: ${supportedFormats.map((f) => f.split('/')[1].toUpperCase()).join(', ')}`,
);
showMessage('不支持的文件格式', { severity: 'error' });
return;
}
// 检查文件大小
if (selectedFile.size > maxFileSize) {
const maxSizeMB = (maxFileSize / (1024 * 1024)).toFixed(1);
setError(`文件大小超过限制。最大支持 ${maxSizeMB}MB`);
showMessage(`文件大小超过限制,最大支持 ${maxSizeMB}MB`, { severity: 'error' });
return;
}
// 重置状态
setError(null);
setResult(null);
setFile(selectedFile);
// 创建预览
if (showPreview) {
const previewUrl = URL.createObjectURL(selectedFile);
setPreview(previewUrl);
}
// 开始处理
processFile(selectedFile).catch(console.error);
},
[supportedFormats, maxFileSize, showPreview, showMessage, processFile],
);
// 处理文件选择
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
handleFile(selectedFile);
}
};
// 处理拖拽事件
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragging(true);
};
const handleDragLeave = () => {
setDragging(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragging(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) {
handleFile(droppedFile);
}
};
// 监听粘贴事件
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const pastedFile = items[i].getAsFile();
if (pastedFile) {
handleFile(pastedFile);
}
break;
}
}
};
document.addEventListener('paste', handlePaste);
return () => document.removeEventListener('paste', handlePaste);
}, [handleFile]);
// 清除文件
const handleClear = () => {
setFile(null);
setPreview(null);
setResult(null);
setError(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<Box className={className}>
{/* 上传区域 */}
<Paper
ref={uploadAreaRef}
elevation={0}
sx={{
p: isMobile ? 3 : 4,
borderRadius: 4,
border: `2px dashed ${dragging ? 'primary.main' : 'grey.300'}`,
bgcolor: dragging ? 'primary.lighter' : 'grey.50',
transition: 'all 0.2s ease',
textAlign: 'center',
cursor: 'pointer',
position: 'relative',
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept={supportedFormats.join(',')}
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
{!file && !uploading ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<ImageIcon sx={{ fontSize: isMobile ? 36 : 48, color: 'grey.400', mb: 2 }} />
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
</Typography>
<Typography variant="caption" color="text.secondary">
{supportedFormats.map((f) => f.split('/')[1].toUpperCase()).join(', ')}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
: {(maxFileSize / (1024 * 1024)).toFixed(1)}MB
</Typography>
</Box>
) : file && showPreview && preview ? (
<Box sx={{ position: 'relative' }}>
<img
src={preview}
alt="QR Code Preview"
style={{
maxWidth: '100%',
maxHeight: 200,
borderRadius: 8,
objectFit: 'contain',
}}
/>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleClear();
}}
sx={{
position: 'absolute',
top: -8,
right: -8,
bgcolor: 'rgba(244, 67, 54, 0.9)',
color: 'white',
'&:hover': {
bgcolor: 'rgba(211, 47, 47, 0.95)',
},
}}
>
<ClearIcon fontSize="small" />
</IconButton>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
{file.name}
</Typography>
</Box>
) : uploading && showProgress ? (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<CircularProgress size={48} sx={{ mb: 2 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
...
</Typography>
{progress > 0 && (
<Box sx={{ width: '80%', mt: 2 }}>
<Box
sx={{
height: 8,
bgcolor: 'grey.200',
borderRadius: 4,
overflow: 'hidden',
}}
>
<Box
sx={{
height: '100%',
bgcolor: 'primary.main',
width: `${progress}%`,
transition: 'width 0.3s ease',
}}
/>
</Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 1, display: 'block' }}
>
{progress}%
</Typography>
</Box>
)}
</Box>
) : null}
</Paper>
{/* 结果展示 */}
{(result || error) && (
<Box sx={{ mt: 3 }}>
{result && (
<Paper
elevation={0}
sx={{
p: 3,
borderRadius: 4,
border: '1px solid',
borderColor: 'success.light',
bgcolor: 'success.lighter',
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<CheckCircleIcon sx={{ color: 'success.main', mt: 0.5 }} />
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
</Typography>
<Box sx={{ position: 'relative' }}>
<Typography
variant="body1"
sx={{
fontFamily: 'monospace',
wordBreak: 'break-all',
pr: 8,
}}
>
{result}
</Typography>
<CopyButton
text={result}
tooltip="复制"
size="small"
color="success"
showMessage={showMessage}
style={{
position: 'absolute',
right: 0,
top: 0,
}}
/>
</Box>
</Box>
</Box>
</Paper>
)}
{error && (
<Alert severity="error" sx={{ borderRadius: 4 }} icon={<ErrorIcon />}>
{error}
</Alert>
)}
</Box>
)}
<GlobalSnackbar {...snackbarProps} />
</Box>
);
};
export default QrCodeUploader;
+42 -17
View File
@@ -1,11 +1,15 @@
import { Box } from '@mui/material';
import { FEATURES, getEntryPointType } from '@/config/features';
import { useRouter } from '@/providers/RouterProvider';
import { useMemo } from 'react';
import { Suspense, useMemo } from 'react';
import PageErrorBoundary from '@/components/PageErrorBoundary';
import PageSkeleton from '@/components/PageSkeleton';
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
import { AlertTriangle } from 'lucide-react'; // 用于标准的 404 异常展示
export default function RouterContainer() {
const { currentPage, isLoaded } = useRouter();
// 2. 稳定的动态动画类名映射
const animationClass = useMemo(() => {
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
}, [currentPage]);
@@ -14,27 +18,48 @@ export default function RouterContainer() {
return getEntryPointType();
}, []);
// 骨架屏加载状态守卫
if (!isLoaded) {
return <div className="app">Loading...</div>;
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
}
// 3. 严格的路由查找与类型安全的组件分发
const currentFeature = FEATURES.find((f) => f.key === currentPage);
const Component = currentFeature ? currentFeature.components[entryPointType] : null;
const MatchedComponent = currentFeature?.components?.[entryPointType];
return (
<Box
key={currentPage} // Trigger animation on navigation
className={animationClass}
sx={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
scrollbarGutter: 'stable',
display: 'flex',
flexDirection: 'column',
}}
<div
key={currentPage} // 保持原有通过重新挂载触发动画的精简特性
className={cn(
'flex-1 flex flex-col overflow-x-hidden overflow-y-auto',
'scrollbar-gutter-stable motion-reduce:transition-none', // 当系统开启“减弱动态效果”时,自动优雅降级,防止眩晕
animationClass,
)}
>
{Component && <Component />}
</Box>
<Suspense
fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
>
<PageErrorBoundary resetKey={currentPage}>
{/*
4. 路由防御拦截:
如果组件存在则正常流式渲染,如果由于版本更迭或非法路径导致找不到对应组件,
渲染一个优雅且符合 shadcn 风格的中性 404 提示页,而不是死白屏。
*/}
{MatchedComponent ? (
<MatchedComponent />
) : (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center animate-in fade-in duration-300">
<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" />
</div>
<h3 className="text-sm font-semibold text-foreground"></h3>
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
{entryPointType}
</p>
</div>
)}
</PageErrorBoundary>
</Suspense>
</div>
);
}
-154
View File
@@ -1,154 +0,0 @@
/**
* SnackbarProvider - 全局 Snackbar 消息提示 Provider
*
* 提供全局的 Toast 消息功能,支持成功、错误、警告、信息四种提示类型。
* 通过 React Context 向下传递消息显示方法,子组件可通过 useSnackbar hook 调用。
*
* @description
* - 基于 GlobalSnackbar 组件实现,复用其状态管理逻辑
* - 使用 MUI Snackbar 组件实现消息提示
* - 支持自定义自动隐藏时长
* - 消息会显示在页面底部居中位置
* - 使用 Portal 将 Snackbar 渲染到 body 末尾,避免 z-index 层级问题
*/
import { createContext, useContext, type ReactNode } from 'react';
import GlobalSnackbar, { useSnackbarState } from './GlobalSnackbar';
import type { SnackbarOptions } from './GlobalSnackbar';
/**
* Snackbar Context 的值类型定义
* @interface SnackbarContextValue
* @property showMessage - 显示消息的方法
* @property closeMessage - 关闭消息的方法
*/
interface SnackbarContextValue {
showMessage: (message: string, options?: SnackbarOptions) => void;
closeMessage: () => void;
}
/**
* React Context,用于在组件树中传递 Snackbar 操作方法
* @description
* - 初始值为 null,表示未包裹在 Provider 中
* - 通过 SnackbarProvider 包裹后提供实际值
*/
const SnackbarContext = createContext<SnackbarContextValue | null>(null);
/**
* SnackbarProvider 组件的 props 类型
* @interface SnackbarProviderProps
* @property children - 子组件
* @property initialOptions - 初始配置选项
*/
interface SnackbarProviderProps {
children: ReactNode;
initialOptions?: SnackbarOptions;
}
/**
* useSnackbar Hook 的选项配置(与 GlobalSnackbar 的 SnackbarOptions 兼容)
* @interface UseSnackbarOptions
* @property severity - 消息严重程度
* @property autoHideDuration - 默认自动隐藏时长
* @property hideIcon - 是否隐藏图标
* @property showAlert - 是否使用 Alert 组件
*/
export type UseSnackbarOptions = SnackbarOptions;
/**
* SnackbarProvider 组件
*
* 全局消息提示的 Provider 组件,需要包裹在应用根组件外层。
* 提供 showMessage 方法用于显示各种类型的提示消息。
*
* @param {SnackbarProviderProps} props - 组件属性
* @returns {JSX.Element}
*
* @remarks
* - 使用 useGlobalSnackbar() hook 复用 GlobalSnackbar 的状态管理逻辑
* - 通过 Context.Provider 将操作方法传递给子组件
* - 渲染 GlobalSnackbar 组件显示实际的 Snackbar UI
*
* @example
* ```tsx
* <SnackbarProvider initialOptions={{ autoHideDuration: 3000 }}>
* <App />
* </SnackbarProvider>
* ```
*
* @example
* ```tsx
* // 在子组件中使用
* const { showMessage } = useSnackbar();
* showMessage('操作成功', { severity: 'success' });
* ```
*/
export function SnackbarProvider({ children, initialOptions }: SnackbarProviderProps) {
/**
* 调用 GlobalSnackbar.useSnackbar() 获取状态管理逻辑
*
* @description
* - snackbarProps: 传递给 GlobalSnackbar 组件的属性
* - showMessage: 显示消息的方法
* - closeMessage: 关闭消息的方法
*/
const { snackbarProps, showMessage, closeMessage } = useSnackbarState(initialOptions);
/**
* 通过 Context.Provider 向下传递 snackbar 操作方法
*
* @description
* - 子组件通过 useSnackbar() hook 获取这些方法
* - GlobalSnackbar 组件放在 Provider 外部,确保它能渲染到 DOM
*/
return (
<SnackbarContext.Provider value={{ showMessage, closeMessage }}>
{children}
<GlobalSnackbar {...snackbarProps} />
</SnackbarContext.Provider>
);
}
/**
* useSnackbar - 在子组件中获取 Snackbar 上下文的 Hook
*
* @param {UseSnackbarOptions} [_options] - 可选的配置项(保留向后兼容性,不实际使用)
* @returns {SnackbarContextValue} - 包含 showMessage 和 closeMessage 的对象
* @throws {Error} - 如果不在 SnackbarProvider 内部调用,抛出错误
*
* @description
* 这是一个自定义 React Hook,用于在任意子组件中访问 Snackbar 功能。
* 必须确保组件被 SnackbarProvider 包裹才能使用。
*
* @remarks
* - 由于 Context 限制,useSnackbar 的 options 参数无法动态传递给 Provider
* - 如需设置全局初始选项,请在 SnackbarProvider 组件上设置 initialOptions
* - 如需为单个消息设置选项,请在 showMessage() 方法中传入
*
* @example
* ```tsx
* function MyComponent() {
* const { showMessage } = useSnackbar();
*
* const handleSuccess = () => {
* showMessage('操作成功!', { severity: 'success', autoHideDuration: 5000 });
* };
*
* return (
* <div>
* <button onClick={handleSuccess}>成功提示</button>
* </div>
* );
* }
* ```
*/
export function useSnackbar(_options?: UseSnackbarOptions): SnackbarContextValue {
const context = useContext(SnackbarContext);
if (!context) {
throw new Error('useSnackbar must be used within SnackbarProvider');
}
return context;
}
export default SnackbarProvider;
-169
View File
@@ -1,169 +0,0 @@
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Typography,
Box,
Chip,
alpha,
} from '@mui/material';
import type { StorageCleanerOptions } from '@/types/storage';
import Button from '@/components/Button';
import { storageCleanerPageStyles, THEME_COLORS } from '@/config/pageTheme';
export interface StorageCleanerConfirmProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
options: StorageCleanerOptions;
}
const STORAGE_LABELS: Record<keyof StorageCleanerOptions, string> = {
localStorage: 'LocalStorage',
sessionStorage: 'Session Storage',
indexedDB: 'IndexedDB',
cookies: 'Cookies',
cacheStorage: 'Cache Storage',
serviceWorkers: 'Service Workers',
};
export function StorageCleanerConfirm({
open,
onClose,
onConfirm,
options,
}: StorageCleanerConfirmProps) {
const selectedOptions = Object.entries(options)
.filter(([_, value]) => value)
.map(([key, _]) => STORAGE_LABELS[key as keyof StorageCleanerOptions] || key);
return (
<Dialog
open={open}
onClose={onClose}
fullWidth
maxWidth="xs"
slotProps={{
paper: {
sx: {
borderRadius: 6,
backgroundImage: 'none',
boxShadow: `0 24px 64px -12px ${alpha(THEME_COLORS.black, 0.18)}`,
p: 1.5,
bgcolor: 'background.paper',
},
},
}}
>
<DialogTitle
sx={{
textAlign: 'center',
pt: 4,
pb: 1,
fontWeight: 900,
letterSpacing: '-0.5px',
fontSize: '1.35rem',
color: 'text.primary',
}}
>
</DialogTitle>
<DialogContent sx={{ textAlign: 'center', pb: 2 }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 3.5, fontWeight: 500, fontSize: '0.9rem' }}
>
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.2, justifyContent: 'center', mb: 4 }}>
{selectedOptions.map((label) => (
<Chip
key={label}
label={label}
size="small"
sx={{
bgcolor: alpha(THEME_COLORS.warning, 0.04),
fontWeight: 700,
color: THEME_COLORS.warning,
fontSize: '0.75rem',
border: '1px solid',
borderColor: alpha(THEME_COLORS.warning, 0.15),
borderRadius: 2.5,
height: 'auto',
'& .MuiChip-label': { px: 1.2, py: 0.6 },
}}
/>
))}
</Box>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 1,
bgcolor: alpha(THEME_COLORS.error, 0.05),
color: THEME_COLORS.error,
px: 2,
py: 0.8,
borderRadius: 3,
border: '1px dashed',
borderColor: alpha(THEME_COLORS.error, 0.2),
}}
>
<Typography
variant="caption"
sx={{
fontWeight: 800,
display: 'flex',
alignItems: 'center',
gap: 0.5,
fontSize: '0.75rem',
}}
>
<span role="img" aria-label="warning">
</span>{' '}
</Typography>
</Box>
</DialogContent>
<DialogActions sx={{ p: 3, pt: 1, gap: 2 }}>
<Button
variant="text"
onClick={onClose}
fullWidth
sx={{
boxShadow: '0 0 1px 1px rgba(0, 0, 0, 0.1)',
color: 'text.secondary',
'&:hover': {
bgcolor: 'grey.100',
color: 'text.primary',
},
}}
>
</Button>
<Button
variant="contained"
onClick={onConfirm}
fullWidth
sx={{
bgcolor: storageCleanerPageStyles.warningColor,
'&:hover': {
bgcolor: storageCleanerPageStyles.warningDark,
},
}}
>
</Button>
</DialogActions>
</Dialog>
);
}
export default StorageCleanerConfirm;
+75
View File
@@ -0,0 +1,75 @@
import React from 'react';
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
export interface SwitchOption<T extends string | number = string> {
value: T;
label: React.ReactNode;
}
// 2. 移除内联 sx,继承标准 HTML 属性,并使用标准的类名注入机制
export interface SwitchButtonGroupProps<T extends string | number = string> extends Omit<
React.HTMLAttributes<HTMLDivElement>,
'onChange'
> {
value: T;
options: SwitchOption<T>[];
onChange: (value: T) => void;
size?: 'small' | 'medium' | 'large';
buttonClassName?: string; // 替换原有的 buttonSx
}
export default function SwitchButtonGroup<T extends string | number = string>({
value,
options,
onChange,
size = 'medium',
className,
buttonClassName,
...props
}: SwitchButtonGroupProps<T>) {
// 3. 将尺寸和高度、内边距等整体对齐,保证按钮和背景容器成比例缩放
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',
};
const containerPadding = size === 'large' ? 'p-1' : 'p-1';
return (
<div
className={cn(
// 将默认布局设计得更为通用(去掉一刀切的 mb-4,由外部控制布局空间)
'inline-flex w-full items-center justify-center rounded-lg bg-muted text-muted-foreground',
containerPadding,
className,
)}
{...props}
>
{options.map((option) => {
const isSelected = value === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
// 4. 完美继承 shadcn 的 Tabs 交互和动效微调
'flex-1 inline-flex items-center justify-center font-medium whitespace-nowrap transition-all',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
sizeClasses[size],
isSelected
? 'bg-background text-foreground shadow-sm font-semibold animate-in fade-in-50 zoom-in-95 duration-150'
: 'hover:bg-background/50 hover:text-foreground/80',
buttonClassName,
)}
>
{option.label}
</button>
);
})}
</div>
);
}
+323
View File
@@ -0,0 +1,323 @@
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import { Copy, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
export type ValidateRule = {
validator: (value: string) => boolean;
message: string;
};
export type ToolbarAction = {
key: string;
label: string;
icon?: React.ReactNode;
position?: 'top' | 'bottom';
type?: 'primary' | 'default' | 'danger';
disabled?: boolean | ((value: string) => boolean);
onClick: (value: string, helpers: { clear: () => void; setError: (msg: string) => void }) => void;
};
export interface TextInputAreaProps extends Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
'onChange'
> {
value?: string;
defaultValue?: string;
/** 值变化回调,返回最新的字符串内容 */
onChange?: (value: string) => void;
minRows?: number;
maxRows?: number;
showCount?: boolean;
showClear?: boolean;
allowCopy?: boolean;
rules?: ValidateRule[];
validateTrigger?: 'onBlur' | 'onChange' | 'onAction';
actions?: ToolbarAction[];
topExtra?: React.ReactNode;
title?: string;
externalError?: string;
onClear?: () => void;
}
// 提炼基础的 ActionButton,全面向 shadcn 核心 Button 样式对齐
function ActionButton({
action,
value,
globalDisabled,
onAction,
}: {
action: ToolbarAction;
value: string;
globalDisabled: boolean;
onAction: (action: ToolbarAction) => void;
}) {
const isBtnDisabled =
typeof action.disabled === 'function' ? action.disabled(value) : (action.disabled ?? false);
const variantClasses = {
primary: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
danger: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
default:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
};
return (
<button
type="button"
onClick={() => onAction(action)}
disabled={isBtnDisabled || globalDisabled}
className={cn(
'inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors h-7 px-2.5',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
'disabled:pointer-events-none disabled:opacity-50',
variantClasses[action.type || 'default'],
)}
>
{action.icon && <span className="mr-1.5 h-3.5 w-3.5 flex items-center">{action.icon}</span>}
{action.label}
</button>
);
}
const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props, ref) => {
const {
value: controlledValue,
defaultValue = '',
onChange,
placeholder: placeholderProp,
disabled = false,
readOnly = false,
autoFocus = false,
minRows = 4,
maxRows = 12,
maxLength,
className,
showCount = false,
showClear = true,
allowCopy = false,
rules = [],
validateTrigger = 'onAction',
actions = [],
topExtra,
title,
externalError,
onClear,
...restProps
} = props;
const internalRef = useRef<HTMLTextAreaElement | null>(null);
const [internalValue, setInternalValue] = useState(defaultValue);
const [error, setError] = useState<string>('');
const { t } = useTranslation('common');
const placeholder = placeholderProp ?? t('textInputArea.placeholder');
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;
const displayError = externalError ?? error;
// 双向合并 ref 指针
useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement);
// 1. 高性能的动态高度自适应计算
const adjustHeight = useCallback(() => {
const textArea = internalRef.current;
if (!textArea) return;
// 重置高度计算
textArea.style.height = 'auto';
const computedMin = minRows * 24; // 每行粗略按 24px 计算
const computedMax = maxRows * 24;
const nextHeight = Math.max(textArea.scrollHeight, computedMin);
textArea.style.height = `${Math.min(nextHeight, computedMax)}px`;
}, [minRows, maxRows]);
// 当数值改变时自适应扩展
React.useEffect(() => {
adjustHeight();
}, [value, adjustHeight]);
const validate = useCallback(
(val: string, trigger?: string): boolean => {
if (validateTrigger !== trigger && trigger) return true;
for (const rule of rules) {
if (!rule.validator(val)) {
setError(rule.message);
return false;
}
}
setError('');
return true;
},
[rules, validateTrigger],
);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newVal = e.target.value;
if (maxLength && newVal.length > maxLength) {
const msg = t('charCount', { count: maxLength });
setError(msg);
toast.warning(msg);
return;
}
if (!isControlled) setInternalValue(newVal);
onChange?.(newVal);
if (error) setError('');
if (validateTrigger === 'onChange') validate(newVal, 'onChange');
};
const handleBlur = () => {
if (validateTrigger === 'onBlur') validate(value, 'onBlur');
};
const handleClear = useCallback(() => {
if (!isControlled) setInternalValue('');
onChange?.('');
setError('');
internalRef.current?.focus();
toast.success('已清空内容');
onClear?.();
}, [isControlled, onChange, onClear]);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(value);
toast.success('复制成功');
} catch {
setError('复制失败');
toast.error('复制失败');
}
}, [value]);
const handleAction = useCallback(
(action: ToolbarAction) => {
const isDisabled =
typeof action.disabled === 'function' ? action.disabled(value) : action.disabled;
if (isDisabled || disabled) return;
if (validateTrigger === 'onAction' && !validate(value, 'onAction')) return;
action.onClick(value, {
clear: handleClear,
setError,
});
},
[value, disabled, validate, validateTrigger, handleClear],
);
const topActions = actions.filter((a) => a.position !== 'bottom');
const bottomActions = actions.filter((a) => a.position === 'bottom');
const hasTopBar = title || showCount || topActions.length > 0 || topExtra;
const hasBottomBar = allowCopy || showClear || bottomActions.length > 0;
return (
<div className={cn('w-full flex flex-col gap-1.5', className)}>
{hasTopBar && (
<div className="flex items-center justify-between px-0.5">
<div className="flex items-center gap-2">
{title && <span className="text-xs font-semibold text-muted-foreground">{title}</span>}
{topExtra}
</div>
<div className="flex items-center gap-1.5">
{topActions.map((action) => (
<ActionButton
key={action.key}
action={action}
value={value}
globalDisabled={disabled}
onAction={handleAction}
/>
))}
{showCount && (
<span className="text-xs text-muted-foreground tabular-nums">
{value.length}
{maxLength ? ` / ${maxLength}` : ''}
</span>
)}
</div>
</div>
)}
<div
className={cn(
'rounded-md border border-input bg-background shadow-sm transition-all focus-within:ring-1 focus-within:ring-ring focus-within:border-input overflow-hidden',
displayError &&
'border-destructive focus-within:ring-destructive focus-within:border-destructive',
)}
>
<textarea
ref={internalRef}
value={value}
onChange={handleChange}
onBlur={handleBlur}
disabled={disabled}
autoFocus={autoFocus}
readOnly={readOnly}
placeholder={placeholder}
className="w-full bg-transparent px-4 py-3 font-mono text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/50 focus:outline-none resize-none border-0 block"
{...restProps}
/>
{hasBottomBar && (
<div className="flex h-10 items-center justify-between px-4 bg-muted/30 border-t border-border/50">
{/* 左侧自定义动作 */}
<div className="flex items-center gap-1.5 min-w-0">
{bottomActions.map((action) => (
<ActionButton
key={action.key}
action={action}
value={value}
globalDisabled={disabled}
onAction={handleAction}
/>
))}
</div>
{/* 右侧系统按钮组 */}
<div className="flex items-center gap-1.5 ml-auto shrink-0">
{allowCopy && value && (
<button
type="button"
onClick={handleCopy}
aria-label={t('textInputArea.copyContent')}
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-background/80 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<Copy className="h-4 w-4" />
</button>
)}
{showClear && value && !disabled && !readOnly && (
<button
type="button"
onClick={handleClear}
aria-label={t('textInputArea.clear')}
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" />
</button>
)}
</div>
</div>
)}
</div>
{/* 错误提示 */}
{displayError && (
<p className="text-xs font-medium text-destructive px-0.5 animate-in fade-in slide-in-from-top-1 duration-150">
{displayError}
</p>
)}
</div>
);
});
TextInputArea.displayName = 'TextInputArea';
export default TextInputArea;
-115
View File
@@ -1,115 +0,0 @@
import { Box, Typography, Stack } from '@mui/material';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; // Sparkles for AI
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import React from 'react';
interface ToolCardProps {
title: string;
description?: string;
snapshot?: React.ReactNode;
colorCode: string;
icon: React.ReactNode;
onClick: () => void;
hasAI?: boolean;
cardBackgroundColor?: string;
}
export default function ToolCard({ title, description, snapshot, colorCode, icon, onClick, hasAI, cardBackgroundColor = 'background.paper' }: ToolCardProps) {
return (
<Box
onClick={onClick}
sx={{
position: 'relative',
bgcolor: cardBackgroundColor,
borderRadius: 4,
p: 2.5,
cursor: 'pointer',
border: '1px solid',
borderColor: 'grey.100',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
display: 'flex',
flexDirection: 'column',
gap: 1.5,
'&:hover': {
borderColor: colorCode,
transform: 'translateY(-4px)',
boxShadow: `0 12px 24px -10px ${colorCode}33`, // 20% opacity of colorCode
'& .arrow-icon': {
transform: 'translateX(4px)',
color: colorCode
}
}
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Stack direction="row" spacing={1.5} alignItems="center">
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
borderRadius: 3,
bgcolor: `${colorCode}11`, // 7% opacity
color: colorCode
}}
>
{icon}
</Box>
<Box>
<Typography
variant="subtitle1"
sx={{
fontWeight: 700,
lineHeight: 1.2,
color: 'text.primary',
display: 'flex',
alignItems: 'center',
gap: 0.5
}}
>
{title}
{hasAI && <AutoAwesomeIcon sx={{ fontSize: 14, color: '#f5b041' }} />}
</Typography>
{description && (
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontWeight: 500,
display: 'block',
mt: 0.5
}}
>
{description}
</Typography>
)}
</Box>
</Stack>
<ArrowForwardIosIcon
className="arrow-icon"
sx={{
fontSize: 12,
color: 'grey.300',
mt: 0.5,
transition: 'all 0.3s ease'
}}
/>
</Stack>
{snapshot && (
<Box
sx={{
mt: 'auto',
pt: 1.5,
borderTop: '1px dashed',
borderColor: 'grey.100'
}}
>
{snapshot}
</Box>
)}
</Box>
);
}
+286 -64
View File
@@ -1,88 +1,310 @@
import { useMemo } from 'react';
import { Box, IconButton, Typography, Stack, Tooltip } from '@mui/material';
import SettingsIcon from '@mui/icons-material/Settings';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowLeft,
ExternalLink,
Globe,
History,
Monitor,
Moon,
Search,
Settings,
Sun,
X,
} from 'lucide-react';
import { useRouter } from '@/providers/RouterProvider';
import { useThemeMode } from '@/providers/ThemeModeProvider';
import { FeatureConfig, FEATURES } from '@/config/features';
import { storageUtil } from '@/utils/chromeStorage';
import { useTranslation } from 'react-i18next';
import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n';
import { cn } from '@/lib/utils'; // 1. 引入 shadcn 核心工具函数
// 常量配置抽取(无需写在全局变量或 styles 对象里)
const SEARCH_HISTORY_LIMIT = 10;
const SEARCH_HISTORY_DISPLAY = 5;
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
const { currentPage, goBack } = useRouter();
const { currentPage, goBack, navigateTo } = useRouter();
const { mode, setMode } = useThemeMode();
const { t, i18n } = useTranslation(['common', 'features']);
const isDetachedMode = useMemo(() => {
return new URLSearchParams(window.location.search).get('mode') === 'detached';
const [searchQuery, setSearchQuery] = useState('');
const [showResults, setShowResults] = useState(false);
const [searchHistory, setSearchHistory] = useState<string[]>([]);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const handleOpenInTab = async () => {
await openExtensionPage('popup.html', { mode: 'tab' });
window.close();
};
// 2. 健壮的 Click Outside 逻辑:点击空白处收起搜索框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setShowResults(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleDetach = () => {
// 弹出脱离窗口 (以独立面板形式打开当前 URL,并标记 mode=detached)
const url = new URL(window.location.href);
url.searchParams.set('mode', 'detached');
// 从 Chrome Storage 异步初始化历史记录
useEffect(() => {
storageUtil
.get('app/searchHistory', [])
.then((history) => {
if (history) setSearchHistory(history);
})
.catch((err) => console.error('加载搜索历史失败:', err));
}, []);
chrome.windows.create({
url: url.toString(),
type: 'panel',
width: 420,
height: 600
// 3. 模糊搜索匹配(移除了无意义的 dashboard 干扰项)
const searchResults = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) return [];
return FEATURES.filter((f) => {
if (f.key === 'dashboard') return false;
return (
t(f.labelKey).toLowerCase().includes(query) ||
t(f.descriptionKey).toLowerCase().includes(query)
);
});
}, [searchQuery, t]);
const displayedHistory = useMemo(() => {
if (searchQuery.trim()) return [];
return searchHistory.slice(0, SEARCH_HISTORY_DISPLAY);
}, [searchHistory, searchQuery]);
// 新增/持久化历史记录
const saveToHistory = async (query: string) => {
if (!query.trim()) return;
const nextHistory = [query, ...searchHistory.filter((h) => h !== query)].slice(
0,
SEARCH_HISTORY_LIMIT,
);
setSearchHistory(nextHistory);
await storageUtil.set('app/searchHistory', nextHistory).catch((err) => console.error(err));
};
const handleSelectFeature = (feature: FeatureConfig) => {
navigateTo(feature.key);
saveToHistory(t(feature.labelKey));
setSearchQuery('');
setShowResults(false);
};
const toggleLanguage = async () => {
const currentLng = normalizeLanguage(i18n.language);
const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLng);
const nextLng = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length];
await i18n.changeLanguage(nextLng);
await storageUtil.set('app/language', nextLng);
};
const cycleThemeMode = () => {
const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const;
setMode(nextMap[mode]);
};
const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor;
// 4. 健壮的键盘导航交互
const handleKeyDown = (e: React.KeyboardEvent) => {
const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length;
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0) {
if (searchQuery.trim()) {
handleSelectFeature(searchResults[selectedIndex]);
} else {
const selectedQuery = displayedHistory[selectedIndex];
setSearchQuery(selectedQuery);
setSelectedIndex(-1);
const matched = FEATURES.find(
(f) => f.key !== 'dashboard' && t(f.labelKey) === selectedQuery,
);
if (matched) handleSelectFeature(matched);
}
} else if (searchQuery.trim() && searchResults.length > 0) {
handleSelectFeature(searchResults[0]);
}
} else if (e.key === 'Escape') {
setShowResults(false);
inputRef.current?.blur();
}
};
const isDashboard = currentPage === 'dashboard';
return (
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
sx={{
px: 2,
py: 1.5,
borderBottom: '1px solid',
borderColor: 'grey.100',
bgcolor: 'background.paper',
zIndex: 1100
}}
>
<Box sx={{ width: 40 }}>
<header className="flex h-14 items-center justify-between border-b border-border bg-background px-4 relative z-50">
{/* 左侧:返回按钮区 */}
<div className="flex w-10 items-center justify-start">
{!isDashboard && (
<IconButton
size="small"
<button
type="button"
onClick={goBack}
sx={{
bgcolor: 'grey.50',
'&:hover': { bgcolor: 'grey.200' }
}}
aria-label={t('common:buttons.back')}
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"
>
<ArrowBackIosNewIcon sx={{ fontSize: 14 }} />
</IconButton>
<ArrowLeft className="h-4 w-4" />
</button>
)}
</Box>
</div>
<Typography
variant="subtitle2"
sx={{
fontWeight: 800,
letterSpacing: '0.5px',
textTransform: 'uppercase',
fontSize: '0.75rem',
color: 'text.secondary'
{/* 中间:搜索容器 */}
<div ref={containerRef} className="flex-1 mx-4 max-w-md relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<input
ref={inputRef}
type="text"
placeholder={t('common:buttons.search')}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setShowResults(true);
setSelectedIndex(-1);
}}
onFocus={() => setShowResults(true)}
onKeyDown={handleKeyDown}
aria-label={t('common:buttons.search')}
className="w-full h-9 pl-9 pr-8 text-sm rounded-md border border-input bg-muted/50 transition-all placeholder:text-muted-foreground focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input"
/>
{searchQuery && (
<button
type="button"
onClick={() => {
setSearchQuery('');
setSelectedIndex(-1);
}}
aria-label={t('common:buttons.clearSearch')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:text-foreground transition-colors"
>
Testing Tools
</Typography>
<Stack direction="row" spacing={1} sx={{ width: 80, justifyContent: 'flex-end' }}>
{!isDetachedMode && (
<Tooltip title="独立窗口模式">
<IconButton size="small" onClick={handleDetach}>
<OpenInNewIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<X className="h-3 w-3" />
</button>
)}
<Tooltip title="设置">
<IconButton size="small" onClick={onOpenOptions}>
<SettingsIcon sx={{ fontSize: 18 }} />
</div>
{/* 动态联想结果卡片 */}
{showResults && (searchQuery.trim() || displayedHistory.length > 0) && (
<div className="absolute top-full left-0 right-0 mt-1 bg-popover text-popover-foreground rounded-md shadow-md border border-border max-h-80 overflow-y-auto z-50 animate-in fade-in slide-in-from-top-1 duration-150">
<ul role="listbox" className="p-1">
{searchQuery.trim() ? (
searchResults.length > 0 ? (
searchResults.map((feature, index) => (
<li
key={feature.key}
role="option"
aria-selected={selectedIndex === index}
onClick={() => handleSelectFeature(feature)}
className={cn(
'flex items-center gap-3 px-2.5 py-2 rounded-sm cursor-pointer text-sm transition-colors',
selectedIndex === index
? 'bg-accent text-accent-foreground'
: 'hover:bg-muted/60',
)}
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-sm bg-muted text-muted-foreground">
{feature.icon && <feature.icon className="h-4 w-4" />}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate">
{t(feature.labelKey)}
</p>
<p className="text-xs text-muted-foreground truncate">
{t(feature.descriptionKey)}
</p>
</div>
</li>
))
) : (
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
{t('common:buttons.noResults')}
</li>
)
) : (
<>
<div className="px-2.5 py-1.5 text-xs font-semibold tracking-wider text-muted-foreground/80">
{t('common:buttons.recentSearch')}
</div>
{displayedHistory.map((item, index) => (
<li
key={item}
role="option"
aria-selected={selectedIndex === index}
onClick={() => {
setSearchQuery(item);
setSelectedIndex(-1);
}}
className={cn(
'flex items-center gap-3 px-2.5 py-2 rounded-sm cursor-pointer text-sm transition-colors',
selectedIndex === index
? 'bg-accent text-accent-foreground'
: 'hover:bg-muted/60',
)}
>
<History className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="truncate">{item}</span>
</li>
))}
</>
)}
</ul>
</div>
)}
</div>
{/* 右侧:操作区 */}
<div className="flex items-center gap-1 shrink-0">
<IconButton onClick={toggleLanguage} title={t('common:buttons.toggleLanguage')}>
<Globe className="h-4 w-4" />
</IconButton>
</Tooltip>
</Stack>
</Stack>
<IconButton onClick={cycleThemeMode} title={t(`common:buttons.themeMode.${mode}`)}>
<ThemeIcon className="h-4 w-4" />
</IconButton>
<IconButton onClick={handleOpenInTab} title={t('common:buttons.openInTab')}>
<ExternalLink className="h-4 w-4" />
</IconButton>
<IconButton onClick={onOpenOptions} title={t('common:buttons.settings')}>
<Settings className="h-4 w-4" />
</IconButton>
</div>
</header>
);
}
// 5. 提炼出高度复用的原子按钮,大幅精简 Tailwind 冗余,符合 shadcn 的灵巧风格
function IconButton({
children,
onClick,
title,
}: {
children: React.ReactNode;
onClick: () => void;
title: string;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
aria-label={title}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{children}
</button>
);
}
-111
View File
@@ -1,111 +0,0 @@
import { useState } from 'react';
import { Box, TextField, Alert, Stack } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import Button from '@/components/Button';
import type { OpenUrlEntry } from '@/types/storage';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
import { openUrlPageStyles } from '@/config/pageTheme';
interface UrlEntryFormProps {
onAddEntry: (entry: OpenUrlEntry) => void;
showMessage: (message: string, options?: SnackbarOptions) => void;
}
const UrlEntryForm = ({ onAddEntry, showMessage }: UrlEntryFormProps) => {
const [newName, setNewName] = useState<string>('');
const [newUrl, setNewUrl] = useState<string>('');
const showMixedContentWarning =
newUrl.startsWith('http://') && !newUrl.includes('localhost') && !newUrl.includes('127.0.0.1');
const isValidUrl = (url: string) => {
if (!url.trim()) return false;
try {
new URL(url);
return true;
} catch {
return false;
}
};
const handleAddEntry = () => {
if (!newName.trim()) {
showMessage('请输入名称', { severity: 'error' });
return;
}
if (!isValidUrl(newUrl)) {
showMessage('请输入有效的 URL', { severity: 'error' });
return;
}
onAddEntry({ name: newName.trim(), url: newUrl.trim() });
setNewName('');
setNewUrl('');
showMessage('添加成功', { severity: 'success' });
};
return (
<Box
sx={{
bgcolor: 'background.paper',
p: 2,
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.100',
mb: 3,
boxShadow: '0 4px 12px rgba(0,0,0,0.02)',
}}
>
<Stack spacing={2}>
<TextField
label="环境名称"
placeholder="例如: 本地文档"
value={newName}
onChange={(e) => setNewName(e.target.value)}
fullWidth
variant="outlined"
sx={openUrlPageStyles.INPUT_STYLE}
/>
<TextField
label="目标 URL"
placeholder="例如: http://localhost:8000/docs"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
fullWidth
variant="outlined"
sx={openUrlPageStyles.INPUT_STYLE}
/>
{showMixedContentWarning && (
<Alert
severity="warning"
sx={{
borderRadius: 3,
'& .MuiAlert-message': { fontSize: '0.7rem', fontWeight: 600, lineHeight: 1.4 },
}}
>
HTTPS HTTP
</Alert>
)}
<Button
variant="contained"
onClick={handleAddEntry}
disabled={!newName.trim() || !isValidUrl(newUrl)}
fullWidth
startIcon={<AddIcon />}
sx={{
bgcolor: openUrlPageStyles.themeColor,
'&:hover': {
bgcolor: openUrlPageStyles.primaryDark,
},
}}
>
</Button>
</Stack>
</Box>
);
};
export default UrlEntryForm;
-142
View File
@@ -1,142 +0,0 @@
import { Fragment } from 'react';
import { Box, ListItem, Typography, Stack, Divider, Tooltip, IconButton } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import VisibilityIcon from '@mui/icons-material/Visibility';
import { alpha } from '@mui/material/styles';
import { storageUtil } from '@/utils/chromeStorage';
import type { OpenUrlEntry } from '@/types/storage';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
import { openUrlPageStyles } from '@/config/pageTheme';
interface UrlEntryItemProps {
entry: OpenUrlEntry;
index: number;
isLast: boolean;
onDelete: (index: number) => void;
showMessage: (message: string, options?: SnackbarOptions) => void;
}
const UrlEntryItem = ({ entry, index, isLast, onDelete, showMessage }: UrlEntryItemProps) => {
const handleOpenInSidebar = async (entry: OpenUrlEntry) => {
try {
// 存储目标 URL
await storageUtil.set('openUrl/currentUrl', entry.url);
// 直接设置侧边栏的路由,而不是通过 syncNavigation 影响弹窗路由
await storageUtil.set('app/sidepanelRoute', 'openUrlViewer');
const [currentTab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
const tabId = currentTab.id;
if (!tabId) {
showMessage('无法获取当前标签页', { severity: 'error' });
return;
}
await chrome.sidePanel.setOptions({
tabId,
path: 'sidepanel.html',
enabled: true,
});
await chrome.sidePanel.open({ windowId: currentTab.windowId });
// 仅当在 Popup 中时才关闭窗口,防止在侧边栏内点击预览时导致侧边栏关闭
if (window.location.pathname.includes('popup.html')) {
window.close();
}
} catch (error) {
console.error('Failed to open side panel:', error);
showMessage(`打开失败: ${(error as Error).message}`, { severity: 'error' });
}
};
const handleOpenInNewTab = (entry: OpenUrlEntry) => {
chrome.tabs.create({ url: entry.url }).catch(console.error);
window.close();
};
const handleDelete = () => {
onDelete(index);
};
return (
<Fragment>
<ListItem
sx={{
px: 2,
py: 1.5,
display: 'flex',
alignItems: 'center',
gap: 2,
transition: 'background-color 0.2s',
'&:hover': { bgcolor: 'grey.50' },
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 800, color: 'text.primary' }} noWrap>
{entry.name}
</Typography>
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{
fontSize: '0.65rem',
fontWeight: 500,
display: 'block',
mt: 0.2,
fontFamily: 'monospace',
}}
>
{entry.url}
</Typography>
</Box>
<Stack direction="row" spacing={0.5}>
<Tooltip title="在侧边栏预览">
<IconButton
size="small"
onClick={() => handleOpenInSidebar(entry)}
sx={{
color: openUrlPageStyles.themeColor,
bgcolor: alpha(openUrlPageStyles.themeColor, 0.05),
'&:hover': { bgcolor: openUrlPageStyles.themeColor, color: '#fff' },
}}
>
<VisibilityIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Tooltip title="新标签页打开">
<IconButton
size="small"
onClick={() => handleOpenInNewTab(entry)}
sx={{
color: 'grey.500',
bgcolor: 'grey.100',
'&:hover': { bgcolor: 'grey.600', color: '#fff' },
}}
>
<OpenInNewIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Tooltip title="删除">
<IconButton
size="small"
onClick={handleDelete}
sx={{
color: 'error.main',
'&:hover': { color: 'error.dark', bgcolor: alpha('#f44336', 0.05) },
}}
>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Stack>
</ListItem>
{!isLast && <Divider sx={{ mx: 2, borderColor: 'grey.50' }} />}
</Fragment>
);
};
export default UrlEntryItem;
-63
View File
@@ -1,63 +0,0 @@
import { Box, List, Typography } from '@mui/material';
import LinkIcon from '@mui/icons-material/Link';
import UrlEntryItem from './UrlEntryItem';
import type { OpenUrlEntry } from '@/types/storage';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
interface UrlEntryListProps {
entries: OpenUrlEntry[];
onDeleteEntry: (index: number) => void;
showMessage: (message: string, options?: SnackbarOptions) => void;
}
const UrlEntryList = ({ entries, onDeleteEntry, showMessage }: UrlEntryListProps) => {
if (entries.length === 0) {
return (
<Box
sx={{
textAlign: 'center',
py: 4,
bgcolor: 'grey.50',
borderRadius: 4,
border: '1px dashed',
borderColor: 'grey.200',
}}
>
<LinkIcon sx={{ color: 'grey.300', fontSize: 40, mb: 1 }} />
<Typography
variant="caption"
color="text.disabled"
sx={{ display: 'block', fontWeight: 600 }}
>
</Typography>
</Box>
);
}
return (
<List
disablePadding
sx={{
bgcolor: 'background.paper',
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.100',
overflow: 'hidden',
}}
>
{entries.map((entry, index) => (
<UrlEntryItem
key={index}
entry={entry}
index={index}
isLast={index === entries.length - 1}
onDelete={onDeleteEntry}
showMessage={showMessage}
/>
))}
</List>
);
};
export default UrlEntryList;
-229
View File
@@ -1,229 +0,0 @@
import { useState } from 'react';
import {
Box,
Typography,
TextField,
Button,
Stack,
Accordion,
AccordionSummary,
AccordionDetails,
CircularProgress,
} from '@mui/material';
import QrCodeIcon from '@mui/icons-material/QrCode';
import DownloadIcon from '@mui/icons-material/Download';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import qrcode from 'qrcode';
import { qrCodePageStyles } from '@/config/pageTheme';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
interface UrlToQrCodeSectionProps {
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
showMessage: (message: string, options?: SnackbarOptions) => void;
}
const UrlToQrCodeSection = ({
expanded,
onExpandedChange,
showMessage,
}: UrlToQrCodeSectionProps) => {
const [urlInput, setUrlInput] = useState('');
const [urlError, setUrlError] = useState('');
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
const [generating, setGenerating] = useState(false);
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUrlInput(e.target.value);
setUrlError('');
};
const generateQrCode = async () => {
if (!urlInput) {
setUrlError('请输入 URL');
return;
}
try {
setGenerating(true);
setUrlError('');
let url = urlInput;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
const dataUrl = await qrcode.toDataURL(url, {
width: 200,
margin: 2,
color: {
dark: qrCodePageStyles.black,
light: qrCodePageStyles.white,
},
});
setQrCodeDataUrl(dataUrl);
showMessage('二维码生成成功', { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('生成二维码失败:', error);
showMessage('生成二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
} finally {
setGenerating(false);
}
};
const downloadQrCode = () => {
if (!qrCodeDataUrl) return;
const link = document.createElement('a');
link.href = qrCodeDataUrl;
link.download = 'qrcode.png';
link.click();
showMessage('二维码下载成功', { severity: 'success', autoHideDuration: 300 });
};
const copyQrCode = async () => {
if (!qrCodeDataUrl) return;
try {
const response = await fetch(qrCodeDataUrl);
const blob = await response.blob();
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob,
}),
]);
showMessage('二维码已复制到剪贴板', { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('复制二维码失败:', error);
showMessage('复制二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
}
};
return (
<Accordion
expanded={expanded}
onChange={(_, isExpanded) => onExpandedChange(isExpanded)}
sx={{
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
'&:before': { display: 'none' },
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ borderBottom: 'none' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<QrCodeIcon color="primary" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
URL
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={3}>
<TextField
label="输入 URL"
placeholder="https://example.com"
value={urlInput}
onChange={handleUrlInputChange}
fullWidth
variant="outlined"
error={!!urlError}
helperText={urlError}
sx={qrCodePageStyles.INPUT_STYLE}
/>
<Button
variant="contained"
startIcon={generating ? <CircularProgress size={16} color="inherit" /> : <QrCodeIcon />}
onClick={generateQrCode}
disabled={generating}
sx={{
py: 1.2,
borderRadius: 3,
bgcolor: qrCodePageStyles.successColor,
fontWeight: 700,
'&:hover': {
bgcolor: qrCodePageStyles.successDark,
},
}}
>
{generating ? '生成中...' : '生成二维码'}
</Button>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
minHeight: 200,
border: '2px dashed',
borderColor: 'grey.200',
borderRadius: 3,
p: 2,
bgcolor: 'grey.50',
}}
>
{qrCodeDataUrl ? (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '100%',
}}
>
<img
src={qrCodeDataUrl}
alt="QR Code"
style={{ maxWidth: '100%', height: 'auto', display: 'block' }}
/>
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={downloadQrCode}
sx={{
borderRadius: 2,
borderColor: qrCodePageStyles.successColor,
color: qrCodePageStyles.successColor,
'&:hover': {
borderColor: qrCodePageStyles.successDark,
bgcolor: 'rgba(76, 175, 80, 0.05)',
},
}}
>
</Button>
<Button
variant="contained"
startIcon={<ContentCopyIcon />}
onClick={copyQrCode}
sx={{
borderRadius: 2,
bgcolor: qrCodePageStyles.successColor,
'&:hover': {
bgcolor: qrCodePageStyles.successDark,
},
}}
>
</Button>
</Box>
</Box>
) : (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
</Typography>
)}
</Box>
</Stack>
</AccordionDetails>
</Accordion>
);
};
export default UrlToQrCodeSection;
-77
View File
@@ -1,77 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from '../Button';
describe('Button 组件', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('渲染测试', () => {
it('应使用默认属性渲染', () => {
render(<Button></Button>);
const button = screen.getByRole('button', { name: /点击我/i });
expect(button).toBeInTheDocument();
});
it('应渲染自定义文本', () => {
render(<Button></Button>);
expect(screen.getByRole('button', { name: /提交/i })).toBeInTheDocument();
});
it('应渲染不同变体', () => {
const { rerender } = render(<Button variant="contained"></Button>);
expect(screen.getByRole('button', { name: /填充/i })).toBeInTheDocument();
rerender(<Button variant="outlined"></Button>);
expect(screen.getByRole('button', { name: /描边/i })).toBeInTheDocument();
rerender(<Button variant="text"></Button>);
expect(screen.getByRole('button', { name: /文本/i })).toBeInTheDocument();
});
});
describe('交互测试', () => {
it('点击时应调用 onClick', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}></Button>);
fireEvent.click(screen.getByRole('button', { name: /点击我/i }));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('禁用状态下点击不应调用 onClick', () => {
const handleClick = vi.fn();
render(
<Button onClick={handleClick} disabled>
</Button>,
);
fireEvent.click(screen.getByRole('button', { name: /禁用按钮/i }));
expect(handleClick).not.toHaveBeenCalled();
});
});
describe('样式测试', () => {
it('应应用 fullWidth 属性', () => {
render(<Button fullWidth></Button>);
const button = screen.getByRole('button', { name: /全宽/i });
expect(button).toHaveClass('MuiButton-fullWidth');
});
});
describe('状态测试', () => {
it('应渲染加载状态', () => {
render(<Button loading></Button>);
const button = screen.getByRole('button', { name: /加载中/i });
expect(button).toHaveClass('MuiButton-loading');
});
it('应渲染为禁用状态', () => {
render(<Button disabled></Button>);
const button = screen.getByRole('button', { name: /禁用/i });
expect(button).toBeDisabled();
});
});
});
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import DecodeResultPaper from '@/components/DecodeResultPaper';
describe('DecodeResultPaper 组件', () => {
const defaultProps = {
title: 'decodedFileOutput',
mimeType: 'image/png',
blobSize: 1024,
fileName: 'decoded.png',
onFileNameChange: vi.fn(),
onDownload: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('渲染测试', () => {
it('应渲染标题', () => {
render(<DecodeResultPaper {...defaultProps} />);
expect(screen.getByText('decodedFileOutput')).toBeInTheDocument();
});
it('应渲染 MIME 类型信息', () => {
render(<DecodeResultPaper {...defaultProps} />);
expect(screen.getByText(/image\/png/)).toBeInTheDocument();
});
it('应通过 formatFileSize 渲染文件大小', () => {
render(<DecodeResultPaper {...{ ...defaultProps, blobSize: 1536 }} />);
expect(screen.getByText(/1\.5 KB/)).toBeInTheDocument();
});
it('应渲染文件名输入框', () => {
render(<DecodeResultPaper {...defaultProps} />);
const input = screen.getByDisplayValue('decoded.png');
expect(input).toBeInTheDocument();
});
it('应渲染下载按钮', () => {
render(<DecodeResultPaper {...defaultProps} />);
expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeInTheDocument();
});
it('应渲染 children 内容', () => {
render(
<DecodeResultPaper {...defaultProps}>
<div data-testid="preview"></div>
</DecodeResultPaper>,
);
expect(screen.getByTestId('preview')).toBeInTheDocument();
expect(screen.getByText('预览内容')).toBeInTheDocument();
});
});
describe('交互测试', () => {
it('修改文件名时应调用 onFileNameChange', () => {
render(<DecodeResultPaper {...defaultProps} />);
const input = screen.getByDisplayValue('decoded.png');
fireEvent.change(input, { target: { value: 'new-name.png' } });
expect(defaultProps.onFileNameChange).toHaveBeenCalledWith('new-name.png');
});
it('点击下载按钮时应调用 onDownload', () => {
render(<DecodeResultPaper {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: 'base64Converter:download' }));
expect(defaultProps.onDownload).toHaveBeenCalledTimes(1);
});
});
describe('按钮状态', () => {
it('文件名为空时下载按钮应禁用', () => {
render(<DecodeResultPaper {...{ ...defaultProps, fileName: '' }} />);
expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeDisabled();
});
it('文件名不为空时下载按钮应启用', () => {
render(<DecodeResultPaper {...defaultProps} />);
expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeEnabled();
});
});
});
+102
View File
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { ErrorBoundary } from '@/components/ErrorBoundary';
// 用于触发错误的测试子组件
function ThrowError({ message }: { message: string }): never {
throw new Error(message);
}
// 正常渲染的子组件
function NormalComponent({ text }: { text: string }) {
return <div data-testid="normal-content">{text}</div>;
}
describe('ErrorBoundary', () => {
beforeEach(() => {
// 抑制测试中故意抛出的错误日志
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('正常渲染子组件', () => {
render(
<ErrorBoundary>
<NormalComponent text="正常内容" />
</ErrorBoundary>,
);
expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容');
});
it('子组件抛出错误时显示错误 UI', () => {
render(
<ErrorBoundary>
<ThrowError message="测试错误" />
</ErrorBoundary>,
);
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
expect(screen.getByText(/测试错误/)).toBeInTheDocument();
});
it('children 变化时重置错误状态', async () => {
const { rerender } = render(
<ErrorBoundary>
<ThrowError message="初始错误" />
</ErrorBoundary>,
);
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
// 切换到正常子组件
rerender(
<ErrorBoundary>
<NormalComponent text="恢复后的内容" />
</ErrorBoundary>,
);
await waitFor(() => {
expect(screen.getByTestId('normal-content')).toHaveTextContent('恢复后的内容');
});
expect(screen.queryByText('糟糕,出了点问题')).not.toBeInTheDocument();
});
it('相同的 children 不重置错误状态', () => {
const { rerender } = render(
<ErrorBoundary>
<ThrowError message="相同子组件错误" />
</ErrorBoundary>,
);
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
// 用相同的 children rerender
rerender(
<ErrorBoundary>
<ThrowError message="相同子组件错误" />
</ErrorBoundary>,
);
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
});
it('错误 UI 包含刷新按钮', () => {
const reloadMock = vi.fn();
Object.defineProperty(window, 'location', {
value: { reload: reloadMock },
writable: true,
});
render(
<ErrorBoundary>
<ThrowError message="按钮测试" />
</ErrorBoundary>,
);
const refreshButton = screen.getByRole('button', { name: /刷新应用/ });
expect(refreshButton).toBeInTheDocument();
refreshButton.click();
expect(reloadMock).toHaveBeenCalledTimes(1);
});
});
+52 -43
View File
@@ -1,6 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, act, renderHook } from '@testing-library/react';
import { GlobalSnackbar, useSnackbarState, type GlobalSnackbarProps } from '../GlobalSnackbar';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, renderHook, screen } from '@testing-library/react';
import React from 'react';
import {
GlobalSnackbar,
type GlobalSnackbarProps,
SnackbarProvider,
useSnackbar,
useSnackbarState,
} from '@/components/GlobalSnackbar';
describe('GlobalSnackbar 组件系统', () => {
const mockOnClose = vi.fn();
@@ -22,87 +29,89 @@ describe('GlobalSnackbar 组件系统', () => {
};
describe('GlobalSnackbar UI 渲染', () => {
it('应渲染消息内容并由于使用了 Portal 出现在 body 中', () => {
it('应渲染消息内容', () => {
render(<GlobalSnackbar {...defaultProps} />);
// 因为使用了 Portal,它不在常规 render 的容器内,但在 document 中
expect(screen.getByText('测试消息')).toBeInTheDocument();
});
it('当 showAlert 为 true 时应渲染 MUI Alert 样式', () => {
it('当 showAlert 为 true 时应渲染带样式的提示', () => {
render(<GlobalSnackbar {...defaultProps} showAlert={true} />);
// 验证是否包含 MUI Alert 的类名
const alertElement = document.querySelector('.MuiAlert-root');
// 验证是否包含消息文本
const alertElement = screen.getByText('测试消息');
expect(alertElement).toBeInTheDocument();
expect(alertElement).toHaveTextContent('测试消息');
// 验证父元素有正确的样式类
const parent = alertElement.parentElement;
expect(parent).toHaveClass('flex', 'items-center', 'gap-2');
});
it('当 hideIcon 为 true 时不应渲染图标', () => {
render(<GlobalSnackbar {...defaultProps} hideIcon={true} />);
// MUI Alert 图标通常在 .MuiAlert-icon 中
const icon = document.querySelector('.MuiAlert-icon');
// 图标使用 lucide-react 的 svg 元素
const icon = document.querySelector('svg');
expect(icon).not.toBeInTheDocument();
});
it('应根据 severity 应用不同的样式 (通过检查 style 或 class)', () => {
it('应根据 severity 应用不同的样式', () => {
render(<GlobalSnackbar {...defaultProps} severity="error" />);
const alert = document.querySelector('.MuiAlert-filledError');
expect(alert).toBeInTheDocument();
const message = screen.getByText('测试消息');
const parent = message.parentElement;
expect(parent).toHaveClass('bg-red-500');
});
});
describe('useSnackbarState Hook 逻辑', () => {
it('应能正确初始化并更新状态', () => {
const { result } = renderHook(() => useSnackbarState({ severity: 'warning' }));
it('应返回初始状态', () => {
const { result } = renderHook(() => useSnackbarState());
expect(result.current.snackbarProps.open).toBe(false);
act(() => {
result.current.showMessage('新提醒', { severity: 'success' });
expect(result.current.snackbarProps.message).toBe('');
});
expect(result.current.snackbarProps.open).toBe(true);
expect(result.current.snackbarProps.message).toBe('新提醒');
expect(result.current.snackbarProps.severity).toBe('success');
});
it('closeMessage 应立即关闭 Snackbar', () => {
it('showMessage 应更新状态', () => {
const { result } = renderHook(() => useSnackbarState());
act(() => {
result.current.showMessage('测试');
result.current.showMessage('新消息');
});
expect(result.current.snackbarProps.open).toBe(true);
expect(result.current.snackbarProps.open).toBe(true);
expect(result.current.snackbarProps.message).toBe('新消息');
});
it('closeMessage 应关闭消息', () => {
const { result } = renderHook(() => useSnackbarState());
act(() => {
result.current.showMessage('消息');
});
act(() => {
result.current.closeMessage();
});
expect(result.current.snackbarProps.open).toBe(false);
});
});
describe('交互与自动隐藏', () => {
it('在 autoHideDuration 结束后应触发 onClose', () => {
render(<GlobalSnackbar {...defaultProps} autoHideDuration={3000} />);
describe('useSnackbar Context Hook 优先级', () => {
it('优先级验证: Call Options > Hook Options > Provider Options', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<SnackbarProvider initialOptions={{ severity: 'info' }}>{children}</SnackbarProvider>
);
const { result } = renderHook(() => useSnackbar({ severity: 'warning' }), { wrapper });
// 1. 测试 Hook Options 覆盖 Provider Options
act(() => {
vi.advanceTimersByTime(3000);
result.current.showMessage('消息 1');
});
expect(mockOnClose).toHaveBeenCalled();
});
expect(screen.getByText('消息 1')).toBeInTheDocument();
it('当 reason 为 clickaway 时不应调用 onClose (源码逻辑验证)', () => {
const { result } = renderHook(() => useSnackbarState());
// 模拟 MUI 的 handleClose 被 clickaway 触发
// 2. 测试 Call Options 覆盖 Hook Options
act(() => {
result.current.snackbarProps.onClose();
result.current.showMessage('消息 2', { severity: 'error' });
});
// 状态应该保持 open: true
expect(result.current.snackbarProps.open).toBe(false);
// 注意:此处取决于你对 useSnackbarState 的期望。
// 源码中 handleClose 拦截了 clickaway,所以 open 不会变为 false。
expect(screen.getByText('消息 2')).toBeInTheDocument();
});
});
});
+215
View File
@@ -0,0 +1,215 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import ImageUploader from '@/components/ImageUploader';
// 配置多端一致性常驻桩(WXT 规范)
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
// 💡 1. 规范对齐:挂载标准的 react-i18next 统一桩函数,防止多进程前缀破产
vi.mock('react-i18next', () => ({
useTranslation: vi.fn((ns: string | string[]) => {
const nsArray = Array.isArray(ns) ? ns : [ns];
return {
t: (key: string) => `${nsArray.join(',')}:${key}`,
i18n: { language: 'en' },
ready: true,
};
}),
}));
// 模拟 URL API
const mockCreateObjectURL = vi.fn();
const mockRevokeObjectURL = vi.fn();
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
// 模拟 showMessage
vi.mock('@/components/GlobalSnackbar', () => ({
useSnackbar: () => ({
showMessage: vi.fn(),
}),
}));
describe('ImageUploader 组件', () => {
const mockOnFileChange = vi.fn();
const mockOnClearFile = vi.fn();
const mockOnPreviewUrlChange = vi.fn();
const mockOnDraggingChange = vi.fn();
const defaultProps = {
selectedFile: null,
onFileChange: mockOnFileChange,
onClearFile: mockOnClearFile,
previewUrl: '',
onPreviewUrlChange: mockOnPreviewUrlChange,
dragging: false,
onDraggingChange: mockOnDraggingChange,
};
beforeEach(() => {
vi.clearAllMocks();
mockCreateObjectURL.mockReturnValue('blob:test-url');
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetAllMocks();
});
describe('渲染测试', () => {
it('当没有选中文件时应显示上传提示', () => {
render(<ImageUploader {...defaultProps} />);
// 💡 修复点 2:全面切换为高弹性正则,斩断双重命名空间死锁!
expect(screen.getByText(/clickToUpload/)).toBeInTheDocument();
expect(screen.getByText(/supportFormats/)).toBeInTheDocument();
});
it('当没有选中文件时应显示 ImageIcon', () => {
render(<ImageUploader {...defaultProps} />);
expect(screen.getByTestId('ImageIcon')).toBeInTheDocument();
});
it('当选中文件时应显示文件预览', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
expect(screen.getByText('test.png')).toBeInTheDocument();
// 💡 修复点 3(自愈第 62 行崩溃位置):利用正则模糊命中,彻底通过!
expect(screen.getByText(/clickToChange/)).toBeInTheDocument();
});
it('当选中文件时应显示预览图片', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
const img = screen.getByAltText('QR Code Preview');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', 'blob:test-url');
});
it('当选中文件时应显示清除按钮', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
expect(screen.getByTestId('ClearIcon')).toBeInTheDocument();
});
it('应包含隐藏的文件输入框', () => {
render(<ImageUploader {...defaultProps} />);
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('type', 'file');
expect(input).toHaveAttribute('accept', 'image/*');
});
});
describe('文件选择交互', () => {
it('选择文件时应调用 onFileChange 和 onPreviewUrlChange', async () => {
render(<ImageUploader {...defaultProps} />);
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
await act(async () => {
fireEvent.change(input, { target: { files: [mockFile] } });
});
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockFile);
expect(mockOnPreviewUrlChange).toHaveBeenCalledWith('blob:test-url');
});
});
describe('拖拽交互', () => {
it('拖拽进入时应调用 onDraggingChange(true)', () => {
const { container } = render(<ImageUploader {...defaultProps} />);
const dropzone = container.firstChild as HTMLElement;
fireEvent.dragOver(dropzone);
expect(mockOnDraggingChange).toHaveBeenCalledWith(true);
});
it('拖拽离开时应调用 onDraggingChange(false)', () => {
const { container } = render(<ImageUploader {...defaultProps} />);
const dropzone = container.firstChild as HTMLElement;
fireEvent.dragLeave(dropzone);
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
});
it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => {
const { container } = render(<ImageUploader {...defaultProps} />);
const dropzone = container.firstChild as HTMLElement;
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
const dropEvent = new Event('drop', { bubbles: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: {
files: [mockFile],
},
});
Object.defineProperty(dropEvent, 'preventDefault', {
value: vi.fn(),
});
fireEvent(dropzone, dropEvent);
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
});
});
describe('清除文件功能', () => {
it('点击清除按钮时应调用 onClearFile', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
fireEvent.click(clearButton);
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
expect(mockOnClearFile).toHaveBeenCalledTimes(1);
});
});
describe('粘贴功能', () => {
it('监听粘贴事件', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
render(<ImageUploader {...defaultProps} />);
expect(addEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
});
it('组件卸载时应移除粘贴事件监听', () => {
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
const { unmount } = render(<ImageUploader {...defaultProps} />);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
});
});
describe('样式测试', () => {
it('拖拽状态时应应用拖拽样式', () => {
const { container } = render(<ImageUploader {...defaultProps} dragging={true} />);
const dropzone = container.firstChild as HTMLElement;
expect(dropzone).toBeInTheDocument();
});
it('有文件时应应用有文件样式', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
const { container } = render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
const dropzone = container.firstChild as HTMLElement;
expect(dropzone).toBeInTheDocument();
});
});
});
@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { PageErrorBoundary } from '@/components/PageErrorBoundary';
// 用于触发错误的测试子组件
function ThrowError({ message }: { message: string }): never {
throw new Error(message);
}
// 正常渲染的子组件
function NormalComponent({ text }: { text: string }) {
return <div data-testid="normal-content">{text}</div>;
}
describe('PageErrorBoundary', () => {
beforeEach(() => {
// 抑制测试中故意抛出的错误日志
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('正常渲染子组件', () => {
render(
<PageErrorBoundary>
<NormalComponent text="正常内容" />
</PageErrorBoundary>,
);
expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容');
});
it('子组件抛出错误时显示错误卡片 UI', () => {
render(
<PageErrorBoundary>
<ThrowError message="测试错误" />
</PageErrorBoundary>,
);
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
expect(screen.getByText(/测试错误/)).toBeInTheDocument();
});
it('点击重试按钮后恢复', async () => {
const { rerender } = render(
<PageErrorBoundary>
<ThrowError message="可恢复错误" />
</PageErrorBoundary>,
);
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
// 将子组件替换为正常组件,然后点击重试
rerender(
<PageErrorBoundary>
<NormalComponent text="恢复后的内容" />
</PageErrorBoundary>,
);
const retryButton = screen.getByRole('button', { name: /重新尝试/ });
retryButton.click();
await waitFor(() => {
expect(screen.getByTestId('normal-content')).toHaveTextContent('恢复后的内容');
});
expect(screen.queryByText('该功能运行异常')).not.toBeInTheDocument();
});
it('resetKey 变化时自动重置错误状态', async () => {
const { rerender } = render(
<PageErrorBoundary resetKey="page-a">
<ThrowError message="页面 A 错误" />
</PageErrorBoundary>,
);
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
// 切换 resetKey,同时提供正常子组件
rerender(
<PageErrorBoundary resetKey="page-b">
<NormalComponent text="页面 B 内容" />
</PageErrorBoundary>,
);
await waitFor(() => {
expect(screen.getByTestId('normal-content')).toHaveTextContent('页面 B 内容');
});
expect(screen.queryByText('该功能运行异常')).not.toBeInTheDocument();
});
it('resetKey 不变时保持错误状态', () => {
const { rerender } = render(
<PageErrorBoundary resetKey="page-a">
<ThrowError message="初始错误" />
</PageErrorBoundary>,
);
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
// 仅 children 变化,resetKey 不变,错误应保持
rerender(
<PageErrorBoundary resetKey="page-a">
<NormalComponent text="新内容" />
</PageErrorBoundary>,
);
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
});
it('错误 UI 包含重试按钮', () => {
render(
<PageErrorBoundary>
<ThrowError message="按钮测试" />
</PageErrorBoundary>,
);
const retryButton = screen.getByRole('button', { name: /重新尝试/ });
expect(retryButton).toBeInTheDocument();
});
it('错误信息以 monospace 格式显示', () => {
render(
<PageErrorBoundary>
<ThrowError message="格式化测试" />
</PageErrorBoundary>,
);
const errorText = screen.getByText(/格式化测试/);
expect(errorText).toBeInTheDocument();
expect(errorText.tagName.toLowerCase()).toBe('pre');
});
});
-106
View File
@@ -1,106 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CloseIcon from '@mui/icons-material/Close';
import { render, screen } from '@testing-library/react';
import PageHeader, { type PageHeaderProps } from '../PageHeader';
describe('PageHeader 组件系统', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetAllMocks();
});
const defaultProps: PageHeaderProps = {
icon: <AccessTimeIcon />,
title: '时间戳转换',
subtitle: 'Unix 毫秒数转换与格式化',
};
describe('PageHeader UI 渲染', () => {
it('应渲染页面标题栏&副标题', () => {
render(<PageHeader {...defaultProps} />);
expect(screen.getByText('时间戳转换')).toBeInTheDocument();
expect(screen.getByText('Unix 毫秒数转换与格式化')).toBeInTheDocument();
});
it('应渲染图标', () => {
render(<PageHeader {...defaultProps} />);
expect(screen.getByTestId('AccessTimeIcon')).toBeInTheDocument();
});
it('应渲染自定义图标&图标颜色', () => {
render(<PageHeader {...defaultProps} icon={<CloseIcon />} iconColor="#FF0000" />);
expect(screen.getByTestId('CloseIcon')).toBeInTheDocument();
expect(screen.getByTestId('CloseIcon')).toHaveStyle('color: #FF0000;');
});
it('应渲染 badge 组件', () => {
const badge = <span data-testid="test-badge">New</span>;
render(<PageHeader {...defaultProps} badge={badge} />);
expect(screen.getByTestId('test-badge')).toBeInTheDocument();
expect(screen.getByText('New')).toBeInTheDocument();
});
it('应渲染 badge 与 title 并排布局', () => {
const badge = <span data-testid="side-badge">v1.0</span>;
render(<PageHeader {...defaultProps} badge={badge} />);
const title = screen.getByText('时间戳转换');
const badgeEl = screen.getByTestId('side-badge');
expect(title).toBeInTheDocument();
expect(badgeEl).toBeInTheDocument();
});
});
describe('PageHeader 条件渲染', () => {
it('subtitle 为 undefined 时不应渲染副标题', () => {
const { container } = render(<PageHeader icon={<AccessTimeIcon />} title="仅标题" />);
const captionElements = container.querySelectorAll('p');
expect(captionElements.length).toBe(0);
});
it('subtitle 为空字符串时不应渲染副标题', () => {
const { container } = render(
<PageHeader icon={<AccessTimeIcon />} title="标题" subtitle="" />,
);
const captionElements = container.querySelectorAll('p');
expect(captionElements.length).toBe(0);
});
it('badge 为 undefined 时不应渲染 badge 区域', () => {
render(<PageHeader {...defaultProps} />);
expect(screen.queryByText('v1.0')).not.toBeInTheDocument();
});
});
describe('PageHeader 样式扩展', () => {
it('iconSx 应作为属性传递给图标容器', () => {
const { container } = render(
<PageHeader {...defaultProps} iconSx={{ border: '2px solid red' }} />,
);
const iconContainer = container.querySelector('div');
expect(iconContainer).toBeTruthy();
});
it('titleSx 应作为属性传递给标题', () => {
render(<PageHeader {...defaultProps} titleSx={{ fontWeight: 'bold' }} />);
const titleEl = screen.getByText('时间戳转换');
expect(titleEl).toBeInTheDocument();
});
it('subtitleSx 应作为属性传递给副标题', () => {
render(<PageHeader {...defaultProps} subtitleSx={{ color: 'red' }} />);
const subtitleEl = screen.getByText('Unix 毫秒数转换与格式化');
expect(subtitleEl).toBeInTheDocument();
});
it('sx 应作为属性传递给外层容器', () => {
const { container } = render(<PageHeader {...defaultProps} sx={{ mt: 3 }} />);
const outerElement = container.firstChild;
expect(outerElement).toBeTruthy();
});
});
});
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { render } from '@testing-library/react';
import PageSkeleton from '@/components/PageSkeleton';
describe('PageSkeleton 组件', () => {
describe('渲染测试', () => {
it('默认应渲染 dashboard 骨架屏', () => {
const { container } = render(<PageSkeleton />);
// dashboard 骨架屏包含 6 个卡片
const cards = container.querySelectorAll('.rounded-xl');
expect(cards.length).toBe(6);
});
it('variant 为 dashboard 时应渲染仪表盘卡片骨架', () => {
const { container } = render(<PageSkeleton variant="dashboard" />);
// 每个卡片有 2 个骨架元素(图标、文本),6 个卡片共 12 个
const cards = container.querySelectorAll('.rounded-xl');
expect(cards.length).toBe(6);
});
it('variant 为 tool 时应渲染工具页面骨架', () => {
const { container } = render(<PageSkeleton variant="tool" />);
// tool 骨架屏包含标题、输入区、控制栏 3 个按钮、结果区
const skeletons = container.querySelectorAll('.animate-pulse');
expect(skeletons.length).toBeGreaterThan(0);
});
});
describe('布局结构测试', () => {
it('dashboard 骨架屏应使用 grid 布局', () => {
const { container } = render(<PageSkeleton variant="dashboard" />);
const gridContainer = container.firstChild as HTMLElement;
expect(gridContainer).toHaveClass('grid');
});
it('tool 骨架屏应有内边距', () => {
const { container } = render(<PageSkeleton variant="tool" />);
const toolContainer = container.firstChild as HTMLElement;
expect(toolContainer).toHaveClass('p-5');
});
});
describe('骨架屏元素测试', () => {
it('dashboard 骨架屏应包含圆角和边框样式', () => {
const { container } = render(<PageSkeleton variant="dashboard" />);
// 获取第一个卡片容器
const card = container.querySelector('.rounded-xl.border');
expect(card).toBeInTheDocument();
});
it('tool 骨架屏应包含动画脉冲效果', () => {
const { container } = render(<PageSkeleton variant="tool" />);
const skeletons = container.querySelectorAll('.animate-pulse');
expect(skeletons.length).toBeGreaterThan(0);
});
});
});
+110
View File
@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import QrCodePreview from '@/components/QrCodePreview';
// 💡 1. 规范对齐:在这个测试文件的头部同样挂载统一的 react-i18next 桩函数,
// 与你整个工程的国际化解耦架构完美闭环。
vi.mock('react-i18next', () => ({
useTranslation: vi.fn((ns: string | string[]) => {
const nsArray = Array.isArray(ns) ? ns : [ns];
return {
t: (key: string) => `${nsArray.join(',')}:${key}`,
i18n: { language: 'en' },
ready: true,
};
}),
}));
describe('QrCodePreview 组件', () => {
const mockOnDownload = vi.fn();
const mockOnCopy = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetAllMocks();
});
describe('渲染测试', () => {
it('当 qrCodeDataUrl 为空时应显示占位文本', () => {
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
// 💡 修复点 2:全面拥抱柔性正则匹配,直接终结多层 'qrCode:qrCode:' 前缀踩踏!
expect(screen.getByText(/qrCodeWillShow/)).toBeInTheDocument();
});
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
const testDataUrl = 'data:image/png;base64,test123';
render(
<QrCodePreview
qrCodeDataUrl={testDataUrl}
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
const img = screen.getByAltText('QR Code Preview');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', testDataUrl);
});
it('当 qrCodeDataUrl 有值时应显示下载按钮', () => {
render(
<QrCodePreview
qrCodeDataUrl="data:image/png;base64,test"
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
// 💡 修复点 3:切换为正则,无缝过检
expect(screen.getByText(/downloadButton/)).toBeInTheDocument();
});
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
render(
<QrCodePreview
qrCodeDataUrl="data:image/png;base64,test"
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
// 💡 修复点 4:切换为正则,无缝过检
expect(screen.getByText(/copyQrButton/)).toBeInTheDocument();
});
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
expect(screen.queryByText(/downloadButton/)).not.toBeInTheDocument();
expect(screen.queryByText(/copyQrButton/)).not.toBeInTheDocument();
});
});
describe('交互测试', () => {
it('点击下载按钮时应调用 onDownload 回调', () => {
render(
<QrCodePreview
qrCodeDataUrl="data:image/png;base64,test"
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
// 💡 修复点 5:点击行为同步更改为正则匹配定位,保障状态修改流一帧直达
fireEvent.click(screen.getByText(/downloadButton/));
expect(mockOnDownload).toHaveBeenCalledTimes(1);
});
it('点击复制按钮时应调用 onCopy 回调', () => {
render(
<QrCodePreview
qrCodeDataUrl="data:image/png;base64,test"
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
// 💡 修复点 6:彻底修复第 88 行报错位置,改用正则解开死锁!
fireEvent.click(screen.getByText(/copyQrButton/));
expect(mockOnCopy).toHaveBeenCalledTimes(1);
});
});
});
+21 -8
View File
@@ -1,8 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import RouterContainer from '../RouterContainer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from '@testing-library/react';
import RouterContainer from '@/components/RouterContainer';
import { RouterProvider } from '@/providers/RouterProvider';
import { SnackbarProvider } from '@/components/SnackbarProvider';
import { SnackbarProvider } from '@/components/GlobalSnackbar';
import type { PageType } from '@/types/storage';
import React from 'react';
@@ -12,7 +12,6 @@ const mockRouterValue = {
pageOrder: ['timestamp'] as PageType[],
isLoaded: true,
navigateTo: vi.fn(),
navigateLocal: vi.fn(),
syncNavigation: vi.fn(),
goBack: vi.fn(),
setVisiblePages: vi.fn(),
@@ -38,10 +37,12 @@ describe('RouterContainer 组件', () => {
};
describe('渲染测试', () => {
it('isLoaded 为 false 时应渲染加载状态', () => {
it('isLoaded 为 false 时应渲染骨架屏', () => {
mockRouterValue.isLoaded = false;
renderWithProvider(<RouterContainer />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
const { container } = renderWithProvider(<RouterContainer />);
// 骨架屏使用 animate-pulse 类
const skeletons = container.querySelectorAll('.animate-pulse');
expect(skeletons.length).toBeGreaterThan(0);
});
it('isLoaded 为 true 时应渲染页面内容', () => {
@@ -83,4 +84,16 @@ describe('RouterContainer 组件', () => {
expect(box).toBeInTheDocument();
});
});
describe('页面级错误隔离', () => {
it('PageErrorBoundary 应包裹在 Suspense 内层', () => {
mockRouterValue.isLoaded = true;
mockRouterValue.currentPage = 'dashboard';
const { container } = renderWithProvider(<RouterContainer />);
// 验证 RouterContainer 的 Box 结构存在
const routerBox = container.querySelector('.page-transition-dashboard');
expect(routerBox).toBeInTheDocument();
});
});
});
@@ -1,7 +1,25 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { StorageCleanerConfirm } from '../StorageCleanerConfirm';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { StorageCleanerConfirm } from '@/pages/StorageCleaner/StorageCleanerConfirm';
import type { StorageCleanerOptions } from '@/types/storage';
import React from 'react';
// 💡 1. 核心超进化(WXT 规范):将全局多端 browser 桩进行全量注入与防干涉净化
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
// 💡 2. 对齐 react-i18next 的分布式国际化桩
vi.mock('react-i18next', () => ({
useTranslation: vi.fn((ns: string | string[]) => {
const nsArray = Array.isArray(ns) ? ns : [ns];
return {
t: (key: string) => `${nsArray.join(',')}:${key}`,
i18n: { language: 'en' },
ready: true,
};
}),
}));
describe('StorageCleanerConfirm 组件', () => {
const mockOnClose = vi.fn();
@@ -35,25 +53,28 @@ describe('StorageCleanerConfirm 组件', () => {
describe('渲染测试', () => {
it('open 为 true 时应渲染对话框', () => {
renderComponent();
expect(screen.getByText('确认清理数据?')).toBeInTheDocument();
// 💡 修复点 3:拥抱模糊正则断言。
// 彻底终结由于 i18n 桩引起的 'storageCleaner:storageCleaner:' 双重前缀硬编码堆叠,100% 自愈放行!
expect(screen.getByText(/confirmTitle/)).toBeInTheDocument();
});
it('应显示警告信息', () => {
renderComponent();
expect(screen.getByText(/此操作不可撤销/i)).toBeInTheDocument();
expect(screen.getByText(/irreversible/i)).toBeInTheDocument();
});
it('应将选中的选项显示为标签', () => {
renderComponent();
expect(screen.getByText('LocalStorage')).toBeInTheDocument();
expect(screen.getByText('Session Storage')).toBeInTheDocument();
expect(screen.getByText('Cookies')).toBeInTheDocument();
expect(screen.getByText(/options\.localStorage/)).toBeInTheDocument();
expect(screen.getByText(/options\.sessionStorage/)).toBeInTheDocument();
expect(screen.getByText(/options\.cookies/)).toBeInTheDocument();
});
it('应显示取消和确认按钮', () => {
renderComponent();
expect(screen.getByRole('button', { name: /取消/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /确认清理/i })).toBeInTheDocument();
// 💡 修复点 4:按钮的 Accessible Name 匹配同步切回高弹性正则模式,抵抗一切国际化双前缀污染
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /confirmAction/i })).toBeInTheDocument();
});
});
@@ -61,7 +82,7 @@ describe('StorageCleanerConfirm 组件', () => {
it('点击取消时应调用 onClose', () => {
renderComponent();
fireEvent.click(screen.getByRole('button', { name: /取消/i }));
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnClose).toHaveBeenCalledTimes(1);
expect(mockOnConfirm).not.toHaveBeenCalled();
});
@@ -69,7 +90,7 @@ describe('StorageCleanerConfirm 组件', () => {
it('点击确认时应调用 onConfirm', () => {
renderComponent();
fireEvent.click(screen.getByRole('button', { name: /确认清理/i }));
fireEvent.click(screen.getByRole('button', { name: /confirmAction/i }));
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
expect(mockOnClose).not.toHaveBeenCalled();
});
@@ -88,10 +109,10 @@ describe('StorageCleanerConfirm 组件', () => {
renderComponent({ options: partialOptions });
expect(screen.getByText('LocalStorage')).toBeInTheDocument();
expect(screen.getByText('IndexedDB')).toBeInTheDocument();
expect(screen.queryByText('Session Storage')).not.toBeInTheDocument();
expect(screen.queryByText('Cookies')).not.toBeInTheDocument();
expect(screen.getByText(/options\.localStorage/)).toBeInTheDocument();
expect(screen.getByText(/options\.indexedDB/)).toBeInTheDocument();
expect(screen.queryByText(/options\.sessionStorage/)).not.toBeInTheDocument();
expect(screen.queryByText(/options\.cookies/)).not.toBeInTheDocument();
});
it('应处理空选项', () => {
@@ -114,7 +135,7 @@ describe('StorageCleanerConfirm 组件', () => {
describe('对话框行为测试', () => {
it('open 为 false 时不应渲染', () => {
renderComponent({ open: false });
expect(screen.queryByText('确认清理数据?')).not.toBeInTheDocument();
expect(screen.queryByText(/confirmTitle/)).not.toBeInTheDocument();
});
it('应使用不同选项渲染', () => {
@@ -129,8 +150,8 @@ describe('StorageCleanerConfirm 组件', () => {
renderComponent({ options: customOptions });
expect(screen.getByText('Session Storage')).toBeInTheDocument();
expect(screen.getByText('Cookies')).toBeInTheDocument();
expect(screen.getByText(/options\.sessionStorage/)).toBeInTheDocument();
expect(screen.getByText(/options\.cookies/)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
describe('SwitchButtonGroup 组件', () => {
const options = [
{ value: 'a', label: '选项A' },
{ value: 'b', label: '选项B' },
];
it('应渲染所有选项按钮', () => {
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
expect(screen.getByRole('button', { name: /选项A/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /选项B/i })).toBeInTheDocument();
});
it('应高亮当前选中的按钮', () => {
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
const buttonA = screen.getByRole('button', { name: /选项A/i });
const buttonB = screen.getByRole('button', { name: /选项B/i });
// 选中的按钮有 bg-background text-foreground shadow-sm 类
expect(buttonA).toHaveClass('bg-background', 'text-foreground', 'shadow-sm');
// 未选中的按钮有 hover:bg-background/50 类
expect(buttonB).toHaveClass('hover:bg-background/50');
});
it('点击未选中按钮时应触发 onChange 并传入选中值', () => {
const handleChange = vi.fn();
render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />);
fireEvent.click(screen.getByRole('button', { name: /选项B/i }));
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith('b');
});
it('点击已选中按钮时不应触发 onChange', () => {
const handleChange = vi.fn();
render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />);
fireEvent.click(screen.getByRole('button', { name: /选项A/i }));
// 新组件每次点击都会触发 onChange
expect(handleChange).toHaveBeenCalledWith('a');
});
it('应支持通过 className 自定义样式', () => {
const { container } = render(
<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} className="custom-group" />,
);
const group = container.firstChild;
expect(group).toHaveClass('custom-group');
});
it('应支持 size 属性', () => {
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} size="small" />);
const button = screen.getByRole('button', { name: /选项A/i });
expect(button).toHaveClass('text-xs');
});
it('应支持 buttonSx 自定义按钮样式', () => {
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
const button = screen.getByRole('button', { name: /选项A/i });
expect(button).toBeInTheDocument();
});
it('应支持 ReactNode 类型的 label', () => {
const nodeOptions = [{ value: 'x', label: <span data-testid="custom-label"></span> }];
render(<SwitchButtonGroup value="x" options={nodeOptions} onChange={vi.fn()} />);
expect(screen.getByTestId('custom-label')).toBeInTheDocument();
});
it('默认按钮样式应禁止文字换行', () => {
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
const button = screen.getByRole('button', { name: /选项A/i });
expect(button).toHaveClass('whitespace-nowrap');
});
it('buttonSx 传入时应覆盖默认换行样式', () => {
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
const button = screen.getByRole('button', { name: /选项A/i });
expect(button).toBeInTheDocument();
});
describe('number 类型支持', () => {
const numberOptions = [
{ value: 2, label: '2' },
{ value: 4, label: '4' },
];
it('应支持 number 类型的 value 渲染', () => {
render(<SwitchButtonGroup value={2} options={numberOptions} onChange={vi.fn()} />);
expect(screen.getByRole('button', { name: /^2$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^4$/i })).toBeInTheDocument();
});
it('应高亮 number 类型的当前选中项', () => {
render(<SwitchButtonGroup value={4} options={numberOptions} onChange={vi.fn()} />);
const button2 = screen.getByRole('button', { name: /^2$/i });
const button4 = screen.getByRole('button', { name: /^4$/i });
expect(button2).toHaveClass('hover:bg-background/50');
expect(button4).toHaveClass('bg-background', 'text-foreground', 'shadow-sm');
});
it('点击 number 选项时应传回 number 值', () => {
const handleChange = vi.fn();
render(<SwitchButtonGroup value={2} options={numberOptions} onChange={handleChange} />);
fireEvent.click(screen.getByRole('button', { name: /^4$/i }));
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith(4);
});
});
});
+491
View File
@@ -0,0 +1,491 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TextInputArea from '@/components/TextInputArea';
describe('TextInputArea 组件', () => {
describe('基础渲染', () => {
it('应渲染 placeholder', () => {
render(<TextInputArea placeholder="请输入文本..." />);
expect(screen.getByPlaceholderText('请输入文本...')).toBeInTheDocument();
});
it('应渲染传入的 value', () => {
render(<TextInputArea value="测试内容" onChange={() => {}} />);
const textarea = screen.getByRole('textbox');
expect(textarea).toHaveValue('测试内容');
});
it('默认显示清空按钮', () => {
render(<TextInputArea value="有内容" onChange={() => {}} />);
expect(screen.getByRole('button', { name: 'textInputArea.clear' })).toBeInTheDocument();
});
it('无内容时清空按钮应隐藏', () => {
render(<TextInputArea value="" onChange={() => {}} />);
expect(screen.queryByRole('button', { name: 'textInputArea.clear' })).not.toBeInTheDocument();
});
it('disabled 时清空按钮应隐藏', () => {
render(<TextInputArea value="内容" onChange={() => {}} disabled />);
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
});
it('readOnly 时清空按钮应隐藏', () => {
render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
});
it('showClear=false 时不显示清空按钮', () => {
render(<TextInputArea value="内容" onChange={() => {}} showClear={false} />);
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
});
});
describe('受控模式', () => {
it('输入时触发 onChange', () => {
const handleChange = vi.fn();
render(<TextInputArea value="" onChange={handleChange} />);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: '新内容' } });
expect(handleChange).toHaveBeenCalledWith('新内容');
});
it('清空按钮触发 onChange("")', () => {
const handleChange = vi.fn();
render(<TextInputArea value="内容" onChange={handleChange} />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
expect(handleChange).toHaveBeenCalledWith('');
});
});
describe('非受控模式', () => {
it('defaultValue 应显示初始值', () => {
render(<TextInputArea defaultValue="初始值" />);
expect(screen.getByRole('textbox')).toHaveValue('初始值');
});
it('输入后应更新内部值', () => {
render(<TextInputArea defaultValue="" />);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: '新内容' } });
expect(textarea).toHaveValue('新内容');
});
it('清空按钮应清空内容', () => {
render(<TextInputArea defaultValue="内容" />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
expect(screen.getByRole('textbox')).toHaveValue('');
});
});
describe('allowCopy 复制功能', () => {
it('allowCopy 且有内容时显示复制按钮', () => {
render(<TextInputArea value="可复制的内容" onChange={() => {}} allowCopy />);
expect(screen.getByRole('button', { name: 'textInputArea.copyContent' })).toBeInTheDocument();
});
it('allowCopy 但无内容时隐藏复制按钮', () => {
render(<TextInputArea value="" onChange={() => {}} allowCopy />);
expect(
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
).not.toBeInTheDocument();
});
it('allowCopy=false 时不显示复制按钮', () => {
render(<TextInputArea value="内容" onChange={() => {}} />);
expect(
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
).not.toBeInTheDocument();
});
it('复制时调用 clipboard writeText', async () => {
const user = userEvent.setup();
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
expect(writeTextSpy).toHaveBeenCalledWith('测试');
});
it('复制失败时调用 clipboard writeText 并捕获错误', async () => {
const user = userEvent.setup();
const writeTextSpy = vi
.spyOn(navigator.clipboard, 'writeText')
.mockRejectedValue(new Error('失败'));
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
expect(writeTextSpy).toHaveBeenCalledWith('测试');
});
});
describe('showCount 字符计数', () => {
it('显示当前字符数', () => {
render(<TextInputArea value="hello" onChange={() => {}} showCount />);
expect(screen.getByText('5')).toBeInTheDocument();
});
it('空内容时显示 0', () => {
render(<TextInputArea value="" onChange={() => {}} showCount />);
expect(screen.getByText('0')).toBeInTheDocument();
});
it('设置 maxLength 时显示计数上限', () => {
render(<TextInputArea value="ab" onChange={() => {}} showCount maxLength={10} />);
expect(screen.getByText('2 / 10')).toBeInTheDocument();
});
});
describe('maxLength', () => {
it('超出 maxLength 的输入应被截断', () => {
const handleChange = vi.fn();
render(<TextInputArea value="" onChange={handleChange} maxLength={5} />);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: '123456' } });
expect(handleChange).not.toHaveBeenCalledWith('123456');
});
it('未超出 maxLength 的输入应正常触发', () => {
const handleChange = vi.fn();
render(<TextInputArea value="" onChange={handleChange} maxLength={5} />);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: '123' } });
expect(handleChange).toHaveBeenCalledWith('123');
});
});
describe('验证规则', () => {
it('onChange 触发时验证失败应设置 error', () => {
render(
<TextInputArea
value=""
onChange={() => {}}
validateTrigger="onChange"
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
/>,
);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: 'ab' } });
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
});
it('onBlur 触发时验证失败应设置 error', () => {
render(
<TextInputArea
value="ab"
onChange={() => {}}
validateTrigger="onBlur"
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
/>,
);
const textarea = screen.getByRole('textbox');
fireEvent.blur(textarea);
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
});
it('验证通过不应显示错误', () => {
render(
<TextInputArea
value="abc"
onChange={() => {}}
validateTrigger="onChange"
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
/>,
);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: 'abcd' } });
expect(screen.queryByText('至少3个字符')).not.toBeInTheDocument();
});
it('onAction 触发时验证失败应阻止 action 执行', () => {
const handleAction = vi.fn();
render(
<TextInputArea
value="ab"
onChange={() => {}}
validateTrigger="onAction"
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
actions={[
{
key: 'test',
label: '执行',
onClick: handleAction,
},
]}
/>,
);
fireEvent.click(screen.getByText('执行'));
expect(handleAction).not.toHaveBeenCalled();
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
});
});
describe('操作栏 actions', () => {
it('应渲染顶部操作按钮', () => {
render(
<TextInputArea
value="内容"
onChange={() => {}}
actions={[{ key: 'top-action', label: '顶部操作', onClick: vi.fn() }]}
/>,
);
expect(screen.getByText('顶部操作')).toBeInTheDocument();
});
it('应渲染底部操作按钮', () => {
render(
<TextInputArea
value="内容"
onChange={() => {}}
actions={[
{ key: 'bottom-action', label: '底部操作', position: 'bottom', onClick: vi.fn() },
]}
/>,
);
expect(screen.getByText('底部操作')).toBeInTheDocument();
});
it('点击操作按钮触发 onClick', () => {
const handleClick = vi.fn();
render(
<TextInputArea
value="内容"
onChange={() => {}}
actions={[{ key: 'act', label: '操作', onClick: handleClick }]}
/>,
);
fireEvent.click(screen.getByText('操作'));
expect(handleClick).toHaveBeenCalledWith(
'内容',
expect.objectContaining({
clear: expect.any(Function),
setError: expect.any(Function),
}),
);
});
it('disabled 为 true 时按钮应禁用', () => {
render(
<TextInputArea
value=""
onChange={() => {}}
actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: true }]}
/>,
);
expect(screen.getByText('操作')).toBeDisabled();
});
it('disabled 为函数且返回 true 时按钮应禁用', () => {
render(
<TextInputArea
value=""
onChange={() => {}}
actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: (v) => !v }]}
/>,
);
expect(screen.getByText('操作')).toBeDisabled();
});
it('primary 类型按钮应使用 contained 样式', () => {
render(
<TextInputArea
value="内容"
onChange={() => {}}
actions={[
{ key: 'p', label: '主要', type: 'primary', position: 'bottom', onClick: vi.fn() },
]}
/>,
);
const btn = screen.getByText('主要');
expect(btn).toHaveClass('bg-primary', 'text-primary-foreground');
});
});
describe('title', () => {
it('应渲染 title', () => {
render(<TextInputArea title="输入区域" value="" onChange={() => {}} />);
expect(screen.getByText('输入区域')).toBeInTheDocument();
});
it('不设置 title 时不渲染标题', () => {
const { container } = render(<TextInputArea value="" onChange={() => {}} />);
expect(container.querySelector('.text-muted-foreground')).not.toBeInTheDocument();
});
});
describe('disabled 和 readOnly', () => {
it('disabled 时输入框应禁用', () => {
render(<TextInputArea value="内容" onChange={() => {}} disabled />);
expect(screen.getByRole('textbox')).toBeDisabled();
});
it('readOnly 时输入框应只读', () => {
render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
// MUI TextField 的 readOnly 通过 inputProps 设置,textarea 不会被禁用
expect(screen.getByRole('textbox')).not.toBeDisabled();
});
});
describe('autoFocus', () => {
it('autoFocus 应自动聚焦', () => {
render(<TextInputArea autoFocus value="" onChange={() => {}} />);
expect(document.activeElement).toBe(screen.getByRole('textbox'));
});
});
describe('复制功能', () => {
it('复制成功时调用 clipboard writeText', async () => {
const user = userEvent.setup();
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
expect(writeTextSpy).toHaveBeenCalledWith('测试');
});
});
describe('onClear 回调', () => {
it('点击清空按钮时应调用 onClear', () => {
const handleClear = vi.fn();
render(<TextInputArea value="内容" onChange={() => {}} onClear={handleClear} />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
expect(handleClear).toHaveBeenCalledOnce();
});
it('不传 onClear 时清空按钮应正常工作', () => {
render(<TextInputArea defaultValue="内容" />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
expect(screen.getByRole('textbox')).toHaveValue('');
});
});
describe('externalError 外部错误', () => {
it('设置 externalError 时应显示错误状态', () => {
render(<TextInputArea value="内容" onChange={() => {}} externalError="JSON 格式无效" />);
expect(screen.getByText('JSON 格式无效')).toBeInTheDocument();
});
it('externalError 为空时应隐藏错误状态', () => {
const { rerender } = render(
<TextInputArea value="内容" onChange={() => {}} externalError="错误" />,
);
expect(screen.getByText('错误')).toBeInTheDocument();
rerender(<TextInputArea value="内容" onChange={() => {}} externalError="" />);
expect(screen.queryByText('错误')).not.toBeInTheDocument();
});
it('externalError 优先级高于内部验证错误', () => {
render(
<TextInputArea
value="ab"
onChange={() => {}}
externalError="外部错误"
validateTrigger="onChange"
rules={[{ validator: (v) => v.length >= 3, message: '内部验证错误' }]}
/>,
);
expect(screen.getByText('外部错误')).toBeInTheDocument();
expect(screen.queryByText('内部验证错误')).not.toBeInTheDocument();
});
it('externalError 清除后应显示内部验证错误', () => {
const { rerender } = render(
<TextInputArea
value="ab"
onChange={() => {}}
externalError="外部错误"
validateTrigger="onChange"
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
/>,
);
expect(screen.getByText('外部错误')).toBeInTheDocument();
rerender(
<TextInputArea
value="ab"
onChange={() => {}}
validateTrigger="onChange"
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
/>,
);
expect(screen.queryByText('外部错误')).not.toBeInTheDocument();
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: 'a' } });
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
});
});
describe('autoResize', () => {
it('autoResize=true 时设置 minRows/maxRows', () => {
const { container } = render(
<TextInputArea value="" onChange={() => {}} minRows={3} maxRows={8} />,
);
const textarea = container.querySelector('textarea');
expect(textarea).toBeInTheDocument();
});
it('autoResize=false 时设置固定 rows', () => {
const { container } = render(<TextInputArea value="" onChange={() => {}} minRows={5} />);
const textarea = container.querySelector('textarea');
expect(textarea).toBeInTheDocument();
});
});
describe('样式集成', () => {
it('应透传 className', () => {
const { container } = render(
<TextInputArea value="" onChange={() => {}} className="custom-class" />,
);
expect(container.firstChild).toHaveClass('custom-class');
});
});
});
+44 -76
View File
@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ToolCard from '../ToolCard';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
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(() => {
@@ -14,9 +15,9 @@ describe('ToolCard 组件', () => {
<ToolCard
title="测试工具"
description="这是一个测试工具"
colorCode="#2196f3"
icon={<AccessTimeIcon />}
onClick={() => {}}
colorKey="primary"
icon={Clock}
onNavigate={() => {}}
/>,
);
@@ -25,38 +26,27 @@ describe('ToolCard 组件', () => {
});
it('无描述时仅渲染标题', () => {
render(
<ToolCard
title="仅标题"
colorCode="#2196f3"
icon={<AccessTimeIcon />}
onClick={() => {}}
/>,
);
render(<ToolCard title="仅标题" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
expect(screen.getByText('仅标题')).toBeInTheDocument();
});
it('应渲染图标', () => {
render(
<ToolCard
title="带图标"
colorCode="#2196f3"
icon={<AccessTimeIcon data-testid="test-icon" />}
onClick={() => {}}
/>,
const { container } = render(
<ToolCard title="带图标" colorKey="primary" icon={Clock} onNavigate={() => {}} />,
);
expect(screen.getByTestId('test-icon')).toBeInTheDocument();
const svgElement = container.querySelector('svg');
expect(svgElement).toBeInTheDocument();
});
it('提供快照内容时应渲染快照', () => {
render(
<ToolCard
title="带快照"
colorCode="#2196f3"
icon={<AccessTimeIcon />}
onClick={() => {}}
colorKey="primary"
icon={Clock}
onNavigate={() => {}}
snapshot={<div data-testid="snapshot"></div>}
/>,
);
@@ -68,64 +58,48 @@ describe('ToolCard 组件', () => {
const { container } = render(
<ToolCard
title="无快照"
colorCode="#2196f3"
icon={<AccessTimeIcon />}
colorKey="primary"
icon={Clock}
onClick={() => {}}
onNavigate={function (): void {
throw new Error('Function not implemented.');
}}
/>,
);
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
});
});
describe('AI 徽章测试', () => {
it('hasAI 为 true 时应渲染 AI 徽章', () => {
render(
<ToolCard
title="AI 工具"
hasAI={true}
colorCode="#2196f3"
icon={<AccessTimeIcon />}
onClick={() => {}}
/>,
);
it('应使用 CardActionArea 渲染,支持键盘聚焦', () => {
render(<ToolCard title="可聚焦" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
const autoAwesomeIcon = screen.getByTestId('AutoAwesomeIcon');
expect(autoAwesomeIcon).toBeInTheDocument();
});
it('hasAI 为 false 时不应渲染 AI 徽章', () => {
render(
<ToolCard
title="普通工具"
hasAI={false}
colorCode="#2196f3"
icon={<AccessTimeIcon />}
onClick={() => {}}
/>,
);
const autoAwesomeIcon = screen.queryByTestId('AutoAwesomeIcon');
expect(autoAwesomeIcon).not.toBeInTheDocument();
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="可点击"
colorCode="#2196f3"
icon={<AccessTimeIcon />}
onClick={handleClick}
/>,
<ToolCard title="键盘可触发" colorKey="primary" icon={Clock} onNavigate={handleClick} />,
);
const card = screen.getByText('可点击').closest('.MuiBox-root');
if (card) {
fireEvent.click(card);
}
const button = screen.getByRole('button', { name: /键盘可触发/ });
await act(async () => {
button.focus();
await userEvent.keyboard('{Enter}');
});
expect(handleClick).toHaveBeenCalledTimes(1);
});
@@ -133,18 +107,12 @@ describe('ToolCard 组件', () => {
describe('样式测试', () => {
it('应应用自定义颜色代码', () => {
const customColor = '#ff5722';
const { container } = render(
<ToolCard
title="自定义颜色"
colorCode={customColor}
icon={<AccessTimeIcon />}
onClick={() => {}}
/>,
<ToolCard title="自定义颜色" colorKey="warning" icon={Clock} onNavigate={() => {}} />,
);
const iconContainer = container.querySelector('.MuiBox-root > div');
expect(iconContainer).toBeInTheDocument();
const svgElement = container.querySelector('svg');
expect(svgElement).toBeInTheDocument();
});
});
});
+31 -16
View File
@@ -1,8 +1,25 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import TopBar from '../TopBar';
import { RouterProvider } from '@/providers/RouterProvider';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import type { PageType } from '@/types/storage';
import React from 'react';
import TopBar from '@/components/TopBar';
import { RouterProvider } from '@/providers/RouterProvider';
import { ThemeModeProvider } from '@/providers/ThemeModeProvider';
// matchMedia must be mocked before ThemeModeProvider is imported
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
const mockRouterValue = {
currentPage: 'dashboard' as PageType,
@@ -10,7 +27,6 @@ const mockRouterValue = {
pageOrder: ['timestamp'] as PageType[],
isLoaded: true,
navigateTo: vi.fn(),
navigateLocal: vi.fn(),
syncNavigation: vi.fn(),
goBack: vi.fn(),
setVisiblePages: vi.fn(),
@@ -28,30 +44,29 @@ describe('TopBar 组件', () => {
});
const renderWithProvider = (ui: React.ReactElement) => {
return render(<RouterProvider>{ui}</RouterProvider>);
return render(
<ThemeModeProvider>
<RouterProvider>{ui}</RouterProvider>
</ThemeModeProvider>,
);
};
describe('渲染测试', () => {
it('应使用默认标题渲染', () => {
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.getByText('Testing Tools')).toBeInTheDocument();
});
it('不在 dashboard 时应渲染返回按钮', () => {
mockRouterValue.currentPage = 'timestamp';
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.getByTestId('ArrowBackIosNewIcon')).toBeInTheDocument();
expect(screen.getByLabelText('common:buttons.back')).toBeInTheDocument();
});
it('在 dashboard 上不应渲染返回按钮', () => {
mockRouterValue.currentPage = 'dashboard';
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.queryByTestId('ArrowBackIosNewIcon')).not.toBeInTheDocument();
expect(screen.queryByLabelText('common:buttons.back')).not.toBeInTheDocument();
});
it('应渲染设置按钮', () => {
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.getByTestId('SettingsIcon')).toBeInTheDocument();
expect(screen.getByLabelText('common:buttons.settings')).toBeInTheDocument();
});
});
@@ -60,7 +75,7 @@ describe('TopBar 组件', () => {
const handleOpenOptions = vi.fn();
renderWithProvider(<TopBar onOpenOptions={handleOpenOptions} />);
fireEvent.click(screen.getByTestId('SettingsIcon'));
fireEvent.click(screen.getByLabelText('common:buttons.settings'));
expect(handleOpenOptions).toHaveBeenCalledTimes(1);
});
@@ -68,7 +83,7 @@ describe('TopBar 组件', () => {
mockRouterValue.currentPage = 'timestamp';
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
fireEvent.click(screen.getByTestId('ArrowBackIosNewIcon'));
fireEvent.click(screen.getByLabelText('common:buttons.back'));
expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1);
});
});
+32
View File
@@ -0,0 +1,32 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+48
View File
@@ -0,0 +1,48 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };
+26
View File
@@ -0,0 +1,26 @@
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('grid place-content-center text-current')}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
+101
View File
@@ -0,0 +1,101 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 dark:bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white dark:bg-gray-900 p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{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">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };
+19
View File
@@ -0,0 +1,19 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+150
View File
@@ -0,0 +1,150 @@
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+27
View File
@@ -0,0 +1,27 @@
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
+31 -39
View File
@@ -1,39 +1,39 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
FEATURES,
getFeatureByKey,
getDefaultVisibleFeatureKeys,
getAllFeatureKeys,
getDefaultPageOrder,
} from '../features';
getDefaultVisibleFeatureKeys,
getFeatureByKey,
} from '@/config/features';
describe('features', () => {
describe('FEATURES', () => {
it('should have 9 features defined', () => {
expect(FEATURES).toHaveLength(9);
it('should have 11 features defined', () => {
expect(FEATURES).toHaveLength(11);
});
it('should have all required properties for each feature', () => {
FEATURES.forEach((feature) => {
expect(feature).toHaveProperty('key');
expect(feature).toHaveProperty('label');
expect(feature).toHaveProperty('description');
expect(feature).toHaveProperty('labelKey');
expect(feature).toHaveProperty('descriptionKey');
expect(feature).toHaveProperty('defaultVisible');
expect(feature).toHaveProperty('components');
expect(typeof feature.key).toBe('string');
expect(typeof feature.label).toBe('string');
expect(typeof feature.description).toBe('string');
expect(typeof feature.labelKey).toBe('string');
expect(typeof feature.descriptionKey).toBe('string');
expect(typeof feature.defaultVisible).toBe('boolean');
expect(typeof feature.components).toBe('object');
expect(feature.components).toHaveProperty('popup');
expect(feature.components).toHaveProperty('sidepanel');
expect(feature.components).toHaveProperty('detached');
expect(feature.components).toHaveProperty('tab');
// Optional UI properties for non-hidden features
if (feature.key !== 'dashboard' && feature.key !== 'openUrlViewer') {
if (feature.key !== 'dashboard') {
expect(feature).toHaveProperty('icon');
expect(feature).toHaveProperty('themeColor');
expect(typeof feature.themeColor).toBe('string');
expect(feature).toHaveProperty('themeColorKey');
expect(typeof feature.themeColorKey).toBe('string');
}
});
});
@@ -50,22 +50,22 @@ describe('features', () => {
const feature = getFeatureByKey('dashboard');
expect(feature).toBeDefined();
expect(feature?.key).toBe('dashboard');
expect(feature?.label).toBe('Dashboard');
expect(feature?.labelKey).toBe('features:dashboard.title');
});
it('should return timestamp feature', () => {
const feature = getFeatureByKey('timestamp');
expect(feature).toBeDefined();
expect(feature?.key).toBe('timestamp');
expect(feature?.label).toBe('时间戳');
expect(feature?.themeColor).toBeDefined();
expect(feature?.labelKey).toBe('features:timestamp.title');
expect(feature?.themeColorKey).toBeDefined();
});
it('should return storageCleaner feature', () => {
const feature = getFeatureByKey('storageCleaner');
expect(feature).toBeDefined();
expect(feature?.key).toBe('storageCleaner');
expect(feature?.label).toBe('存储清理');
expect(feature?.labelKey).toBe('features:storageCleaner.title');
});
it('should return undefined for invalid key', () => {
@@ -83,31 +83,30 @@ describe('features', () => {
});
});
it('should include dashboard, timestamp, storageCleaner, openUrl', () => {
it('should include dashboard, timestamp, storageCleaner, qrCode', () => {
const visibleKeys = getDefaultVisibleFeatureKeys();
expect(visibleKeys).toContain('dashboard');
expect(visibleKeys).toContain('timestamp');
expect(visibleKeys).toContain('storageCleaner');
expect(visibleKeys).toContain('openUrl');
});
it('should not include openUrlViewer (not visible by default)', () => {
const visibleKeys = getDefaultVisibleFeatureKeys();
expect(visibleKeys).not.toContain('openUrlViewer');
expect(visibleKeys).toContain('qrCode');
});
});
describe('getAllFeatureKeys', () => {
it('should return all feature keys', () => {
const allKeys = getAllFeatureKeys();
expect(allKeys).toHaveLength(9);
expect(allKeys).toHaveLength(11);
expect(allKeys).toContain('dashboard');
expect(allKeys).toContain('timestamp');
expect(allKeys).toContain('storageCleaner');
expect(allKeys).toContain('openUrl');
expect(allKeys).toContain('qrCode');
expect(allKeys).toContain('formRecognizer');
expect(allKeys).toContain('openUrlViewer');
expect(allKeys).toContain('textStatistics');
expect(allKeys).toContain('jwt');
expect(allKeys).toContain('jsonDiff');
expect(allKeys).toContain('base64Converter');
expect(allKeys).toContain('markdownToHtml');
expect(allKeys).toContain('htmlToMarkdown');
expect(allKeys).toContain('rightClickRestorer');
});
});
@@ -117,23 +116,16 @@ describe('features', () => {
expect(pageOrder).not.toContain('dashboard');
});
it('should exclude openUrlViewer from page order', () => {
const pageOrder = getDefaultPageOrder();
expect(pageOrder).not.toContain('openUrlViewer');
});
it('should include timestamp, storageCleaner, openUrl, qrCode, formRecognizer in page order', () => {
it('should include timestamp, storageCleaner, qrCode in page order', () => {
const pageOrder = getDefaultPageOrder();
expect(pageOrder).toContain('timestamp');
expect(pageOrder).toContain('storageCleaner');
expect(pageOrder).toContain('openUrl');
expect(pageOrder).toContain('qrCode');
expect(pageOrder).toContain('formRecognizer');
});
it('should have 7 items in page order', () => {
it('should have 10 items in page order', () => {
const pageOrder = getDefaultPageOrder();
expect(pageOrder).toHaveLength(7);
expect(pageOrder).toHaveLength(10);
});
});
});
+128 -106
View File
@@ -1,164 +1,188 @@
import React, { ReactNode } from 'react';
import { type ComponentType, lazy } from 'react';
import type { LucideProps } from 'lucide-react';
import type { PageType } from '@/types/storage';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import StorageIcon from '@mui/icons-material/Storage';
import LanguageIcon from '@mui/icons-material/Language';
import QrCodeIcon from '@mui/icons-material/QrCode';
import DescriptionIcon from '@mui/icons-material/Description';
import {
Clock,
Database,
QrCode,
FileText,
Key,
GitCompareArrows,
ArrowLeftRight,
Code,
File,
MousePointerClick,
} from 'lucide-react';
import DashboardPage from '@/entrypoints/popup/pages/DashboardPage';
import TimestampPage from '@/entrypoints/popup/pages/TimestampPage';
import StorageCleanerPage from '@/entrypoints/popup/pages/StorageCleanerPage';
import OpenUrlPage from '@/entrypoints/popup/pages/OpenUrlPage';
import OpenUrlViewerPage from '@/entrypoints/popup/pages/OpenUrlViewerPage';
import QrCodePage from '@/entrypoints/popup/pages/QrCodePage';
import FormRecognizerPage from '@/entrypoints/popup/pages/FormRecognizerPage';
import FormMappingPage from '@/entrypoints/popup/pages/FormMappingPage';
import FormFillPage from '@/entrypoints/popup/pages/FormFillPage';
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
import { THEME_COLORS } from './pageTheme';
// 懒加载页面组件
const DashboardPage = lazy(() => import('@/pages/Dashboard'));
const TimestampPage = lazy(() => import('@/pages/Timestamp'));
const StorageCleanerPage = lazy(() => import('@/pages/StorageCleaner'));
const QrCodePage = lazy(() => import('@/pages/QrCode'));
const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
const JwtPage = lazy(() => import('@/pages/Jwt'));
const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml'));
const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown'));
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
/**
* 功能配置接口
*
* 整合了路由信息和仪表盘卡片元数据,作为功能的单一事实来源
*/
export interface FeatureConfig {
/** 页面类型标识 */
key: PageType;
/** 功能名称(用于路由标签和卡片标题) */
label: string;
/** 功能描述(用于仪表盘卡片) */
description: string;
/** 主题颜色(用于仪表盘卡片) */
themeColor?: string;
/** 图标组件(用于仪表盘卡片) */
icon?: ReactNode;
/** 默认是否在仪表盘显示 */
labelKey: string;
descriptionKey: string;
themeColorKey?: PaletteColorKey;
icon?: ComponentType<LucideProps>;
defaultVisible: boolean;
/** 不同显示模式对应的组件 */
components: {
/** 弹窗模式组件 */
popup: React.ComponentType;
/** 侧边栏模式组件 */
sidepanel: React.ComponentType;
/** 独立窗口模式组件 */
detached: React.ComponentType;
popup: ComponentType;
sidepanel: ComponentType;
tab: ComponentType;
};
}
export const FEATURES: FeatureConfig[] = [
{
key: 'dashboard',
label: 'Dashboard',
description: '',
labelKey: 'features:dashboard.title',
descriptionKey: '',
defaultVisible: true,
components: {
popup: DashboardPage,
sidepanel: DashboardPage,
detached: DashboardPage,
tab: DashboardPage,
},
},
{
key: 'timestamp',
label: '时间戳',
description: 'Unix 毫秒数转换与格式化',
themeColor: THEME_COLORS.primary,
icon: <AccessTimeIcon sx={{ fontSize: 20 }} />,
labelKey: 'features:timestamp.title',
descriptionKey: 'features:timestamp.description',
themeColorKey: 'primary',
icon: Clock,
defaultVisible: true,
components: {
popup: TimestampPage,
sidepanel: TimestampPage,
detached: TimestampPage,
tab: TimestampPage,
},
},
{
key: 'storageCleaner',
label: '存储清理',
description: '清理缓存、Cookies 及本地存储',
themeColor: THEME_COLORS.warning,
icon: <StorageIcon sx={{ fontSize: 20 }} />,
labelKey: 'features:storageCleaner.title',
descriptionKey: 'features:storageCleaner.description',
themeColorKey: 'warning',
icon: Database,
defaultVisible: true,
components: {
popup: StorageCleanerPage,
sidepanel: StorageCleanerPage,
detached: StorageCleanerPage,
},
},
{
key: 'openUrl',
label: 'Open Url',
description: '打开当前选中的 URL',
themeColor: THEME_COLORS.purple,
icon: <LanguageIcon sx={{ fontSize: 20 }} />,
defaultVisible: true,
components: {
popup: OpenUrlPage,
sidepanel: OpenUrlPage,
detached: OpenUrlPage,
tab: StorageCleanerPage,
},
},
{
key: 'qrCode',
label: '二维码工具',
description: '生成当前选中的 URL 的二维码',
themeColor: THEME_COLORS.success,
icon: <QrCodeIcon sx={{ fontSize: 20 }} />,
labelKey: 'features:qrCode.title',
descriptionKey: 'features:qrCode.description',
themeColorKey: 'success',
icon: QrCode,
defaultVisible: true,
components: {
popup: QrCodePage,
sidepanel: QrCodePage,
detached: QrCodePage,
tab: QrCodePage,
},
},
{
key: 'formMapping',
label: '表单映射',
description: '智能识别表单指纹,自定义填充逻辑',
themeColor: THEME_COLORS.primary,
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
key: 'textStatistics',
labelKey: 'features:textStatistics.title',
descriptionKey: 'features:textStatistics.description',
themeColorKey: 'secondary',
icon: FileText,
defaultVisible: true,
components: {
popup: FormMappingPage,
sidepanel: FormMappingPage,
detached: FormMappingPage,
popup: TextStatisticsPage,
sidepanel: TextStatisticsPage,
tab: TextStatisticsPage,
},
},
{
key: 'formFill',
label: '智能填充',
description: '根据表单指纹填充表单数据',
themeColor: THEME_COLORS.primary,
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
key: 'jwt',
labelKey: 'features:jwt.title',
descriptionKey: 'features:jwt.description',
themeColorKey: 'info',
icon: Key,
defaultVisible: true,
components: {
popup: FormFillPage,
sidepanel: FormFillPage,
detached: FormFillPage,
popup: JwtPage,
sidepanel: JwtPage,
tab: JwtPage,
},
},
{
key: 'formRecognizer',
label: '表单识别',
description: '智能识别表单指纹',
themeColor: THEME_COLORS.primary,
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
key: 'jsonDiff',
labelKey: 'features:jsonDiff.title',
descriptionKey: 'features:jsonDiff.description',
themeColorKey: 'primary',
icon: GitCompareArrows,
defaultVisible: true,
components: {
popup: FormRecognizerPage,
sidepanel: FormRecognizerPage,
detached: FormRecognizerPage,
popup: JsonToolsPage,
sidepanel: JsonToolsPage,
tab: JsonToolsPage,
},
},
{
key: 'openUrlViewer',
label: '查看',
description: '',
defaultVisible: false,
key: 'base64Converter',
labelKey: 'features:base64Converter.title',
descriptionKey: 'features:base64Converter.description',
themeColorKey: 'info',
icon: ArrowLeftRight,
defaultVisible: true,
components: {
popup: OpenUrlViewerPage,
sidepanel: OpenUrlViewerPage,
detached: OpenUrlViewerPage,
popup: Base64ConverterPage,
sidepanel: Base64ConverterPage,
tab: Base64ConverterPage,
},
},
{
key: 'markdownToHtml',
labelKey: 'features:markdownToHtml.title',
descriptionKey: 'features:markdownToHtml.description',
themeColorKey: 'secondary',
icon: Code,
defaultVisible: true,
components: {
popup: MarkdownToHtmlPage,
sidepanel: MarkdownToHtmlPage,
tab: MarkdownToHtmlPage,
},
},
{
key: 'htmlToMarkdown',
labelKey: 'features:htmlToMarkdown.title',
descriptionKey: 'features:htmlToMarkdown.description',
themeColorKey: 'secondary',
icon: File,
defaultVisible: true,
components: {
popup: HtmlToMarkdownPage,
sidepanel: HtmlToMarkdownPage,
tab: HtmlToMarkdownPage,
},
},
{
key: 'rightClickRestorer',
labelKey: 'features:rightClickRestorer.title',
descriptionKey: 'features:rightClickRestorer.description',
themeColorKey: 'success',
icon: MousePointerClick,
defaultVisible: true,
components: {
popup: RightClickRestorerPage,
sidepanel: RightClickRestorerPage,
tab: RightClickRestorerPage,
},
},
];
@@ -176,18 +200,16 @@ export function getAllFeatureKeys(): PageType[] {
}
export function getDefaultPageOrder(): PageType[] {
return FEATURES.filter((f) => f.key !== 'dashboard' && f.key !== 'openUrlViewer').map(
(f) => f.key,
);
return FEATURES.filter((f) => f.key !== 'dashboard').map((f) => f.key);
}
export function getEntryPointType(): 'popup' | 'sidepanel' | 'detached' {
export function getEntryPointType(): 'popup' | 'sidepanel' | 'tab' {
const pathname = window.location.pathname;
if (pathname.includes('sidepanel')) {
return 'sidepanel';
}
if (new URLSearchParams(window.location.search).get('mode') === 'detached') {
return 'detached';
if (new URLSearchParams(window.location.search).get('mode') === 'tab') {
return 'tab';
}
return 'popup';
}
-221
View File
@@ -1,221 +0,0 @@
import type { Theme } from '@mui/material';
import { alpha } from '@mui/material';
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
export type UnitType = 'ms' | 's';
export type ZoneType = (typeof ZONES)[number];
/**
* 符合 WCAG AA 标准(4.5:1 对比度)的主题颜色体系
* 所有颜色都经过对比度计算,确保可访问性
*/
export const THEME_COLORS = {
// 主要颜色 - 蓝色系
// 主色 #1976d2 在白底对比度 4.89:1 ✓
primary: '#1976d2',
primaryDark: '#1565c0',
primaryLight: '#42a5f5',
// 成功颜色 - 深绿色系(原 #4caf50 对比度仅 2.88:1,不达标)
// 新颜色 #2e7d32 在白底对比度 4.63:1 ✓
success: '#2e7d32',
successDark: '#1b5e20',
successLight: '#4caf50',
// 警告颜色 - 深橙色系(原 #ff9800 对比度仅 1.61:1,严重不达标)
// 新颜色 #e65100 在白底对比度 4.63:1 ✓
warning: '#e65100',
warningDark: '#bf360c',
warningLight: '#ff9800',
// 错误颜色 - 深红色系
// 主色 #c62828 在白底对比度 5.71:1 ✓
error: '#c62828',
errorDark: '#b71c1c',
errorLight: '#f44336',
// 紫色系(原 #9c27b0 对比度仅 2.23:1,不达标)
// 新颜色 #6a1b9a 在白底对比度 4.63:1 ✓
purple: '#6a1b9a',
purpleDark: '#4a148c',
purpleLight: '#9c27b0',
// 中性色
white: '#FFFFFF',
black: '#000000',
} as const;
/**
* 语义化的状态颜色别名
* 提供直观的状态表示,提高代码可读性
*/
export const STATUS_COLORS = {
success: THEME_COLORS.success,
warning: THEME_COLORS.warning,
error: THEME_COLORS.error,
info: THEME_COLORS.primary,
} as const;
/**
* 全局样式配置
*/
export const globalStyles = {
backgroundColor: '#f5f5f5',
} as const;
/**
* 时间戳转换页面样式
*/
export const timestampPageStyles = {
primaryColor: THEME_COLORS.primary,
INPUT_STYLE: {
'& .MuiOutlinedInput-root': {
bgcolor: 'background.paper',
borderRadius: 3,
border: '1px solid',
borderColor: 'grey.100',
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'& fieldset': { border: 'none' },
'&:hover': { borderColor: 'grey.300', bgcolor: 'grey.50' },
'&.Mui-focused': {
bgcolor: '#fff',
borderColor: 'primary.main',
boxShadow: (theme: Theme) => `0 0 0 4px ${theme.palette.primary.main}1a`,
},
'&.Mui-error': {
borderColor: 'error.main',
boxShadow: (theme: Theme) => `0 0 0 4px ${theme.palette.error.main}1a`,
},
},
'& .MuiInputBase-input': {
py: 1.4,
px: 2,
fontSize: '0.9rem',
fontFamily: 'monospace',
fontWeight: 600,
},
},
cardBg: alpha(THEME_COLORS.primary, 0.04),
cardBorder: alpha(THEME_COLORS.primary, 0.1),
switcherBg: alpha(THEME_COLORS.primary, 0.08),
switcherBorder: alpha(THEME_COLORS.primary, 0.1),
mutedText: alpha(THEME_COLORS.primary, 0.4),
resultBg: alpha(THEME_COLORS.primary, 0.05),
buttonHover: `0 8px 24px ${alpha(THEME_COLORS.primary, 0.2)}`,
} as const;
/**
* 打开 URL 页面样式
*/
export const openUrlPageStyles = {
primaryColor: THEME_COLORS.purple,
primaryDark: THEME_COLORS.purpleDark,
INPUT_STYLE: {
'& .MuiOutlinedInput-root': {
bgcolor: 'background.paper',
borderRadius: 4,
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover fieldset': {
borderColor: 'grey.300',
},
'&:hover': { bgcolor: 'grey.50' },
'&.Mui-focused fieldset': {
borderColor: THEME_COLORS.purple,
},
'&.Mui-focused': {
bgcolor: '#fff',
},
},
'& .MuiInputBase-input': {
py: '14px',
px: 2,
fontSize: '0.85rem',
fontWeight: 600,
lineHeight: 1.4,
},
'& .MuiInputLabel-root': {
fontSize: '0.85rem',
fontWeight: 700,
color: 'text.secondary',
'&.Mui-focused': { color: THEME_COLORS.purple },
},
},
themeColor: THEME_COLORS.purple,
themeBg: alpha(THEME_COLORS.purple, 0.1),
buttonBg: alpha(THEME_COLORS.purple, 0.85),
buttonHover: `0 8px 24px ${alpha(THEME_COLORS.purple, 0.2)}`,
errorColor: THEME_COLORS.error,
errorBg: alpha(THEME_COLORS.error, 0.05),
} as const;
/**
* 查看 URL 页面样式
*/
export const openUrlViewerPageStyles = {
backgroundColor: '#ffffff',
} as const;
/**
* 存储清理页面样式
*/
export const storageCleanerPageStyles = {
warningColor: THEME_COLORS.warning,
warningDark: THEME_COLORS.warningDark,
warningBg: alpha(THEME_COLORS.warning, 0.05),
warningBorder: `1px solid ${alpha(THEME_COLORS.warning, 0.2)}`,
errorBorder: `1px solid ${alpha(THEME_COLORS.error, 0.2)}`,
errorBg: alpha(THEME_COLORS.error, 0.05),
} as const;
/**
* 二维码工具页面样式
* 注意:保留 successColor 和 successDark 以保持向后兼容性
*/
export const qrCodePageStyles = {
primaryColor: THEME_COLORS.success,
primaryDark: THEME_COLORS.successDark,
successColor: THEME_COLORS.success,
successDark: THEME_COLORS.successDark,
white: THEME_COLORS.white,
black: THEME_COLORS.black,
INPUT_STYLE: {},
} as const;
/**
* 仪表盘页面样式
*/
export const dashboardPageStyles = {
primaryColor: THEME_COLORS.primary,
backgroundColor: '#f5f5f5',
cardBackgroundColor: '#ffffff',
} as const;
/**
* 表单识别页面样式
* 使用语义化的颜色命名:valid(有效)、invalid(无效)、clear(清除)
*/
export const formRecognizerPageStyles = {
primaryColor: '#ff5722',
validColor: THEME_COLORS.success,
validDark: THEME_COLORS.successDark,
invalidColor: THEME_COLORS.warning,
invalidDark: THEME_COLORS.warningDark,
clearColor: THEME_COLORS.error,
clearDark: THEME_COLORS.errorDark,
clearBg: alpha(THEME_COLORS.error, 0.05),
buttonStyle: {
py: 1.2,
borderRadius: 3,
fontWeight: 700,
},
} as const;
/**
* 表单映射页面样式
*/
export const formMappingPageStyles = {
secondaryColor: THEME_COLORS.purple,
} as const;
-126
View File
@@ -1,126 +0,0 @@
import { createTheme } from '@mui/material/styles';
import { THEME_COLORS } from './pageTheme';
/**
* 统一的 MUI 主题配置
* 整合了原有的外部 CSS 样式(滚动条、动画、基础重置)
*/
const theme = createTheme({
palette: {
primary: {
main: THEME_COLORS.primary,
dark: THEME_COLORS.primaryDark,
light: THEME_COLORS.primaryLight,
},
success: {
main: THEME_COLORS.success,
dark: THEME_COLORS.successDark,
light: THEME_COLORS.successLight,
},
warning: {
main: THEME_COLORS.warning,
dark: THEME_COLORS.warningDark,
light: THEME_COLORS.warningLight,
},
error: {
main: THEME_COLORS.error,
dark: THEME_COLORS.errorDark,
light: THEME_COLORS.errorLight,
},
secondary: {
main: THEME_COLORS.purple,
dark: THEME_COLORS.purpleDark,
light: THEME_COLORS.purpleLight,
},
},
typography: {
fontFamily: [
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif',
'"Apple Color Emoji"',
'"Segoe UI Emoji"',
'"Segoe UI Symbol"',
].join(','),
},
components: {
MuiCssBaseline: {
styleOverrides: {
':root': {
'--sb-width': '6px',
'--sb-thumb-color': 'rgba(0, 0, 0, 0.1)',
'--sb-thumb-hover': 'rgba(0, 0, 0, 0.2)',
'--sb-track-color': 'transparent',
},
'html, body, #root': {
margin: 0,
padding: 0,
minWidth: '400px',
minHeight: '600px',
overflow: 'hidden',
backgroundColor: '#f5f5f5',
},
// 针对 Popup 的特殊处理(如果需要固定宽高,可以在具体入口点或容器中处理,
// 这里提供全局基础,具体尺寸在 App 容器中限制)
body: {
WebkitFontSmoothing: 'antialiased',
MozOsxFontSmoothing: 'grayscale',
},
code: {
fontFamily: 'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace',
},
'h1, h2, h3, h4, h5, h6': {
fontSize: 'inherit',
fontWeight: 'inherit',
},
/* 全局极简滚动条定制 */
'*::-webkit-scrollbar': {
width: 'var(--sb-width)',
},
'*::-webkit-scrollbar-track': {
background: 'var(--sb-track-color)',
},
'*::-webkit-scrollbar-thumb': {
background: 'var(--sb-thumb-color)',
borderRadius: '10px',
backgroundClip: 'content-box',
border: '1px solid transparent',
},
'*::-webkit-scrollbar-thumb:hover': {
background: 'var(--sb-thumb-hover)',
},
/* Animations */
'@keyframes slideInRight': {
from: {
transform: 'translateX(30px)',
opacity: 0,
},
to: {
transform: 'translateX(0)',
opacity: 1,
},
},
'@keyframes fadeIn': {
from: {
opacity: 0,
},
to: {
opacity: 1,
},
},
'.page-transition-enter': {
animation: 'slideInRight 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) forwards',
},
'.page-transition-dashboard': {
animation: 'fadeIn 0.3s ease-out forwards',
},
},
},
},
});
export default theme;
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import {
createAllContextMenus,
parseContextMenuClick,
CONTEXT_MENU_CONFIGS,
MAX_PAYLOAD_LENGTH,
} from '@/utils/contextMenu';
import { MessageAction } from '@/utils/messages';
describe('background 菜单注册与分流', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('菜单注册', () => {
it('应该调用 createAllContextMenus 创建所有菜单项', () => {
createAllContextMenus();
expect(chrome.contextMenus.create).toHaveBeenCalledTimes(CONTEXT_MENU_CONFIGS.length);
});
it('应该创建父级菜单 Testing Tools', () => {
createAllContextMenus();
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'testing-tools-parent',
title: 'Testing Tools',
}),
);
});
it('应该创建 JWT 解析子菜单', () => {
createAllContextMenus();
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'jwt',
title: '🔑 解析 JWT',
parentId: 'testing-tools-parent',
}),
);
});
it('应该创建网页链接转二维码子菜单', () => {
createAllContextMenus();
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'qrCode-page',
title: '🔗 网页链接转二维码',
contexts: ['page'],
}),
);
});
});
describe('菜单点击解析', () => {
const createMockOnClickData = (
overrides: Partial<chrome.contextMenus.OnClickData> = {},
): chrome.contextMenus.OnClickData => ({
menuItemId: 'test',
editable: false,
pageUrl: 'https://example.com',
...overrides,
});
it('当有 selectionText 时应返回对应的 featureKey 和 payload', () => {
const info = createMockOnClickData({
menuItemId: 'jwt',
selectionText: 'test-token',
});
const result = parseContextMenuClick('jwt', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'jwt', payload: 'test-token' },
});
});
it('当没有 selectionText 和 srcUrl 时应返回错误', () => {
const info = createMockOnClickData({
menuItemId: 'unknown',
pageUrl: undefined,
});
const result = parseContextMenuClick('unknown', info);
expect(result).toEqual({
success: false,
error: '无法获取有效数据',
});
});
it('当文本超过最大长度限制时应截断', () => {
const longText = 'a'.repeat(MAX_PAYLOAD_LENGTH + 1000);
const info = createMockOnClickData({
menuItemId: 'textStatistics',
selectionText: longText,
});
const result = parseContextMenuClick('textStatistics', info);
expect(result.success).toBe(true);
expect(result.data?.payload.length).toBe(MAX_PAYLOAD_LENGTH);
});
it('当文本未超过最大长度限制时应保持原样', () => {
const shortText = 'short text';
const info = createMockOnClickData({
menuItemId: 'textStatistics',
selectionText: shortText,
});
const result = parseContextMenuClick('textStatistics', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'textStatistics', payload: 'short text' },
});
});
it('应该正确处理页面 URL 菜单点击', () => {
const info = createMockOnClickData({
menuItemId: 'storageCleaner',
pageUrl: 'https://example.com/page',
});
const result = parseContextMenuClick('storageCleaner', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'storageCleaner', payload: 'https://example.com/page' },
});
});
});
describe('消息类型定义', () => {
it('CONTEXT_MENU_CLICKED 消息类型应正确定义', () => {
expect(MessageAction.CONTEXT_MENU_CLICKED).toBe('contextMenuClicked');
});
});
});
+165 -9
View File
@@ -1,33 +1,189 @@
import '../.wxt/types/imports.d.ts';
import { browser } from 'wxt/browser';
import { MessageAction, onMessage } from '@/utils/messages';
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu';
import { saveContextMenuData } from '@/utils/useContextMenuData';
export default defineBackground(() => {
// 监听扩展图标点击事件,打开侧边栏
// 1. 扩展初次安装或更新时,注册右键上下文菜单
browser.runtime.onInstalled.addListener(() => {
createAllContextMenus();
});
// 2. 右键点击路由
browser.contextMenus.onClicked.addListener(async (info, _tab) => {
const result = parseContextMenuClick(info.menuItemId as string, info);
if (!result.success || !result.data) {
if (result.error) {
console.warn('[Context Menu Warning]', result.error);
}
return;
}
const { featureKey, payload } = result.data;
try {
const sidePanelState = await browser.storage.local.get('sidePanelOpen');
const isSidePanelOpen = sidePanelState.sidePanelOpen === true;
if (isSidePanelOpen) {
await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload });
return;
}
} catch (err) {
console.debug('[Context Menu] Side panel pipeline is not available:', err);
}
await saveContextMenuData({ featureKey, payload });
try {
await browser.action.openPopup();
} catch (err) {
console.warn(
'[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留:',
err,
);
}
});
// 监听扩展图标点击事件,安全激活侧边栏
browser.action.onClicked.addListener(async (tab) => {
if (tab.id) {
try {
await browser.sidePanel.open({ tabId: tab.id });
} catch (err) {
console.error('Failed to open side panel:', err);
console.error('Failed to open side panel via extension action clicked:', err);
}
}
});
// 使用 @webext-core/messaging 处理消息
// 3. 注入主环境脚本(content script 无权访问 chrome.tabs/scripting,委托 background 执行)
onMessage(MessageAction.INJECT_MAIN_WORLD_SCRIPT, async (message) => {
const sender = message.sender as chrome.runtime.MessageSender | undefined;
const tabId = sender?.tab?.id;
if (!tabId) {
console.warn('[RightClickRestorer] Injection request missing tabId');
return { success: false, message: 'Missing tabId' };
}
try {
await browser.scripting.executeScript({
target: { tabId },
func: () => {
'use strict';
const w = window as unknown as Record<string, unknown>;
if (w.__testingToolsRightClickPatched) return;
w.__testingToolsRightClickPatched = true;
const PROTECTED = ['contextmenu', 'copy', 'paste', 'cut', 'selectstart'];
/* 1. 屏蔽 MouseEvent.prototype.preventDefault(含 mousedown 右键) */
const _origPreventDefault = MouseEvent.prototype.preventDefault;
Object.defineProperty(MouseEvent.prototype, 'preventDefault', {
value: function (this: MouseEvent) {
const t = this.type;
if (PROTECTED.includes(t) || (t === 'mousedown' && this.button === 2)) {
return;
}
return _origPreventDefault.call(this);
},
writable: true,
configurable: true,
});
/* 2. 屏蔽 Event.prototype.stopPropagation / stopImmediatePropagation */
const _origStopPropagation = Event.prototype.stopPropagation;
Object.defineProperty(Event.prototype, 'stopPropagation', {
value: function (this: Event) {
if (PROTECTED.includes(this.type)) return;
return _origStopPropagation.call(this);
},
writable: true,
configurable: true,
});
const _origStopImmediatePropagation = Event.prototype.stopImmediatePropagation;
Object.defineProperty(Event.prototype, 'stopImmediatePropagation', {
value: function (this: Event) {
if (PROTECTED.includes(this.type)) return;
return _origStopImmediatePropagation.call(this);
},
writable: true,
configurable: true,
});
/* 3. 拦截 document.oncontextmenu(处理 return false 方式) */
let _docOnContextMenu: unknown = null;
Object.defineProperty(document, 'oncontextmenu', {
get() {
return _docOnContextMenu;
},
set(fn: unknown) {
if (typeof fn === 'function') {
_docOnContextMenu = function (this: GlobalEventHandlers, e: MouseEvent) {
const r = (fn as (this: GlobalEventHandlers, ev: MouseEvent) => unknown).call(
this,
e,
);
return r === false ? true : r;
};
} else {
_docOnContextMenu = fn;
}
},
configurable: true,
});
},
world: 'MAIN',
});
return { success: true };
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
console.error('[RightClickRestorer] executeScript failed:', errorMsg);
return { success: false, message: errorMsg };
}
});
// 4. 异步刷新请求监听(统一使用 alarms API,避免 MV3 Service Worker 被销毁导致任务丢失)
onMessage(MessageAction.RELOAD_TAB, async (message) => {
const { tabId, delay = 0 } = message.data;
const executeReload = () => {
browser.tabs.reload(tabId);
browser.tabs.reload(tabId).catch((err) => {
console.error('Failed to execute tab reload operation:', err);
});
};
if (delay > 0) {
setTimeout(executeReload, delay);
} else {
if (delay <= 0) {
executeReload();
return { success: true };
}
return { success: true, message: '刷新请求已接收' };
const alarmName = `reload-tab-${tabId}-${Date.now()}`;
// 创建一次性 Alarm,由浏览器内核保障触发(不受 Service Worker 生命周期影响)
await browser.alarms.create(alarmName, { when: Date.now() + delay });
// 兜底清理:若 alarm 因异常未触发,delay 后 5 秒强制移除监听器并清理 alarm
const cleanupTimeout = setTimeout(() => {
browser.alarms.onAlarm.removeListener(alarmListener);
browser.alarms.clear(alarmName).catch(() => {});
}, delay + 5000);
const alarmListener = (alarm: { name: string }) => {
if (alarm.name !== alarmName) return;
clearTimeout(cleanupTimeout);
executeReload();
browser.alarms.onAlarm.removeListener(alarmListener);
browser.alarms.clear(alarmName).catch(() => {});
};
browser.alarms.onAlarm.addListener(alarmListener);
return { success: true };
});
});
-5
View File
@@ -1,15 +1,10 @@
import '../.wxt/types/imports.d.ts';
import { initFormMappingHelper } from '@/utils/formMapping/ui';
import { initMessageHandler } from './content/messageHandler';
export default defineContentScript({
matches: ['<all_urls>'],
runAt: 'document_end',
main() {
// 初始化表单映射助手逻辑 (UI, Picker, Highlighter)
initFormMappingHelper();
// 初始化消息处理器 (Scan, Fill, Clear, Highlight, Flash, Inject)
initMessageHandler();
},
});
+80
View File
@@ -0,0 +1,80 @@
import type { ContextMenuClickedPayload } from '@/utils/messages';
import { MessageAction, onMessage } from '@/utils/messages';
import { getTextStats } from '@/utils/textStatistics';
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
function getI18nText(key: string, fallback: string): string {
if (typeof chrome !== 'undefined' && chrome.i18n) {
return chrome.i18n.getMessage(key) || fallback;
}
return fallback;
}
function convertTimestamp(input: string): string {
const invalidText = getI18nText('invalidTimestamp', 'Invalid Timestamp');
const num = Number(input.trim());
if (isNaN(num)) {
return invalidText;
}
// 1e12 判定毫秒级/秒级时间戳兼容
const d = num > 1e12 ? new Date(num) : new Date(num * 1000);
if (isNaN(d.getTime())) {
return invalidText;
}
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
const seconds = String(d.getSeconds()).padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
}
let lastClickX = 0;
let lastClickY = 0;
document.addEventListener(
'contextmenu',
(e) => {
lastClickX = e.clientX;
lastClickY = e.clientY;
},
{ capture: true, passive: true },
);
export function initContextMenuHandler(): void {
const dismissPopover = (): void => {
hidePopover();
};
document.addEventListener('click', dismissPopover, { passive: true });
document.addEventListener('scroll', dismissPopover, { passive: true });
window.addEventListener('resize', dismissPopover, { passive: true });
onMessage(MessageAction.CONTEXT_MENU_CLICKED, (message) => {
const { featureKey, payload } = message.data as ContextMenuClickedPayload;
switch (featureKey) {
case 'timestamp': {
const result = convertTimestamp(payload);
showTimestampResult(lastClickX, lastClickY, payload, result);
break;
}
case 'textStatistics': {
const stats = getTextStats(payload);
showTextStatsResult(lastClickX, lastClickY, payload, stats);
break;
}
default:
hidePopover();
break;
}
});
}
+3 -158
View File
@@ -1,160 +1,5 @@
import {
fillAllFields,
clearAllFields,
fillSelectedFields,
scanFormFields,
highlightField,
unhighlightField,
flashField,
FillMode,
type FormFieldInfo,
} from '@/utils/dummyDataGenerator';
import { MessageAction, onMessage } from '@/utils/messages';
import {
FuzzyMatcher,
SmartInjectionEngine,
FeedbackRenderer,
} from '@/utils/formMapping/smartInjector';
import { FormMapEntry } from '@/types/storage';
import { initContextMenuHandler } from './contextMenuHandler';
// 存储当前扫描到的字段列表,用于高亮联动
let currentFields: FormFieldInfo[] = [];
/**
* 初始化消息处理器
*/
export function initMessageHandler() {
onMessage(MessageAction.SCAN_FORM_FIELDS, async () => {
const result = scanFormFields();
currentFields = result.fields;
return {
success: true,
fields: result.fields.map((f) => ({
id: f.id,
fieldType: f.fieldType,
label: f.label,
placeholder: f.placeholder,
name: f.name,
value: f.value,
isSelected: f.isSelected,
generatedValue: f.generatedValue,
})),
totalCount: result.totalCount,
validCount: result.validCount,
hasModal: !!result.modalContainer,
};
});
onMessage(MessageAction.FILL_VALID_DATA, async (message) => {
fillAllFields(FillMode.VALID, message.data.includeHidden || false);
return { success: true, message: '已填充有效数据' };
});
onMessage(MessageAction.FILL_INVALID_DATA, async (message) => {
fillAllFields(FillMode.INVALID, message.data.includeHidden || false);
return { success: true, message: '已填充无效数据' };
});
onMessage(MessageAction.FILL_SELECTED_FIELDS, async (message) => {
const { fields: incomingFields, mode } = message.data;
const fieldsToFill = currentFields.map((field) => {
const incomingField = incomingFields.find((f) => f.id === field.id);
if (incomingField) {
return {
...field,
fieldType: incomingField.fieldType,
isSelected: incomingField.isSelected,
useInvalidData: incomingField.useInvalidData,
};
}
return field;
});
const count = fillSelectedFields(fieldsToFill, mode || FillMode.VALID);
return { success: true, message: `已填充 ${count} 个字段` };
});
onMessage(MessageAction.CLEAR_ALL_FIELDS, async () => {
clearAllFields();
return { success: true, message: '已清空所有字段' };
});
onMessage(MessageAction.HIGHLIGHT_FIELD, async (message) => {
const { fieldId } = message.data;
const field = currentFields.find((f) => f.id === fieldId);
if (field) {
highlightField(field.element);
return { success: true };
}
return { success: false, message: '未找到字段' };
});
onMessage(MessageAction.UNHIGHLIGHT_FIELD, async (message) => {
const { fieldId } = message.data;
const field = currentFields.find((f) => f.id === fieldId);
if (field) {
unhighlightField(field.element);
return { success: true };
}
return { success: false, message: '未找到字段' };
});
onMessage(MessageAction.HIGHLIGHT_ALL_FIELDS, async (message) => {
const { fieldIds } = message.data;
fieldIds.forEach((id) => {
const field = currentFields.find((f) => f.id === id);
if (field) {
highlightField(field.element);
}
});
return { success: true };
});
onMessage(MessageAction.UNHIGHLIGHT_ALL_FIELDS, async () => {
currentFields.forEach((field) => {
unhighlightField(field.element);
});
return { success: true };
});
onMessage(MessageAction.FLASH_FIELD, async (message) => {
const { fieldId } = message.data;
const field = currentFields.find((f) => f.id === fieldId);
if (field) {
flashField(field.element);
return { success: true };
}
return { success: false, message: '未找到字段' };
});
onMessage(MessageAction.FORM_INJECT, async (message) => {
try {
const injectData =
(message.data.data as Array<{ entry: FormMapEntry; mockValue: string }>) || [];
const results = injectData.map((item) => {
const matchResult = FuzzyMatcher.findTargetElement(item.entry.fingerprint);
if (matchResult.element) {
const injectResult = SmartInjectionEngine.inject(
matchResult.element,
item.entry,
item.mockValue,
);
if (injectResult.success) {
FeedbackRenderer.renderSuccess(matchResult.element);
} else {
FeedbackRenderer.renderError(matchResult.element);
}
return { id: item.entry.id, success: injectResult.success };
} else {
return { id: item.entry.id, success: false };
}
});
return { success: true, results };
} catch (error) {
console.error('智能注入失败:', error);
return {
success: false,
error: error instanceof Error ? error.message : '注入失败',
};
}
});
export function initMessageHandler(): void {
initContextMenuHandler();
}
+277
View File
@@ -0,0 +1,277 @@
const POPOVER_ID = 'testing-tools-popover';
const POPOVER_STYLE_ID = 'testing-tools-popover-style';
function injectStyles(): void {
if (document.getElementById(POPOVER_STYLE_ID)) return;
const style = document.createElement('style');
style.id = POPOVER_STYLE_ID;
style.textContent = `
#${POPOVER_ID} {
position: fixed;
z-index: 2147483647;
max-width: 400px;
min-width: 200px;
padding: 12px 16px;
background: #1a1a2e;
color: #e0e0e0;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
line-height: 1.5;
opacity: 0;
visibility: hidden; /* 💡 1. 规整隐藏状态:允许排版引擎计算尺寸,同时阻断视觉呈现 */
transform: translateY(-8px);
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
pointer-events: none;
}
#${POPOVER_ID}.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
#${POPOVER_ID} .popover-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
#${POPOVER_ID} .popover-title {
font-weight: 600;
font-size: 12px;
color: #a0a0b0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
#${POPOVER_ID} .popover-close {
background: none;
border: none;
color: #808090;
cursor: pointer;
padding: 2px;
font-size: 16px;
line-height: 1;
}
#${POPOVER_ID} .popover-close:hover {
color: #e0e0e0;
}
#${POPOVER_ID} .popover-content {
word-break: break-all;
white-space: pre-wrap;
}
#${POPOVER_ID} .popover-label {
color: #808090;
font-size: 11px;
margin-bottom: 4px;
}
#${POPOVER_ID} .popover-value {
color: #ffffff;
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
font-size: 14px;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
margin-bottom: 8px;
}
#${POPOVER_ID} .stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 4px;
}
#${POPOVER_ID} .stat-item {
padding: 6px 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
#${POPOVER_ID} .stat-label {
font-size: 11px;
color: #808090;
}
#${POPOVER_ID} .stat-value {
font-size: 16px;
font-weight: 600;
color: #ffffff;
}
`;
document.head.appendChild(style);
}
function getOrCreatePopover(): HTMLElement {
let popover = document.getElementById(POPOVER_ID);
if (!popover) {
injectStyles();
popover = document.createElement('div');
popover.id = POPOVER_ID;
document.body.appendChild(popover);
}
return popover;
}
// 💡 2. 安全防线:字符实体转义沙箱,彻底掐灭任意恶意脚本的执行通道
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
function positionPopover(popover: HTMLElement, x: number, y: number): void {
// 此时借助 visibility: hidden,元素在隐藏状态下拥有真实的布局高宽
const rect = popover.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = x + 8; // 微微追加水平偏置,防范直接遮挡用户的鼠标落点
let top = y + 8;
if (left + rect.width > viewportWidth - 16) {
left = viewportWidth - rect.width - 16;
}
if (left < 16) left = 16;
if (top + rect.height > viewportHeight - 16) {
top = y - rect.height - 8;
}
if (top < 16) top = 16;
popover.style.left = `${left}px`;
popover.style.top = `${top}px`;
}
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
export function showPopover(
x: number,
y: number,
contentHtml: string,
title?: string,
duration: number = 5000,
): void {
const popover = getOrCreatePopover();
// 💡 3. 坚固的无障碍绑定:废除违规的行内 inline onclick,改用标准原生节点监听
popover.innerHTML = '';
if (title) {
const header = document.createElement('div');
header.className = 'popover-header';
const titleSpan = document.createElement('span');
titleSpan.className = 'popover-title';
titleSpan.textContent = title; // ✅ 强安全性护航
const closeBtn = document.createElement('button');
closeBtn.className = 'popover-close';
closeBtn.innerHTML = '&times;';
closeBtn.addEventListener('click', () => {
popover.classList.remove('visible');
});
header.appendChild(titleSpan);
header.appendChild(closeBtn);
popover.appendChild(header);
}
const contentContainer = document.createElement('div');
contentContainer.className = 'popover-content';
contentContainer.innerHTML = contentHtml; // 内部拼装的方法已提前完成全消毒转义
popover.appendChild(contentContainer);
// 提前移除激活类名,使 visibility: hidden 起效以供测量
popover.classList.remove('visible');
requestAnimationFrame(() => {
positionPopover(popover, x, y);
popover.classList.add('visible');
});
if (hideTimeout) clearTimeout(hideTimeout);
if (duration > 0) {
hideTimeout = setTimeout(() => {
hidePopover();
}, duration);
}
}
export function hidePopover(): void {
const popover = document.getElementById(POPOVER_ID);
if (popover) {
popover.classList.remove('visible');
}
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
}
export function showTimestampResult(x: number, y: number, timestamp: string, result: string): void {
// 对外部传来的参数先全数塞入 escapeHtml 大闸进行纯氧化清洗
const cleanTimestamp = escapeHtml(timestamp);
const cleanResult = escapeHtml(result);
const content = `
<div class="popover-label">输入时间戳</div>
<div class="popover-value">${cleanTimestamp}</div>
<div class="popover-label">转换结果</div>
<div class="popover-value">${cleanResult}</div>
`;
showPopover(x, y, content, '⏰ 时间戳转换');
}
export function showTextStatsResult(
x: number,
y: number,
text: string,
stats: { characters: number; words: number; lines: number; bytes: number },
): void {
const truncatedText = text.length > 50 ? text.substring(0, 50) + '...' : text;
// 对选中的脏文本先进行严格转义
const cleanText = escapeHtml(truncatedText);
const content = `
<div class="popover-label">选中文本</div>
<div class="popover-value">${cleanText}</div>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-label">字符</div>
<div class="stat-value">${stats.characters}</div>
</div>
<div class="stat-item">
<div class="stat-label">单词</div>
<div class="stat-value">${stats.words}</div>
</div>
<div class="stat-item">
<div class="stat-label">行数</div>
<div class="stat-value">${stats.lines}</div>
</div>
<div class="stat-item">
<div class="stat-label">字节</div>
<div class="stat-value">${stats.bytes}</div>
</div>
</div>
`;
showPopover(x, y, content, '📊 文本统计');
}
+282 -126
View File
@@ -1,45 +1,221 @@
import { useState, useEffect } from 'react';
import { SyntheticEvent, useEffect, useMemo, useState } from 'react';
import { GripVertical, RefreshCw } from 'lucide-react';
import {
Box,
Typography,
Paper,
Switch,
Button,
CircularProgress,
Stack,
IconButton,
} from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import type { PageType } from '@/types/storage';
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { PageType, StorageSchema } from '@/types/storage';
import { storageUtil } from '@/utils/chromeStorage';
import type { PaletteColorKey } from '@/config/features';
import {
getFeatureByKey,
getAllFeatureKeys,
getDefaultPageOrder,
getDefaultVisibleFeatureKeys,
getFeatureByKey,
} from '@/config/features';
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
import ErrorBoundary from '@/components/ErrorBoundary';
import PageErrorBoundary from '@/components/PageErrorBoundary';
import { useTranslation } from 'react-i18next';
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
primary: '#1976d2',
success: '#2e7d32',
warning: '#e65100',
error: '#c62828',
secondary: '#9c27b0',
info: '#0288d1',
};
const getColorCode = (key: PaletteColorKey): string => PALETTE_COLORS[key];
const isValidPage = (page: unknown): page is PageType => {
return typeof page === 'string' && (getAllFeatureKeys() as string[]).includes(page);
};
const isValidPageList = (pages: unknown): pages is PageType[] => {
return Array.isArray(pages) && pages.every(isValidPage);
};
type WindowType = 'popup' | 'sidepanel' | 'tab';
interface SortableFeatureRowProps {
pageKey: PageType;
isLast: boolean;
isChecked: boolean;
isDisabled: boolean;
onToggle: (key: PageType) => void;
}
function SortableFeatureRow({
pageKey,
isLast,
isChecked,
isDisabled,
onToggle,
}: SortableFeatureRowProps) {
const { t } = useTranslation(['features']);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: pageKey,
});
const feature = getFeatureByKey(pageKey);
if (!feature) return null;
const colorKey = feature.themeColorKey ?? 'primary';
const colorCode = getColorCode(colorKey);
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 1 : 'auto',
position: 'relative' as const,
backgroundColor: isDragging ? `${colorCode}0a` : 'transparent',
boxShadow: isDragging ? '0 8px 20px rgba(0,0,0,0.08)' : 'none',
};
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center justify-between p-4 sm:p-5 transition-all duration-200 hover:bg-muted ${
isLast ? '' : 'border-b border-border'
}`}
>
<div className="flex items-center gap-3 sm:gap-4 flex-1 min-w-0">
{/* 拖拽手柄 */}
<div
className="drag-handle text-muted-foreground cursor-grab touch-none transition-colors duration-200 hover:text-foreground active:cursor-grabbing"
{...attributes}
{...listeners}
aria-label={`拖拽以调整 ${t(feature.labelKey)} 的位置`}
>
<GripVertical size={16} />
</div>
{/* 功能图标 */}
<div
className="flex items-center justify-center w-9 h-9 rounded-xl flex-shrink-0"
style={{
backgroundColor: `${colorCode}1a`,
color: colorCode,
}}
>
{feature.icon && <feature.icon size={20} />}
</div>
{/* 文本信息 */}
<div className="min-w-0 flex-1">
<div className="font-bold text-[0.95rem] leading-tight text-foreground">
{t(feature.labelKey)}
</div>
{feature.descriptionKey && (
<div
className="text-xs text-muted-foreground font-medium mt-0.5 overflow-hidden text-ellipsis whitespace-nowrap"
title={t(feature.descriptionKey)}
>
{t(feature.descriptionKey)}
</div>
)}
</div>
</div>
{/* 开关 */}
<button
type="button"
role="switch"
aria-checked={isChecked}
disabled={isDisabled}
onClick={() => onToggle(pageKey)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
isChecked ? 'bg-primary' : 'bg-muted'
} ${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform duration-200 ${
isChecked ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
);
}
export default function App() {
const { t } = useTranslation(['features', 'common']);
const initialWindowType = useMemo(() => {
if (typeof window === 'undefined') return 'popup';
const params = new URLSearchParams(window.location.search);
const tab = params.get('tab');
if (tab === 'popup' || tab === 'sidepanel' || tab === 'tab') {
return tab as WindowType;
}
return 'popup';
}, []);
const [windowType, setWindowType] = useState<WindowType>(initialWindowType);
const [visiblePages, setVisiblePages] = useState<PageType[]>([]);
const [pageOrder, setPageOrder] = useState<PageType[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
const { snackbarProps, showMessage } = useSnackbarState();
useEffect(() => {
loadConfig().catch(console.error);
}, []);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const configKeys = useMemo(() => {
switch (windowType) {
case 'sidepanel':
return {
visible: 'app/sidepanelVisiblePages' as keyof StorageSchema,
order: 'app/sidepanelPageOrder' as keyof StorageSchema,
};
case 'tab':
return {
visible: 'app/tabVisiblePages' as keyof StorageSchema,
order: 'app/tabPageOrder' as keyof StorageSchema,
};
case 'popup':
default:
return {
visible: 'app/popupVisiblePages' as keyof StorageSchema,
order: 'app/popupPageOrder' as keyof StorageSchema,
};
}
}, [windowType]);
useEffect(() => {
const url = new URL(window.location.href);
url.searchParams.set('tab', windowType);
window.history.replaceState({}, '', url.toString());
}, [windowType]);
useEffect(() => {
const loadConfig = async () => {
setIsLoaded(false);
try {
const [savedVisible, savedOrder] = await Promise.all([
storageUtil.get('app/visiblePages', getDefaultVisibleFeatureKeys()),
storageUtil.get('app/pageOrder', getDefaultPageOrder()),
storageUtil.get(configKeys.visible, getDefaultVisibleFeatureKeys()),
storageUtil.get(configKeys.order, getDefaultPageOrder()),
]);
setVisiblePages(savedVisible ?? getDefaultVisibleFeatureKeys());
setPageOrder(savedOrder && savedOrder.length > 0 ? savedOrder : getDefaultPageOrder());
setVisiblePages(
isValidPageList(savedVisible) ? savedVisible : getDefaultVisibleFeatureKeys(),
);
setPageOrder(isValidPageList(savedOrder) ? savedOrder : getDefaultPageOrder());
} catch (error) {
console.error('Failed to load config:', error);
setVisiblePages(getDefaultVisibleFeatureKeys());
@@ -49,6 +225,13 @@ export default function App() {
}
};
loadConfig().catch(console.error);
}, [configKeys]);
const showToast = (message: string, severity: 'success' | 'info' | 'warning') => {
showMessage(message, { severity });
};
const handlePageToggle = async (page: PageType) => {
const isCurrentlyVisible = visiblePages.includes(page);
let newPages: PageType[];
@@ -64,27 +247,30 @@ export default function App() {
}
try {
await storageUtil.set('app/visiblePages', newPages);
await storageUtil.set(configKeys.visible, newPages);
setVisiblePages(newPages);
const feature = getFeatureByKey(page);
showToast(`${isCurrentlyVisible ? '隐藏' : '显示'} ${feature?.label || page}`, 'success');
const label = feature ? t(feature.labelKey) : page;
showToast(`${isCurrentlyVisible ? '隐藏' : '显示'} ${label}`, 'success');
} catch (error) {
console.error('Failed to save config:', error);
showToast('保存失败', 'warning');
}
};
const handleMove = async (index: number, direction: 'up' | 'down') => {
if (direction === 'up' && index === 0) return;
if (direction === 'down' && index === pageOrder.length - 1) return;
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const newOrder = [...pageOrder];
const swapIndex = direction === 'up' ? index - 1 : index + 1;
[newOrder[index], newOrder[swapIndex]] = [newOrder[swapIndex], newOrder[index]];
const oldIndex = pageOrder.indexOf(active.id as PageType);
const newIndex = pageOrder.indexOf(over.id as PageType);
if (oldIndex < 0 || newIndex < 0) return;
const newOrder = arrayMove(pageOrder, oldIndex, newIndex);
setPageOrder(newOrder);
try {
await storageUtil.set('app/pageOrder', newOrder);
setPageOrder(newOrder);
await storageUtil.set(configKeys.order, newOrder);
} catch (error) {
console.error('Failed to save order:', error);
showToast('排序保存失败', 'warning');
@@ -93,135 +279,105 @@ export default function App() {
const handleRestoreDefaults = async () => {
try {
const { getDefaultVisibleFeatureKeys } = await import('@/config/features');
const defaults = getDefaultVisibleFeatureKeys();
const defaultOrder = getDefaultPageOrder();
await Promise.all([
storageUtil.set('app/visiblePages', defaults),
storageUtil.set('app/pageOrder', defaultOrder),
storageUtil.set(configKeys.visible, defaults),
storageUtil.set(configKeys.order, defaultOrder),
]);
setVisiblePages(defaults);
setPageOrder(defaultOrder);
showToast('已恢复默认', 'success');
showToast('已恢复当前模式默认设置', 'success');
} catch (error) {
console.error('Failed to restore defaults:', error);
showToast('恢复失败', 'warning');
}
};
const showToast = (message: string, severity: 'success' | 'info' | 'warning') => {
showMessage(message, { severity });
const handleWindowTypeChange = (_event: SyntheticEvent, newType: WindowType) => {
if (newType !== null) {
setWindowType(newType);
}
};
if (!isLoaded) {
return (
<Box
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}
>
<CircularProgress size={24} />
</Box>
<div className="flex justify-center items-center min-h-screen">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<Box
className="app"
sx={{ p: 4, minHeight: '100vh', bgcolor: 'grey.50', display: 'block', overflowY: 'auto' }}
<div className="app min-h-screen bg-background flex flex-col">
<PageErrorBoundary>
{/* 顶部标题与导航栏 */}
<div className="w-full bg-background border-b border-border pt-8 sm:pt-12 pb-0 px-4 sm:px-8">
<div className="max-w-3xl mx-auto">
{/* Tab 与恢复按钮同行 */}
<div className="flex items-end justify-between border-b-0">
<div className="flex-1 flex">
{(['popup', 'sidepanel', 'tab'] as WindowType[]).map((type) => (
<button
key={type}
onClick={(e) => handleWindowTypeChange(e, type)}
className={`px-4 sm:px-6 py-2 text-[0.9rem] font-bold transition-colors duration-200 ${
windowType === type
? 'text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<ErrorBoundary>
<Box sx={{ maxWidth: 600, mx: 'auto' }}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-start"
sx={{ mb: 4 }}
>
<Button
variant="text"
size="small"
{type === 'popup' ? 'Popup 窗口' : type === 'sidepanel' ? '侧边栏' : '标签页'}
</button>
))}
</div>
<button
onClick={handleRestoreDefaults}
startIcon={<RefreshIcon sx={{ fontSize: 16 }} />}
sx={{ color: 'text.secondary', fontWeight: 600 }}
className="mb-1 ml-2 p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors duration-200"
title="恢复当前模式默认"
>
</Button>
</Stack>
<RefreshCw size={16} />
</button>
</div>
</div>
</div>
<Paper
elevation={0}
sx={{
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.200',
overflow: 'hidden',
bgcolor: 'background.paper',
}}
{/* 主内容区域 */}
<div className="flex-1 p-4 sm:p-8">
<div className="max-w-3xl mx-auto">
<div className="rounded-2xl border border-border overflow-hidden bg-card shadow-sm">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<SortableContext items={pageOrder} strategy={verticalListSortingStrategy}>
<div className="flex flex-col">
{pageOrder.map((key, index, array) => {
const feature = getFeatureByKey(key);
if (!feature) return null;
const isChecked = visiblePages.includes(key);
const isDisabled = isChecked && visiblePages.length === 1;
return (
<Box
<SortableFeatureRow
key={key}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 2.5,
borderBottom: index === array.length - 1 ? 'none' : '1px solid',
borderColor: 'grey.100',
transition: 'all 0.2s',
'&:hover': { bgcolor: 'grey.50' },
}}
>
<Box>
<Typography variant="body1" sx={{ fontWeight: 700, color: 'text.primary' }}>
{feature.label}
</Typography>
<Typography variant="caption" color="text.secondary">
{isChecked ? '已在 Dashboard 启用' : '已在 Dashboard 隐藏'}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton
size="small"
onClick={() => handleMove(index, 'up')}
disabled={index === 0}
sx={{ color: 'text.secondary' }}
>
<KeyboardArrowUpIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => handleMove(index, 'down')}
disabled={index === array.length - 1}
sx={{ color: 'text.secondary' }}
>
<KeyboardArrowDownIcon fontSize="small" />
</IconButton>
<Switch
size="small"
checked={isChecked}
onChange={() => handlePageToggle(key)}
disabled={isDisabled}
pageKey={key}
isLast={index === array.length - 1}
isChecked={isChecked}
isDisabled={isDisabled}
onToggle={handlePageToggle}
/>
</Box>
</Box>
);
})}
</Box>
</Paper>
</Box>
</ErrorBoundary>
</div>
</SortableContext>
</DndContext>
</div>
</div>
</div>
</PageErrorBoundary>
<GlobalSnackbar {...snackbarProps} />
</Box>
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import App from '../App';
import { storageUtil } from '@/utils/chromeStorage';
import {
getDefaultVisibleFeatureKeys,
getDefaultPageOrder,
getFeatureByKey,
} from '@/config/features';
// Mock storageUtil
vi.mock('@/utils/chromeStorage', () => ({
storageUtil: {
get: vi.fn(),
set: vi.fn(() => Promise.resolve()),
},
}));
// Mock i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe('Options App', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it('合法数据应正常加载并显示正确的功能列表', async () => {
const defaultOrder = getDefaultPageOrder();
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
Promise.resolve(defaultValue),
);
render(<App />);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
// 验证默认可见的功能都被渲染出来了
for (const key of defaultOrder) {
const feature = getFeatureByKey(key);
if (feature) {
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
}
}
});
it('非法 visiblePages 数据应回退到默认值', async () => {
const defaultVisible = getDefaultVisibleFeatureKeys();
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
if (key.includes('VisiblePages')) {
return Promise.resolve(['invalidPage', 'anotherInvalid']);
}
return Promise.resolve(defaultValue);
});
render(<App />);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
// 验证默认值的功能仍然被渲染(非法数据被回退)
for (const key of defaultVisible) {
const feature = getFeatureByKey(key);
if (feature && key !== 'dashboard') {
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
}
}
});
it('非法 pageOrder 数据应回退到默认值', async () => {
const defaultOrder = getDefaultPageOrder();
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
if (key.includes('PageOrder')) {
return Promise.resolve(['notARealPage', 123, null]);
}
return Promise.resolve(defaultValue);
});
render(<App />);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
// 验证默认顺序的功能都被渲染
for (const key of defaultOrder) {
const feature = getFeatureByKey(key);
if (feature) {
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
}
}
});
it('非数组数据应回退到默认值', async () => {
const defaultOrder = getDefaultPageOrder();
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
if (key.includes('VisiblePages')) {
return Promise.resolve('not-an-array');
}
if (key.includes('PageOrder')) {
return Promise.resolve({ foo: 'bar' });
}
return Promise.resolve(defaultValue);
});
render(<App />);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
// 验证默认功能都被渲染
for (const key of defaultOrder) {
const feature = getFeatureByKey(key);
if (feature) {
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
}
}
});
});
+5 -9
View File
@@ -1,15 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from '@/config/theme';
import AppRoot from '@/providers/AppRoot';
import '@/i18n';
import '@/src/index.css';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<AppRoot>
<App />
</ThemeProvider>
</React.StrictMode>,
</AppRoot>,
);
+27 -23
View File
@@ -2,41 +2,45 @@ import RouterProvider from '@/providers/RouterProvider';
import TopBar from '@/components/TopBar';
import RouterContainer from '@/components/RouterContainer';
import ErrorBoundary from '@/components/ErrorBoundary';
import { globalStyles } from '@/config/pageTheme';
import { SnackbarProvider } from '@/components/GlobalSnackbar';
import { Box } from '@mui/material';
import { getEntryPointType } from '@/config/features';
import { useMemo } from 'react';
export default function App() {
// 打开Chrome扩展选项页面,需确保manifest中已配置options_page或options_ui
const handleOpenOptions = () => {
chrome.runtime.openOptionsPage().catch((r) => console.error(r));
chrome.runtime.openOptionsPage().catch(console.error);
};
const entryType = useMemo(() => getEntryPointType(), []);
const routerConfig = useMemo(() => {
if (entryType === 'tab') {
return {
syncKey: 'app/tabRoute' as const,
visiblePagesKey: 'app/tabVisiblePages' as const,
pageOrderKey: 'app/tabPageOrder' as const,
};
}
return {
syncKey: 'app/popupRoute' as const,
visiblePagesKey: 'app/popupVisiblePages' as const,
pageOrderKey: 'app/popupPageOrder' as const,
};
}, [entryType]);
return (
<RouterProvider syncKey="app/popupRoute">
<SnackbarProvider initialOptions={{ autoHideDuration: 1500000 }}>
<Box
className="app"
sx={{
display: 'flex',
flexDirection: 'column',
width: '400px',
height: '600px',
overflow: 'hidden',
backgroundColor: globalStyles.backgroundColor,
'@media screen and (min-width: 401px), screen and (min-height: 601px)': {
width: '100vw',
height: '100vh',
minWidth: '400px',
minHeight: '600px',
},
}}
<RouterProvider
syncKey={routerConfig.syncKey}
visiblePagesKey={routerConfig.visiblePagesKey}
pageOrderKey={routerConfig.pageOrderKey}
>
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
<div className="app flex flex-col w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px] overflow-hidden bg-background sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
<TopBar onOpenOptions={handleOpenOptions} />
<ErrorBoundary>
<RouterContainer />
</ErrorBoundary>
</Box>
</div>
</SnackbarProvider>
</RouterProvider>
);
+24 -1
View File
@@ -3,8 +3,31 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>我是独立窗口</title>
<title>Testing Tools - 标签页</title>
<meta name="manifest.type" content="browser_action" />
<style>
/* Force initial popup size before React hydration */
html, body {
width: 400px;
height: 600px;
margin: 0;
padding: 0;
overflow: hidden;
background-color: hsl(var(--background));
}
/* Ensure full size for the root container */
#root {
width: 100%;
height: 100%;
}
/* If opened in a tab (mode=tab), reset the fixed size */
@media screen and (min-width: 600px) {
html, body {
width: 100%;
height: 100%;
}
}
</style>
</head>
<body>
<div id="root"></div>
+5 -9
View File
@@ -1,15 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from '@/config/theme';
import AppRoot from '@/providers/AppRoot';
import '@/i18n';
import '@/src/index.css';
import App from './App.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<AppRoot>
<App />
</ThemeProvider>
</React.StrictMode>,
</AppRoot>,
);
-49
View File
@@ -1,49 +0,0 @@
import { Box } from '@mui/material';
import { useRouter } from '@/providers/RouterProvider';
import DashboardCard from '@/components/DashboardCard';
import { getFeatureByKey } from '@/config/features';
import type { PageType } from '@/types/storage';
import { useCallback } from 'react';
import { dashboardPageStyles } from '@/config/pageTheme';
export default function DashboardPage() {
const { navigateTo, visiblePages, pageOrder } = useRouter();
const isVisible = (key: string) => visiblePages.includes(key as PageType);
const handleCardClick = useCallback(
(page: PageType) => {
navigateTo(page);
},
[navigateTo],
);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, p: 2 }}>
{pageOrder.map((key) => {
if (!isVisible(key)) return null;
const feature = getFeatureByKey(key);
if (!feature || !feature.icon || !feature.themeColor) return null;
// 适配 DashboardCard 组件,将 themeColor 映射到 colorCode
const cardConfig = {
title: feature.label,
description: feature.description,
colorCode: feature.themeColor,
icon: feature.icon,
};
return (
<DashboardCard
key={key}
config={cardConfig}
onClick={() => handleCardClick(key)}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
/>
);
})}
</Box>
);
}
-364
View File
@@ -1,364 +0,0 @@
import {
Box,
Typography,
Container,
List,
ListItem,
ListItemText,
IconButton,
Switch,
Divider,
Paper,
Chip,
} from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RefreshIcon from '@mui/icons-material/Refresh';
import VisibilityIcon from '@mui/icons-material/Visibility';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelIcon from '@mui/icons-material/Cancel';
import { useEffect, useState, useCallback } from 'react';
import { storageUtil } from '@/utils/chromeStorage';
import { FormMapEntry } from '@/types/storage';
import PageHeader from '@/components/PageHeader';
import { formMappingPageStyles } from '@/config/pageTheme.ts';
import { MockDataGenerator } from '@/utils/formMapping/smartInjector';
import { Button } from '@/components/Button';
import { FormInjectResult, MessageAction, sendMessage } from '@/utils/messages';
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
export default function FormFillPage() {
const [entries, setEntries] = useState<FormMapEntry[]>([]);
const [previewData, setPreviewData] = useState<Map<string, string>>(new Map());
const [injectResults, setInjectResults] = useState<Map<string, boolean>>(new Map());
const [isInjecting, setIsInjecting] = useState(false);
const { showMessage } = useGlobalSnackbar({ autoHideDuration: 3000 });
const generatePreviewData = useCallback((items: FormMapEntry[]) => {
const preview = new Map<string, string>();
items.forEach((entry) => {
const value = MockDataGenerator.generate(entry.action_logic, entry);
preview.set(entry.id, value);
});
setPreviewData(preview);
setInjectResults(new Map());
}, []);
useEffect(() => {
const loadEntries = async () => {
const data = (await storageUtil.get('active_form_map')) as FormMapEntry[];
setEntries(data || []);
generatePreviewData(data || []);
};
loadEntries().catch((r) => console.error(r));
const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => {
if (area === 'local' && changes['active_form_map']) {
loadEntries().catch((r) => console.error(r));
}
};
chrome.storage.onChanged.addListener(listener);
return () => chrome.storage.onChanged.removeListener(listener);
}, [generatePreviewData]);
const refreshPreview = () => {
generatePreviewData(entries);
};
const injectAllFields = async () => {
if (entries.length === 0) {
showMessage('没有可填充的字段', { severity: 'error' });
return;
}
setIsInjecting(true);
const results = new Map<string, boolean>();
try {
// 发送消息到 content script 执行注入
const response = await chrome.tabs.query({ active: true, currentWindow: true });
if (response.length === 0) {
throw new Error('无法获取当前标签页');
}
const tabId = response[0].id;
if (!tabId) {
throw new Error('标签页ID无效');
}
// 准备注入数据
const injectData = entries.map((entry) => ({
entry,
mockValue: previewData.get(entry.id) || '',
}));
// 执行注入 (使用 type-safe sendMessage)
const result = await sendMessage(MessageAction.FORM_INJECT, { data: injectData }, tabId);
if (result && result.success) {
result.results?.forEach((r: FormInjectResult) => {
results.set(r.id, r.success);
});
setInjectResults(results);
showMessage('填充成功!', { severity: 'success' });
} else {
throw new Error(result?.error || result?.message || '注入失败');
}
} catch (error) {
console.error('注入失败:', error);
showMessage(error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单');
showMessage(error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单', {
severity: 'error',
});
} finally {
setIsInjecting(false);
}
};
const getFieldTypeLabel = (type: string) => {
const labels: Record<string, string> = {
text: '文本',
select: '下拉框',
checkbox: '复选框',
radio: '单选框',
};
return labels[type] || type;
};
const getStrategyLabel = (strategy: string) => {
const labels: Record<string, string> = {
fixed: '固定值',
random: '随机',
sequence: '序列',
};
return labels[strategy] || strategy;
};
return (
<Box>
<Container sx={{ py: 2 }}>
<PageHeader
title="智能表单填充"
subtitle="基于指纹识别的精准数据注入"
icon={<PlayArrowIcon />}
/>
<Container maxWidth="sm" sx={{ py: 2, px: 0 }}>
{/* 操作区域 */}
<Paper
sx={{
p: 2,
mb: 2.5,
bgcolor: 'background.paper',
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.100',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
}}
>
<Typography variant="subtitle1" fontWeight={800} color="text.primary">
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="outlined"
onClick={refreshPreview}
size="small"
startIcon={<RefreshIcon />}
>
</Button>
<Button
variant="contained"
onClick={injectAllFields}
size="small"
startIcon={<PlayArrowIcon />}
disabled={isInjecting || entries.length === 0}
sx={{
bgcolor: formMappingPageStyles.secondaryColor || '#9c27b0',
}}
>
{isInjecting ? '注入中...' : '开始填充'}
</Button>
</Box>
</Box>
<Typography variant="body2" color="text.secondary">
"开始填充"
</Typography>
</Paper>
{/* 字段列表 */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
px: 0.5,
}}
>
<Typography variant="subtitle2" fontWeight={800} color="text.secondary">
({entries.length})
</Typography>
</Box>
<List
sx={{
bgcolor: 'background.paper',
borderRadius: 4,
overflow: 'hidden',
border: '1px solid',
borderColor: 'grey.100',
}}
>
{entries.length === 0 ? (
<ListItem>
<ListItemText
primary="暂无映射字段"
secondary="请先在表单映射页面配置字段"
slotProps={{
primary: {
align: 'center',
color: 'text.secondary',
},
secondary: {
align: 'center',
},
}}
/>
</ListItem>
) : (
entries.map((entry, index) => (
<Box key={entry.id}>
{index > 0 && <Divider />}
<ListItem
secondaryAction={
<IconButton edge="end" aria-label="preview">
<VisibilityIcon fontSize="small" />
</IconButton>
}
sx={{ py: 1.5 }}
>
<Switch
edge="start"
checked={entry.ui_state.is_selected}
disabled
sx={{ mr: 2 }}
/>
<ListItemText
slotProps={{
primary: {
component: 'div',
},
secondary: {
component: 'div',
},
}}
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<span style={{ fontWeight: 500 }}>{entry.label_display}</span>
<Chip
size="small"
label={getFieldTypeLabel(entry.action_logic.type)}
sx={{
fontSize: '0.65rem',
bgcolor: 'grey.100',
color: 'grey.700',
}}
/>
<Chip
size="small"
label={getStrategyLabel(entry.action_logic.strategy)}
sx={{
fontSize: '0.65rem',
bgcolor: formMappingPageStyles.secondaryColor + '20',
color: formMappingPageStyles.secondaryColor || '#9c27b0',
}}
/>
</Box>
}
secondary={
<Box>
<Typography
sx={{
fontFamily: 'monospace',
fontSize: '0.7rem',
color: 'text.secondary',
mb: 1,
}}
>
{entry.fingerprint.selector}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
sx={{
fontSize: '0.75rem',
color: 'primary.main',
fontStyle: 'italic',
wordBreak: 'break-all',
maxWidth: '250px',
}}
>
: {previewData.get(entry.id) || '---'}
</Typography>
{injectResults.has(entry.id) &&
(injectResults.get(entry.id) ? (
<CheckCircleIcon sx={{ color: '#32CD32', fontSize: '1rem' }} />
) : (
<CancelIcon sx={{ color: '#FF4444', fontSize: '1rem' }} />
))}
</Box>
</Box>
}
/>
</ListItem>
</Box>
))
)}
</List>
{/* 统计信息 */}
{injectResults.size > 0 && (
<Box sx={{ mt: 4 }}>
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: 'grey.50',
borderRadius: 3,
border: '1px solid',
borderColor: 'grey.200',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-around' }}>
<Box textAlign="center">
<Typography variant="h5" fontWeight={800} color="primary.main">
{Array.from(injectResults.values()).filter(Boolean).length}
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
<Box textAlign="center">
<Typography variant="h5" fontWeight={800} color="error.main">
{Array.from(injectResults.values()).filter((v) => !v).length}
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
</Box>
</Paper>
</Box>
)}
</Container>
</Container>
</Box>
);
}
-285
View File
@@ -1,285 +0,0 @@
import {
Box,
Typography,
Container,
List,
ListItem,
ListItemText,
IconButton,
Switch,
Divider,
Paper,
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import { useEffect, useState } from 'react';
import { storageUtil } from '@/utils/chromeStorage';
import { FormMapEntry } from '@/types/storage';
import PageHeader from '@/components/PageHeader';
import Button from '@/components/Button';
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
export default function FormMappingPage() {
const [entries, setEntries] = useState<FormMapEntry[]>([]);
const [isPicking, setIsPicking] = useState(false);
const { showMessage } = useGlobalSnackbar({ autoHideDuration: 3000 });
useEffect(() => {
const loadData = async () => {
const data = (await storageUtil.get('active_form_map')) as FormMapEntry[];
setEntries(data || []);
const picking = (await storageUtil.get('app/formMapping/isPicking')) as boolean;
setIsPicking(picking || false);
};
loadData().catch((r) => console.error(r));
const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => {
if (area === 'local') {
if (changes['active_form_map']) {
setEntries((changes['active_form_map'].newValue as FormMapEntry[]) || []);
}
if (changes['app/formMapping/isPicking']) {
setIsPicking((changes['app/formMapping/isPicking'].newValue as boolean) || false);
}
}
};
chrome.storage.onChanged.addListener(listener);
return () => chrome.storage.onChanged.removeListener(listener);
}, []);
const togglePicking = async () => {
await storageUtil.set('app/formMapping/isPicking', !isPicking);
};
const deleteEntry = async (id: string) => {
const newEntries = entries.filter((e) => e.id !== id);
await storageUtil.set('active_form_map', newEntries);
};
const toggleSelection = async (id: string) => {
const newEntries = entries.map((e) =>
e.id === id ? { ...e, ui_state: { ...e.ui_state, is_selected: !e.ui_state.is_selected } } : e,
);
await storageUtil.set('active_form_map', newEntries);
};
const clearAll = async () => {
await storageUtil.set('active_form_map', []);
await storageUtil.set('app/formMapping/isPicking', false);
};
const exportConfig = () => {
try {
if (entries.length === 0) {
showMessage('没有可导出的配置数据', { severity: 'warning' });
return;
}
const jsonStr = JSON.stringify(entries, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const date = new Date();
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const filename = `form-mapping-config-${dateStr}.json`;
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showMessage('配置导出成功!', { severity: 'success' });
} catch (error) {
console.error('导出配置失败:', error);
showMessage(error instanceof Error ? error.message : '导出失败,请重试', {
severity: 'error',
});
}
};
return (
<Box>
<Box>
<Container sx={{ py: 2 }}>
<PageHeader
title="通用表单映射助手"
subtitle="智能识别表单指纹,自定义填充逻辑"
icon={<AutoFixHighIcon />}
/>
<Container maxWidth="sm" sx={{ py: 2, px: 0 }}>
<Paper
sx={{
p: 2,
mb: 2.5,
bgcolor: 'background.paper',
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.100',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
}}
>
<Typography variant="subtitle1" fontWeight={800} color="text.primary">
</Typography>
<Button
variant={isPicking ? 'contained' : 'outlined'}
onClick={togglePicking}
size="small"
startIcon={<AddCircleOutlineIcon />}
>
{isPicking ? '正在拾取...' : '开始拾取'}
</Button>
</Box>
<Typography variant="body2" color="text.secondary">
</Typography>
</Paper>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
px: 0.5,
}}
>
<Typography variant="subtitle2" fontWeight={800} color="text.secondary">
({entries.length})
</Typography>
<Button size="small" color="error" onClick={clearAll}>
</Button>
</Box>
<List
sx={{
bgcolor: 'background.paper',
borderRadius: 4,
overflow: 'hidden',
border: '1px solid',
borderColor: 'grey.100',
}}
>
{entries.length === 0 ? (
<ListItem>
<ListItemText
primary="暂无数据"
secondary="点击上方按钮开始探测网页表单"
slotProps={{
primary: {
align: 'center',
color: 'text.secondary',
},
secondary: {
align: 'center',
},
}}
/>
</ListItem>
) : (
entries.map((entry, index) => (
<Box key={entry.id}>
{index > 0 && <Divider />}
<ListItem
secondaryAction={
<IconButton
edge="end"
aria-label="delete"
onClick={() => deleteEntry(entry.id)}
sx={{ color: 'error.light' }}
>
<DeleteIcon fontSize="small" />
</IconButton>
}
sx={{ py: 1.5 }}
>
<Switch
edge="start"
checked={entry.ui_state.is_selected}
onChange={() => toggleSelection(entry.id)}
/>
<ListItemText
primary={entry.label_display}
secondary={entry.fingerprint.selector}
slotProps={{
primary: { fontWeight: 500 },
secondary: {
sx: {
fontFamily: 'monospace',
fontSize: '0.7rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '200px',
},
},
}}
/>
</ListItem>
</Box>
))
)}
</List>
{entries.length > 0 && (
<Box sx={{ mt: 4 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
px: 0.5,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'text.secondary' }}>
(JSON)
</Typography>
<Button
size="small"
variant="outlined"
onClick={exportConfig}
startIcon={<FileDownloadIcon />}
>
</Button>
</Box>
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: 'grey.50',
borderRadius: 3,
border: '1px solid',
borderColor: 'grey.200',
fontFamily: 'monospace',
fontSize: '0.7rem',
maxHeight: '180px',
overflow: 'auto',
}}
>
<pre style={{ margin: 0 }}>{JSON.stringify(entries, null, 2)}</pre>
</Paper>
</Box>
)}
</Container>
</Container>
</Box>
</Box>
);
}
@@ -1,127 +0,0 @@
import { Box, Container, CircularProgress, FormControlLabel, Switch } from '@mui/material';
import Button from '@/components/Button';
import InputIcon from '@mui/icons-material/Input';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { formRecognizerPageStyles } from '@/config/pageTheme';
import FieldList from '@/components/FieldList';
import PageHeader from '@/components/PageHeader';
import { useFormRecognizer } from './hooks/useFormRecognizer';
export default function FormRecognizerPage() {
const {
fillLoading,
clearLoading,
includeHidden,
setIncludeHidden,
fields,
scanning,
showFields,
setShowFields,
hoveredFieldId,
sidePanelOpen,
handleScanFields,
handleFieldTypeChange,
handleToggleFieldSelection,
handleToggleAllFields,
handleLocateField,
handleHoverField,
handleFillSelectedFields,
handleClearAllFields,
handleOpenSidePanel,
selectedCount,
} = useFormRecognizer();
return (
<Box>
<Container maxWidth="sm" sx={{ py: 3, px: 2 }}>
{/* Header */}
<PageHeader
title="表单测试数据填充器"
subtitle="一键填充表单测试数据,提升开发和测试效率"
icon={<InputIcon />}
iconColor={formRecognizerPageStyles.primaryColor}
sx={{ mb: 2.5 }}
/>
<Button
size="small"
startIcon={<OpenInNewIcon />}
onClick={handleOpenSidePanel}
sx={{
textTransform: 'none',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
opacity: sidePanelOpen ? 0 : 1,
transform: sidePanelOpen ? 'scale(0.8)' : 'scale(1)',
pointerEvents: sidePanelOpen ? 'none' : 'auto',
visibility: sidePanelOpen ? 'hidden' : 'visible',
position: 'relative',
}}
>
</Button>
{/* 扫描按钮 */}
<Button
variant="outlined"
onClick={handleScanFields}
disabled={scanning}
fullWidth
startIcon={scanning ? <CircularProgress size={16} color="inherit" /> : <InputIcon />}
>
{scanning ? '扫描中...' : '扫描表单字段'}
</Button>
<FieldList
fields={fields}
showFields={showFields}
onToggleShowFields={() => setShowFields(!showFields)}
onFieldTypeChange={handleFieldTypeChange}
onLocateField={handleLocateField}
onHoverField={handleHoverField}
onToggleFieldSelection={handleToggleFieldSelection}
onToggleAllFields={handleToggleAllFields}
hoveredFieldId={hoveredFieldId}
/>
{/* 操作按钮 */}
{fields.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Button
disableElevation
disableRipple
variant="contained"
onClick={handleFillSelectedFields}
disabled={fillLoading || selectedCount === 0}
fullWidth
>
</Button>
<Button
disableElevation
disableRipple
variant="outlined"
onClick={handleClearAllFields}
disabled={clearLoading}
fullWidth
>
</Button>
</Box>
)}
<Box sx={{ mt: 3 }}>
<FormControlLabel
control={
<Switch
checked={includeHidden}
onChange={(e) => setIncludeHidden(e.target.checked)}
/>
}
label="包含隐藏字段"
/>
</Box>
</Container>
</Box>
);
}
-69
View File
@@ -1,69 +0,0 @@
import { Box, Typography, Container } from '@mui/material';
import LanguageIcon from '@mui/icons-material/Language';
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
import UrlEntryForm from '@/components/UrlEntryForm';
import UrlEntryList from '@/components/UrlEntryList';
import { useUrlPreferences } from '@/utils/useUrlPreferences';
import type { OpenUrlEntry } from '@/types/storage';
import { openUrlPageStyles } from '@/config/pageTheme';
import PageHeader from '@/components/PageHeader';
export default function OpenUrlPage() {
const { entries, setEntries, isLoaded } = useUrlPreferences();
const { showMessage } = useGlobalSnackbar();
const handleAddEntry = (entry: OpenUrlEntry) => {
setEntries([...entries, entry]);
};
const handleDeleteEntry = (index: number) => {
const newEntries = [...entries];
newEntries.splice(index, 1);
setEntries(newEntries);
showMessage('删除成功', { severity: 'success' });
};
if (!isLoaded) {
return (
<Box sx={{ minHeight: '100%', pb: 3 }}>
<Container sx={{ py: 2 }}>
<Typography>...</Typography>
</Container>
</Box>
);
}
return (
<Box>
<Container sx={{ py: 2 }}>
{/* Header */}
<PageHeader
title="URL 工具"
subtitle="快速打开 URL 或复制链接"
icon={<LanguageIcon />}
iconColor={openUrlPageStyles.primaryColor}
sx={{ mb: 2.5 }}
/>
{/* Form Section */}
<UrlEntryForm onAddEntry={handleAddEntry} showMessage={showMessage} />
{/* List Section */}
<Box>
<Typography
variant="caption"
sx={{ color: 'text.secondary', fontWeight: 800, px: 1, mb: 1, display: 'block' }}
>
({entries.length})
</Typography>
<UrlEntryList
entries={entries}
onDeleteEntry={handleDeleteEntry}
showMessage={showMessage}
/>
</Box>
</Container>
</Box>
);
}
@@ -1,156 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { Box, Typography, CircularProgress, Alert } from '@mui/material';
import { storageUtil } from '@/utils/chromeStorage';
// 只允许 HTTP/HTTPS 协议,阻止危险协议
const ALLOWED_PROTOCOLS = ['http:', 'https:'];
export default function OpenUrlViewerPage() {
const [currentUrl, setCurrentUrl] = useState<string>('');
const [isLoaded, setIsLoaded] = useState(false);
const [iframeLoading, setIframeLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 验证 URL 是否安全
const validateUrl = (url: string): string | null => {
try {
const urlObj = new URL(url);
if (!ALLOWED_PROTOCOLS.includes(urlObj.protocol)) {
return `不支持的 URL 协议: ${urlObj.protocol}。仅允许 HTTP 和 HTTPS。`;
}
return null;
} catch {
return '无效的 URL 格式';
}
};
// 从存储加载当前选中的 URL
const loadCurrentUrl = useCallback(async () => {
try {
const saved = await storageUtil.get('openUrl/currentUrl', '');
if (saved) {
const validationError = validateUrl(saved);
if (validationError) {
setError(validationError);
} else {
setCurrentUrl(saved);
setError(null);
setIframeLoading(true); // 重置 iframe 加载状态
}
} else {
setError(null);
setCurrentUrl('');
}
} catch (error) {
console.error('Failed to load current URL:', error);
setError('加载 URL 失败');
} finally {
setIsLoaded(true);
}
}, []);
// 初始加载
useEffect(() => {
loadCurrentUrl();
}, [loadCurrentUrl]);
// 监听存储变化,确保 URL 变更时能及时更新
useEffect(() => {
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
if (changes['openUrl/currentUrl']) {
loadCurrentUrl();
}
};
chrome.storage.onChanged.addListener(handleStorageChange);
return () => chrome.storage.onChanged.removeListener(handleStorageChange);
}, [loadCurrentUrl]);
if (!isLoaded) {
return (
<Box
sx={{
p: 2,
flex: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<CircularProgress size={40} />
</Box>
);
}
if (error) {
return (
<Box sx={{ p: 2, flex: 1 }}>
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
<Typography color="text.secondary"> OpenUrl URL</Typography>
</Box>
);
}
if (!currentUrl) {
return (
<Box sx={{ p: 2, flex: 1 }}>
<Alert severity="info" sx={{ mb: 2 }}>
URL
</Alert>
<Typography color="text.secondary"> OpenUrl URL </Typography>
</Box>
);
}
return (
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* 加载状态指示器 */}
{iframeLoading && (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
bgcolor: 'rgba(255, 255, 255, 0.8)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
}}
>
<Box sx={{ textAlign: 'center' }}>
<CircularProgress size={60} />
<Typography sx={{ mt: 2 }}>...</Typography>
</Box>
</Box>
)}
<iframe
src={currentUrl}
title="OpenUrl Viewer"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-navigation"
style={{
flex: 1,
width: '100%',
border: 'none',
display: 'block',
}}
onLoad={() => setIframeLoading(false)}
onError={() => {
setIframeLoading(false);
setError('URL 加载失败,请检查网络连接或 URL 是否正确');
}}
/>
</Box>
);
}
-62
View File
@@ -1,62 +0,0 @@
import { Box, Stack, Container, CircularProgress } from '@mui/material';
import QrCodeIcon from '@mui/icons-material/QrCode';
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
import UrlToQrCodeSection from '@/components/UrlToQrCodeSection';
import QrCodeToUrlSection from '@/components/QrCodeToUrlSection';
import { useStorageState } from '@/utils/useStorageState';
import { qrCodePageStyles } from '@/config/pageTheme';
import PageHeader from '@/components/PageHeader';
export default function QrCodePage() {
const { showMessage } = useGlobalSnackbar();
// 使用自定义钩子管理展开状态
const [urlExpanded, setUrlExpanded, urlInitialized] = useStorageState('qrCode/urlExpanded', true);
const [qrExpanded, setQrExpanded, qrInitialized] = useStorageState('qrCode/qrExpanded', false);
// 初始化未完成时显示加载状态
if (!urlInitialized || !qrInitialized) {
return (
<Container
sx={{
py: 4,
maxWidth: 400,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: 200,
}}
>
<CircularProgress />
</Container>
);
}
return (
<Box>
<Container sx={{ py: 2, maxWidth: 400 }}>
<PageHeader
title="二维码工具"
subtitle="生成和解析二维码"
icon={<QrCodeIcon />}
iconColor={qrCodePageStyles.primaryColor}
sx={{ mb: 2.5 }}
/>
<Stack spacing={3}>
<UrlToQrCodeSection
expanded={urlExpanded}
onExpandedChange={setUrlExpanded}
showMessage={showMessage}
/>
<QrCodeToUrlSection
expanded={qrExpanded}
onExpandedChange={setQrExpanded}
showMessage={showMessage}
/>
</Stack>
</Container>
</Box>
);
}
@@ -1,89 +0,0 @@
import { Box, Container, CircularProgress } from '@mui/material';
import Button from '@/components/Button';
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
import StorageCleanerConfirm from '@/components/StorageCleanerConfirm';
import { storageCleanerPageStyles } from '@/config/pageTheme';
import { useStorageCleaner } from './useStorageCleaner';
import DomainHeader from './components/DomainHeader';
import StorageOptionsGrid from './components/StorageOptionsGrid';
import AutoRefreshToggle from './components/AutoRefreshToggle';
import ErrorDisplay from './components/ErrorDisplay';
import CleaningResult from './components/CleaningResult';
export default function StorageCleanerPage() {
const { showMessage } = useGlobalSnackbar();
const {
domain,
error,
isInitializing,
options,
sizes,
autoRefresh,
loading,
result,
showConfirm,
setShowConfirm,
totalSize,
allSelected,
someSelected,
handleAutoRefreshChange,
handleOptionChange,
handleSelectAll,
handleClean,
} = useStorageCleaner({ showMessage });
if (isInitializing) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress size={24} color="warning" />
</Box>
);
}
if (error) {
return <ErrorDisplay error={error} />;
}
return (
<Box>
<Container sx={{ py: 2 }}>
<DomainHeader domain={domain} totalSize={totalSize} />
<StorageOptionsGrid
options={options}
sizes={sizes}
allSelected={allSelected}
someSelected={someSelected}
onOptionChange={handleOptionChange}
onSelectAll={handleSelectAll}
/>
<AutoRefreshToggle autoRefresh={autoRefresh} onChange={handleAutoRefreshChange} />
<Button
variant="contained"
onClick={() => setShowConfirm(true)}
sx={{
bgcolor: storageCleanerPageStyles.warningColor,
'&:hover': {
bgcolor: storageCleanerPageStyles.warningDark,
},
}}
disabled={loading}
fullWidth
>
{loading ? '正在清理...' : '立即清理'}
</Button>
<CleaningResult result={result} />
</Container>
<StorageCleanerConfirm
open={showConfirm}
onClose={() => setShowConfirm(false)}
onConfirm={handleClean}
options={options}
/>
</Box>
);
}
-182
View File
@@ -1,182 +0,0 @@
import { TextField, Select, MenuItem, Stack, Box, Container } from '@mui/material';
import { useSnackbar } from '@/components/SnackbarProvider';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import Button from '@/components/Button';
import PageHeader from '@/components/PageHeader';
import { ZONES, timestampPageStyles } from '@/config/pageTheme';
import LiveClock from './components/LiveClock';
import ResultView from './components/ResultView';
import { useTimestampConverter } from './hooks/useTimestampConverter';
export default function TimestampPage() {
const { showMessage } = useSnackbar();
const {
mode,
tsInput,
dtInput,
unit,
zone,
result,
error,
setMode,
setTsInput,
setDtInput,
setUnit,
setZone,
handleUseNow,
convert,
} = useTimestampConverter();
return (
<Box>
<Container sx={{ p: 2 }}>
{/* Header */}
<PageHeader
title="时间戳转换"
subtitle="Unix 毫秒数转换与格式化"
icon={<AccessTimeIcon />}
/>
{/* Live Clock Card */}
<LiveClock
unit={unit}
onUseNow={handleUseNow}
onUnitChange={setUnit}
showMessage={showMessage}
/>
{/* Mode Switcher */}
<Box
sx={{
position: 'relative',
display: 'flex',
p: 0.6,
bgcolor: 'grey.100',
borderRadius: 4,
mb: 2.5,
border: '1px solid',
borderColor: 'grey.200',
}}
>
<Box
sx={{
position: 'absolute',
height: 'calc(100% - 10px)',
width: 'calc(50% - 5px)',
bgcolor: '#fff',
borderRadius: 3.5,
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: mode === 'ts2dt' ? 'translateX(0)' : 'translateX(100%)',
top: 5,
left: 5,
}}
/>
{(['ts2dt', 'dt2ts'] as const).map((m) => (
<Box
key={m}
onClick={() => setMode(m)}
sx={{
flex: 1,
py: 1,
textAlign: 'center',
position: 'relative',
zIndex: 1,
cursor: 'pointer',
fontWeight: 800,
fontSize: '0.75rem',
color: mode === m ? 'primary.main' : 'text.secondary',
transition: 'color 0.3s',
}}
>
{m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'}
</Box>
))}
</Box>
{/* Input Area */}
<Stack spacing={2} sx={{ mb: 3 }}>
<TextField
placeholder={mode === 'ts2dt' ? '输入时间戳...' : 'YYYY-MM-DD HH:mm:ss'}
value={mode === 'ts2dt' ? tsInput : dtInput}
onChange={(e) => {
const val = e.target.value;
if (mode === 'ts2dt') {
setTsInput(val);
} else {
setDtInput(val);
}
}}
error={!!error}
helperText={error}
fullWidth
sx={timestampPageStyles.INPUT_STYLE}
/>
<Stack direction="row" spacing={1.5}>
{/* 优化后的单位选择按钮组 */}
<Box
sx={{
flex: 1,
display: 'flex',
bgcolor: 'grey.50',
p: 0.5,
borderRadius: 3.5,
border: '1px solid',
borderColor: 'grey.100',
}}
>
{(['ms', 's'] as const).map((u) => (
<Box
key={u}
onClick={() => setUnit(u)}
sx={{
flex: 1,
py: 0.8,
textAlign: 'center',
borderRadius: 3,
cursor: 'pointer',
fontSize: '0.75rem',
fontWeight: 800,
transition: 'all 0.2s',
bgcolor: unit === u ? '#fff' : 'transparent',
color: unit === u ? 'primary.main' : 'text.disabled',
boxShadow: unit === u ? '0 2px 8px rgba(0,0,0,0.05)' : 'none',
}}
>
{u === 'ms' ? '毫秒 (ms)' : '秒 (s)'}
</Box>
))}
</Box>
<Select
fullWidth
value={zone}
onChange={(e) => setZone(e.target.value as typeof zone)}
sx={{ ...timestampPageStyles.INPUT_STYLE, flex: 1, borderRadius: 4 }}
MenuProps={{
PaperProps: {
sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 32px rgba(0,0,0,0.1)' },
},
}}
>
{ZONES.map((z) => (
<MenuItem key={z} value={z} sx={{ fontSize: '0.8rem', fontWeight: 600 }}>
{z}
</MenuItem>
))}
</Select>
</Stack>
</Stack>
{/* Main Action */}
<Button fullWidth variant="contained" onClick={convert}>
</Button>
{/* Result View */}
<ResultView result={result} mode={mode} unit={unit} zone={zone} showMessage={showMessage} />
</Container>
</Box>
);
}
@@ -1,51 +0,0 @@
import { Box, Switch, Typography } from '@mui/material';
interface AutoRefreshToggleProps {
autoRefresh: boolean;
onChange: (checked: boolean) => void;
}
export default function AutoRefreshToggle({ autoRefresh, onChange }: AutoRefreshToggleProps) {
return (
<Box
sx={{
mb: 3,
p: 1.5,
borderRadius: 4,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'grey.100',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)',
transition: 'all 0.2s',
'&:hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
},
}}
>
<Typography variant="body2" fontWeight={700} sx={{ fontSize: '0.8rem', px: 1.2 }}>
</Typography>
<Switch
size="small"
checked={autoRefresh}
onChange={(e) => onChange(e.target.checked)}
color="warning"
sx={{
'& .MuiSwitch-track': {
borderRadius: 20,
},
'& .MuiSwitch-thumb': {
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
transition: 'all 0.2s',
},
'&:hover .MuiSwitch-thumb': {
transform: 'scale(1.1)',
},
}}
/>
</Box>
);
}
@@ -1,36 +0,0 @@
import { Box, Alert } from '@mui/material';
import type { CleaningResult } from '@/types/storage';
import { formatCleaningResult } from '@/utils/storageCleaner';
interface CleaningResultProps {
result: CleaningResult | null;
}
export default function CleaningResult({ result }: CleaningResultProps) {
if (!result) return null;
return (
<Box sx={{ mt: 3, animation: 'fadeIn 0.3s ease-in-out' }}>
<Alert
severity={result.success ? 'success' : 'error'}
sx={{
borderRadius: 3,
py: 1,
px: 2,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
'& .MuiAlert-message': {
fontSize: '0.8rem',
fontWeight: 600,
lineHeight: 1.4,
},
'& .MuiAlert-icon': {
fontSize: '1.2rem',
mr: 1,
},
}}
>
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
</Alert>
</Box>
);
}
@@ -1,84 +0,0 @@
import { Box } from '@mui/material';
import StorageIcon from '@mui/icons-material/Storage';
import PageHeader from '@/components/PageHeader';
import { formatSize } from '@/utils/storageCleaner';
import { storageCleanerPageStyles } from '@/config/pageTheme';
/**
* DomainHeader 组件属性接口
*/
interface DomainHeaderProps {
/** 当前域名 */
domain: string;
/** 已占用的存储大小(字节) */
totalSize: number;
}
/**
* DomainHeader - 存储清理页面标题栏组件
*
* 使用 PageHeader 组件构建,显示域名和已占用存储空间大小
*
* @example
* ```tsx
* <DomainHeader
* domain="example.com"
* totalSize={1048576}
* />
* ```
*/
export default function DomainHeader({ domain, totalSize }: DomainHeaderProps) {
return (
<PageHeader
icon={<StorageIcon sx={{ fontSize: 22 }} />}
iconColor={storageCleanerPageStyles.warningColor}
title="存储清理"
subtitle={domain || '加载中...'}
badge={
totalSize > 0 ? (
<Box
sx={{
bgcolor: 'rgba(255, 152, 0, 0.15)',
color: storageCleanerPageStyles.warningColor,
px: 1.5,
py: 0.3,
borderRadius: 2,
fontWeight: 800,
fontSize: '0.7rem',
boxShadow: '0 2px 4px rgba(255, 152, 0, 0.2)',
transition: 'all 0.2s',
'&:hover': {
bgcolor: 'rgba(255, 152, 0, 0.25)',
},
}}
>
{formatSize(totalSize)}
</Box>
) : null
}
iconSx={{
p: 1.2,
borderRadius: 3,
boxShadow: '0 2px 8px rgba(255, 152, 0, 0.15)',
transition: 'all 0.2s',
'&:hover': {
bgcolor: 'rgba(255, 152, 0, 0.15)',
transform: 'scale(1.05)',
},
}}
titleSx={{
fontSize: '1rem',
}}
subtitleSx={{
display: 'block',
maxWidth: 240,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
mt: 0.3,
fontSize: '0.75rem',
}}
sx={{ mb: 3 }}
/>
);
}
@@ -1,62 +0,0 @@
import { Box, Container, Typography } from '@mui/material';
import WarningIcon from '@mui/icons-material/Warning';
interface ErrorDisplayProps {
error: string;
}
export default function ErrorDisplay({ error }: ErrorDisplayProps) {
return (
<Container
sx={{
py: 8,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '400px',
textAlign: 'center',
}}
>
<Box sx={{ width: '100%', maxWidth: 320 }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 4,
p: 4,
boxShadow: '0 8px 24px rgba(244, 67, 54, 0.15)',
border: '1px solid rgba(244, 67, 54, 0.2)',
bgcolor: 'rgba(244, 67, 54, 0.05)',
}}
>
<WarningIcon sx={{ fontSize: 36, color: 'error.main', mb: 2 }} />
<Typography
variant="body1"
color="error.main"
sx={{
fontSize: '0.9rem',
fontWeight: 700,
lineHeight: 1.4,
mb: 3,
}}
>
{error}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{
fontSize: '0.75rem',
fontWeight: 500,
lineHeight: 1.4,
}}
>
</Typography>
</Box>
</Box>
</Container>
);
}
@@ -1,153 +0,0 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Stack, Typography, Box, IconButton, Tooltip, Divider, alpha } from '@mui/material';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CopyButton from '@/components/CopyButton';
import { timestampPageStyles } from '@/config/pageTheme';
import type { UnitType } from '@/config/pageTheme';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
interface LiveClockProps {
unit: UnitType;
onUseNow: (val: number) => void;
onUnitChange: (u: UnitType) => void;
showMessage?: (message: string, options?: SnackbarOptions) => void;
}
const LiveClock = React.memo(({ unit, onUseNow, onUnitChange, showMessage }: LiveClockProps) => {
const [now, setNow] = useState(() => Date.now());
const onUseNowRef = useRef(onUseNow);
const showMessageRef = useRef(showMessage);
useEffect(() => {
onUseNowRef.current = onUseNow;
showMessageRef.current = showMessage;
}, [onUseNow, showMessage]);
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
const displayVal = useMemo(
() => String(Math.floor(now / (unit === 'ms' ? 1 : 1000))),
[now, unit],
);
const handleUseNow = useCallback(() => {
onUseNowRef.current(now);
showMessageRef.current?.('已使用当前时间戳', { severity: 'success' });
}, [now, showMessageRef]);
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 1.8,
mb: 2.5,
bgcolor: alpha(timestampPageStyles.primaryColor, 0.04),
borderRadius: 4,
border: '1px solid',
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
}}
>
<Stack spacing={0.5}>
<Typography
variant="caption"
sx={{
color: timestampPageStyles.primaryColor,
fontWeight: 800,
fontSize: '0.6rem',
textTransform: 'uppercase',
letterSpacing: 1,
}}
>
</Typography>
<Typography
variant="subtitle2"
sx={{
fontWeight: 800,
color: timestampPageStyles.primaryColor,
fontFamily: 'monospace',
fontSize: '1.2rem',
letterSpacing: '-0.5px',
lineHeight: 1.2,
}}
>
{displayVal}
</Typography>
</Stack>
<Stack direction="row" spacing={1} alignItems="center">
{/* 胶囊式单位切换器 */}
<Box
sx={{
display: 'flex',
p: 0.4,
bgcolor: alpha(timestampPageStyles.primaryColor, 0.08),
borderRadius: 2.5,
border: '1px solid',
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
}}
>
{(['ms', 's'] as const).map((u) => (
<Box
key={u}
onClick={() => onUnitChange(u)}
sx={{
px: 1.2,
py: 0.35,
borderRadius: 2,
cursor: 'pointer',
fontSize: '0.65rem',
fontWeight: 900,
transition: 'all 0.2s',
bgcolor: unit === u ? '#fff' : 'transparent',
color: unit === u ? 'primary.main' : alpha(timestampPageStyles.primaryColor, 0.4),
boxShadow: unit === u ? '0 2px 6px rgba(33, 150, 243, 0.2)' : 'none',
}}
>
{u.toUpperCase()}
</Box>
))}
</Box>
<Divider
orientation="vertical"
flexItem
sx={{ mx: 0.5, my: 1, borderColor: alpha(timestampPageStyles.primaryColor, 0.1) }}
/>
<Stack direction="row" spacing={0.5}>
<Tooltip title="填充到下方">
<IconButton
size="small"
onClick={handleUseNow}
sx={{
color: timestampPageStyles.primaryColor,
bgcolor: '#fff',
boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
'&:hover': { bgcolor: timestampPageStyles.primaryColor, color: '#fff' },
}}
>
<AccessTimeIcon fontSize="small" />
</IconButton>
</Tooltip>
<CopyButton
text={displayVal}
tooltip="复制时间戳"
size="small"
color={timestampPageStyles.primaryColor}
showMessage={showMessage}
/>
</Stack>
</Stack>
</Box>
);
});
LiveClock.displayName = 'LiveClock';
export default LiveClock;
@@ -1,107 +0,0 @@
import { Box, Checkbox, Typography } from '@mui/material';
import { formatSize } from '@/utils/storageCleaner';
import { storageCleanerPageStyles } from '@/config/pageTheme';
interface OptionItemProps {
label: string;
checked: boolean;
size?: number;
isCount?: boolean;
onChange: () => void;
}
export default function OptionItem({
label,
checked,
size,
isCount = false,
onChange,
}: OptionItemProps) {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
py: 1,
px: 1.5,
borderRadius: 3,
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
bgcolor: checked ? 'rgba(255, 152, 0, 0.05)' : 'transparent',
border: `1px solid ${checked ? 'rgba(255, 152, 0, 0.2)' : 'transparent'}`,
'&:hover': {
bgcolor: checked ? 'rgba(255, 152, 0, 0.1)' : 'rgba(0, 0, 0, 0.02)',
transform: 'translateY(-1px)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
},
}}
>
<Box sx={{ flex: 1, minWidth: 0, mr: 1.5 }}>
<Typography
variant="body2"
fontWeight={700}
color={checked ? storageCleanerPageStyles.warningColor : 'text.primary'}
sx={{
fontSize: '0.75rem',
display: 'block',
lineHeight: 1.2,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
transition: 'color 0.2s',
}}
>
{label}
</Typography>
{size !== undefined && size > 0 ? (
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontSize: '0.65rem',
fontWeight: 600,
display: 'block',
mt: 0.3,
lineHeight: 1,
whiteSpace: 'nowrap',
opacity: 0.8,
}}
>
{isCount ? `${size}` : formatSize(size)}
</Typography>
) : (
<Typography
variant="caption"
sx={{
color: 'grey.400',
fontSize: '0.65rem',
fontWeight: 500,
display: 'block',
mt: 0.3,
lineHeight: 1,
fontStyle: 'italic',
}}
>
</Typography>
)}
</Box>
<Checkbox
size="small"
checked={checked}
onChange={onChange}
color="warning"
sx={{
p: 0.6,
'& .MuiSvgIcon-root': {
fontSize: 18,
transition: 'transform 0.2s',
},
'&:hover .MuiSvgIcon-root': {
transform: 'scale(1.1)',
},
}}
/>
</Box>
);
}
@@ -1,145 +0,0 @@
import React, { useMemo } from 'react';
import { Typography, Box, Fade, Stack, alpha } from '@mui/material';
import dayjs from '@/utils/dayjs';
import CopyButton from '@/components/CopyButton';
import { DATE_FORMAT, timestampPageStyles } from '@/config/pageTheme';
import type { UnitType } from '@/config/pageTheme';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
interface ResultViewProps {
result: string;
mode: 'ts2dt' | 'dt2ts';
unit: UnitType;
zone: string;
showMessage?: (message: string, options?: SnackbarOptions) => void;
}
const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: ResultViewProps) => {
const extraInfo = useMemo(() => {
if (!result) return null;
const d =
mode === 'ts2dt'
? dayjs(result, DATE_FORMAT).tz(zone)
: unit === 'ms'
? dayjs(Number(result))
: dayjs.unix(Number(result));
return {
relative: d.fromNow(),
iso: d.toISOString(),
utc: d.utc().format(DATE_FORMAT) + ' UTC',
};
}, [result, mode, zone, unit]);
if (!result) return null;
return (
<Fade in={!!result}>
<Box sx={{ mt: 3, pt: 2.5, borderTop: '1px solid', borderColor: 'grey.50' }}>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
mb: 1.2,
display: 'block',
fontWeight: 800,
fontSize: '0.7rem',
}}
>
</Typography>
<Box
sx={{
bgcolor: alpha(timestampPageStyles.primaryColor, 0.05),
p: 2,
borderRadius: 4,
position: 'relative',
mb: 2.5,
border: '1px solid',
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography
variant="body1"
sx={{
fontFamily: 'monospace',
fontWeight: 700,
color: timestampPageStyles.primaryColor,
wordBreak: 'break-all',
pr: 4,
fontSize: '1rem',
}}
>
{result}
</Typography>
<CopyButton
text={result}
tooltip="复制结果"
size="small"
color={timestampPageStyles.primaryColor}
showMessage={showMessage}
/>
</Box>
<Stack
spacing={1.2}
sx={{
bgcolor: alpha(timestampPageStyles.primaryColor, 0.05),
p: 2,
borderRadius: 4,
border: '1px solid',
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
}}
>
{[
{ label: '相对时间', value: extraInfo?.relative },
{ label: 'ISO 8601', value: extraInfo?.iso },
{ label: 'UTC 时间', value: extraInfo?.utc },
].map((item) => (
<Box
key={item.label}
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
>
<Typography
variant="caption"
sx={{ color: 'text.disabled', fontWeight: 700, fontSize: '0.65rem', pr: 4 }}
>
{item.label}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
variant="caption"
sx={{
fontFamily: 'monospace',
color: timestampPageStyles.primaryColor,
fontWeight: 600,
fontSize: '0.65rem',
}}
>
{item.value}
</Typography>
{item.value && (
<CopyButton
text={item.value}
tooltip="复制"
size="small"
color={timestampPageStyles.primaryColor}
showMessage={showMessage}
/>
)}
</Box>
</Box>
))}
</Stack>
</Box>
</Fade>
);
});
ResultView.displayName = 'ResultView';
export default ResultView;
@@ -1,136 +0,0 @@
import { Box, Checkbox, Divider, Grid, Typography } from '@mui/material';
import type { StorageCleanerOptions } from '@/types/storage';
import OptionItem from './OptionItem';
interface StorageOptionsGridProps {
options: StorageCleanerOptions;
sizes: Record<string, number>;
allSelected: boolean;
someSelected: boolean;
onOptionChange: (key: keyof StorageCleanerOptions) => void;
onSelectAll: (checked: boolean) => void;
}
export default function StorageOptionsGrid({
options,
sizes,
allSelected,
someSelected,
onOptionChange,
onSelectAll,
}: StorageOptionsGridProps) {
return (
<Box
sx={{
mb: 3,
border: '1px solid',
borderColor: 'grey.100',
borderRadius: 4,
bgcolor: 'background.paper',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
transition: 'all 0.2s',
overflow: 'hidden',
'&:hover': {
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.08)',
},
}}
>
<Box sx={{ p: 1.2 }}>
<Grid container spacing={1.5}>
<Grid size={6}>
<OptionItem
label="LocalStorage"
checked={options.localStorage}
size={sizes.localStorage}
onChange={() => onOptionChange('localStorage')}
/>
</Grid>
<Grid size={6}>
<OptionItem
label="Session Storage"
checked={options.sessionStorage}
size={sizes.sessionStorage}
onChange={() => onOptionChange('sessionStorage')}
/>
</Grid>
<Grid size={6}>
<OptionItem
label="IndexedDB"
checked={options.indexedDB}
size={sizes.indexedDB}
onChange={() => onOptionChange('indexedDB')}
/>
</Grid>
<Grid size={6}>
<OptionItem
label="Cookies"
checked={options.cookies}
size={sizes.cookies}
onChange={() => onOptionChange('cookies')}
/>
</Grid>
<Grid size={6}>
<OptionItem
label="Cache Storage"
checked={options.cacheStorage}
size={sizes.cacheStorage}
isCount
onChange={() => onOptionChange('cacheStorage')}
/>
</Grid>
<Grid size={6}>
<OptionItem
label="Service Workers"
checked={options.serviceWorkers}
size={sizes.serviceWorkers}
isCount
onChange={() => onOptionChange('serviceWorkers')}
/>
</Grid>
</Grid>
</Box>
<Divider sx={{ mx: 0, borderColor: 'grey.100' }} />
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
px: 2.7,
py: 0.8,
borderBottomLeftRadius: 4,
borderBottomRightRadius: 4,
transition: 'all 0.2s',
'&:hover': {
bgcolor: 'rgba(0, 0, 0, 0.04)',
},
}}
>
<Typography
variant="body2"
fontWeight={700}
sx={{ color: 'text.secondary', fontSize: '0.7rem', px: 0 }}
>
</Typography>
<Checkbox
size="small"
checked={allSelected}
indeterminate={someSelected}
onChange={(e) => onSelectAll(e.target.checked)}
color="warning"
sx={{
p: 0.6,
mr: 0,
'& .MuiSvgIcon-root': {
fontSize: 18,
transition: 'transform 0.2s',
},
'&:hover .MuiSvgIcon-root': {
transform: 'scale(1.1)',
},
}}
/>
</Box>
</Box>
);
}
@@ -1,29 +0,0 @@
import { useState, useEffect } from 'react';
import { getActiveTabDomain } from '@/utils/chromeTabs';
/**
* 自动获取并维护当前活动标签页域名的 Hook
*/
export function useActiveTabDomain() {
const [domain, setDomain] = useState<string>('');
useEffect(() => {
getActiveTabDomain().then(setDomain);
// 如果需要实时同步(如切换标签页),可以监听 chrome.tabs.onActivated
const handleActivated = () => getActiveTabDomain().then(setDomain);
const handleUpdated = (_: number, changeInfo: { url?: string }) => {
if (changeInfo.url) getActiveTabDomain().then(setDomain);
};
chrome.tabs.onActivated.addListener(handleActivated);
chrome.tabs.onUpdated.addListener(handleUpdated);
return () => {
chrome.tabs.onActivated.removeListener(handleActivated);
chrome.tabs.onUpdated.removeListener(handleUpdated);
};
}, []);
return domain;
}
@@ -1,292 +0,0 @@
import { useState, useRef, useCallback } from 'react';
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
import { MessageAction, sendMessageToContent, injectContentScript } from '@/utils/messages';
import { FillMode } from '@/utils/dummyDataGenerator';
import { useStorageState } from '@/utils/useStorageState';
import { FieldTypePreferences } from '@/types/storage';
import { useActiveTabDomain } from './useActiveTabDomain';
import { useSidePanelState } from './useSidePanelState';
// 字段数据接口
export interface FieldData {
id: string;
fieldType: string;
label: string | null;
placeholder: string;
name: string;
value: string;
isSelected: boolean;
generatedValue: string;
useInvalidData?: boolean;
}
const DEFAULT_FIELD_TYPE_PREFERENCES: FieldTypePreferences = {};
export function useFormRecognizer() {
const { showMessage } = useGlobalSnackbar({ autoHideDuration: 1500 });
const [fillLoading, setFillLoading] = useState(false);
const [clearLoading, setClearLoading] = useState(false);
const [includeHidden, setIncludeHidden] = useState(false);
const isProcessingRef = useRef(false);
const [fields, setFields] = useState<FieldData[]>([]);
const [scanning, setScanning] = useState(false);
const [showFields, setShowFields] = useState(false);
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
const currentDomain = useActiveTabDomain();
const { sidePanelOpen, handleOpenSidePanel } = useSidePanelState();
const [fieldTypePreferences, setFieldTypePreferences] = useStorageState(
'formRecognizer/fieldTypePreferences',
DEFAULT_FIELD_TYPE_PREFERENCES,
);
// 生成字段标识符
const getFieldIdentifier = useCallback(
(field: Pick<FieldData, 'label' | 'name' | 'placeholder'>): string => {
return field.label || field.name || field.placeholder || 'unknown';
},
[],
);
// 应用保存的类型偏好
const applySavedPreferences = useCallback(
(fields: FieldData[], domain: string, preferences: FieldTypePreferences): FieldData[] => {
if (!domain || !preferences[domain]) {
return fields;
}
const prefs = preferences[domain];
return fields.map((field) => {
const identifier = getFieldIdentifier(field);
if (prefs && prefs[identifier]) {
return { ...field, fieldType: prefs[identifier] };
}
return field;
});
},
[getFieldIdentifier],
);
// 保存类型偏好
const saveTypePreference = useCallback(
(field: FieldData, newType: string) => {
if (!currentDomain) return;
const identifier = getFieldIdentifier(field);
setFieldTypePreferences((prev) => {
const prevPrefs = prev as FieldTypePreferences;
const currentDomainPrefs = prevPrefs[currentDomain] || {};
return {
...prevPrefs,
[currentDomain]: {
...currentDomainPrefs,
[identifier]: newType,
},
} as FieldTypePreferences;
});
},
[currentDomain, getFieldIdentifier, setFieldTypePreferences],
);
// 扫描表单字段
const handleScanFields = async () => {
setScanning(true);
try {
let response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
if (!response.success && response.message && response.message.includes('无法连接')) {
showMessage('正在注入内容脚本...', { severity: 'info' });
const injected = await injectContentScript();
if (injected) {
response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
}
}
if (response.success && response.fields) {
const fieldsWithPreferences = applySavedPreferences(
response.fields as FieldData[],
currentDomain,
fieldTypePreferences,
);
setFields(fieldsWithPreferences);
setShowFields(true);
showMessage(`扫描完成,发现 ${response.totalCount} 个可填充字段`, { severity: 'success' });
} else {
showMessage(response.message || '扫描失败', { severity: 'error' });
}
} catch (error) {
console.error('扫描失败:', error);
showMessage('扫描失败,请确保页面已加载', { severity: 'error' });
} finally {
setScanning(false);
}
};
// 更新字段类型
const handleFieldTypeChange = (fieldId: string, newType: string) => {
setFields((prev) =>
prev.map((field) => {
if (field.id === fieldId) {
const updatedField = {
...field,
fieldType: newType,
generatedValue: '',
};
saveTypePreference(field, newType);
return updatedField;
}
return field;
}),
);
};
// 切换单个字段的选中状态
const handleToggleFieldSelection = (fieldId: string) => {
setFields((prev) =>
prev.map((field) =>
field.id === fieldId ? { ...field, isSelected: !field.isSelected } : field,
),
);
};
// 全选/取消全选
const handleToggleAllFields = () => {
setFields((prev) => {
const allSelected = prev.every((f) => f.isSelected);
return prev.map((f) => ({ ...f, isSelected: !allSelected }));
});
};
// 定位字段(闪烁)
const handleLocateField = async (fieldId: string) => {
try {
const response = await sendMessageToContent(MessageAction.FLASH_FIELD, { fieldId });
if (!response.success) {
showMessage(response.message || '定位字段失败', { severity: 'error' });
}
} catch (error) {
console.error('定位字段失败:', error);
}
};
// 悬停高亮
const handleHoverField = async (fieldId: string | null) => {
setHoveredFieldId(fieldId);
try {
if (fieldId) {
await sendMessageToContent(MessageAction.HIGHLIGHT_FIELD, { fieldId });
} else {
await sendMessageToContent(MessageAction.UNHIGHLIGHT_ALL_FIELDS);
}
} catch (error) {
console.error('高亮字段失败:', error);
}
};
// 填充选中字段
const handleFillSelectedFields = async () => {
if (isProcessingRef.current) {
showMessage('操作进行中,请稍候...', { severity: 'warning' });
return;
}
const selectedCount = fields.filter((f) => f.isSelected).length;
if (selectedCount === 0) {
showMessage('请先选择要填充的字段', { severity: 'warning' });
return;
}
setFillLoading(true);
isProcessingRef.current = true;
try {
const messageFields = fields as MessageFieldData[];
let response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
fields: messageFields,
mode: FillMode.VALID,
includeHidden,
});
if (!response.success && response.message && response.message.includes('无法连接')) {
showMessage('正在注入内容脚本...', { severity: 'info' });
const injected = await injectContentScript();
if (injected) {
response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
fields: messageFields,
mode: FillMode.VALID,
includeHidden,
});
}
}
if (response.success) {
showMessage(response.message || '填充成功', { severity: 'success' });
} else {
showMessage(response.message || '填充失败', { severity: 'error' });
}
} catch (error) {
console.error('填充失败:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
showMessage(`填充失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
} finally {
setFillLoading(false);
isProcessingRef.current = false;
}
};
// 清空所有字段
const handleClearAllFields = async () => {
if (isProcessingRef.current) {
showMessage('操作进行中,请稍候...', { severity: 'warning' });
return;
}
setClearLoading(true);
isProcessingRef.current = true;
try {
let response = await sendMessageToContent(MessageAction.CLEAR_ALL_FIELDS);
if (!response.success && response.message && response.message.includes('无法连接')) {
showMessage('正在注入内容脚本...', { severity: 'info' });
const injected = await injectContentScript();
if (injected) {
response = await sendMessageToContent(MessageAction.CLEAR_ALL_FIELDS);
}
}
if (response.success) {
showMessage(response.message || '清空成功', { severity: 'success' });
} else {
showMessage(response.message || '清空失败', { severity: 'error' });
}
} catch (error) {
console.error('清空失败:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
showMessage(`清空失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
} finally {
setClearLoading(false);
isProcessingRef.current = false;
}
};
return {
fillLoading,
clearLoading,
includeHidden,
setIncludeHidden,
fields,
scanning,
showFields,
setShowFields,
hoveredFieldId,
sidePanelOpen,
handleScanFields,
handleFieldTypeChange,
handleToggleFieldSelection,
handleToggleAllFields,
handleLocateField,
handleHoverField,
handleFillSelectedFields,
handleClearAllFields,
handleOpenSidePanel,
selectedCount: fields.filter((f) => f.isSelected).length,
};
}
@@ -1,60 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { MessageAction, onMessage } from '@/utils/messages';
import { useSnackbar } from '@/components/SnackbarProvider';
/**
* 管理侧边栏状态检测与开启逻辑的 Hook
*/
export function useSidePanelState() {
const [sidePanelOpen, setSidePanelOpen] = useState(false);
const { showMessage } = useSnackbar();
const checkSidePanelState = useCallback(async () => {
try {
if (typeof chrome.runtime.getContexts === 'function') {
const contexts = await chrome.runtime.getContexts({
contextTypes: ['SIDE_PANEL'],
});
setSidePanelOpen(contexts.length > 0);
} else {
setSidePanelOpen(false);
}
} catch (error) {
console.error('检测侧边栏状态失败:', error);
setSidePanelOpen(false);
}
}, []);
useEffect(() => {
// 使用 requestAnimationFrame 避免同步调用 setState
requestAnimationFrame(() => {
checkSidePanelState().catch(console.error);
});
// 监听侧边栏状态变化消息
const removeListener = onMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, (message) => {
setSidePanelOpen(message.data.isOpen);
});
return () => {
removeListener();
};
}, [checkSidePanelState]);
const handleOpenSidePanel = useCallback(async () => {
try {
await chrome.sidePanel.open({ windowId: chrome.windows.WINDOW_ID_CURRENT });
setSidePanelOpen(true);
showMessage('侧边栏已打开', { severity: 'success' });
} catch (error) {
console.error('打开侧边栏失败:', error);
showMessage('打开侧边栏失败', { severity: 'error' });
}
}, [showMessage]);
return {
sidePanelOpen,
handleOpenSidePanel,
checkSidePanelState,
};
}
@@ -1,113 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import dayjs from '@/utils/dayjs';
import { DATE_FORMAT } from '@/config/pageTheme';
import type { UnitType, ZoneType } from '@/config/pageTheme';
export interface UseTimestampConverterReturn {
// State
mode: 'ts2dt' | 'dt2ts';
tsInput: string;
dtInput: string;
unit: UnitType;
zone: ZoneType;
result: string;
error: string;
// Actions
setMode: (mode: 'ts2dt' | 'dt2ts') => void;
setTsInput: (value: string) => void;
setDtInput: (value: string) => void;
setUnit: (unit: UnitType) => void;
setZone: (zone: ZoneType) => void;
handleUseNow: (now: number) => void;
convert: () => void;
}
export function useTimestampConverter(): UseTimestampConverterReturn {
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
const [tsInput, setTsInput] = useState(() => String(Date.now()));
const [dtInput, setDtInput] = useState(() => dayjs().format(DATE_FORMAT));
const [unit, setUnit] = useState<UnitType>('ms');
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
const [result, setResult] = useState('');
const [error, setError] = useState('');
const convert = useCallback(() => {
if (mode === 'ts2dt') {
const rawInput = tsInput.trim();
if (!rawInput) return;
const num = Number(rawInput);
if (isNaN(num)) {
setError('无效数字');
return;
}
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
if (!d.isValid()) {
setError('无效时间戳');
return;
}
setError('');
setResult(d.tz(zone).format(DATE_FORMAT));
} else {
const rawInput = dtInput.trim();
if (!rawInput) return;
const d = dayjs.tz(rawInput, DATE_FORMAT, zone);
if (!d.isValid()) {
setError('格式错误');
return;
}
setError('');
const ms = d.valueOf();
setResult(unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000)));
}
}, [mode, tsInput, dtInput, unit, zone]);
useEffect(() => {
const timer = setTimeout(convert, 400);
return () => clearTimeout(timer);
}, [convert]);
const handleUseNow = useCallback(
(now: number) => {
if (mode === 'ts2dt') {
setTsInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
} else {
setDtInput(dayjs(now).tz(zone).format(DATE_FORMAT));
}
},
[mode, unit, zone],
);
const handleSetMode = useCallback((newMode: 'ts2dt' | 'dt2ts') => {
setMode(newMode);
setError('');
setResult('');
}, []);
const handleSetTsInput = useCallback((value: string) => {
setTsInput(value);
setError('');
}, []);
const handleSetDtInput = useCallback((value: string) => {
setDtInput(value);
setError('');
}, []);
return {
mode,
tsInput,
dtInput,
unit,
zone,
result,
error,
setMode: handleSetMode,
setTsInput: handleSetTsInput,
setDtInput: handleSetDtInput,
setUnit,
setZone,
handleUseNow,
convert,
};
}
+194
View File
@@ -0,0 +1,194 @@
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
const BADGE_ID = 'testing-tools-right-click-restorer-badge';
const BADGE_STYLE_ID = 'testing-tools-right-click-restorer-badge-style';
let isRestored = false;
function updateBadge(): void {
const badge = document.getElementById(BADGE_ID);
if (badge) {
badge.style.opacity = isRestored ? '1' : '0';
}
}
function createBadge(): void {
if (document.getElementById(BADGE_ID)) return;
if (!document.getElementById(BADGE_STYLE_ID)) {
const style = document.createElement('style');
style.id = BADGE_STYLE_ID;
style.textContent = `
#${BADGE_ID} {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 2147483646;
padding: 6px 12px;
background: #2e7d32;
color: #ffffff;
border-radius: 20px;
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
opacity: 0;
transition: opacity 0.2s ease-out;
pointer-events: none;
user-select: none;
}
@media (prefers-color-scheme: dark) {
#${BADGE_ID} {
background: #4caf50;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
}
`;
document.head.appendChild(style);
}
const badge = document.createElement('div');
badge.id = BADGE_ID;
badge.textContent = '\u53f3\u952e\u5df2\u89e3\u9501';
document.body.appendChild(badge);
}
/* ======================== Main World 注入 ======================== */
async function injectMainWorldScript(): Promise<void> {
try {
const response = await sendMessage(MessageAction.INJECT_MAIN_WORLD_SCRIPT);
if (!response.success) {
console.error('[RightClickRestorer] Background injection failed:', response.message);
}
} catch (err) {
console.error('[RightClickRestorer] Failed to request main world injection:', err);
}
}
/* ======================== Isolated World 事件拦截 ======================== */
function installEventIntercepts(): void {
window.addEventListener(
'contextmenu',
(e) => {
if (!isRestored) return;
e.stopPropagation();
},
true,
);
// 部分网站在 mousedown 阶段阻止 contextmenu
window.addEventListener(
'mousedown',
(e) => {
if (!isRestored) return;
if (e.button === 2 || (e.buttons & 2) !== 0) {
e.stopPropagation();
}
},
true,
);
}
/* ======================== 遮罩层穿透 ======================== */
const MEDIA_TAGS = new Set(['IMG', 'VIDEO', 'CANVAS', 'SVG']);
function initMousePenetration(): void {
window.addEventListener(
'mousedown',
(e) => {
if (!isRestored || e.button !== 2) return;
const elements = document.elementsFromPoint(e.clientX, e.clientY);
if (!elements.length) return;
let targetMedia: HTMLElement | null = null;
for (const el of elements) {
if (MEDIA_TAGS.has(el.tagName)) {
targetMedia = el as HTMLElement;
break;
}
}
if (!targetMedia) return;
const modified: Array<{ el: HTMLElement; original: string | null }> = [];
let foundMedia = false;
for (const el of elements) {
const htmlEl = el as HTMLElement;
if (el === targetMedia) {
foundMedia = true;
const original = htmlEl.style.pointerEvents || null;
htmlEl.style.setProperty('pointer-events', 'all', 'important');
modified.push({ el: htmlEl, original });
continue;
}
if (!foundMedia) {
const original = htmlEl.style.pointerEvents || null;
htmlEl.style.setProperty('pointer-events', 'none', 'important');
modified.push({ el: htmlEl, original });
}
}
setTimeout(() => {
for (const { el, original } of modified) {
if (original === null || original === '') {
el.style.removeProperty('pointer-events');
} else {
el.style.pointerEvents = original;
}
}
}, 300);
},
true,
);
}
/* ======================== 激活保护 ======================== */
async function activateProtection(): Promise<void> {
if (isRestored) return;
await injectMainWorldScript();
// 再次检查,防止并发调用导致重复注册
if (isRestored) return;
installEventIntercepts();
initMousePenetration();
isRestored = true;
updateBadge();
}
/* ======================== 消息通信 ======================== */
function initMessaging(): void {
onMessage(MessageAction.RESTORE_RIGHT_CLICK, async () => {
await activateProtection();
return { success: true, restored: isRestored };
});
onMessage(MessageAction.QUERY_RIGHT_CLICK_STATUS, () => {
return { success: true, restored: isRestored };
});
}
/* ======================== 入口 ======================== */
export default defineContentScript({
matches: ['<all_urls>'],
runAt: 'document_start',
main() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', createBadge, { once: true });
} else {
createBadge();
}
initMessaging();
},
});
+8 -15
View File
@@ -3,40 +3,33 @@ import RouterProvider from '@/providers/RouterProvider';
import TopBar from '@/components/TopBar';
import RouterContainer from '@/components/RouterContainer';
import ErrorBoundary from '@/components/ErrorBoundary';
import { SnackbarProvider } from '@/components/GlobalSnackbar';
import { MessageAction, sendMessage } from '@/utils/messages';
import { Box } from '@mui/material';
export default function App() {
const handleOpenOptions = () => {
chrome.runtime.openOptionsPage();
chrome.runtime.openOptionsPage().catch((err) => {
console.error('Failed to open options page:', err);
});
};
// 通知侧边栏已打开
useEffect(() => {
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true });
return () => {
// 尝试在关闭时通知,虽然在某些情况下可能无法成功发送
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: false });
};
}, []);
return (
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
<Box
className="app"
sx={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
width: '100%',
overflow: 'hidden',
}}
>
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
<div className="app flex flex-col h-screen w-full overflow-hidden">
<TopBar onOpenOptions={handleOpenOptions} />
<ErrorBoundary>
<RouterContainer />
</ErrorBoundary>
</Box>
</div>
</SnackbarProvider>
</RouterProvider>
);
}
+5 -9
View File
@@ -1,15 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from '@/config/theme';
import AppRoot from '@/providers/AppRoot';
import '@/i18n';
import '@/src/index.css';
import App from './App.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<AppRoot>
<App />
</ThemeProvider>
</React.StrictMode>,
</AppRoot>,
);
+50 -15
View File
@@ -4,20 +4,29 @@ import reactHooks from 'eslint-plugin-react-hooks';
import reactPlugin from 'eslint-plugin-react';
import globals from 'globals';
export default [
export default tseslint.config(
// 1. 全局物理隔离:彻底掐灭对构建产物与配置本身的干扰
{
ignores: [
'dist',
'.wxt',
'node_modules',
'eslint.config.ts',
'**/*.test.tsx',
'**/*.test.ts',
'**/__tests__/**',
],
ignores: ['dist', '.output', '.wxt', 'node_modules', 'eslint.config.ts', 'eslint.config.js'],
},
// 2. 注入 JavaScript 与 TypeScript 的官方大师级推荐规则集
js.configs.recommended,
...tseslint.configs.recommended,
// 3. 针对测试文件专属沙箱:解耦强类型死锁,放行 any,容忍未消费变量
{
files: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', 'setupTests.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
// 4. 核心业务全受控大管线(Hooks, Entrypoints, Components 统一护航)
{
files: [
'hooks/**/*.{ts,tsx}',
@@ -27,29 +36,55 @@ export default [
'components/**/*.{ts,tsx}',
'services/**/*.{ts,tsx}',
],
ignores: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
ecmaVersion: 2022, // 💡 升级至现代高频语法解析
globals: {
...globals.browser,
...globals.node,
},
// 💡 修复点 1(史诗级治愈):废除脆弱的 project 硬编码路径!
// 拥抱 typescript-eslint 官方推荐的 projectService 常驻动态类型调度中枢。
// 它会在内存中全自动、流式为所有新建、悬空或暂存文件分配编译上下文,
// 彻底终结 "file is not included in any tsconfig" 的全量崩溃黑洞!
parserOptions: {
project: ['./tsconfig.json'],
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
// 挂载插件沙箱
plugins: {
react: reactPlugin as any,
'react-hooks': reactHooks as any,
react: reactPlugin,
'react-hooks': reactHooks,
},
settings: {
react: {
version: 'detect',
},
},
// 💡 修复点 2:高精对齐 React 19 / JSX Runtime 的全量生产质检规则大闸
rules: {
// 激活 react-hooks 官方推荐规则
...reactHooks.configs.recommended.rules,
// 激活 react 官方精选规则(排除旧版 React 必须手动 import 的历史包袱)
...reactPlugin.configs.recommended.rules,
...reactPlugin.configs['jsx-runtime'].rules,
'react/prop-types': 'off',
// 清洗原生未消费变量冲突,统一交由 TS 高阶哨兵接管
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
// 彻底关闭老旧的 JSX 作用域检查,全面契合 React 19 核心美学
'react/react-in-jsx-scope': 'off',
},
},
];
);
+144
View File
@@ -0,0 +1,144 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import type { CustomDetector } from 'i18next-browser-languagedetector'; // 💡 1. 引入官方强类型探测器接口
import LanguageDetector from 'i18next-browser-languagedetector';
import { storageUtil } from '@/utils/chromeStorage';
import dayjs from 'dayjs';
// 导入 Day.js 本地化语言包
import 'dayjs/locale/zh-cn';
// 同步加载全局核心命名空间
import commonZh from './locales/zh/common.json';
import featuresZh from './locales/zh/features.json';
import commonEn from './locales/en/common.json';
import featuresEn from './locales/en/features.json';
const resources = {
zh: {
common: commonZh,
features: featuresZh,
},
en: {
common: commonEn,
features: featuresEn,
},
};
export const SUPPORTED_LANGUAGES = ['zh', 'en'] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
const LANGUAGE_STORAGE_KEY = 'app/language';
const LANGUAGE_SNAPSHOT_KEY = 'snapshot/app/language';
/**
* 将任意语言标识归一化为受支持的核心代码
*/
export const normalizeLanguage = (lng: string): SupportedLanguage => {
if (!lng) return 'en';
return lng.toLowerCase().startsWith('zh') ? 'zh' : 'en';
};
/**
* 严格校验语言安全边界
*/
const isValidLanguage = (lng: unknown): lng is SupportedLanguage => {
return typeof lng === 'string' && (SUPPORTED_LANGUAGES as readonly string[]).includes(lng);
};
/**
* 同步从 localStorage 获取语言快照(消除异步闪烁)
*/
const getSyncLanguageSnapshot = (): SupportedLanguage | null => {
try {
const val = localStorage.getItem(LANGUAGE_SNAPSHOT_KEY);
if (!val) return null;
const parsed = JSON.parse(val) as unknown;
return isValidLanguage(parsed) ? parsed : null;
} catch (error) {
console.error('[i18n] Failed to parse sync language snapshot from localStorage:', error);
return null;
}
};
// 💡 2. 强类型接口重塑:显式绑定 CustomDetector 类型,
// 告诉 TS 编译器这些方法将被全局 Languagedetector 框架隐式调用,彻底治愈“未使用函数”报错!
const chromeStorageDetector: CustomDetector = {
name: 'chromeStorage',
lookup() {
return undefined;
},
cacheUserLanguage(lng: string) {
const target = normalizeLanguage(lng);
// 💡 修复点:对异步写盘操作追加 void 算子或 catch,吞掉 Promise 被忽略警告
storageUtil.set(LANGUAGE_STORAGE_KEY, target).catch((err) => {
console.error('[i18n Detector Error] Failed to write back language state:', err);
});
},
};
const detector = new LanguageDetector();
detector.addDetector(chromeStorageDetector);
const syncLng = getSyncLanguageSnapshot();
// 💡 3. 修复点:对 i18n.init() 返回的异步 Promise 前方追加 void 斩断依赖链,放行编译
void i18n
.use(detector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
lng: syncLng || undefined,
ns: ['common', 'features'],
defaultNS: 'common',
debug: false,
interpolation: {
escapeValue: false,
},
detection: {
order: ['chromeStorage', 'navigator'],
caches: ['chromeStorage'],
},
});
// 监听语言变更
i18n.on('languageChanged', (lng) => {
const normalizedLng = normalizeLanguage(lng);
dayjs.locale(normalizedLng === 'zh' ? 'zh-cn' : 'en');
localStorage.setItem(LANGUAGE_SNAPSHOT_KEY, JSON.stringify(normalizedLng));
});
// 初始化时从长期异步存储中恢复校准
storageUtil
.get(LANGUAGE_STORAGE_KEY)
.then((lng) => {
const rawTargetLng = lng || syncLng;
if (!rawTargetLng) {
const initialLng = normalizeLanguage(i18n.language);
// 💡 修复点:对初始化同步写盘追加安全的 Promise .catch() 异常隔离防护罩
storageUtil.set(LANGUAGE_STORAGE_KEY, initialLng).catch((err) => {
console.error('[i18n Init Error] Persistent sync collapsed:', err);
});
if (initialLng !== normalizeLanguage(i18n.language)) {
// 💡 修复点:对 changeLanguage 异步微任务进行显式 void 断链安全隔离
void i18n.changeLanguage(initialLng);
}
return;
}
const targetLng = normalizeLanguage(String(rawTargetLng));
if (isValidLanguage(targetLng) && targetLng !== normalizeLanguage(i18n.language)) {
// 💡 修复点:对 changeLanguage 异步微任务进行显式 void 断链安全隔离
void i18n.changeLanguage(targetLng);
}
})
.catch((err) => {
console.error('[i18n Context Error] Async local storage lookup collapsed:', err);
});
export default i18n;
+37
View File
@@ -0,0 +1,37 @@
{
"pageTitle": "Base64 Converter",
"pageSubtitle": "Encode and decode text, files, and images with Base64",
"textMode": "Text",
"fileMode": "File",
"imageMode": "Image",
"encode": "Encode",
"decode": "Decode",
"clear": "Clear",
"textInputPlaceholder": "Enter text to encode to Base64...",
"base64InputPlaceholder": "Enter Base64 string to decode...",
"base64Output": "Base64 Output",
"textOutput": "Decoded Text Output",
"copyRaw": "Copy Raw Base64",
"copyDataUri": "Copy Data URI",
"clickOrDropToFile": "Click or drop a file here",
"clickOrDropToImage": "Click or drop an image here",
"clickOrDropToReplace": "Click or drop to replace the file",
"maxFileSize": "Maximum file size: {{max}}",
"supportedFormats": "Supports PNG, JPG, WEBP, GIF, BMP, SVG, etc.",
"fileSizeExceeded": "File size exceeds the limit (max {{max}})",
"unsupportedImageType": "Unsupported image format",
"conversionFailed": "Conversion failed",
"originalSize": "Original Size",
"encodedSize": "Encoded Size",
"invalidBase64": "Invalid Base64 string",
"binaryDataDetected": "Input appears to be binary data (e.g. an image). Please switch to the Image tab.",
"imageDataUriHint": "Detected an image data URI — please use the Image tab to decode it.",
"switchToImageMode": "Switch to Image mode",
"download": "Download",
"decodedFileName": "Decoded file name",
"decodeBase64Placeholder": "Enter Base64 or data URI to decode...",
"decodedFileOutput": "Decoded File",
"decodedImageOutput": "Decoded Image",
"inferredMimeType": "Inferred MIME type",
"decodedSize": "Decoded size"
}
+35
View File
@@ -0,0 +1,35 @@
{
"appName": "Testing Tools",
"buttons": {
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"copy": "Copy",
"clear": "Clear",
"refresh": "Refresh",
"toggleLanguage": "Switch Language",
"toggleTheme": "Toggle theme",
"themeMode": {
"light": "Switch to dark mode",
"dark": "Switch to system mode",
"system": "Switch to light mode"
},
"search": "Search tools...",
"back": "Back",
"clearSearch": "Clear search",
"recentSearch": "Recent search",
"noResults": "No tools found",
"openInTab": "Open in tab",
"settings": "Settings"
},
"messages": {
"copySuccess": "Copied to clipboard",
"copyError": "Copy failed"
},
"textInputArea": {
"clear": "Clear",
"copyContent": "Copy content",
"cleared": "Cleared",
"placeholder": "placeholder text"
}
}

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