Refactor and enhance form recognition, mapping, and UI components (#18)

* 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 配置文件
This commit is contained in:
LingandRX
2026-05-01 18:00:06 +08:00
committed by GitHub
parent 60537f6e2e
commit 8d36f21f1b
64 changed files with 2901 additions and 2197 deletions
+206 -34
View File
@@ -4,7 +4,7 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t
## 项目概述 ## 项目概述
这是一个基于 WXT 框架的浏览器扩展项目,提供测试工具功能,包括时间戳转换等。 这是一个基于 WXT 框架的浏览器扩展项目,提供多种测试工具功能,包括时间戳转换、存储管理、URL 管理、二维码生成、表单识别与填充等。
## 核心命令 ## 核心命令
@@ -33,10 +33,10 @@ npx vitest run components/__tests__/CopyButton.test.tsx
**测试技术栈:** **测试技术栈:**
- Vitest - 测试框架 - Vitest v2 - 测试框架
- @testing-library/react - React 组件测试 - @testing-library/react v16 - React 组件测试
- @testing-library/user-event v13 - 用户交互模拟(注意:v13 不支持 setup(),使用 fireEvent - @testing-library/user-event v14 - 用户交互模拟
- jsdom - 浏览器环境模拟 - jsdom v25 - 浏览器环境模拟
### 依赖与准备 ### 依赖与准备
@@ -48,56 +48,201 @@ npx vitest run components/__tests__/CopyButton.test.tsx
### 技术栈 ### 技术栈
- **框架**: WXT (Web Extension Toolkit) - 浏览器扩展开发框架 - **框架**: WXT v0.20.6 (Web Extension Toolkit) - 浏览器扩展开发框架
- **前端**: React 19 + TypeScript - **前端**: React 19 + TypeScript 5
- **UI 库**: Material UI (MUI) - **UI 库**: Material UI (MUI) v7 + Emotion
- **状态管理**: React Hooks - **状态管理**: React Hooks + 自定义 Hooks
- **路由**: React Router DOM - **路由**: 自定义路由系统(支持 popup/sidepanel/detached 三种模式)
- **测试**: Vitest + Testing Library
- **代码质量**: ESLint v9 + Prettier + Husky + lint-staged
### 目录结构 ### 目录结构
``` ```
├── components/ # 可复用 UI 组件 ├── components/ # 可复用 UI 组件
│ ├── __tests__/ # 组件测试文件
│ ├── Button.tsx # 按钮组件
│ ├── CopyButton.tsx # 复制按钮组件 │ ├── CopyButton.tsx # 复制按钮组件
│ ├── DatetimeToTimestamp.tsx # 日期转时间戳组件 │ ├── DashboardCard.tsx # 仪表盘卡片组件
│ ├── Navbar.tsx # 导航栏组件 │ ├── FieldList.tsx # 字段列表组件
│ ├── RoutePersistence.tsx # 路由持久化组件 │ ├── GlobalSnackbar.tsx # 全局提示消息组件
│ ├── TimestampExecution.tsx # 时间戳执行组件 │ ├── PageHeader.tsx # 页面头部组件
── TimestampToDatetime.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/ # 浏览器扩展入口点 ├── entrypoints/ # 浏览器扩展入口点
│ ├── background.ts # 后台脚本(主进程) │ ├── background.ts # 后台脚本(主进程)
│ ├── content.ts # 内容脚本(注入到页面) │ ├── content.ts # 内容脚本(注入到页面)
── popup/ # 扩展弹窗界面 ── content/
── App.tsx # 弹窗主应用 ── messageHandler.ts # 消息处理器
├── main.tsx # 弹窗入口 ├── options/ # 选项页面
── pages/ # 弹窗页面 ── App.tsx # 选项应用
├── TestPage.tsx # 测试页面 ├── index.html # 选项页面 HTML
└── TimestampPage.tsx # 时间戳工具页面 └── 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/ # 工具函数 ├── utils/ # 工具函数
│ ├── __tests__/ # 工具测试文件
│ ├── formMapping/ # 表单映射工具
│ │ ├── highlighter.ts # 表单高亮器
│ │ ├── scanner.ts # 表单扫描器
│ │ ├── smartInjector.ts # 智能注入器
│ │ └── ui.ts # UI 工具
│ ├── chromeStorage.ts # Chrome 存储工具 │ ├── chromeStorage.ts # Chrome 存储工具
│ ├── chromeTabs.ts # Chrome 标签页工具
│ ├── clipboard.ts # 剪贴板工具
│ ├── dataTemplate.ts # 数据模板
│ ├── dataValidator.ts # 数据验证器
│ ├── dayjs.ts # 日期处理工具 │ ├── dayjs.ts # 日期处理工具
── messages.tsx # 消息通信工具 ── dummyDataGenerator.ts # 虚拟数据生成器(基于 Faker
│ ├── messages.ts # 消息通信工具
│ ├── qrCodeParser.ts # 二维码解析器
│ ├── storageCleaner.ts # 存储清理工具
│ ├── useStorageState.ts # 存储状态 Hook
│ └── useUrlPreferences.ts # URL 偏好设置 Hook
├── types/ # 类型定义 ├── types/ # 类型定义
│ └── storage.d.ts # 存储相关类型 │ └── storage.d.ts # 存储相关类型
├── docs/ # 文档
│ └── plans/ # 计划文档
├── public/ # 静态资源
│ └── icon/ # 扩展图标
└── .github/ # GitHub 配置
└── workflows/ # CI/CD 工作流
├── ci.yml # 持续集成
└── release.yml # 发布流程
``` ```
### 核心功能实现 ### 核心功能模块
#### 1. 时间戳转换工具 #### 1. 时间戳转换工具
- 位置: `components/` 目录下的时间戳相关组件 - 位置: `entrypoints/popup/pages/TimestampPage.tsx`
- Hook: `entrypoints/popup/pages/hooks/useTimestampConverter.ts`
- 依赖: dayjs 库进行日期处理 - 依赖: dayjs 库进行日期处理
- 功能: 支持日期与时间戳的双向转换,支持多种格式 - 功能: 支持日期与时间戳的双向转换,支持多种格式,实时时钟显示
#### 2. 通信系统 #### 2. 存储清理工具
- 位置: `utils/messages.tsx` - 位置: `entrypoints/popup/pages/StorageCleanerPage.tsx`
- Hook: `entrypoints/popup/pages/useStorageCleaner.ts`
- 工具: `utils/storageCleaner.ts`
- 功能: 清理缓存、Cookies、本地存储,支持按域名筛选,自动刷新功能
#### 3. URL 管理工具
- 打开 URL: `entrypoints/popup/pages/OpenUrlPage.tsx`
- 查看 URL: `entrypoints/popup/pages/OpenUrlViewerPage.tsx`
- 组件: `components/UrlEntryForm.tsx`, `components/UrlEntryList.tsx`
- 功能: 批量打开多个 URL,URL 列表管理
#### 4. 二维码工具
- 位置: `entrypoints/popup/pages/QrCodePage.tsx`
- 组件: `components/QrCodeUploader.tsx`, `components/QrCodeToUrlSection.tsx`, `components/UrlToQrCodeSection.tsx`
- 工具: `utils/qrCodeParser.ts`
- 依赖: qrcode, jsqr 库
- 功能: URL 转二维码生成,二维码图片解析为 URL
#### 5. 表单工具套件
**表单识别 (Form Recognizer)**
- 位置: `entrypoints/popup/pages/FormRecognizerPage.tsx`
- Hook: `entrypoints/popup/pages/hooks/useFormRecognizer.ts`
- 功能: 智能识别页面表单指纹
**表单映射 (Form Mapping)**
- 位置: `entrypoints/popup/pages/FormMappingPage.tsx`
- 工具: `utils/formMapping/` 目录
- `scanner.ts` - 表单扫描器
- `highlighter.ts` - 表单高亮器
- `smartInjector.ts` - 智能注入器
- `ui.ts` - UI 工具
- 功能: 表单指纹识别与自定义映射规则配置
**表单填充 (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` 库实现 - 机制: 使用 `@webext-core/messaging` 库实现
- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗 - 内容脚本消息处理: `entrypoints/content/messageHandler.ts`
- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗/侧边栏
#### 3. 数据存储 #### 9. 数据存储
- Chrome Storage API: `utils/chromeStorage.ts` (用于配置等小数据) - Chrome Storage API: `utils/chromeStorage.ts`
- 存储状态 Hook: `utils/useStorageState.ts`
- URL 偏好设置: `utils/useUrlPreferences.ts`
- 类型定义: `types/storage.d.ts`
### 关键配置文件 ### 关键配置文件
@@ -105,8 +250,9 @@ npx vitest run components/__tests__/CopyButton.test.tsx
- 配置 WXT 框架参数 - 配置 WXT 框架参数
- 启用 React 模块 - 启用 React 模块
- 配置浏览器扩展权限 - 配置浏览器扩展权限storage, unlimitedStorage, clipboardWrite, activeTab, scripting, tabs, cookies, sidePanel
- Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码) - Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码)
- 配置侧边栏和选项页面
#### manifest 权限 #### manifest 权限
@@ -118,11 +264,17 @@ permissions: [
'activeTab', // 当前标签页 'activeTab', // 当前标签页
'scripting', // 脚本注入 'scripting', // 脚本注入
'tabs', // 标签页管理 'tabs', // 标签页管理
'debugger', // 调试器 'cookies', // Cookies 管理
'sidePanel', // 侧边栏
], ],
host_permissions:['<all_urls>'] // 访问所有网站 host_permissions:['<all_urls>'] // 访问所有网站
``` ```
#### CI/CD 配置
- `.github/workflows/ci.yml` - 持续集成工作流
- `.github/workflows/release.yml` - 发布工作流
## 开发注意事项 ## 开发注意事项
### 扩展入口点 ### 扩展入口点
@@ -130,14 +282,34 @@ host_permissions: ['<all_urls>'] // 访问所有网站
- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务 - **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务
- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互 - **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互
- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示 - **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示
- **侧边栏**: `entrypoints/sidepanel/main.tsx` - 浏览器侧边栏界面
- **选项页面**: `entrypoints/options/main.tsx` - 扩展设置页面
### 路由系统
- 使用自定义路由系统,支持多种显示模式
- 路由配置在 `config/features.tsx`
- 通过 `getEntryPointType()` 判断当前入口点类型
- 支持页面可见性配置(`defaultVisible`
### 浏览器兼容性 ### 浏览器兼容性
- 支持 Chrome 和 Firefox 浏览器 - 支持 Chrome 和 Firefox 浏览器
- 使用 WXT 框架抽象浏览器差异 - 使用 WXT 框架抽象浏览器差异
- 使用 `@types/chrome``@types/webextension-polyfill` 提供类型支持
### 代码质量 ### 代码质量
- 使用 ESLint 进行代码检查 - 使用 ESLint v9 进行代码检查(基于 typescript-eslint
- Husky 用于 Git 钩子管理 - Prettier 进行代码格式化
- Husky v9 用于 Git 钩子管理
- Lint-staged 确保暂存文件符合规范 - Lint-staged 确保暂存文件符合规范
- GitHub Actions CI/CD 自动化测试和构建
### 测试策略
- 组件测试: `components/__tests__/` 目录
- 工具函数测试: `utils/__tests__/` 目录
- 配置测试: `config/__tests__/` 目录
- 使用 Vitest 作为测试框架
- 使用 Testing Library 进行 React 组件测试
+4 -1
View File
@@ -70,12 +70,15 @@ Testing Tools 是一个轻量级的浏览器扩展,提供多种实用的测试
├── components/ # 可复用 UI 组件 ├── components/ # 可复用 UI 组件
│ ├── Button.tsx │ ├── Button.tsx
│ ├── CopyButton.tsx │ ├── CopyButton.tsx
│ ├── DashboardCard.tsx # 仪表盘卡片组件(React.memo 优化)
│ ├── GlobalSnackbar.tsx │ ├── GlobalSnackbar.tsx
│ ├── PageHeader.tsx # 页面标题栏组件
│ ├── RouterContainer.tsx │ ├── RouterContainer.tsx
│ ├── StorageCleanerConfirm.tsx │ ├── StorageCleanerConfirm.tsx
│ ├── ToolCard.tsx │ ├── ToolCard.tsx
│ └── TopBar.tsx │ └── TopBar.tsx
├── config/ # 路由配置 ├── config/ # 配置文件
│ ├── dashboardCards.tsx # 仪表盘卡片配置数据
│ └── routes.ts # 页面路由定义 │ └── routes.ts # 页面路由定义
├── entrypoints/ # 浏览器扩展入口点 ├── entrypoints/ # 浏览器扩展入口点
│ ├── popup/ # 扩展弹窗界面 │ ├── popup/ # 扩展弹窗界面
+22 -3
View File
@@ -1,11 +1,30 @@
import { Button as MuiButton, ButtonProps as MuiButtonProps } from '@mui/material'; import { Button as MuiButton, ButtonProps as MuiButtonProps } from '@mui/material';
/**
* 按钮属性类型
* 继承自 MUI ButtonProps,支持所有 MUI Button 的属性
*/
export type ButtonProps = MuiButtonProps; export type ButtonProps = MuiButtonProps;
/** /**
* 按钮组件 * Button - 自定义按钮组件
* @param sx 自定义样式 *
* @param props 其他按钮属性 * 基于 MUI Button 的二次封装,提供统一的项目风格:
* - 禁用阴影和涟漪效果
* - 圆角设计 (borderRadius: 4)
* - 固定高度和字体大小
* - hover 时轻微上浮效果
* - 支持 sx 数组合并
*
* @example
* ```tsx
* <Button variant="contained" color="primary">
* 提交
* </Button>
* ```
*
* @param sx - 自定义样式,支持数组或单个样式对象
* @param props - 其他 MUI Button 属性
* @returns 按钮组件 * @returns 按钮组件
*/ */
export function Button({ sx = [], ...props }: ButtonProps) { export function Button({ sx = [], ...props }: ButtonProps) {
+46
View File
@@ -0,0 +1,46 @@
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;
+103
View File
@@ -0,0 +1,103 @@
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';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
/**
* 错误边界组件:捕获子组件树中的 JavaScript 错误
*/
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
private handleReset = () => {
this.setState({ hasError: false, error: null });
window.location.reload();
};
public 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 }}>
</Typography>
{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',
}}
>
{this.state.error.toString()}
</Typography>
</Box>
)}
<Button
variant="contained"
color="error"
startIcon={<RefreshIcon />}
onClick={this.handleReset}
sx={{ borderRadius: 2, fontWeight: 700 }}
>
</Button>
</Paper>
</Container>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
+200 -16
View File
@@ -1,47 +1,110 @@
import { useState } from 'react'; /**
* GlobalSnackbar - 全局 Snackbar 消息提示组件
*
* 提供可复用的 Toast 消息提示功能,支持两种使用方式:
* 1. 作为受控组件使用:通过 props 控制显示状态
* 2. 通过 useSnackbarState Hook 使用:自动管理状态
*
* @module GlobalSnackbar
* @version 1.0.0
*
* @example
* ```tsx
* // 方式一:受控组件
* <GlobalSnackbar
* message="操作成功"
* open={isOpen}
* onClose={() => setIsOpen(false)}
* severity="success"
* />
*
* // 方式二:Hook 方式
* const { snackbarProps, showMessage } = useSnackbarState();
* showMessage('Hello!', { severity: 'info' });
* ```
*/
import React, { JSX, useState } from 'react';
import { Snackbar, Alert, type SxProps, type Theme, alpha, Portal } from '@mui/material'; import { Snackbar, Alert, type SxProps, type Theme, alpha, Portal } from '@mui/material';
/**
* Snackbar 消息严重程度类型
* @description 决定 Alert 组件的颜色和图标
* - success: 绿色,成功提示
* - info: 蓝色,信息提示
* - warning: 橙色,警告提示
* - error: 红色,错误提示
*/
export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error'; export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
/**
* 重新导出 SnackbarProvider 组件
* @description 提供 Context 方式的全局 Snackbar 功能
*/
export { SnackbarProvider } from './SnackbarProvider';
/**
* GlobalSnackbar 组件的属性接口
* @interface GlobalSnackbarProps
*/
export interface GlobalSnackbarProps { export interface GlobalSnackbarProps {
/** 消息内容 */ /** 消息内容,要显示的提示文本 */
message: string; message: string;
/** 是否显示 */ /** 是否显示 Snackbar */
open: boolean; open: boolean;
/** 关闭回调 */ /** 关闭回调函数 */
onClose: () => void; onClose: () => void;
/** 消息级别影响颜色 */ /** 消息级别影响颜色和图标样式,默认 'info' */
severity?: SnackbarSeverity; severity?: SnackbarSeverity;
/** 自动隐藏时间毫秒0 不自动关闭 */ /** 自动隐藏时间毫秒),设为 0 不自动关闭,默认 2000 */
autoHideDuration?: number; autoHideDuration?: number;
/** 弹出位置 */ /** Snackbar 弹出位置,默认 { vertical: 'bottom', horizontal: 'center' } */
anchorOrigin?: { anchorOrigin?: {
vertical: 'top' | 'bottom'; vertical: 'top' | 'bottom';
horizontal: 'left' | 'center' | 'right'; horizontal: 'left' | 'center' | 'right';
}; };
/** 是否使用 Alert 包裹false 则使用原生 Snackbar message */ /** 是否使用 Alert 组件包裹false 则使用原生 Snackbar message,默认 true */
showAlert?: boolean; showAlert?: boolean;
/** Alert 是否隐藏图标 */ /** 是否隐藏 Alert 图标,默认 false */
hideIcon?: boolean; hideIcon?: boolean;
/** 自定义样式,透传给 Snackbar */ /** 自定义样式,透传给外层 Snackbar 组件 */
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
/** 自定义样式,透传给 Alert(仅 showAlert=true 时生效) */ /** 自定义样式,透传给内层 Alert 组件(仅 showAlert=true 时生效) */
alertSx?: SxProps<Theme>; alertSx?: SxProps<Theme>;
} }
/**
* showMessage 方法的选项配置
* @interface SnackbarOptions
*/
export interface SnackbarOptions { export interface SnackbarOptions {
/** 消息级别:success | info | warning | error */
severity?: SnackbarSeverity; severity?: SnackbarSeverity;
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭 */
autoHideDuration?: number; autoHideDuration?: number;
/** 是否隐藏 Alert 图标 */
hideIcon?: boolean; hideIcon?: boolean;
/** 是否使用 Alert 组件包裹 */
showAlert?: boolean; showAlert?: boolean;
} }
export interface UseSnackbarResult { /**
* useSnackbarState Hook 的返回值类型
* @interface UseSnackbarStateResult
*/
export interface UseSnackbarStateResult {
/** 传递给 GlobalSnackbar 组件的属性对象 */
snackbarProps: GlobalSnackbarProps; snackbarProps: GlobalSnackbarProps;
/** 显示消息的方法 */
showMessage: (message: string, options?: SnackbarOptions) => void; showMessage: (message: string, options?: SnackbarOptions) => void;
/** 关闭消息的方法 */
closeMessage: () => void; closeMessage: () => void;
} }
/**
* GlobalSnackbar 组件的默认属性配置
* @description 提供类型安全的默认值选择
*/
const defaultProps: Required< const defaultProps: Required<
Pick< Pick<
GlobalSnackbarProps, GlobalSnackbarProps,
@@ -55,6 +118,32 @@ const defaultProps: Required<
hideIcon: false, hideIcon: false,
}; };
/**
* 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({ export function GlobalSnackbar({
message, message,
open, open,
@@ -63,8 +152,16 @@ export function GlobalSnackbar({
autoHideDuration = defaultProps.autoHideDuration, autoHideDuration = defaultProps.autoHideDuration,
showAlert = defaultProps.showAlert, showAlert = defaultProps.showAlert,
hideIcon = defaultProps.hideIcon, hideIcon = defaultProps.hideIcon,
}: GlobalSnackbarProps) { }: GlobalSnackbarProps): JSX.Element {
// 使用 Portal 将 Snackbar 传送到 DOM 顶层 (body 标签下) /**
* 使用 Portal 将 Snackbar 传送到 DOM 顶层 (body 标签下)
*
* @description
* Portal 的优势:
* - 避免父容器 overflow、z-index 等样式影响
* - 确保 Snackbar 始终显示在最顶层
* - 避免与其他组件的样式冲突
*/
return ( return (
<Portal> <Portal>
<Snackbar <Snackbar
@@ -75,8 +172,12 @@ export function GlobalSnackbar({
disableWindowBlurListener disableWindowBlurListener
sx={{ sx={{
zIndex: 999999, zIndex: 999999,
// 确保距离底部的间距 // 确保距离底部的间距,响应式设计适配不同屏幕
bottom: { xs: '24px', sm: '24px' }, bottom: { xs: '24px', sm: '24px' },
// 固定宽度时使用 transform 实现真正的居中
left: '50%',
transform: 'translateX(-50%)',
minWidth: '140px',
}} }}
> >
{showAlert ? ( {showAlert ? (
@@ -85,19 +186,26 @@ export function GlobalSnackbar({
variant="filled" variant="filled"
icon={hideIcon ? false : undefined} icon={hideIcon ? false : undefined}
sx={{ sx={{
// 胶囊形状,现代化的设计风格
borderRadius: '50px', borderRadius: '50px',
px: 2.5, px: 2.5,
py: 0.2, py: 0.2,
minWidth: '140px', minWidth: '140px',
// 居中内容
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
// 粗体小字
fontWeight: 800, fontWeight: 800,
fontSize: '0.75rem', fontSize: '0.75rem',
// 移除默认渐变背景
backgroundImage: 'none', backgroundImage: 'none',
// 添加阴影效果,颜色根据 severity 自动匹配主题色
boxShadow: (theme: Theme) => boxShadow: (theme: Theme) =>
`0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`, `0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`,
// 图标样式:白色、稍大
'& .MuiAlert-icon': { mr: 0.5, fontSize: '1.1rem', color: '#fff' }, '& .MuiAlert-icon': { mr: 0.5, fontSize: '1.1rem', color: '#fff' },
// 消息文字样式:白色、适当内边距
'& .MuiAlert-message': { color: '#fff', padding: '6px 0' }, '& .MuiAlert-message': { color: '#fff', padding: '6px 0' },
}} }}
> >
@@ -109,26 +217,98 @@ export function GlobalSnackbar({
); );
} }
export function useSnackbar(initialOptions?: SnackbarOptions): UseSnackbarResult { /**
* useSnackbarState - 消息提示的 Hook 方式
*
* 提供状态管理的 Snackbar 功能,自动处理 open、message 等状态。
* 适合在组件内部使用,无需额外的状态管理代码。
*
* @param {SnackbarOptions} [initialOptions] - 初始配置选项
* @returns {UseSnackbarStateResult} 包含 snackbarProps 和操作方法的对象
*
* @description
* - 自动管理 Snackbar 的显示/隐藏状态
* - 支持链式调用 showMessage
* - 合并初始选项和调用时选项
*
* @example
* ```tsx
* function MyComponent() {
* const { snackbarProps, showMessage, closeMessage } = useSnackbarState({
* severity: 'info',
* autoHideDuration: 3000,
* });
*
* const handleSave = () => {
* // 业务逻辑...
* showMessage('保存成功!', { severity: 'success' });
* };
*
* return (
* <>
* <button onClick={handleSave}>保存</button>
* <GlobalSnackbar {...snackbarProps} />
* </>
* );
* }
* ```
*/
export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarStateResult {
// Snackbar 显示状态
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// 当前显示的消息内容
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
// 消息配置选项
const [options, setOptions] = useState<SnackbarOptions>(initialOptions || {}); const [options, setOptions] = useState<SnackbarOptions>(initialOptions || {});
/**
* 显示消息
*
* @param {string} newMessage - 要显示的消息文本
* @param {SnackbarOptions} [newOptions={}] - 新的配置选项
*
* @description
* - 合并初始选项和新的调用选项
* - 新选项会覆盖初始选项
*/
const showMessage = (newMessage: string, newOptions: SnackbarOptions = {}) => { const showMessage = (newMessage: string, newOptions: SnackbarOptions = {}) => {
setMessage(newMessage); setMessage(newMessage);
setOptions({ ...initialOptions, ...newOptions }); setOptions({ ...initialOptions, ...newOptions });
setOpen(true); setOpen(true);
}; };
/**
* 关闭消息
*
* @description
* - 直接将 open 状态设置为 false
*/
const closeMessage = () => { const closeMessage = () => {
setOpen(false); 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?: React.SyntheticEvent | Event, reason?: string) => {
if (reason === 'clickaway') return; if (reason === 'clickaway') return;
closeMessage(); closeMessage();
}; };
/**
* 传递给 GlobalSnackbar 组件的属性
*
* @description
* - 组合当前状态和选项为完整的组件 props
* - onClose 使用 handleClose 包装后的版本
*/
const snackbarProps: GlobalSnackbarProps = { const snackbarProps: GlobalSnackbarProps = {
message, message,
open, open,
@@ -145,4 +325,8 @@ export function useSnackbar(initialOptions?: SnackbarOptions): UseSnackbarResult
}; };
} }
/**
* GlobalSnackbar 组件的默认导出
* @description 方便使用 `import GlobalSnackbar from './GlobalSnackbar'` 方式导入
*/
export default GlobalSnackbar; export default GlobalSnackbar;
+3 -3
View File
@@ -13,7 +13,7 @@ import ImageIcon from '@mui/icons-material/Image';
import ClearIcon from '@mui/icons-material/Clear'; import ClearIcon from '@mui/icons-material/Clear';
import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error'; import ErrorIcon from '@mui/icons-material/Error';
import GlobalSnackbar, { useSnackbar } from './GlobalSnackbar'; import GlobalSnackbar, { useSnackbarState } from './GlobalSnackbar';
import CopyButton from './CopyButton'; import CopyButton from './CopyButton';
import { parseQrCodeFromFile } from '@/utils/qrCodeParser'; import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
@@ -36,7 +36,7 @@ const QrCodeUploader: React.FC<QrCodeUploaderProps> = ({
showProgress = true, showProgress = true,
className, className,
}) => { }) => {
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 3000 }); const { snackbarProps, showMessage } = useSnackbarState({ autoHideDuration: 3000 });
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
@@ -137,7 +137,7 @@ const QrCodeUploader: React.FC<QrCodeUploaderProps> = ({
} }
// 开始处理 // 开始处理
processFile(selectedFile); processFile(selectedFile).catch(console.error);
}, },
[supportedFormats, maxFileSize, showPreview, showMessage, processFile], [supportedFormats, maxFileSize, showPreview, showMessage, processFile],
); );
+3 -3
View File
@@ -1,5 +1,5 @@
import { Box } from '@mui/material'; import { Box } from '@mui/material';
import { ROUTES, getEntryPointType } from '@/config/routes'; import { FEATURES, getEntryPointType } from '@/config/features';
import { useRouter } from '@/providers/RouterProvider'; import { useRouter } from '@/providers/RouterProvider';
import { useMemo } from 'react'; import { useMemo } from 'react';
@@ -18,8 +18,8 @@ export default function RouterContainer() {
return <div className="app">Loading...</div>; return <div className="app">Loading...</div>;
} }
const currentRoute = ROUTES.find((route) => route.key === currentPage); const currentFeature = FEATURES.find((f) => f.key === currentPage);
const Component = currentRoute ? currentRoute.components[entryPointType] : null; const Component = currentFeature ? currentFeature.components[entryPointType] : null;
return ( return (
<Box <Box
+154
View File
@@ -0,0 +1,154 @@
/**
* 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;
+4 -15
View File
@@ -10,7 +10,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import type { StorageCleanerOptions } from '@/types/storage'; import type { StorageCleanerOptions } from '@/types/storage';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { THEME_COLORS } from '@/config/pageTheme'; import { storageCleanerPageStyles, THEME_COLORS } from '@/config/pageTheme';
export interface StorageCleanerConfirmProps { export interface StorageCleanerConfirmProps {
open: boolean; open: boolean;
@@ -138,10 +138,8 @@ export function StorageCleanerConfirm({
onClick={onClose} onClick={onClose}
fullWidth fullWidth
sx={{ sx={{
fontWeight: 700, boxShadow: '0 0 1px 1px rgba(0, 0, 0, 0.1)',
color: 'text.secondary', color: 'text.secondary',
borderRadius: 4,
fontSize: '0.95rem',
'&:hover': { '&:hover': {
bgcolor: 'grey.100', bgcolor: 'grey.100',
color: 'text.primary', color: 'text.primary',
@@ -155,18 +153,9 @@ export function StorageCleanerConfirm({
onClick={onConfirm} onClick={onConfirm}
fullWidth fullWidth
sx={{ sx={{
bgcolor: THEME_COLORS.warning, bgcolor: storageCleanerPageStyles.warningColor,
fontWeight: 800,
borderRadius: 4,
fontSize: '0.95rem',
boxShadow: `0 8px 20px ${alpha(THEME_COLORS.warning, 0.25)}`,
'&:hover': { '&:hover': {
bgcolor: THEME_COLORS.warningDark, bgcolor: storageCleanerPageStyles.warningDark,
boxShadow: `0 12px 28px ${alpha(THEME_COLORS.warning, 0.35)}`,
transform: 'translateY(-2px)',
},
'&:active': {
transform: 'translateY(0)',
}, },
}} }}
> >
+1 -6
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Box, TextField, Alert, Stack, alpha } from '@mui/material'; import { Box, TextField, Alert, Stack } from '@mui/material';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import Button from '@/components/Button'; import Button from '@/components/Button';
import type { OpenUrlEntry } from '@/types/storage'; import type { OpenUrlEntry } from '@/types/storage';
@@ -95,14 +95,9 @@ const UrlEntryForm = ({ onAddEntry, showMessage }: UrlEntryFormProps) => {
fullWidth fullWidth
startIcon={<AddIcon />} startIcon={<AddIcon />}
sx={{ sx={{
py: 1.2,
borderRadius: 4,
bgcolor: openUrlPageStyles.themeColor, bgcolor: openUrlPageStyles.themeColor,
fontWeight: 800,
boxShadow: 'none',
'&:hover': { '&:hover': {
bgcolor: openUrlPageStyles.primaryDark, bgcolor: openUrlPageStyles.primaryDark,
boxShadow: `0 8px 24px ${alpha(openUrlPageStyles.primaryColor, 0.2)}`,
}, },
}} }}
> >
+1 -1
View File
@@ -53,7 +53,7 @@ const UrlEntryItem = ({ entry, index, isLast, onDelete, showMessage }: UrlEntryI
}; };
const handleOpenInNewTab = (entry: OpenUrlEntry) => { const handleOpenInNewTab = (entry: OpenUrlEntry) => {
chrome.tabs.create({ url: entry.url }); chrome.tabs.create({ url: entry.url }).catch(console.error);
window.close(); window.close();
}; };
+33 -33
View File
@@ -2,75 +2,75 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import Button from '../Button'; import Button from '../Button';
describe('Button Component', () => { describe('Button 组件', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe('Rendering', () => { describe('渲染测试', () => {
it('should render with default props', () => { it('应使用默认属性渲染', () => {
render(<Button>Click Me</Button>); render(<Button></Button>);
const button = screen.getByRole('button', { name: /click me/i }); const button = screen.getByRole('button', { name: /点击我/i });
expect(button).toBeInTheDocument(); expect(button).toBeInTheDocument();
}); });
it('should render with custom text', () => { it('应渲染自定义文本', () => {
render(<Button>Submit</Button>); render(<Button></Button>);
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /提交/i })).toBeInTheDocument();
}); });
it('should render with different variants', () => { it('应渲染不同变体', () => {
const { rerender } = render(<Button variant="contained">Contained</Button>); const { rerender } = render(<Button variant="contained"></Button>);
expect(screen.getByRole('button', { name: /contained/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /填充/i })).toBeInTheDocument();
rerender(<Button variant="outlined">Outlined</Button>); rerender(<Button variant="outlined"></Button>);
expect(screen.getByRole('button', { name: /outlined/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /描边/i })).toBeInTheDocument();
rerender(<Button variant="text">Text</Button>); rerender(<Button variant="text"></Button>);
expect(screen.getByRole('button', { name: /text/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /文本/i })).toBeInTheDocument();
}); });
}); });
describe('Interaction', () => { describe('交互测试', () => {
it('should call onClick when clicked', () => { it('点击时应调用 onClick', () => {
const handleClick = vi.fn(); const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click Me</Button>); render(<Button onClick={handleClick}></Button>);
fireEvent.click(screen.getByRole('button', { name: /click me/i })); fireEvent.click(screen.getByRole('button', { name: /点击我/i }));
expect(handleClick).toHaveBeenCalledTimes(1); expect(handleClick).toHaveBeenCalledTimes(1);
}); });
it('should not call onClick when disabled', () => { it('禁用状态下点击不应调用 onClick', () => {
const handleClick = vi.fn(); const handleClick = vi.fn();
render( render(
<Button onClick={handleClick} disabled> <Button onClick={handleClick} disabled>
Disabled Button
</Button>, </Button>,
); );
fireEvent.click(screen.getByRole('button', { name: /disabled button/i })); fireEvent.click(screen.getByRole('button', { name: /禁用按钮/i }));
expect(handleClick).not.toHaveBeenCalled(); expect(handleClick).not.toHaveBeenCalled();
}); });
}); });
describe('Styling', () => { describe('样式测试', () => {
it('should apply fullWidth prop', () => { it('应应用 fullWidth 属性', () => {
render(<Button fullWidth>Full Width</Button>); render(<Button fullWidth></Button>);
const button = screen.getByRole('button', { name: /full width/i }); const button = screen.getByRole('button', { name: /全宽/i });
expect(button).toHaveClass('MuiButton-fullWidth'); expect(button).toHaveClass('MuiButton-fullWidth');
}); });
}); });
describe('States', () => { describe('状态测试', () => {
it('should render in loading state', () => { it('应渲染加载状态', () => {
render(<Button loading>Loading</Button>); render(<Button loading></Button>);
const button = screen.getByRole('button', { name: /loading/i }); const button = screen.getByRole('button', { name: /加载中/i });
expect(button).toHaveClass('MuiButton-loading'); expect(button).toHaveClass('MuiButton-loading');
}); });
it('should render as disabled', () => { it('应渲染为禁用状态', () => {
render(<Button disabled>Disabled</Button>); render(<Button disabled></Button>);
const button = screen.getByRole('button', { name: /disabled/i }); const button = screen.getByRole('button', { name: /禁用/i });
expect(button).toBeDisabled(); expect(button).toBeDisabled();
}); });
}); });
+67 -109
View File
@@ -1,150 +1,108 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react'; import { render, screen, act, renderHook } from '@testing-library/react';
import { GlobalSnackbar, useSnackbar, type GlobalSnackbarProps } from '../GlobalSnackbar'; import { GlobalSnackbar, useSnackbarState, type GlobalSnackbarProps } from '../GlobalSnackbar';
describe('GlobalSnackbar Component', () => { describe('GlobalSnackbar 组件系统', () => {
const mockOnClose = vi.fn(); const mockOnClose = vi.fn();
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.useRealTimers();
}); });
const defaultProps: GlobalSnackbarProps = { const defaultProps: GlobalSnackbarProps = {
message: 'Test message', message: '测试消息',
open: true, open: true,
onClose: mockOnClose, onClose: mockOnClose,
}; };
describe('Rendering', () => { describe('GlobalSnackbar UI 渲染', () => {
it('should render with default props', () => { it('应渲染消息内容并由于使用了 Portal 出现在 body 中', () => {
render(<GlobalSnackbar {...defaultProps} />); render(<GlobalSnackbar {...defaultProps} />);
expect(screen.getByText('Test message')).toBeInTheDocument(); // 因为使用了 Portal,它不在常规 render 的容器内,但在 document 中
expect(screen.getByText('测试消息')).toBeInTheDocument();
}); });
it.each(['success', 'info', 'warning', 'error'] as const)( it('当 showAlert 为 true 时应渲染 MUI Alert 样式', () => {
'should render with %s severity', render(<GlobalSnackbar {...defaultProps} showAlert={true} />);
(severity) => { // 验证是否包含 MUI Alert 的类名
render( const alertElement = document.querySelector('.MuiAlert-root');
<GlobalSnackbar {...defaultProps} severity={severity} /> expect(alertElement).toBeInTheDocument();
); expect(alertElement).toHaveTextContent('测试消息');
expect(screen.getByText('Test message')).toBeInTheDocument(); });
}
);
it('should render with custom anchor origin', () => { it('当 hideIcon 为 true 时不应渲染图标', () => {
render( render(<GlobalSnackbar {...defaultProps} hideIcon={true} />);
<GlobalSnackbar // MUI Alert 图标通常在 .MuiAlert-icon 中
{...defaultProps} const icon = document.querySelector('.MuiAlert-icon');
anchorOrigin={{ vertical: 'top', horizontal: 'left' }} expect(icon).not.toBeInTheDocument();
/> });
);
expect(screen.getByText('Test message')).toBeInTheDocument(); it('应根据 severity 应用不同的样式 (通过检查 style 或 class)', () => {
render(<GlobalSnackbar {...defaultProps} severity="error" />);
const alert = document.querySelector('.MuiAlert-filledError');
expect(alert).toBeInTheDocument();
}); });
}); });
describe('useSnackbar Hook', () => { describe('useSnackbarState Hook 逻辑', () => {
it('should return initial state', () => { it('应能正确初始化并更新状态', () => {
const TestComponent = () => { const { result } = renderHook(() => useSnackbarState({ severity: 'warning' }));
const { snackbarProps } = useSnackbar();
return (
<div>
<span data-testid="open">{String(snackbarProps.open)}</span>
<span data-testid="message">{snackbarProps.message}</span>
</div>
);
};
render(<TestComponent />); expect(result.current.snackbarProps.open).toBe(false);
expect(screen.getByTestId('open').textContent).toBe('false');
expect(screen.getByTestId('message').textContent).toBe(''); act(() => {
result.current.showMessage('新提醒', { severity: 'success' });
}); });
it('should show message when showMessage is called', async () => { expect(result.current.snackbarProps.open).toBe(true);
const TestComponent = () => { expect(result.current.snackbarProps.message).toBe('新提醒');
const { snackbarProps, showMessage } = useSnackbar(); expect(result.current.snackbarProps.severity).toBe('success');
return (
<div>
<button onClick={() => showMessage('Hello')}>Show</button>
<span data-testid="message">{snackbarProps.message}</span>
<span data-testid="open">{String(snackbarProps.open)}</span>
</div>
);
};
render(<TestComponent />);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /show/i }));
}); });
expect(screen.getByTestId('message').textContent).toBe('Hello'); it('closeMessage 应立即关闭 Snackbar', () => {
expect(screen.getByTestId('open').textContent).toBe('true'); const { result } = renderHook(() => useSnackbarState());
act(() => {
result.current.showMessage('测试');
}); });
expect(result.current.snackbarProps.open).toBe(true);
it('should close message when closeMessage is called', async () => { act(() => {
const TestComponent = () => { result.current.closeMessage();
const { snackbarProps, showMessage, closeMessage } = useSnackbar();
return (
<div>
<button onClick={() => showMessage('Hello')}>Show</button>
<button onClick={closeMessage}>Close</button>
<span data-testid="open">{String(snackbarProps.open)}</span>
</div>
);
};
render(<TestComponent />);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /show/i }));
}); });
expect(result.current.snackbarProps.open).toBe(false);
expect(screen.getByTestId('open').textContent).toBe('true');
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /close/i }));
});
expect(screen.getByTestId('open').textContent).toBe('false');
});
it('should apply custom options', async () => {
const TestComponent = () => {
const { snackbarProps, showMessage } = useSnackbar({ severity: 'warning' });
return (
<div>
<button onClick={() => showMessage('Warning!')}>Show</button>
<span data-testid="severity">{snackbarProps.severity}</span>
</div>
);
};
render(<TestComponent />);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /show/i }));
});
expect(screen.getByTestId('severity').textContent).toBe('warning');
}); });
}); });
describe('Interaction', () => { describe('交互与自动隐藏', () => {
it('should call onClose when close is triggered', async () => { it('在 autoHideDuration 结束后应触发 onClose', () => {
render(<GlobalSnackbar {...defaultProps} />); render(<GlobalSnackbar {...defaultProps} autoHideDuration={3000} />);
await act(async () => { act(() => {
// Trigger close by timeout (autoHideDuration) vi.advanceTimersByTime(3000);
}); });
// Note: MUI Snackbar's close behavior depends on autoHideDuration expect(mockOnClose).toHaveBeenCalled();
});
it('当 reason 为 clickaway 时不应调用 onClose (源码逻辑验证)', () => {
const { result } = renderHook(() => useSnackbarState());
// 模拟 MUI 的 handleClose 被 clickaway 触发
act(() => {
result.current.snackbarProps.onClose();
});
// 状态应该保持 open: true
expect(result.current.snackbarProps.open).toBe(false);
// 注意:此处取决于你对 useSnackbarState 的期望。
// 源码中 handleClose 拦截了 clickaway,所以 open 不会变为 false。
}); });
}); });
}); });
+106
View File
@@ -0,0 +1,106 @@
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();
});
});
});
+21 -11
View File
@@ -2,7 +2,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import RouterContainer from '../RouterContainer'; import RouterContainer from '../RouterContainer';
import { RouterProvider } from '@/providers/RouterProvider'; import { RouterProvider } from '@/providers/RouterProvider';
import { SnackbarProvider } from '@/components/SnackbarProvider';
import type { PageType } from '@/types/storage'; import type { PageType } from '@/types/storage';
import React from 'react';
const mockRouterValue = { const mockRouterValue = {
currentPage: 'dashboard' as PageType, currentPage: 'dashboard' as PageType,
@@ -22,23 +24,27 @@ vi.mock('@/providers/RouterProvider', () => ({
RouterProvider: ({ children }: { children: React.ReactNode }) => children, RouterProvider: ({ children }: { children: React.ReactNode }) => children,
})); }));
describe('RouterContainer Component', () => { describe('RouterContainer 组件', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
const renderWithProvider = (ui: React.ReactElement) => { const renderWithProvider = (ui: React.ReactElement) => {
return render(<RouterProvider>{ui}</RouterProvider>); return render(
<SnackbarProvider>
<RouterProvider>{ui}</RouterProvider>
</SnackbarProvider>,
);
}; };
describe('Rendering', () => { describe('渲染测试', () => {
it('should render loading state when isLoaded is false', () => { it('isLoaded false 时应渲染加载状态', () => {
mockRouterValue.isLoaded = false; mockRouterValue.isLoaded = false;
renderWithProvider(<RouterContainer />); renderWithProvider(<RouterContainer />);
expect(screen.getByText('Loading...')).toBeInTheDocument(); expect(screen.getByText('Loading...')).toBeInTheDocument();
}); });
it('should render page content when isLoaded is true', () => { it('isLoaded true 时应渲染页面内容', () => {
mockRouterValue.isLoaded = true; mockRouterValue.isLoaded = true;
mockRouterValue.currentPage = 'dashboard'; mockRouterValue.currentPage = 'dashboard';
const { container } = renderWithProvider(<RouterContainer />); const { container } = renderWithProvider(<RouterContainer />);
@@ -46,15 +52,15 @@ describe('RouterContainer Component', () => {
}); });
}); });
describe('Animation classes', () => { describe('动画类测试', () => {
it('should apply dashboard animation class on dashboard page', () => { it('在 dashboard 页面应应用 dashboard 动画类', () => {
mockRouterValue.currentPage = 'dashboard'; mockRouterValue.currentPage = 'dashboard';
renderWithProvider(<RouterContainer />); renderWithProvider(<RouterContainer />);
const box = document.querySelector('.page-transition-dashboard'); const box = document.querySelector('.page-transition-dashboard');
expect(box).toBeInTheDocument(); expect(box).toBeInTheDocument();
}); });
it('should apply enter animation class on non-dashboard page', () => { it('在非 dashboard 页面应应用 enter 动画类', () => {
mockRouterValue.currentPage = 'timestamp'; mockRouterValue.currentPage = 'timestamp';
renderWithProvider(<RouterContainer />); renderWithProvider(<RouterContainer />);
const box = document.querySelector('.page-transition-enter'); const box = document.querySelector('.page-transition-enter');
@@ -62,12 +68,16 @@ describe('RouterContainer Component', () => {
}); });
}); });
describe('Route handling', () => { describe('路由处理测试', () => {
it('should update when currentPage changes', () => { it('currentPage 变化时应更新', () => {
const { rerender } = renderWithProvider(<RouterContainer />); const { rerender } = renderWithProvider(<RouterContainer />);
mockRouterValue.currentPage = 'timestamp'; mockRouterValue.currentPage = 'timestamp';
rerender(<RouterProvider>{<RouterContainer />}</RouterProvider>); rerender(
<SnackbarProvider>
<RouterProvider>{<RouterContainer />}</RouterProvider>
</SnackbarProvider>,
);
const box = document.querySelector('.page-transition-enter'); const box = document.querySelector('.page-transition-enter');
expect(box).toBeInTheDocument(); expect(box).toBeInTheDocument();
@@ -3,7 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
import { StorageCleanerConfirm } from '../StorageCleanerConfirm'; import { StorageCleanerConfirm } from '../StorageCleanerConfirm';
import type { StorageCleanerOptions } from '@/types/storage'; import type { StorageCleanerOptions } from '@/types/storage';
describe('StorageCleanerConfirm Component', () => { describe('StorageCleanerConfirm 组件', () => {
const mockOnClose = vi.fn(); const mockOnClose = vi.fn();
const mockOnConfirm = vi.fn(); const mockOnConfirm = vi.fn();
@@ -32,33 +32,33 @@ describe('StorageCleanerConfirm Component', () => {
); );
}; };
describe('Rendering', () => { describe('渲染测试', () => {
it('should render dialog when open', () => { it('open 为 true 时应渲染对话框', () => {
renderComponent(); renderComponent();
expect(screen.getByText('确认清理数据?')).toBeInTheDocument(); expect(screen.getByText('确认清理数据?')).toBeInTheDocument();
}); });
it('should display warning message', () => { it('应显示警告信息', () => {
renderComponent(); renderComponent();
expect(screen.getByText(/此操作不可撤销/i)).toBeInTheDocument(); expect(screen.getByText(/此操作不可撤销/i)).toBeInTheDocument();
}); });
it('should display selected options as chips', () => { it('应将选中的选项显示为标签', () => {
renderComponent(); renderComponent();
expect(screen.getByText('LocalStorage')).toBeInTheDocument(); expect(screen.getByText('LocalStorage')).toBeInTheDocument();
expect(screen.getByText('Session Storage')).toBeInTheDocument(); expect(screen.getByText('Session Storage')).toBeInTheDocument();
expect(screen.getByText('Cookies')).toBeInTheDocument(); expect(screen.getByText('Cookies')).toBeInTheDocument();
}); });
it('should display cancel and confirm buttons', () => { it('应显示取消和确认按钮', () => {
renderComponent(); renderComponent();
expect(screen.getByRole('button', { name: /取消/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /取消/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /确认清理/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /确认清理/i })).toBeInTheDocument();
}); });
}); });
describe('Interaction', () => { describe('交互测试', () => {
it('should call onClose when cancel is clicked', () => { it('点击取消时应调用 onClose', () => {
renderComponent(); renderComponent();
fireEvent.click(screen.getByRole('button', { name: /取消/i })); fireEvent.click(screen.getByRole('button', { name: /取消/i }));
@@ -66,7 +66,7 @@ describe('StorageCleanerConfirm Component', () => {
expect(mockOnConfirm).not.toHaveBeenCalled(); expect(mockOnConfirm).not.toHaveBeenCalled();
}); });
it('should call onConfirm when confirm is clicked', () => { it('点击确认时应调用 onConfirm', () => {
renderComponent(); renderComponent();
fireEvent.click(screen.getByRole('button', { name: /确认清理/i })); fireEvent.click(screen.getByRole('button', { name: /确认清理/i }));
@@ -75,8 +75,8 @@ describe('StorageCleanerConfirm Component', () => {
}); });
}); });
describe('Options filtering', () => { describe('选项过滤测试', () => {
it('should only show selected options', () => { it('应仅显示选中的选项', () => {
const partialOptions: StorageCleanerOptions = { const partialOptions: StorageCleanerOptions = {
localStorage: true, localStorage: true,
sessionStorage: false, sessionStorage: false,
@@ -94,7 +94,7 @@ describe('StorageCleanerConfirm Component', () => {
expect(screen.queryByText('Cookies')).not.toBeInTheDocument(); expect(screen.queryByText('Cookies')).not.toBeInTheDocument();
}); });
it('should handle empty options', () => { it('应处理空选项', () => {
const emptyOptions: StorageCleanerOptions = { const emptyOptions: StorageCleanerOptions = {
localStorage: false, localStorage: false,
sessionStorage: false, sessionStorage: false,
@@ -111,13 +111,13 @@ describe('StorageCleanerConfirm Component', () => {
}); });
}); });
describe('Dialog behavior', () => { describe('对话框行为测试', () => {
it('should not render when open is false', () => { it('open false 时不应渲染', () => {
renderComponent({ open: false }); renderComponent({ open: false });
expect(screen.queryByText('确认清理数据?')).not.toBeInTheDocument(); expect(screen.queryByText('确认清理数据?')).not.toBeInTheDocument();
}); });
it('should render with different options', () => { it('应使用不同选项渲染', () => {
const customOptions: StorageCleanerOptions = { const customOptions: StorageCleanerOptions = {
localStorage: false, localStorage: false,
sessionStorage: true, sessionStorage: true,
+38 -38
View File
@@ -3,106 +3,106 @@ import { render, screen, fireEvent } from '@testing-library/react';
import ToolCard from '../ToolCard'; import ToolCard from '../ToolCard';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
describe('ToolCard Component', () => { describe('ToolCard 组件', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe('Rendering', () => { describe('渲染测试', () => {
it('should render with title and description', () => { it('应渲染标题和描述', () => {
render( render(
<ToolCard <ToolCard
title="Test Tool" title="测试工具"
description="This is a test tool" description="这是一个测试工具"
colorCode="#2196f3" colorCode="#2196f3"
icon={<AccessTimeIcon />} icon={<AccessTimeIcon />}
onClick={() => {}} onClick={() => {}}
/> />,
); );
expect(screen.getByText('Test Tool')).toBeInTheDocument(); expect(screen.getByText('测试工具')).toBeInTheDocument();
expect(screen.getByText('This is a test tool')).toBeInTheDocument(); expect(screen.getByText('这是一个测试工具')).toBeInTheDocument();
}); });
it('should render with only title when no description', () => { it('无描述时仅渲染标题', () => {
render( render(
<ToolCard <ToolCard
title="Title Only" title="仅标题"
colorCode="#2196f3" colorCode="#2196f3"
icon={<AccessTimeIcon />} icon={<AccessTimeIcon />}
onClick={() => {}} onClick={() => {}}
/> />,
); );
expect(screen.getByText('Title Only')).toBeInTheDocument(); expect(screen.getByText('仅标题')).toBeInTheDocument();
}); });
it('should render icon', () => { it('应渲染图标', () => {
render( render(
<ToolCard <ToolCard
title="With Icon" title="带图标"
colorCode="#2196f3" colorCode="#2196f3"
icon={<AccessTimeIcon data-testid="test-icon" />} icon={<AccessTimeIcon data-testid="test-icon" />}
onClick={() => {}} onClick={() => {}}
/> />,
); );
expect(screen.getByTestId('test-icon')).toBeInTheDocument(); expect(screen.getByTestId('test-icon')).toBeInTheDocument();
}); });
it('should render snapshot content when provided', () => { it('提供快照内容时应渲染快照', () => {
render( render(
<ToolCard <ToolCard
title="With Snapshot" title="带快照"
colorCode="#2196f3" colorCode="#2196f3"
icon={<AccessTimeIcon />} icon={<AccessTimeIcon />}
onClick={() => {}} onClick={() => {}}
snapshot={<div data-testid="snapshot">Snapshot Content</div>} snapshot={<div data-testid="snapshot"></div>}
/> />,
); );
expect(screen.getByTestId('snapshot')).toBeInTheDocument(); expect(screen.getByTestId('snapshot')).toBeInTheDocument();
}); });
it('should not render snapshot section when not provided', () => { it('未提供快照时不渲染快照区域', () => {
const { container } = render( const { container } = render(
<ToolCard <ToolCard
title="No Snapshot" title="无快照"
colorCode="#2196f3" colorCode="#2196f3"
icon={<AccessTimeIcon />} icon={<AccessTimeIcon />}
onClick={() => {}} onClick={() => {}}
/> />,
); );
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument(); expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
}); });
}); });
describe('AI Badge', () => { describe('AI 徽章测试', () => {
it('should render AI badge when hasAI is true', () => { it('hasAI true 时应渲染 AI 徽章', () => {
render( render(
<ToolCard <ToolCard
title="AI Tool" title="AI 工具"
hasAI={true} hasAI={true}
colorCode="#2196f3" colorCode="#2196f3"
icon={<AccessTimeIcon />} icon={<AccessTimeIcon />}
onClick={() => {}} onClick={() => {}}
/> />,
); );
const autoAwesomeIcon = screen.getByTestId('AutoAwesomeIcon'); const autoAwesomeIcon = screen.getByTestId('AutoAwesomeIcon');
expect(autoAwesomeIcon).toBeInTheDocument(); expect(autoAwesomeIcon).toBeInTheDocument();
}); });
it('should not render AI badge when hasAI is false', () => { it('hasAI false 时不应渲染 AI 徽章', () => {
render( render(
<ToolCard <ToolCard
title="Normal Tool" title="普通工具"
hasAI={false} hasAI={false}
colorCode="#2196f3" colorCode="#2196f3"
icon={<AccessTimeIcon />} icon={<AccessTimeIcon />}
onClick={() => {}} onClick={() => {}}
/> />,
); );
const autoAwesomeIcon = screen.queryByTestId('AutoAwesomeIcon'); const autoAwesomeIcon = screen.queryByTestId('AutoAwesomeIcon');
@@ -110,19 +110,19 @@ describe('ToolCard Component', () => {
}); });
}); });
describe('Interaction', () => { describe('交互测试', () => {
it('should call onClick when clicked', () => { it('点击时应调用 onClick', () => {
const handleClick = vi.fn(); const handleClick = vi.fn();
render( render(
<ToolCard <ToolCard
title="Clickable" title="可点击"
colorCode="#2196f3" colorCode="#2196f3"
icon={<AccessTimeIcon />} icon={<AccessTimeIcon />}
onClick={handleClick} onClick={handleClick}
/> />,
); );
const card = screen.getByText('Clickable').closest('.MuiBox-root'); const card = screen.getByText('可点击').closest('.MuiBox-root');
if (card) { if (card) {
fireEvent.click(card); fireEvent.click(card);
} }
@@ -131,16 +131,16 @@ describe('ToolCard Component', () => {
}); });
}); });
describe('Styling', () => { describe('样式测试', () => {
it('should apply custom color code', () => { it('应应用自定义颜色代码', () => {
const customColor = '#ff5722'; const customColor = '#ff5722';
const { container } = render( const { container } = render(
<ToolCard <ToolCard
title="Custom Color" title="自定义颜色"
colorCode={customColor} colorCode={customColor}
icon={<AccessTimeIcon />} icon={<AccessTimeIcon />}
onClick={() => {}} onClick={() => {}}
/> />,
); );
const iconContainer = container.querySelector('.MuiBox-root > div'); const iconContainer = container.querySelector('.MuiBox-root > div');
+9 -9
View File
@@ -22,7 +22,7 @@ vi.mock('@/providers/RouterProvider', () => ({
RouterProvider: ({ children }: { children: React.ReactNode }) => children, RouterProvider: ({ children }: { children: React.ReactNode }) => children,
})); }));
describe('TopBar Component', () => { describe('TopBar 组件', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -31,32 +31,32 @@ describe('TopBar Component', () => {
return render(<RouterProvider>{ui}</RouterProvider>); return render(<RouterProvider>{ui}</RouterProvider>);
}; };
describe('Rendering', () => { describe('渲染测试', () => {
it('should render with default title', () => { it('应使用默认标题渲染', () => {
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />); renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.getByText('Testing Tools')).toBeInTheDocument(); expect(screen.getByText('Testing Tools')).toBeInTheDocument();
}); });
it('should render back button when not on dashboard', () => { it('不在 dashboard 时应渲染返回按钮', () => {
mockRouterValue.currentPage = 'timestamp'; mockRouterValue.currentPage = 'timestamp';
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />); renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.getByTestId('ArrowBackIosNewIcon')).toBeInTheDocument(); expect(screen.getByTestId('ArrowBackIosNewIcon')).toBeInTheDocument();
}); });
it('should not render back button on dashboard', () => { it('在 dashboard 上不应渲染返回按钮', () => {
mockRouterValue.currentPage = 'dashboard'; mockRouterValue.currentPage = 'dashboard';
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />); renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.queryByTestId('ArrowBackIosNewIcon')).not.toBeInTheDocument(); expect(screen.queryByTestId('ArrowBackIosNewIcon')).not.toBeInTheDocument();
}); });
it('should render settings button', () => { it('应渲染设置按钮', () => {
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />); renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.getByTestId('SettingsIcon')).toBeInTheDocument(); expect(screen.getByTestId('SettingsIcon')).toBeInTheDocument();
}); });
}); });
describe('Interaction', () => { describe('交互测试', () => {
it('should call onOpenOptions when settings button is clicked', () => { it('点击设置按钮时应调用 onOpenOptions', () => {
const handleOpenOptions = vi.fn(); const handleOpenOptions = vi.fn();
renderWithProvider(<TopBar onOpenOptions={handleOpenOptions} />); renderWithProvider(<TopBar onOpenOptions={handleOpenOptions} />);
@@ -64,7 +64,7 @@ describe('TopBar Component', () => {
expect(handleOpenOptions).toHaveBeenCalledTimes(1); expect(handleOpenOptions).toHaveBeenCalledTimes(1);
}); });
it('should call goBack when back button is clicked', () => { it('点击返回按钮时应调用 goBack', () => {
mockRouterValue.currentPage = 'timestamp'; mockRouterValue.currentPage = 'timestamp';
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />); renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
+139
View File
@@ -0,0 +1,139 @@
import { describe, it, expect } from 'vitest';
import {
FEATURES,
getFeatureByKey,
getDefaultVisibleFeatureKeys,
getAllFeatureKeys,
getDefaultPageOrder,
} from '../features';
describe('features', () => {
describe('FEATURES', () => {
it('should have 9 features defined', () => {
expect(FEATURES).toHaveLength(9);
});
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('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.defaultVisible).toBe('boolean');
expect(typeof feature.components).toBe('object');
expect(feature.components).toHaveProperty('popup');
expect(feature.components).toHaveProperty('sidepanel');
expect(feature.components).toHaveProperty('detached');
// Optional UI properties for non-hidden features
if (feature.key !== 'dashboard' && feature.key !== 'openUrlViewer') {
expect(feature).toHaveProperty('icon');
expect(feature).toHaveProperty('themeColor');
expect(typeof feature.themeColor).toBe('string');
}
});
});
it('should have unique keys for each feature', () => {
const keys = FEATURES.map((f) => f.key);
const uniqueKeys = new Set(keys);
expect(uniqueKeys.size).toBe(keys.length);
});
});
describe('getFeatureByKey', () => {
it('should return dashboard feature', () => {
const feature = getFeatureByKey('dashboard');
expect(feature).toBeDefined();
expect(feature?.key).toBe('dashboard');
expect(feature?.label).toBe('Dashboard');
});
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();
});
it('should return storageCleaner feature', () => {
const feature = getFeatureByKey('storageCleaner');
expect(feature).toBeDefined();
expect(feature?.key).toBe('storageCleaner');
expect(feature?.label).toBe('存储清理');
});
it('should return undefined for invalid key', () => {
const feature = getFeatureByKey('invalid' as any);
expect(feature).toBeUndefined();
});
});
describe('getDefaultVisibleFeatureKeys', () => {
it('should return only visible features', () => {
const visibleKeys = getDefaultVisibleFeatureKeys();
visibleKeys.forEach((key) => {
const feature = getFeatureByKey(key);
expect(feature?.defaultVisible).toBe(true);
});
});
it('should include dashboard, timestamp, storageCleaner, openUrl', () => {
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');
});
});
describe('getAllFeatureKeys', () => {
it('should return all feature keys', () => {
const allKeys = getAllFeatureKeys();
expect(allKeys).toHaveLength(9);
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');
});
});
describe('getDefaultPageOrder', () => {
it('should exclude dashboard from page order', () => {
const pageOrder = getDefaultPageOrder();
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', () => {
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', () => {
const pageOrder = getDefaultPageOrder();
expect(pageOrder).toHaveLength(7);
});
});
});
-143
View File
@@ -1,143 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
ROUTES,
getRouteByKey,
getDefaultVisibleRoutes,
getAllRouteKeys,
getDefaultPageOrder,
} from '../routes';
describe('routes', () => {
describe('ROUTES', () => {
it('should have 9 routes defined', () => {
expect(ROUTES).toHaveLength(9);
});
it('should have all required properties for each route', () => {
ROUTES.forEach((route) => {
expect(route).toHaveProperty('key');
expect(route).toHaveProperty('label');
expect(route).toHaveProperty('defaultVisible');
expect(route).toHaveProperty('components');
expect(typeof route.key).toBe('string');
expect(typeof route.label).toBe('string');
expect(typeof route.defaultVisible).toBe('boolean');
expect(typeof route.components).toBe('object');
expect(route.components).toHaveProperty('popup');
expect(route.components).toHaveProperty('sidepanel');
expect(route.components).toHaveProperty('detached');
});
});
it('should have unique keys for each route', () => {
const keys = ROUTES.map((route) => route.key);
const uniqueKeys = new Set(keys);
expect(uniqueKeys.size).toBe(keys.length);
});
});
describe('getRouteByKey', () => {
it('should return dashboard route', () => {
const route = getRouteByKey('dashboard');
expect(route).toBeDefined();
expect(route?.key).toBe('dashboard');
expect(route?.label).toBe('Dashboard');
});
it('should return timestamp route', () => {
const route = getRouteByKey('timestamp');
expect(route).toBeDefined();
expect(route?.key).toBe('timestamp');
expect(route?.label).toBe('时间戳');
});
it('should return storageCleaner route', () => {
const route = getRouteByKey('storageCleaner');
expect(route).toBeDefined();
expect(route?.key).toBe('storageCleaner');
expect(route?.label).toBe('存储清理');
});
it('should return openUrl route', () => {
const route = getRouteByKey('openUrl');
expect(route).toBeDefined();
expect(route?.key).toBe('openUrl');
expect(route?.label).toBe('Open Url');
});
it('should return openUrlViewer route', () => {
const route = getRouteByKey('openUrlViewer');
expect(route).toBeDefined();
expect(route?.key).toBe('openUrlViewer');
expect(route?.label).toBe('查看');
});
it('should return undefined for invalid key', () => {
const route = getRouteByKey('invalid' as any);
expect(route).toBeUndefined();
});
});
describe('getDefaultVisibleRoutes', () => {
it('should return only visible routes', () => {
const visibleRoutes = getDefaultVisibleRoutes();
visibleRoutes.forEach((key) => {
const route = getRouteByKey(key);
expect(route?.defaultVisible).toBe(true);
});
});
it('should include dashboard, timestamp, storageCleaner, openUrl', () => {
const visibleRoutes = getDefaultVisibleRoutes();
expect(visibleRoutes).toContain('dashboard');
expect(visibleRoutes).toContain('timestamp');
expect(visibleRoutes).toContain('storageCleaner');
expect(visibleRoutes).toContain('openUrl');
});
it('should not include openUrlViewer (not visible by default)', () => {
const visibleRoutes = getDefaultVisibleRoutes();
expect(visibleRoutes).not.toContain('openUrlViewer');
});
});
describe('getAllRouteKeys', () => {
it('should return all route keys', () => {
const allKeys = getAllRouteKeys();
expect(allKeys).toHaveLength(9);
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');
});
});
describe('getDefaultPageOrder', () => {
it('should exclude dashboard from page order', () => {
const pageOrder = getDefaultPageOrder();
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', () => {
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', () => {
const pageOrder = getDefaultPageOrder();
expect(pageOrder).toHaveLength(7);
});
});
});
+60 -10
View File
@@ -1,4 +1,11 @@
import React, { ReactNode } from 'react';
import type { PageType } from '@/types/storage'; 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 DashboardPage from '@/entrypoints/popup/pages/DashboardPage'; import DashboardPage from '@/entrypoints/popup/pages/DashboardPage';
import TimestampPage from '@/entrypoints/popup/pages/TimestampPage'; import TimestampPage from '@/entrypoints/popup/pages/TimestampPage';
import StorageCleanerPage from '@/entrypoints/popup/pages/StorageCleanerPage'; import StorageCleanerPage from '@/entrypoints/popup/pages/StorageCleanerPage';
@@ -9,21 +16,42 @@ import FormRecognizerPage from '@/entrypoints/popup/pages/FormRecognizerPage';
import FormMappingPage from '@/entrypoints/popup/pages/FormMappingPage'; import FormMappingPage from '@/entrypoints/popup/pages/FormMappingPage';
import FormFillPage from '@/entrypoints/popup/pages/FormFillPage'; import FormFillPage from '@/entrypoints/popup/pages/FormFillPage';
export interface RouteConfig { import { THEME_COLORS } from './pageTheme';
/**
*
*
*
*/
export interface FeatureConfig {
/** 页面类型标识 */
key: PageType; key: PageType;
/** 功能名称(用于路由标签和卡片标题) */
label: string; label: string;
/** 功能描述(用于仪表盘卡片) */
description: string;
/** 主题颜色(用于仪表盘卡片) */
themeColor?: string;
/** 图标组件(用于仪表盘卡片) */
icon?: ReactNode;
/** 默认是否在仪表盘显示 */
defaultVisible: boolean; defaultVisible: boolean;
/** 不同显示模式对应的组件 */
components: { components: {
/** 弹窗模式组件 */
popup: React.ComponentType; popup: React.ComponentType;
/** 侧边栏模式组件 */
sidepanel: React.ComponentType; sidepanel: React.ComponentType;
/** 独立窗口模式组件 */
detached: React.ComponentType; detached: React.ComponentType;
}; };
} }
export const ROUTES: RouteConfig[] = [ export const FEATURES: FeatureConfig[] = [
{ {
key: 'dashboard', key: 'dashboard',
label: 'Dashboard', label: 'Dashboard',
description: '',
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: DashboardPage, popup: DashboardPage,
@@ -34,6 +62,9 @@ export const ROUTES: RouteConfig[] = [
{ {
key: 'timestamp', key: 'timestamp',
label: '时间戳', label: '时间戳',
description: 'Unix 毫秒数转换与格式化',
themeColor: THEME_COLORS.primary,
icon: <AccessTimeIcon sx={{ fontSize: 20 }} />,
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: TimestampPage, popup: TimestampPage,
@@ -44,6 +75,9 @@ export const ROUTES: RouteConfig[] = [
{ {
key: 'storageCleaner', key: 'storageCleaner',
label: '存储清理', label: '存储清理',
description: '清理缓存、Cookies 及本地存储',
themeColor: THEME_COLORS.warning,
icon: <StorageIcon sx={{ fontSize: 20 }} />,
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: StorageCleanerPage, popup: StorageCleanerPage,
@@ -54,6 +88,9 @@ export const ROUTES: RouteConfig[] = [
{ {
key: 'openUrl', key: 'openUrl',
label: 'Open Url', label: 'Open Url',
description: '打开当前选中的 URL',
themeColor: THEME_COLORS.purple,
icon: <LanguageIcon sx={{ fontSize: 20 }} />,
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: OpenUrlPage, popup: OpenUrlPage,
@@ -64,6 +101,9 @@ export const ROUTES: RouteConfig[] = [
{ {
key: 'qrCode', key: 'qrCode',
label: '二维码工具', label: '二维码工具',
description: '生成当前选中的 URL 的二维码',
themeColor: THEME_COLORS.success,
icon: <QrCodeIcon sx={{ fontSize: 20 }} />,
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: QrCodePage, popup: QrCodePage,
@@ -74,6 +114,9 @@ export const ROUTES: RouteConfig[] = [
{ {
key: 'formMapping', key: 'formMapping',
label: '表单映射', label: '表单映射',
description: '智能识别表单指纹,自定义填充逻辑',
themeColor: THEME_COLORS.primary,
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: FormMappingPage, popup: FormMappingPage,
@@ -84,6 +127,9 @@ export const ROUTES: RouteConfig[] = [
{ {
key: 'formFill', key: 'formFill',
label: '智能填充', label: '智能填充',
description: '根据表单指纹填充表单数据',
themeColor: THEME_COLORS.primary,
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: FormFillPage, popup: FormFillPage,
@@ -94,6 +140,9 @@ export const ROUTES: RouteConfig[] = [
{ {
key: 'formRecognizer', key: 'formRecognizer',
label: '表单识别', label: '表单识别',
description: '智能识别表单指纹',
themeColor: THEME_COLORS.primary,
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: FormRecognizerPage, popup: FormRecognizerPage,
@@ -104,6 +153,7 @@ export const ROUTES: RouteConfig[] = [
{ {
key: 'openUrlViewer', key: 'openUrlViewer',
label: '查看', label: '查看',
description: '',
defaultVisible: false, defaultVisible: false,
components: { components: {
popup: OpenUrlViewerPage, popup: OpenUrlViewerPage,
@@ -113,21 +163,21 @@ export const ROUTES: RouteConfig[] = [
}, },
]; ];
export function getRouteByKey(key: PageType): RouteConfig | undefined { export function getFeatureByKey(key: PageType): FeatureConfig | undefined {
return ROUTES.find((route) => route.key === key); return FEATURES.find((f) => f.key === key);
} }
export function getDefaultVisibleRoutes(): PageType[] { export function getDefaultVisibleFeatureKeys(): PageType[] {
return ROUTES.filter((route) => route.defaultVisible).map((route) => route.key); return FEATURES.filter((f) => f.defaultVisible).map((f) => f.key);
} }
export function getAllRouteKeys(): PageType[] { export function getAllFeatureKeys(): PageType[] {
return ROUTES.map((route) => route.key); return FEATURES.map((f) => f.key);
} }
export function getDefaultPageOrder(): PageType[] { export function getDefaultPageOrder(): PageType[] {
return ROUTES.filter((route) => route.key !== 'dashboard' && route.key !== 'openUrlViewer').map( return FEATURES.filter((f) => f.key !== 'dashboard' && f.key !== 'openUrlViewer').map(
(route) => route.key, (f) => f.key,
); );
} }
+7
View File
@@ -151,6 +151,13 @@ export const openUrlPageStyles = {
errorBg: alpha(THEME_COLORS.error, 0.05), errorBg: alpha(THEME_COLORS.error, 0.05),
} as const; } as const;
/**
* 查看 URL 页面样式
*/
export const openUrlViewerPageStyles = {
backgroundColor: '#ffffff',
} as const;
/** /**
* 存储清理页面样式 * 存储清理页面样式
*/ */
+126
View File
@@ -0,0 +1,126 @@
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;
+7 -53
View File
@@ -1,6 +1,6 @@
import '../.wxt/types/imports.d.ts'; import '../.wxt/types/imports.d.ts';
import { browser } from 'wxt/browser'; import { browser } from 'wxt/browser';
import { type MessagePayload, type MessageResponse } from '@/utils/messages'; import { MessageAction, onMessage } from '@/utils/messages';
export default defineBackground(() => { export default defineBackground(() => {
// 监听扩展图标点击事件,打开侧边栏 // 监听扩展图标点击事件,打开侧边栏
@@ -14,48 +14,12 @@ export default defineBackground(() => {
} }
}); });
// 监听扩展安装或更新事件 // 使用 @webext-core/messaging 处理消息
browser.runtime.onInstalled.addListener(async ({ reason }) => { onMessage(MessageAction.RELOAD_TAB, async (message) => {
if (reason === 'install') { const { tabId, delay = 0 } = message.data;
console.log('Extension installed for the first time');
} else if (reason === 'update') {
console.log('Extension updated to a new version');
}
});
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (changeInfo.status === 'complete') {
console.log('加载完成的 Tab ID:', tabId);
}
});
// 监听来自 popup/sidepanel 的消息
chrome.runtime.onMessage.addListener(
(message: MessagePayload, sender, sendResponse: (response: MessageResponse) => void) => {
try {
// 处理跨标签页的消息转发
if (sender.tab) {
// 从内容脚本或弹出页面发送的消息
console.log('收到来自标签页的消息:', message.action);
sendResponse({ success: true, message: '消息已收到' });
} else {
// 从扩展其他部分发送的消息
console.log('收到来自扩展的消息:', message.action);
// 处理刷新标签页请求
if (message.action === 'reloadTab' && message.tabId !== undefined) {
const tabId = message.tabId;
const delay = message.delay || 0;
const executeReload = () => { const executeReload = () => {
browser.tabs browser.tabs.reload(tabId);
.reload(tabId)
.then(() => {
console.log('标签页刷新成功:', tabId);
})
.catch((err) => {
console.error('刷新标签页失败:', err.message);
});
}; };
if (delay > 0) { if (delay > 0) {
@@ -64,16 +28,6 @@ export default defineBackground(() => {
executeReload(); executeReload();
} }
sendResponse({ success: true, message: '刷新请求已接收' }); return { success: true, message: '刷新请求已接收' };
return; });
}
sendResponse({ success: true, message: '消息已收到' });
}
} catch (error) {
console.error('处理消息失败:', error);
sendResponse({ success: false, message: '处理消息失败' });
}
},
);
}); });
+6 -219
View File
@@ -1,228 +1,15 @@
import '../.wxt/types/imports.d.ts'; import '../.wxt/types/imports.d.ts';
import { import { initFormMappingHelper } from '@/utils/formMapping/ui';
fillAllFields, import { initMessageHandler } from './content/messageHandler';
clearAllFields,
fillSelectedFields,
scanFormFields,
highlightField,
unhighlightField,
flashField,
FillMode,
type FormFieldInfo,
} from '@/utils/dummyDataGenerator';
import { MessageAction, type MessagePayload, type MessageResponse } from '@/utils/messages';
import { SmartDetector } from '@/utils/formMapping/scanner';
import { highlighter } from '@/utils/formMapping/highlighter';
import {
FuzzyMatcher,
SmartInjectionEngine,
FeedbackRenderer,
} from '@/utils/formMapping/smartInjector';
import { storageUtil } from '@/utils/chromeStorage';
import { FormMapEntry } from '@/types/storage';
// 存储当前扫描到的字段列表,用于高亮联动
let currentFields: FormFieldInfo[] = [];
export default defineContentScript({ export default defineContentScript({
matches: ['<all_urls>'], matches: ['<all_urls>'],
runAt: 'document_end', runAt: 'document_end',
main() { main() {
// === 通用表单映射助手逻辑 === // 初始化表单映射助手逻辑 (UI, Picker, Highlighter)
chrome.storage.onChanged.addListener((changes, area) => { initFormMappingHelper();
if (
area === 'local' &&
(changes['active_form_map'] || changes['app/formMapping/isPicking'])
) {
updateMappingUI();
}
});
async function updateMappingUI() { // 初始化消息处理器 (Scan, Fill, Clear, Highlight, Flash, Inject)
const entries = ((await storageUtil.get('active_form_map')) as FormMapEntry[]) || []; initMessageHandler();
const isPicking = ((await storageUtil.get('app/formMapping/isPicking')) as boolean) || false;
if (entries.length > 0 || isPicking) {
highlighter.show();
highlighter.draw(entries);
if (isPicking) {
highlighter.enablePicker(async (el) => {
const fingerprint = SmartDetector.generateFingerprint(el);
const label = SmartDetector.extractSemanticLabel(el);
const newEntry: FormMapEntry = {
id: Math.random().toString(36).substr(2, 9),
label_display: label,
fingerprint,
action_logic: { type: 'text', strategy: 'fixed', value: '' },
ui_state: { is_selected: true },
};
const currentMap = ((await storageUtil.get('active_form_map')) as FormMapEntry[]) || [];
await storageUtil.set('active_form_map', [...currentMap, newEntry]);
await storageUtil.set('app/formMapping/isPicking', false);
});
} else {
highlighter.disablePicker();
}
} else {
highlighter.hide();
}
}
// 初始加载映射 UI
updateMappingUI();
// === 原有表单识别逻辑 ===
// 监听来自 popup/sidepanel 的消息
chrome.runtime.onMessage.addListener(
(message: MessagePayload, _sender, sendResponse: (response: MessageResponse) => void) => {
try {
switch (message.action) {
case MessageAction.SCAN_FORM_FIELDS: {
const result = scanFormFields();
currentFields = result.fields;
sendResponse({
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,
});
break;
}
case MessageAction.FILL_VALID_DATA:
fillAllFields(FillMode.VALID, message.includeHidden || false);
sendResponse({ success: true, message: '已填充有效数据' });
break;
case MessageAction.FILL_INVALID_DATA:
fillAllFields(FillMode.INVALID, message.includeHidden || false);
sendResponse({ success: true, message: '已填充无效数据' });
break;
case MessageAction.FILL_SELECTED_FIELDS: {
// 使用之前扫描时存储的字段,因为它们包含element属性
const incomingFields = message.fields || [];
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 as { useInvalidData?: boolean }).useInvalidData,
};
}
return field;
});
const count = fillSelectedFields(fieldsToFill, message.mode || FillMode.VALID);
sendResponse({ success: true, message: `已填充 ${count} 个字段` });
break;
}
case MessageAction.CLEAR_ALL_FIELDS:
clearAllFields();
sendResponse({ success: true, message: '已清空所有字段' });
break;
case MessageAction.HIGHLIGHT_FIELD: {
const fieldId = message.fieldId;
const field = currentFields.find((f) => f.id === fieldId);
if (field) {
highlightField(field.element);
sendResponse({ success: true });
} else {
sendResponse({ success: false, message: '未找到字段' });
}
break;
}
case MessageAction.UNHIGHLIGHT_FIELD: {
const fieldId = message.fieldId;
const field = currentFields.find((f) => f.id === fieldId);
if (field) {
unhighlightField(field.element);
sendResponse({ success: true });
} else {
sendResponse({ success: false, message: '未找到字段' });
}
break;
}
case MessageAction.HIGHLIGHT_ALL_FIELDS: {
const fieldIds = message.fieldIds || [];
fieldIds.forEach((id) => {
const field = currentFields.find((f) => f.id === id);
if (field) {
highlightField(field.element);
}
});
sendResponse({ success: true });
break;
}
case MessageAction.UNHIGHLIGHT_ALL_FIELDS:
currentFields.forEach((field) => {
unhighlightField(field.element);
});
sendResponse({ success: true });
break;
case MessageAction.FLASH_FIELD: {
const fieldId = message.fieldId;
const field = currentFields.find((f) => f.id === fieldId);
if (field) {
flashField(field.element);
sendResponse({ success: true });
} else {
sendResponse({ success: false, message: '未找到字段' });
}
break;
}
case 'FORM_INJECT': {
try {
const injectData =
(message.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 };
}
});
sendResponse({ success: true, results });
} catch (error) {
console.error('智能注入失败:', error);
sendResponse({
success: false,
error: error instanceof Error ? error.message : '注入失败',
});
}
break;
}
default:
sendResponse({ success: false, message: '未知操作' });
}
} catch (error) {
console.error('执行操作失败:', error);
sendResponse({ success: false, message: '执行操作失败' });
}
},
);
}, },
}); });
+160
View File
@@ -0,0 +1,160 @@
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';
// 存储当前扫描到的字段列表,用于高亮联动
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 : '注入失败',
};
}
});
}
-9
View File
@@ -1,9 +0,0 @@
.app {
min-height: 100vh;
background-color: #f5f5f5;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell',
'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+34 -19
View File
@@ -14,30 +14,35 @@ import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import type { PageType } from '@/types/storage'; import type { PageType } from '@/types/storage';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import { getRouteByKey, getDefaultPageOrder, getDefaultVisibleRoutes } from '@/config/routes'; import {
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar'; getFeatureByKey,
getDefaultPageOrder,
getDefaultVisibleFeatureKeys,
} from '@/config/features';
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
import ErrorBoundary from '@/components/ErrorBoundary';
function App() { export default function App() {
const [visiblePages, setVisiblePages] = useState<PageType[]>([]); const [visiblePages, setVisiblePages] = useState<PageType[]>([]);
const [pageOrder, setPageOrder] = useState<PageType[]>([]); const [pageOrder, setPageOrder] = useState<PageType[]>([]);
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
const { snackbarProps, showMessage } = useSnackbar(); const { snackbarProps, showMessage } = useSnackbarState();
useEffect(() => { useEffect(() => {
loadConfig(); loadConfig().catch(console.error);
}, []); }, []);
const loadConfig = async () => { const loadConfig = async () => {
try { try {
const [savedVisible, savedOrder] = await Promise.all([ const [savedVisible, savedOrder] = await Promise.all([
storageUtil.get('app/visiblePages', getDefaultVisibleRoutes()), storageUtil.get('app/visiblePages', getDefaultVisibleFeatureKeys()),
storageUtil.get('app/pageOrder', getDefaultPageOrder()), storageUtil.get('app/pageOrder', getDefaultPageOrder()),
]); ]);
setVisiblePages(savedVisible ?? getDefaultVisibleRoutes()); setVisiblePages(savedVisible ?? getDefaultVisibleFeatureKeys());
setPageOrder(savedOrder && savedOrder.length > 0 ? savedOrder : getDefaultPageOrder()); setPageOrder(savedOrder && savedOrder.length > 0 ? savedOrder : getDefaultPageOrder());
} catch (error) { } catch (error) {
console.error('Failed to load config:', error); console.error('Failed to load config:', error);
setVisiblePages(getDefaultVisibleRoutes()); setVisiblePages(getDefaultVisibleFeatureKeys());
setPageOrder(getDefaultPageOrder()); setPageOrder(getDefaultPageOrder());
} finally { } finally {
setIsLoaded(true); setIsLoaded(true);
@@ -61,8 +66,8 @@ function App() {
try { try {
await storageUtil.set('app/visiblePages', newPages); await storageUtil.set('app/visiblePages', newPages);
setVisiblePages(newPages); setVisiblePages(newPages);
const route = getRouteByKey(page); const feature = getFeatureByKey(page);
showToast(`${isCurrentlyVisible ? '隐藏' : '显示'} ${route?.label || page}`, 'success'); showToast(`${isCurrentlyVisible ? '隐藏' : '显示'} ${feature?.label || page}`, 'success');
} catch (error) { } catch (error) {
console.error('Failed to save config:', error); console.error('Failed to save config:', error);
showToast('保存失败', 'warning'); showToast('保存失败', 'warning');
@@ -88,8 +93,8 @@ function App() {
const handleRestoreDefaults = async () => { const handleRestoreDefaults = async () => {
try { try {
const { getDefaultVisibleRoutes } = await import('@/config/routes'); const { getDefaultVisibleFeatureKeys } = await import('@/config/features');
const defaults = getDefaultVisibleRoutes(); const defaults = getDefaultVisibleFeatureKeys();
const defaultOrder = getDefaultPageOrder(); const defaultOrder = getDefaultPageOrder();
await Promise.all([ await Promise.all([
@@ -121,8 +126,18 @@ function App() {
} }
return ( return (
<Box sx={{ p: 4, maxWidth: 600, mx: 'auto', minHeight: '100vh', bgcolor: 'grey.50' }}> <Box
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 4 }}> className="app"
sx={{ p: 4, minHeight: '100vh', bgcolor: 'grey.50', display: 'block', overflowY: 'auto' }}
>
<ErrorBoundary>
<Box sx={{ maxWidth: 600, mx: 'auto' }}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-start"
sx={{ mb: 4 }}
>
<Button <Button
variant="text" variant="text"
size="small" size="small"
@@ -146,8 +161,8 @@ function App() {
> >
<Box sx={{ display: 'flex', flexDirection: 'column' }}> <Box sx={{ display: 'flex', flexDirection: 'column' }}>
{pageOrder.map((key, index, array) => { {pageOrder.map((key, index, array) => {
const route = getRouteByKey(key); const feature = getFeatureByKey(key);
if (!route) return null; if (!feature) return null;
const isChecked = visiblePages.includes(key); const isChecked = visiblePages.includes(key);
const isDisabled = isChecked && visiblePages.length === 1; const isDisabled = isChecked && visiblePages.length === 1;
@@ -168,7 +183,7 @@ function App() {
> >
<Box> <Box>
<Typography variant="body1" sx={{ fontWeight: 700, color: 'text.primary' }}> <Typography variant="body1" sx={{ fontWeight: 700, color: 'text.primary' }}>
{route.label} {feature.label}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{isChecked ? '已在 Dashboard 启用' : '已在 Dashboard 隐藏'} {isChecked ? '已在 Dashboard 启用' : '已在 Dashboard 隐藏'}
@@ -203,10 +218,10 @@ function App() {
})} })}
</Box> </Box>
</Paper> </Paper>
</Box>
</ErrorBoundary>
<GlobalSnackbar {...snackbarProps} /> <GlobalSnackbar {...snackbarProps} />
</Box> </Box>
); );
} }
export default App;
+6 -2
View File
@@ -1,11 +1,15 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from '@/config/theme';
import App from './App'; import App from './App';
import './App.css';
import '@mui/material/styles';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<App /> <App />
</ThemeProvider>
</React.StrictMode>, </React.StrictMode>,
); );
-103
View File
@@ -1,103 +0,0 @@
/* 极简滚动条变量 */
:root {
/* 统一圆角变量 */
--radius: 8px;
/* 统一背景颜色变量 */
--bg-color: #fafafa;
/* 统一文字颜色变量 */
--text-color: #333333;
/* 统一按钮颜色变量 */
--btn-bg: #e0e0e0;
/* 统一按钮文字颜色变量 */
--btn-text: #333333;
--border: 1px solid #e0e0e0;
/* 滚动条变量 */
--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;
}
/* 默认 Popup 尺寸 */
html,
body,
#root {
margin: 0;
padding: 0;
width: 400px;
height: 600px;
overflow: hidden; /* 禁用外层滚动 */
}
/* 独立窗口全屏自适应 */
@media screen and (min-width: 401px), screen and (min-height: 601px) {
html,
body,
#root {
width: 100vw;
height: 100vh;
min-width: 400px;
min-height: 600px;
}
}
/* 全局极简滚动条定制 */
*::-webkit-scrollbar {
width: var(--sb-width);
}
*::-webkit-scrollbar-track {
background: var(--sb-track-color);
}
*::-webkit-scrollbar-thumb {
background: var(--sb-thumb-color);
border-radius: 10px;
background-clip: content-box;
border: 1px solid transparent;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--sb-thumb-hover);
}
.app {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
box-sizing: border-box;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
overflow: hidden; /* 内部由 flex 子项控制滚动 */
}
/* 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;
}
+28 -9
View File
@@ -1,24 +1,43 @@
import './App.css';
import RouterProvider from '@/providers/RouterProvider'; import RouterProvider from '@/providers/RouterProvider';
import TopBar from '@/components/TopBar'; import TopBar from '@/components/TopBar';
import RouterContainer from '@/components/RouterContainer'; 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';
function App() { export default function App() {
// 打开Chrome扩展选项页面,需确保manifest中已配置options_page或options_ui
const handleOpenOptions = () => { const handleOpenOptions = () => {
chrome.runtime.openOptionsPage(); chrome.runtime.openOptionsPage().catch((r) => console.error(r));
}; };
return ( return (
<RouterProvider defaultRoute="dashboard" syncRoute={true} syncKey="app/popupRoute"> <RouterProvider syncKey="app/popupRoute">
<div <SnackbarProvider initialOptions={{ autoHideDuration: 1500000 }}>
<Box
className="app" className="app"
style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }} 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',
},
}}
> >
<TopBar onOpenOptions={handleOpenOptions} /> <TopBar onOpenOptions={handleOpenOptions} />
<ErrorBoundary>
<RouterContainer /> <RouterContainer />
</div> </ErrorBoundary>
</Box>
</SnackbarProvider>
</RouterProvider> </RouterProvider>
); );
} }
export default App;
+6 -1
View File
@@ -1,10 +1,15 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from '@/config/theme';
import App from './App.tsx'; import App from './App.tsx';
import './style.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<App /> <App />
</ThemeProvider>
</React.StrictMode>, </React.StrictMode>,
); );
+32 -126
View File
@@ -1,143 +1,49 @@
import { Box, Typography, Container } from '@mui/material'; import { Box } from '@mui/material';
import { useRouter } from '@/providers/RouterProvider'; import { useRouter } from '@/providers/RouterProvider';
import ToolCard from '@/components/ToolCard'; import DashboardCard from '@/components/DashboardCard';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import { getFeatureByKey } from '@/config/features';
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 AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import type { PageType } from '@/types/storage'; import type { PageType } from '@/types/storage';
import { useEffect, useState } from 'react'; import { useCallback } from 'react';
import dayjs from '@/utils/dayjs';
import { dashboardPageStyles } from '@/config/pageTheme'; import { dashboardPageStyles } from '@/config/pageTheme';
export default function DashboardPage() { export default function DashboardPage() {
const { navigateTo, visiblePages, pageOrder } = useRouter(); const { navigateTo, visiblePages, pageOrder } = useRouter();
const [now, setNow] = useState(dayjs());
useEffect(() => {
const timer = setInterval(() => setNow(dayjs()), 1000);
return () => clearInterval(timer);
}, []);
const isVisible = (key: string) => visiblePages.includes(key as PageType); const isVisible = (key: string) => visiblePages.includes(key as PageType);
const renderCard = (key: PageType) => { const handleCardClick = useCallback(
switch (key) { (page: PageType) => {
case 'timestamp': navigateTo(page);
return ( },
<ToolCard [navigateTo],
key={key}
title="时间戳"
description="Unix 毫秒数转换与格式化"
colorCode="#2196f3"
icon={<AccessTimeIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('timestamp')}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
snapshot={
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography
sx={{
fontFamily: 'monospace',
fontWeight: 600,
color: dashboardPageStyles.primaryColor,
fontSize: '0.85rem',
}}
>
{now.valueOf()}
</Typography>
<Typography sx={{ fontSize: '0.7rem', color: 'text.secondary' }}>
{now.format('HH:mm:ss')}
</Typography>
</Box>
}
/>
); );
case 'storageCleaner':
return ( return (
<ToolCard <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, p: 2 }}>
key={key} {pageOrder.map((key) => {
title="存储管理" if (!isVisible(key)) return null;
description="清理缓存、Cookies 及本地存储"
colorCode="#ff9800" const feature = getFeatureByKey(key);
icon={<StorageIcon sx={{ fontSize: 20 }} />} if (!feature || !feature.icon || !feature.themeColor) return null;
onClick={() => navigateTo('storageCleaner')}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor} // 适配 DashboardCard 组件,将 themeColor 映射到 colorCode
/> const cardConfig = {
); title: feature.label,
case 'openUrl': description: feature.description,
return ( colorCode: feature.themeColor,
<ToolCard icon: feature.icon,
key={key}
title="URL 工具"
description="快速打开 URL 或复制链接"
colorCode="#9c27b0"
icon={<LanguageIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('openUrl')}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
/>
);
case 'qrCode':
return (
<ToolCard
key={key}
title="二维码工具"
description="URL 转二维码与二维码解析"
colorCode="#4caf50"
icon={<QrCodeIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('qrCode')}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
/>
);
case 'formMapping':
return (
<ToolCard
key={key}
title="通用表单映射助手"
description="智能识别表单指纹,自定义填充逻辑"
colorCode="#3f51b5"
icon={<AutoFixHighIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('formMapping')}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
/>
);
case 'formRecognizer':
return (
<ToolCard
key={key}
title="表单识别"
description="识别标签页中的表单内容"
colorCode="#ff5722"
icon={<DescriptionIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('formRecognizer')}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
/>
);
case 'formFill':
return (
<ToolCard
key={key}
title="智能填充"
description="根据表单指纹智能填充表单内容"
colorCode="#2196f3"
icon={<AutoFixHighIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('formFill')}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
/>
);
default:
return null;
}
}; };
return ( return (
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 4 }}> <DashboardCard
<Container maxWidth="sm" sx={{ py: 3, px: 2 }}> key={key}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> config={cardConfig}
{pageOrder.map((key) => (isVisible(key) ? renderCard(key) : null))} onClick={() => handleCardClick(key)}
</Box> cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
</Container> />
);
})}
</Box> </Box>
); );
} }
+27 -47
View File
@@ -9,11 +9,7 @@ import {
Switch, Switch,
Divider, Divider,
Paper, Paper,
Button,
Chip, Chip,
Snackbar,
Alert,
alpha,
} from '@mui/material'; } from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RefreshIcon from '@mui/icons-material/Refresh'; import RefreshIcon from '@mui/icons-material/Refresh';
@@ -24,17 +20,18 @@ import { useEffect, useState, useCallback } from 'react';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import { FormMapEntry } from '@/types/storage'; import { FormMapEntry } from '@/types/storage';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import { globalStyles, formMappingPageStyles } from '@/config/pageTheme.ts'; import { formMappingPageStyles } from '@/config/pageTheme.ts';
import { MockDataGenerator } from '@/utils/formMapping/smartInjector'; 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() { export default function FormFillPage() {
const [entries, setEntries] = useState<FormMapEntry[]>([]); const [entries, setEntries] = useState<FormMapEntry[]>([]);
const [previewData, setPreviewData] = useState<Map<string, string>>(new Map()); const [previewData, setPreviewData] = useState<Map<string, string>>(new Map());
const [injectResults, setInjectResults] = useState<Map<string, boolean>>(new Map()); const [injectResults, setInjectResults] = useState<Map<string, boolean>>(new Map());
const [isInjecting, setIsInjecting] = useState(false); const [isInjecting, setIsInjecting] = useState(false);
const [showSuccess, setShowSuccess] = useState(false); const { showMessage } = useGlobalSnackbar({ autoHideDuration: 3000 });
const [showError, setShowError] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const generatePreviewData = useCallback((items: FormMapEntry[]) => { const generatePreviewData = useCallback((items: FormMapEntry[]) => {
const preview = new Map<string, string>(); const preview = new Map<string, string>();
@@ -53,10 +50,10 @@ export default function FormFillPage() {
generatePreviewData(data || []); generatePreviewData(data || []);
}; };
loadEntries(); loadEntries().catch((r) => console.error(r));
const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => { const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => {
if (area === 'local' && changes['active_form_map']) { if (area === 'local' && changes['active_form_map']) {
loadEntries(); loadEntries().catch((r) => console.error(r));
} }
}; };
chrome.storage.onChanged.addListener(listener); chrome.storage.onChanged.addListener(listener);
@@ -69,8 +66,7 @@ export default function FormFillPage() {
const injectAllFields = async () => { const injectAllFields = async () => {
if (entries.length === 0) { if (entries.length === 0) {
setErrorMessage('没有可填充的字段'); showMessage('没有可填充的字段', { severity: 'error' });
setShowError(true);
return; return;
} }
@@ -95,27 +91,24 @@ export default function FormFillPage() {
mockValue: previewData.get(entry.id) || '', mockValue: previewData.get(entry.id) || '',
})); }));
// 执行注入 // 执行注入 (使用 type-safe sendMessage)
const result = await chrome.tabs.sendMessage(tabId, { const result = await sendMessage(MessageAction.FORM_INJECT, { data: injectData }, tabId);
type: 'FORM_INJECT',
data: injectData,
});
if (result && result.success) { if (result && result.success) {
result.results.forEach((r: { id: string; success: boolean }) => { result.results?.forEach((r: FormInjectResult) => {
results.set(r.id, r.success); results.set(r.id, r.success);
}); });
setInjectResults(results); setInjectResults(results);
setShowSuccess(true); showMessage('填充成功!', { severity: 'success' });
} else { } else {
throw new Error(result?.error || '注入失败'); throw new Error(result?.error || result?.message || '注入失败');
} }
} catch (error) { } catch (error) {
console.error('注入失败:', error); console.error('注入失败:', error);
setErrorMessage( showMessage(error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单');
error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单', showMessage(error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单', {
); severity: 'error',
setShowError(true); });
} finally { } finally {
setIsInjecting(false); setIsInjecting(false);
} }
@@ -141,8 +134,8 @@ export default function FormFillPage() {
}; };
return ( return (
<Box sx={{ bgcolor: globalStyles.backgroundColor, minHeight: '100%', pb: 3 }}> <Box>
<Container sx={{ py: 2, bgcolor: globalStyles.backgroundColor }}> <Container sx={{ py: 2 }}>
<PageHeader <PageHeader
title="智能表单填充" title="智能表单填充"
subtitle="基于指纹识别的精准数据注入" subtitle="基于指纹识别的精准数据注入"
@@ -177,7 +170,6 @@ export default function FormFillPage() {
onClick={refreshPreview} onClick={refreshPreview}
size="small" size="small"
startIcon={<RefreshIcon />} startIcon={<RefreshIcon />}
sx={{ borderRadius: 3 }}
> >
</Button> </Button>
@@ -188,9 +180,7 @@ export default function FormFillPage() {
startIcon={<PlayArrowIcon />} startIcon={<PlayArrowIcon />}
disabled={isInjecting || entries.length === 0} disabled={isInjecting || entries.length === 0}
sx={{ sx={{
borderRadius: 3,
bgcolor: formMappingPageStyles.secondaryColor || '#9c27b0', bgcolor: formMappingPageStyles.secondaryColor || '#9c27b0',
boxShadow: `0 4px 12px ${alpha(formMappingPageStyles.secondaryColor || '#9c27b0', 0.2)}`,
}} }}
> >
{isInjecting ? '注入中...' : '开始填充'} {isInjecting ? '注入中...' : '开始填充'}
@@ -261,6 +251,14 @@ export default function FormFillPage() {
sx={{ mr: 2 }} sx={{ mr: 2 }}
/> />
<ListItemText <ListItemText
slotProps={{
primary: {
component: 'div',
},
secondary: {
component: 'div',
},
}}
primary={ primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<span style={{ fontWeight: 500 }}>{entry.label_display}</span> <span style={{ fontWeight: 500 }}>{entry.label_display}</span>
@@ -361,24 +359,6 @@ export default function FormFillPage() {
)} )}
</Container> </Container>
</Container> </Container>
{/* 提示消息 */}
<Snackbar
open={showSuccess}
autoHideDuration={3000}
onClose={() => setShowSuccess(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="success"></Alert>
</Snackbar>
<Snackbar
open={showError}
autoHideDuration={4000}
onClose={() => setShowError(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error">{errorMessage}</Alert>
</Snackbar>
</Box> </Box>
); );
} }
+11 -50
View File
@@ -9,9 +9,6 @@ import {
Switch, Switch,
Divider, Divider,
Paper, Paper,
alpha,
Snackbar,
Alert,
} from '@mui/material'; } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
@@ -22,13 +19,12 @@ import { storageUtil } from '@/utils/chromeStorage';
import { FormMapEntry } from '@/types/storage'; import { FormMapEntry } from '@/types/storage';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { globalStyles, formMappingPageStyles } from '@/config/pageTheme.ts'; import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
export default function FormMappingPage() { export default function FormMappingPage() {
const [entries, setEntries] = useState<FormMapEntry[]>([]); const [entries, setEntries] = useState<FormMapEntry[]>([]);
const [isPicking, setIsPicking] = useState(false); const [isPicking, setIsPicking] = useState(false);
const [exportError, setExportError] = useState<string | null>(null); const { showMessage } = useGlobalSnackbar({ autoHideDuration: 3000 });
const [showExportSuccess, setShowExportSuccess] = useState(false);
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
@@ -38,7 +34,7 @@ export default function FormMappingPage() {
setIsPicking(picking || false); setIsPicking(picking || false);
}; };
loadData(); loadData().catch((r) => console.error(r));
const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => { const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => {
if (area === 'local') { if (area === 'local') {
@@ -79,7 +75,7 @@ export default function FormMappingPage() {
const exportConfig = () => { const exportConfig = () => {
try { try {
if (entries.length === 0) { if (entries.length === 0) {
setExportError('没有可导出的配置数据'); showMessage('没有可导出的配置数据', { severity: 'warning' });
return; return;
} }
@@ -99,17 +95,19 @@ export default function FormMappingPage() {
document.body.removeChild(link); document.body.removeChild(link);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
setShowExportSuccess(true); showMessage('配置导出成功!', { severity: 'success' });
} catch (error) { } catch (error) {
console.error('导出配置失败:', error); console.error('导出配置失败:', error);
setExportError(error instanceof Error ? error.message : '导出失败,请重试'); showMessage(error instanceof Error ? error.message : '导出失败,请重试', {
severity: 'error',
});
} }
}; };
return ( return (
<Box> <Box>
<Box sx={{ bgcolor: globalStyles.backgroundColor, minHeight: '100%', pb: 3 }}> <Box>
<Container sx={{ py: 2, bgcolor: globalStyles.backgroundColor }}> <Container sx={{ py: 2 }}>
<PageHeader <PageHeader
title="通用表单映射助手" title="通用表单映射助手"
subtitle="智能识别表单指纹,自定义填充逻辑" subtitle="智能识别表单指纹,自定义填充逻辑"
@@ -142,17 +140,6 @@ export default function FormMappingPage() {
onClick={togglePicking} onClick={togglePicking}
size="small" size="small"
startIcon={<AddCircleOutlineIcon />} startIcon={<AddCircleOutlineIcon />}
sx={{
borderRadius: 3,
px: 2,
fontWeight: 800,
...(isPicking
? {
bgcolor: 'secondary.main',
boxShadow: `0 4px 12px ${alpha(formMappingPageStyles.secondaryColor || '#9c27b0', 0.2)}`,
}
: {}),
}}
> >
{isPicking ? '正在拾取...' : '开始拾取'} {isPicking ? '正在拾取...' : '开始拾取'}
</Button> </Button>
@@ -174,12 +161,7 @@ export default function FormMappingPage() {
<Typography variant="subtitle2" fontWeight={800} color="text.secondary"> <Typography variant="subtitle2" fontWeight={800} color="text.secondary">
({entries.length}) ({entries.length})
</Typography> </Typography>
<Button <Button size="small" color="error" onClick={clearAll}>
size="small"
color="error"
onClick={clearAll}
sx={{ fontWeight: 700, fontSize: '0.75rem' }}
>
</Button> </Button>
</Box> </Box>
@@ -273,7 +255,6 @@ export default function FormMappingPage() {
variant="outlined" variant="outlined"
onClick={exportConfig} onClick={exportConfig}
startIcon={<FileDownloadIcon />} startIcon={<FileDownloadIcon />}
sx={{ fontWeight: 700, fontSize: '0.75rem', borderRadius: 3 }}
> >
</Button> </Button>
@@ -299,26 +280,6 @@ export default function FormMappingPage() {
</Container> </Container>
</Container> </Container>
</Box> </Box>
<Snackbar
open={!!exportError}
autoHideDuration={4000}
onClose={() => setExportError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setExportError(null)}>
{exportError}
</Alert>
</Snackbar>
<Snackbar
open={showExportSuccess}
autoHideDuration={3000}
onClose={() => setShowExportSuccess(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="success" onClose={() => setShowExportSuccess(false)}>
</Alert>
</Snackbar>
</Box> </Box>
); );
} }
+29 -386
View File
@@ -1,339 +1,39 @@
import { useState, useRef, useEffect } from 'react'; import { Box, Container, CircularProgress, FormControlLabel, Switch } from '@mui/material';
import { import Button from '@/components/Button';
Box,
Container,
Button,
CircularProgress,
FormControlLabel,
Switch,
alpha,
} from '@mui/material';
import InputIcon from '@mui/icons-material/Input'; import InputIcon from '@mui/icons-material/Input';
import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar'; import { formRecognizerPageStyles } from '@/config/pageTheme';
import { dashboardPageStyles, formRecognizerPageStyles } from '@/config/pageTheme';
import { MessageAction, sendMessageToContent, injectContentScript } from '@/utils/messages';
import { FillMode } from '@/utils/dummyDataGenerator';
import FieldList from '@/components/FieldList'; import FieldList from '@/components/FieldList';
import { useStorageState } from '@/utils/useStorageState';
import { FieldTypePreferences } from '@/types/storage';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import { useFormRecognizer } from './hooks/useFormRecognizer';
// 字段数据接口 export default function FormRecognizerPage() {
interface FieldData { const {
id: string; fillLoading,
fieldType: string; clearLoading,
label: string | null;
placeholder: string;
name: string;
value: string;
isSelected: boolean;
generatedValue: string;
useInvalidData?: boolean;
}
const DEFAULT_FIELD_TYPE_PREFERENCES: FieldTypePreferences = {};
const FormRecognizerPage = () => {
const { snackbarProps, showMessage } = useSnackbar({ 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, setCurrentDomain] = useState<string>('');
const [sidePanelOpen, setSidePanelOpen] = useState(false);
const [fieldTypePreferences, setFieldTypePreferences] = useStorageState(
'formRecognizer/fieldTypePreferences',
DEFAULT_FIELD_TYPE_PREFERENCES,
);
// 获取当前域名
useEffect(() => {
const getActiveTab = async () => {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab?.url) {
const url = new URL(tab.url);
setCurrentDomain(url.hostname);
}
} catch (error) {
console.error('获取标签页信息失败:', error);
}
};
getActiveTab();
}, []);
// 检测侧边栏状态
useEffect(() => {
console.log('开始检测侧边栏状态');
const checkSidePanelState = async () => {
try {
// 检查 chrome.runtime.getContexts 是否可用
if (typeof chrome.runtime.getContexts === 'function') {
const contexts = await chrome.runtime.getContexts({
contextTypes: ['SIDE_PANEL'],
});
console.log('侧边栏上下文:', contexts);
setSidePanelOpen(contexts.length > 0);
} else {
console.log('chrome.runtime.getContexts 不可用');
setSidePanelOpen(false);
}
} catch (error) {
console.error('检测侧边栏状态失败:', error);
setSidePanelOpen(false);
}
};
// 初始检查
checkSidePanelState();
// 定期检查侧边栏状态(每 500ms)
const interval = setInterval(checkSidePanelState, 500);
console.log('侧边栏状态检测已启动');
return () => {
console.log('清理侧边栏状态检测');
clearInterval(interval);
};
}, []);
// 生成字段标识符
const getFieldIdentifier = (field: Pick<FieldData, 'label' | 'name' | 'placeholder'>): string => {
return field.label || field.name || field.placeholder || 'unknown';
};
// 应用保存的类型偏好
const applySavedPreferences = (fields: FieldData[]): FieldData[] => {
if (!currentDomain || !(fieldTypePreferences as FieldTypePreferences)[currentDomain]) {
return fields;
}
const prefs = (fieldTypePreferences as FieldTypePreferences)[currentDomain];
return fields.map((field) => {
const identifier = getFieldIdentifier(field);
if (prefs && prefs[identifier]) {
return { ...field, fieldType: prefs[identifier] };
}
return field;
});
};
// 保存类型偏好
const saveTypePreference = (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;
});
};
// 扫描表单字段
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[]);
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,保持界面一致性
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 {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messageFields = fields as any;
let response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
fields: messageFields,
mode: FillMode.VALID,
includeHidden, includeHidden,
}); setIncludeHidden,
fields,
if (!response.success && response.message && response.message.includes('无法连接')) { scanning,
showMessage('正在注入内容脚本...', { severity: 'info' }); showFields,
const injected = await injectContentScript(); setShowFields,
if (injected) { hoveredFieldId,
response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, { sidePanelOpen,
fields: messageFields, handleScanFields,
mode: FillMode.VALID, handleFieldTypeChange,
includeHidden, handleToggleFieldSelection,
}); handleToggleAllFields,
} handleLocateField,
} handleHoverField,
handleFillSelectedFields,
if (response.success) { handleClearAllFields,
showMessage(response.message || '填充成功', { severity: 'success' }); handleOpenSidePanel,
} else { selectedCount,
showMessage(response.message || '填充失败', { severity: 'error' }); } = useFormRecognizer();
}
} 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;
}
};
// 打开侧边栏
const handleOpenSidePanel = async () => {
try {
await chrome.sidePanel.open({ windowId: chrome.windows.WINDOW_ID_CURRENT });
showMessage('侧边栏已打开', { severity: 'success' });
} catch (error) {
console.error('打开侧边栏失败:', error);
showMessage('打开侧边栏失败', { severity: 'error' });
}
};
const selectedCount = fields.filter((f) => f.isSelected).length;
return ( return (
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 4 }}> <Box>
<Container maxWidth="sm" sx={{ py: 3, px: 2, bgcolor: '#f5f5f5' }}> <Container maxWidth="sm" sx={{ py: 3, px: 2 }}>
{/* Header */} {/* Header */}
<PageHeader <PageHeader
title="表单测试数据填充器" title="表单测试数据填充器"
@@ -366,16 +66,6 @@ const FormRecognizerPage = () => {
onClick={handleScanFields} onClick={handleScanFields}
disabled={scanning} disabled={scanning}
fullWidth fullWidth
sx={{
...formRecognizerPageStyles.buttonStyle,
mb: 2,
borderColor: formRecognizerPageStyles.validColor,
color: formRecognizerPageStyles.validColor,
'&:hover': {
borderColor: formRecognizerPageStyles.validDark,
bgcolor: 'rgba(76, 175, 80, 0.05)',
},
}}
startIcon={scanning ? <CircularProgress size={16} color="inherit" /> : <InputIcon />} startIcon={scanning ? <CircularProgress size={16} color="inherit" /> : <InputIcon />}
> >
{scanning ? '扫描中...' : '扫描表单字段'} {scanning ? '扫描中...' : '扫描表单字段'}
@@ -403,27 +93,6 @@ const FormRecognizerPage = () => {
onClick={handleFillSelectedFields} onClick={handleFillSelectedFields}
disabled={fillLoading || selectedCount === 0} disabled={fillLoading || selectedCount === 0}
fullWidth fullWidth
sx={{
py: 1.6,
borderRadius: 4,
bgcolor: formRecognizerPageStyles.validColor,
fontWeight: 700,
fontSize: '1rem',
textTransform: 'none',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
bgcolor: formRecognizerPageStyles.validDark,
boxShadow: `0 8px 24px ${alpha(formRecognizerPageStyles.validColor, 0.3)}`,
transform: 'translateY(-1px)',
},
'&:disabled': {
bgcolor: '#e0e0e0',
color: '#9e9e9e',
},
'&:active': {
transform: 'translateY(0)',
},
}}
> >
</Button> </Button>
@@ -435,28 +104,6 @@ const FormRecognizerPage = () => {
onClick={handleClearAllFields} onClick={handleClearAllFields}
disabled={clearLoading} disabled={clearLoading}
fullWidth fullWidth
sx={{
py: 1.6,
borderRadius: 4,
borderColor: formRecognizerPageStyles.clearColor,
color: formRecognizerPageStyles.clearColor,
fontWeight: 600,
fontSize: '1rem',
textTransform: 'none',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
borderColor: formRecognizerPageStyles.clearDark,
bgcolor: formRecognizerPageStyles.clearBg,
transform: 'translateY(-1px)',
},
'&:disabled': {
borderColor: '#e0e0e0',
color: '#9e9e9e',
},
'&:active': {
transform: 'translateY(0)',
},
}}
> >
</Button> </Button>
@@ -474,11 +121,7 @@ const FormRecognizerPage = () => {
label="包含隐藏字段" label="包含隐藏字段"
/> />
</Box> </Box>
<GlobalSnackbar {...snackbarProps} />
</Container> </Container>
</Box> </Box>
); );
}; }
export default FormRecognizerPage;
+5 -6
View File
@@ -1,16 +1,16 @@
import { Box, Typography, Container } from '@mui/material'; import { Box, Typography, Container } from '@mui/material';
import LanguageIcon from '@mui/icons-material/Language'; import LanguageIcon from '@mui/icons-material/Language';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar'; import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
import UrlEntryForm from '@/components/UrlEntryForm'; import UrlEntryForm from '@/components/UrlEntryForm';
import UrlEntryList from '@/components/UrlEntryList'; import UrlEntryList from '@/components/UrlEntryList';
import { useUrlPreferences } from '@/utils/useUrlPreferences'; import { useUrlPreferences } from '@/utils/useUrlPreferences';
import type { OpenUrlEntry } from '@/types/storage'; import type { OpenUrlEntry } from '@/types/storage';
import { dashboardPageStyles, openUrlPageStyles } from '@/config/pageTheme'; import { openUrlPageStyles } from '@/config/pageTheme';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
export default function OpenUrlPage() { export default function OpenUrlPage() {
const { entries, setEntries, isLoaded } = useUrlPreferences(); const { entries, setEntries, isLoaded } = useUrlPreferences();
const { snackbarProps, showMessage } = useSnackbar(); const { showMessage } = useGlobalSnackbar();
const handleAddEntry = (entry: OpenUrlEntry) => { const handleAddEntry = (entry: OpenUrlEntry) => {
setEntries([...entries, entry]); setEntries([...entries, entry]);
@@ -25,7 +25,7 @@ export default function OpenUrlPage() {
if (!isLoaded) { if (!isLoaded) {
return ( return (
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 3 }}> <Box sx={{ minHeight: '100%', pb: 3 }}>
<Container sx={{ py: 2 }}> <Container sx={{ py: 2 }}>
<Typography>...</Typography> <Typography>...</Typography>
</Container> </Container>
@@ -34,7 +34,7 @@ export default function OpenUrlPage() {
} }
return ( return (
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 3 }}> <Box>
<Container sx={{ py: 2 }}> <Container sx={{ py: 2 }}>
{/* Header */} {/* Header */}
<PageHeader <PageHeader
@@ -64,7 +64,6 @@ export default function OpenUrlPage() {
/> />
</Box> </Box>
</Container> </Container>
<GlobalSnackbar {...snackbarProps} />
</Box> </Box>
); );
} }
@@ -1,7 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Box, Typography, CircularProgress, Alert } from '@mui/material'; import { Box, Typography, CircularProgress, Alert } from '@mui/material';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import { dashboardPageStyles } from '@/config/pageTheme';
// 只允许 HTTP/HTTPS 协议,阻止危险协议 // 只允许 HTTP/HTTPS 协议,阻止危险协议
const ALLOWED_PROTOCOLS = ['http:', 'https:']; const ALLOWED_PROTOCOLS = ['http:', 'https:'];
@@ -112,7 +111,6 @@ export default function OpenUrlViewerPage() {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
overflow: 'hidden', overflow: 'hidden',
bgcolor: dashboardPageStyles.backgroundColor,
}} }}
> >
{/* 加载状态指示器 */} {/* 加载状态指示器 */}
+7 -10
View File
@@ -1,14 +1,14 @@
import { Box, Stack, Container, CircularProgress } from '@mui/material'; import { Box, Stack, Container, CircularProgress } from '@mui/material';
import QrCodeIcon from '@mui/icons-material/QrCode'; import QrCodeIcon from '@mui/icons-material/QrCode';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar'; import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
import UrlToQrCodeSection from '@/components/UrlToQrCodeSection'; import UrlToQrCodeSection from '@/components/UrlToQrCodeSection';
import QrCodeToUrlSection from '@/components/QrCodeToUrlSection'; import QrCodeToUrlSection from '@/components/QrCodeToUrlSection';
import { useStorageState } from '@/utils/useStorageState'; import { useStorageState } from '@/utils/useStorageState';
import { dashboardPageStyles, qrCodePageStyles } from '@/config/pageTheme'; import { qrCodePageStyles } from '@/config/pageTheme';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
const QrCodePage = () => { export default function QrCodePage() {
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 }); const { showMessage } = useGlobalSnackbar();
// 使用自定义钩子管理展开状态 // 使用自定义钩子管理展开状态
const [urlExpanded, setUrlExpanded, urlInitialized] = useStorageState('qrCode/urlExpanded', true); const [urlExpanded, setUrlExpanded, urlInitialized] = useStorageState('qrCode/urlExpanded', true);
@@ -33,8 +33,8 @@ const QrCodePage = () => {
} }
return ( return (
<Box sx={{ minHeight: '100%', pb: 3 }}> <Box>
<Container sx={{ py: 2, maxWidth: 400, bgcolor: dashboardPageStyles.backgroundColor }}> <Container sx={{ py: 2, maxWidth: 400 }}>
<PageHeader <PageHeader
title="二维码工具" title="二维码工具"
subtitle="生成和解析二维码" subtitle="生成和解析二维码"
@@ -56,10 +56,7 @@ const QrCodePage = () => {
showMessage={showMessage} showMessage={showMessage}
/> />
</Stack> </Stack>
<GlobalSnackbar {...snackbarProps} />
</Container> </Container>
</Box> </Box>
); );
}; }
export default QrCodePage;
+3 -15
View File
@@ -1,6 +1,6 @@
import { Box, Container, CircularProgress } from '@mui/material'; import { Box, Container, CircularProgress } from '@mui/material';
import Button from '@/components/Button'; import Button from '@/components/Button';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar'; import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
import StorageCleanerConfirm from '@/components/StorageCleanerConfirm'; import StorageCleanerConfirm from '@/components/StorageCleanerConfirm';
import { storageCleanerPageStyles } from '@/config/pageTheme'; import { storageCleanerPageStyles } from '@/config/pageTheme';
import { useStorageCleaner } from './useStorageCleaner'; import { useStorageCleaner } from './useStorageCleaner';
@@ -11,7 +11,7 @@ import ErrorDisplay from './components/ErrorDisplay';
import CleaningResult from './components/CleaningResult'; import CleaningResult from './components/CleaningResult';
export default function StorageCleanerPage() { export default function StorageCleanerPage() {
const { snackbarProps, showMessage } = useSnackbar(); const { showMessage } = useGlobalSnackbar();
const { const {
domain, domain,
error, error,
@@ -45,7 +45,7 @@ export default function StorageCleanerPage() {
} }
return ( return (
<Box sx={{ bgcolor: '#f5f5f5', minHeight: '100%', pb: 2 }}> <Box>
<Container sx={{ py: 2 }}> <Container sx={{ py: 2 }}>
<DomainHeader domain={domain} totalSize={totalSize} /> <DomainHeader domain={domain} totalSize={totalSize} />
@@ -64,20 +64,9 @@ export default function StorageCleanerPage() {
variant="contained" variant="contained"
onClick={() => setShowConfirm(true)} onClick={() => setShowConfirm(true)}
sx={{ sx={{
py: 1.3,
borderRadius: 4,
bgcolor: storageCleanerPageStyles.warningColor, bgcolor: storageCleanerPageStyles.warningColor,
fontWeight: 800,
fontSize: '0.85rem',
boxShadow: '0 4px 12px rgba(255, 152, 0, 0.25)',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': { '&:hover': {
bgcolor: storageCleanerPageStyles.warningDark, bgcolor: storageCleanerPageStyles.warningDark,
boxShadow: '0 8px 20px rgba(255, 152, 0, 0.35)',
transform: 'translateY(-2px)',
},
'&:active': {
transform: 'translateY(0)',
}, },
}} }}
disabled={loading} disabled={loading}
@@ -95,7 +84,6 @@ export default function StorageCleanerPage() {
onConfirm={handleClean} onConfirm={handleClean}
options={options} options={options}
/> />
<GlobalSnackbar {...snackbarProps} />
</Box> </Box>
); );
} }
+8 -27
View File
@@ -1,15 +1,15 @@
import { TextField, Select, MenuItem, Stack, Box, Container, alpha } from '@mui/material'; import { TextField, Select, MenuItem, Stack, Box, Container } from '@mui/material';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar'; import { useSnackbar } from '@/components/SnackbarProvider';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import Button from '@/components/Button'; import Button from '@/components/Button';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import { ZONES, globalStyles, timestampPageStyles } from '@/config/pageTheme'; import { ZONES, timestampPageStyles } from '@/config/pageTheme';
import LiveClock from './components/LiveClock'; import LiveClock from './components/LiveClock';
import ResultView from './components/ResultView'; import ResultView from './components/ResultView';
import { useTimestampConverter } from './hooks/useTimestampConverter'; import { useTimestampConverter } from './hooks/useTimestampConverter';
export default function TimestampPage() { export default function TimestampPage() {
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 }); const { showMessage } = useSnackbar();
const { const {
mode, mode,
tsInput, tsInput,
@@ -28,14 +28,13 @@ export default function TimestampPage() {
} = useTimestampConverter(); } = useTimestampConverter();
return ( return (
<Box sx={{ bgcolor: globalStyles.backgroundColor, minHeight: '100%', pb: 3 }}> <Box>
<Container sx={{ py: 2, bgcolor: globalStyles.backgroundColor }}> <Container sx={{ p: 2 }}>
{/* Header */} {/* Header */}
<PageHeader <PageHeader
title="时间戳转换" title="时间戳转换"
subtitle="Unix 毫秒数转换与格式化" subtitle="Unix 毫秒数转换与格式化"
icon={<AccessTimeIcon />} icon={<AccessTimeIcon />}
sx={{ mb: 2.5 }}
/> />
{/* Live Clock Card */} {/* Live Clock Card */}
@@ -66,7 +65,7 @@ export default function TimestampPage() {
width: 'calc(50% - 5px)', width: 'calc(50% - 5px)',
bgcolor: '#fff', bgcolor: '#fff',
borderRadius: 3.5, borderRadius: 3.5,
boxShadow: '0 4px 12px rgba(0,0,0,0.08)', boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)', transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: mode === 'ts2dt' ? 'translateX(0)' : 'translateX(100%)', transform: mode === 'ts2dt' ? 'translateX(0)' : 'translateX(100%)',
top: 5, top: 5,
@@ -171,31 +170,13 @@ export default function TimestampPage() {
</Stack> </Stack>
{/* Main Action */} {/* Main Action */}
<Button <Button fullWidth variant="contained" onClick={convert}>
fullWidth
variant="contained"
onClick={convert}
sx={{
py: 1.4,
borderRadius: 4,
bgcolor: 'primary.main',
fontWeight: 800,
fontSize: '0.9rem',
boxShadow: 'none',
'&:hover': {
bgcolor: 'primary.dark',
boxShadow: `0 8px 24px ${alpha(timestampPageStyles.primaryColor, 0.2)}`,
},
}}
>
</Button> </Button>
{/* Result View */} {/* Result View */}
<ResultView result={result} mode={mode} unit={unit} zone={zone} showMessage={showMessage} /> <ResultView result={result} mode={mode} unit={unit} zone={zone} showMessage={showMessage} />
</Container> </Container>
<GlobalSnackbar {...snackbarProps} />
</Box> </Box>
); );
} }
@@ -0,0 +1,29 @@
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;
}
@@ -0,0 +1,292 @@
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,
};
}
@@ -0,0 +1,60 @@
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,
};
}
+22 -9
View File
@@ -17,6 +17,7 @@ import {
getCacheStorageSize, getCacheStorageSize,
getServiceWorkerCount, getServiceWorkerCount,
} from '@/utils/storageCleaner'; } from '@/utils/storageCleaner';
import { MessageAction, sendMessage } from '@/utils/messages';
const DEFAULT_OPTIONS: StorageCleanerOptions = { const DEFAULT_OPTIONS: StorageCleanerOptions = {
localStorage: true, localStorage: true,
@@ -73,16 +74,15 @@ export function useStorageCleaner({
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [result, setResult] = useState<CleaningResult | null>(null); const [result, setResult] = useState<CleaningResult | null>(null);
const [showConfirm, setShowConfirm] = useState<boolean>(false); const [showConfirm, setShowConfirm] = useState<boolean>(false);
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const resultTimeoutRef = useRef<NodeJS.Timeout | null>(null); const resultTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const requestIdRef = useRef<number>(0); const requestIdRef = useRef<number>(0);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
const reloadTimeout = reloadTimeoutRef.current;
const resultTimeout = resultTimeoutRef.current; const resultTimeout = resultTimeoutRef.current;
return () => { return () => {
if (reloadTimeout) clearTimeout(reloadTimeout);
if (resultTimeout) clearTimeout(resultTimeout); if (resultTimeout) clearTimeout(resultTimeout);
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
}; };
}, []); }, []);
@@ -141,13 +141,23 @@ export function useStorageCleaner({
const loadInfoRef = useRef(loadInfo); const loadInfoRef = useRef(loadInfo);
loadInfoRef.current = loadInfo; loadInfoRef.current = loadInfo;
useEffect(() => { const debouncedLoadInfo = useCallback(() => {
loadInfoRef.current(); if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
loadInfoRef.current().then((r) => console.info(r));
}, 300);
}, []);
const handleTabChange = () => loadInfoRef.current(); useEffect(() => {
// 首次加载不防抖
loadInfoRef.current().then((r) => console.info(r));
const handleTabChange = () => debouncedLoadInfo();
const handleTabUpdated = (_tabId: number, changeInfo: { status?: string; url?: string }) => { const handleTabUpdated = (_tabId: number, changeInfo: { status?: string; url?: string }) => {
if (changeInfo.status === 'complete' || changeInfo.url) { if (changeInfo.status === 'complete' || changeInfo.url) {
loadInfoRef.current(); debouncedLoadInfo();
} }
}; };
@@ -159,8 +169,11 @@ export function useStorageCleaner({
chrome.tabs.onActivated.removeListener(handleTabChange); chrome.tabs.onActivated.removeListener(handleTabChange);
chrome.tabs.onUpdated.removeListener(handleTabUpdated); chrome.tabs.onUpdated.removeListener(handleTabUpdated);
chrome.windows.onFocusChanged.removeListener(handleTabChange); chrome.windows.onFocusChanged.removeListener(handleTabChange);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
}; };
}, []); }, [debouncedLoadInfo]);
const handleAutoRefreshChange = useCallback( const handleAutoRefreshChange = useCallback(
async (checked: boolean) => { async (checked: boolean) => {
@@ -219,7 +232,7 @@ export function useStorageCleaner({
if (autoRefresh && cleaningResult.success) { if (autoRefresh && cleaningResult.success) {
showMessage('清理成功,即将刷新页面', { severity: 'success' }); showMessage('清理成功,即将刷新页面', { severity: 'success' });
await chrome.runtime.sendMessage({ action: 'reloadTab', tabId: tab.id, delay: 1000 }); await sendMessage(MessageAction.RELOAD_TAB, { tabId: tab.id, delay: 1000 });
} else { } else {
await loadInfo(); await loadInfo();
} }
-18
View File
@@ -1,18 +0,0 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit; /* 先全部重置为继承大小 */
font-weight: inherit;
}
-91
View File
@@ -1,91 +0,0 @@
/* 极简滚动条变量 */
:root {
/* 统一圆角变量 */
--radius: 8px;
/* 统一背景颜色变量 */
--bg-color: #fafafa;
/* 统一文字颜色变量 */
--text-color: #333333;
/* 统一按钮颜色变量 */
--btn-bg: #e0e0e0;
/* 统一按钮文字颜色变量 */
--btn-text: #333333;
--border: 1px solid #e0e0e0;
/* 滚动条变量 */
--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;
width: 100%;
height: 100vh;
overflow: hidden; /* 禁用外层滚动 */
}
/* 全局极简滚动条定制 */
*::-webkit-scrollbar {
width: var(--sb-width);
}
*::-webkit-scrollbar-track {
background: var(--sb-track-color);
}
*::-webkit-scrollbar-thumb {
background: var(--sb-thumb-color);
border-radius: 10px;
background-clip: content-box;
border: 1px solid transparent;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--sb-thumb-hover);
}
.app {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
box-sizing: border-box;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
overflow: hidden; /* 内部由 flex 子项控制滚动 */
}
/* 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;
}
+27 -6
View File
@@ -1,21 +1,42 @@
import './App.css'; import { useEffect } from 'react';
import RouterProvider from '@/providers/RouterProvider'; import RouterProvider from '@/providers/RouterProvider';
import TopBar from '@/components/TopBar'; import TopBar from '@/components/TopBar';
import RouterContainer from '@/components/RouterContainer'; import RouterContainer from '@/components/RouterContainer';
import ErrorBoundary from '@/components/ErrorBoundary';
import { MessageAction, sendMessage } from '@/utils/messages';
import { Box } from '@mui/material';
function App() { export default function App() {
const handleOpenOptions = () => { const handleOpenOptions = () => {
chrome.runtime.openOptionsPage(); chrome.runtime.openOptionsPage();
}; };
// 通知侧边栏已打开
useEffect(() => {
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true });
return () => {
// 尝试在关闭时通知,虽然在某些情况下可能无法成功发送
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: false });
};
}, []);
return ( return (
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute"> <RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
<div className="app" style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}> <Box
className="app"
sx={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
width: '100%',
overflow: 'hidden',
}}
>
<TopBar onOpenOptions={handleOpenOptions} /> <TopBar onOpenOptions={handleOpenOptions} />
<ErrorBoundary>
<RouterContainer /> <RouterContainer />
</div> </ErrorBoundary>
</Box>
</RouterProvider> </RouterProvider>
); );
} }
export default App;
+6 -1
View File
@@ -1,10 +1,15 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from '@/config/theme';
import App from './App.tsx'; import App from './App.tsx';
import './style.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<App /> <App />
</ThemeProvider>
</React.StrictMode>, </React.StrictMode>,
); );
-22
View File
@@ -1,22 +0,0 @@
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Ubuntu', 'Cantarell', 'Fira Sans',
'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit; /* 先全部重置为继承大小 */
font-weight: inherit;
}
+18 -8
View File
@@ -1,12 +1,23 @@
import js from '@eslint/js'; import js from '@eslint/js';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import * as reactHooks from 'eslint-plugin-react-hooks'; import reactHooks from 'eslint-plugin-react-hooks';
import * as reactPlugin from 'eslint-plugin-react'; import reactPlugin from 'eslint-plugin-react';
import globals from 'globals'; import globals from 'globals';
export default tseslint.config( export default [
{ ignores: ['dist', '.wxt', 'node_modules', 'eslint.config.ts', '**/*.test.tsx', '**/*.test.ts', '**/__tests__/**'] }, {
ignores: [
'dist',
'.wxt',
'node_modules',
'eslint.config.ts',
'**/*.test.tsx',
'**/*.test.ts',
'**/__tests__/**',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{ {
files: [ files: [
'hooks/**/*.{ts,tsx}', 'hooks/**/*.{ts,tsx}',
@@ -16,7 +27,6 @@ export default tseslint.config(
'components/**/*.{ts,tsx}', 'components/**/*.{ts,tsx}',
'services/**/*.{ts,tsx}', 'services/**/*.{ts,tsx}',
], ],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
globals: { globals: {
@@ -29,7 +39,7 @@ export default tseslint.config(
}, },
}, },
plugins: { plugins: {
react: reactPlugin as any, // 现在这里就算写 TS 语法也没事了,因为文件被忽略了 react: reactPlugin as any,
'react-hooks': reactHooks as any, 'react-hooks': reactHooks as any,
}, },
rules: { rules: {
@@ -42,4 +52,4 @@ export default tseslint.config(
'react/react-in-jsx-scope': 'off', 'react/react-in-jsx-scope': 'off',
}, },
}, },
); ];
+162 -38
View File
@@ -1,70 +1,202 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import type { PageType, StorageSchema } from '@/types/storage'; import type { PageType, StorageSchema } from '@/types/storage';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import { getDefaultVisibleRoutes, getDefaultPageOrder } from '@/config/routes'; import {
getDefaultVisibleFeatureKeys,
getDefaultPageOrder,
getAllFeatureKeys,
} from '@/config/features';
/**
* 校验是否为合法的页面类型
*/
const isValidPage = (page: unknown): page is PageType => {
return typeof page === 'string' && (getAllFeatureKeys() as string[]).includes(page);
};
/**
* 校验页面列表是否合法(所有项都必须是合法的 PageType)
*/
const isValidPageList = (pages: unknown): pages is PageType[] => {
return Array.isArray(pages) && pages.every(isValidPage);
};
/**
* 路由上下文类型定义
*/
interface RouterContextType { interface RouterContextType {
/** 当前所在页面 */
currentPage: PageType; currentPage: PageType;
/** 当前可见的页面列表(用于侧边栏/菜单显示) */
visiblePages: PageType[]; visiblePages: PageType[];
/** 页面显示顺序 */
pageOrder: PageType[]; pageOrder: PageType[];
/** 路由数据是否已从存储中加载完成 */
isLoaded: boolean; isLoaded: boolean;
/** 导航到指定页面 */
navigateTo: (page: PageType) => void; navigateTo: (page: PageType) => void;
/** 仅在本地(当前组件状态)跳转,不影响其他同步端 */
navigateLocal: (page: PageType) => void; navigateLocal: (page: PageType) => void;
/** 强制同步当前路由到存储 */
syncNavigation: (page: PageType) => void; syncNavigation: (page: PageType) => void;
/** 返回仪表盘 */
goBack: () => void; goBack: () => void;
/** 设置可见页面列表 */
setVisiblePages: (pages: PageType[]) => void; setVisiblePages: (pages: PageType[]) => void;
/** 设置页面显示顺序 */
setPageOrder: (pages: PageType[]) => void; setPageOrder: (pages: PageType[]) => void;
} }
/**
* 创建路由上下文
*/
const RouterContext = createContext<RouterContextType | null>(null); const RouterContext = createContext<RouterContextType | null>(null);
/**
* 路由提供者组件参数类型
*/
interface RouterProviderProps { interface RouterProviderProps {
/** 子组件 */
children: ReactNode; children: ReactNode;
/** 默认初始路由,默认为 'dashboard' */
defaultRoute?: PageType; defaultRoute?: PageType;
/** 是否同步路由状态到存储,默认为 true */
syncRoute?: boolean; syncRoute?: boolean;
/** 存储路由状态的键名,默认为 'app/currentRoute' */
syncKey?: keyof StorageSchema; syncKey?: keyof StorageSchema;
} }
/**
* 同步从 localStorage 获取存储快照(用于消除异步加载产生的首屏闪烁)
*/
const getSyncSnapshot = <T,>(
key: string,
defaultValue: T,
validator?: (val: unknown) => val is T,
): T => {
try {
const val = localStorage.getItem(`snapshot/${key}`);
if (!val) return defaultValue;
const parsed = JSON.parse(val) as unknown;
if (validator) {
return validator(parsed) ? parsed : defaultValue;
}
return (parsed as T) ?? defaultValue;
} catch (error) {
console.error('解析同步快照失败:', error);
return defaultValue;
}
};
/**
* 路由提供者组件
* 负责管理应用内部的路由状态、页面可见性及排序,并支持与 Chrome Storage 同步
*/
export function RouterProvider({ export function RouterProvider({
children, children,
defaultRoute = 'dashboard', defaultRoute = 'dashboard',
syncRoute = true, syncRoute = true,
syncKey = 'app/currentRoute', syncKey = 'app/currentRoute',
}: RouterProviderProps) { }: RouterProviderProps) {
const [currentPage, setCurrentPage] = useState<PageType>(defaultRoute); // 当前页面状态:优先从同步快照加载,并进行合法性校验
const [visiblePages, setVisiblePages] = useState<PageType[]>(getDefaultVisibleRoutes()); const [currentPage, setCurrentPage] = useState<PageType>(() =>
const [pageOrder, setPageOrder] = useState<PageType[]>(getDefaultPageOrder()); getSyncSnapshot(syncKey as string, defaultRoute, isValidPage),
);
// 可见页面列表状态
const [visiblePages, setVisiblePages] = useState<PageType[]>(() =>
getSyncSnapshot('app/visiblePages', getDefaultVisibleFeatureKeys(), isValidPageList),
);
// 页面排序状态
const [pageOrder, setPageOrder] = useState<PageType[]>(() =>
getSyncSnapshot('app/pageOrder', getDefaultPageOrder(), isValidPageList),
);
// 加载完成标识
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
/**
* 从存储中加载初始路由数据
*/
const loadInitialData = useCallback(async () => {
try {
const savedRoute = await storageUtil.get(syncKey, defaultRoute);
const savedVisiblePages = await storageUtil.get(
'app/visiblePages',
getDefaultVisibleFeatureKeys(),
);
const savedPageOrder = await storageUtil.get('app/pageOrder', getDefaultPageOrder());
// 增加数据合法性校验并进行类型收窄
if (isValidPage(savedRoute) && syncRoute) {
setCurrentPage(savedRoute);
}
if (isValidPageList(savedVisiblePages)) {
setVisiblePages(savedVisiblePages);
}
if (isValidPageList(savedPageOrder) && savedPageOrder.length > 0) {
setPageOrder(savedPageOrder);
}
} catch (error) {
console.error('加载初始路由数据失败:', error);
} finally {
setIsLoaded(true);
}
}, [defaultRoute, syncKey, syncRoute]);
// 组件挂载时加载初始数据
useEffect(() => { useEffect(() => {
loadInitialData(); loadInitialData().catch(console.error);
}, [syncKey]); }, [loadInitialData]);
// 当 currentPage 改变时,如果开启了同步,则持久化到存储和本地快照
useEffect(() => { useEffect(() => {
if (isLoaded && syncRoute) { if (isLoaded && syncRoute) {
storageUtil.set(syncKey, currentPage as any); storageUtil.set(syncKey, currentPage as PageType).catch(console.error);
localStorage.setItem(`snapshot/${syncKey}`, JSON.stringify(currentPage));
} }
}, [currentPage, isLoaded, syncRoute, syncKey]); }, [currentPage, isLoaded, syncRoute, syncKey]);
// Listen for storage changes if sync is enabled // 持久化可见页面列表
useEffect(() => {
if (isLoaded) {
storageUtil.set('app/visiblePages', visiblePages).catch(console.error);
localStorage.setItem('snapshot/app/visiblePages', JSON.stringify(visiblePages));
}
}, [visiblePages, isLoaded]);
// 持久化页面排序
useEffect(() => {
if (isLoaded) {
storageUtil.set('app/pageOrder', pageOrder).catch(console.error);
localStorage.setItem('snapshot/app/pageOrder', JSON.stringify(pageOrder));
}
}, [pageOrder, isLoaded]);
/**
* 监听存储变化,以便在多个入口(如 Popup 和 Options)之间同步路由和设置
*/
useEffect(() => { useEffect(() => {
if (!syncRoute) return; if (!syncRoute) return;
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => { const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
// 同步路由 // 同步当前路由
if (syncRoute && changes[syncKey as string]) { if (syncRoute && changes[syncKey as string]) {
const newRoute = changes[syncKey as string].newValue as PageType; const newRoute = changes[syncKey as string].newValue as PageType;
if (newRoute && newRoute !== currentPage) { if (newRoute && newRoute !== currentPage && isValidPage(newRoute)) {
setCurrentPage(newRoute); setCurrentPage(newRoute);
} }
} }
// 同步可见页面列表 // 同步可见页面列表
if (changes['app/visiblePages']) { if (changes['app/visiblePages']) {
setVisiblePages(changes['app/visiblePages'].newValue as PageType[]); const newPages = changes['app/visiblePages'].newValue;
if (isValidPageList(newPages)) {
setVisiblePages(newPages);
}
} }
// 同步页面排序 // 同步页面排序
if (changes['app/pageOrder']) { if (changes['app/pageOrder']) {
setPageOrder(changes['app/pageOrder'].newValue as PageType[]); const newOrder = changes['app/pageOrder'].newValue;
if (isValidPageList(newOrder)) {
setPageOrder(newOrder);
}
} }
}; };
@@ -72,42 +204,30 @@ export function RouterProvider({
return () => chrome.storage.onChanged.removeListener(handleStorageChange); return () => chrome.storage.onChanged.removeListener(handleStorageChange);
}, [syncRoute, currentPage, syncKey]); }, [syncRoute, currentPage, syncKey]);
const loadInitialData = async () => { /**
try { * 跳转到指定页面
const [savedRoute, savedVisiblePages, savedPageOrder] = await Promise.all([ */
storageUtil.get(syncKey, defaultRoute),
storageUtil.get('app/visiblePages', getDefaultVisibleRoutes()),
storageUtil.get('app/pageOrder', getDefaultPageOrder()),
]);
if (savedRoute && syncRoute) {
setCurrentPage(savedRoute as PageType);
}
if (savedVisiblePages) {
setVisiblePages(savedVisiblePages);
}
if (savedPageOrder && savedPageOrder.length > 0) {
setPageOrder(savedPageOrder);
}
} catch (error) {
console.error('Failed to load initial routing data:', error);
} finally {
setIsLoaded(true);
}
};
const navigateTo = (page: PageType) => { const navigateTo = (page: PageType) => {
setCurrentPage(page); setCurrentPage(page);
}; };
/**
* 仅在本地跳转,不触发自动同步(通常由 handleStorageChange 内部调用)
*/
const navigateLocal = (page: PageType) => { const navigateLocal = (page: PageType) => {
setCurrentPage(page); setCurrentPage(page);
}; };
/**
* 手动同步导航状态到存储
*/
const syncNavigation = (page: PageType) => { const syncNavigation = (page: PageType) => {
storageUtil.set(syncKey, page as any); storageUtil.set(syncKey, page as StorageSchema[typeof syncKey]).catch(console.error);
}; };
/**
* 返回主仪表盘
*/
const goBack = () => { const goBack = () => {
setCurrentPage('dashboard'); setCurrentPage('dashboard');
}; };
@@ -132,6 +252,10 @@ export function RouterProvider({
); );
} }
/**
* 自定义 Hook:获取路由上下文
* @throws {Error} 如果在 RouterProvider 之外使用则抛出异常
*/
export function useRouter() { export function useRouter() {
const context = useContext(RouterContext); const context = useContext(RouterContext);
if (!context) { if (!context) {
+86 -9
View File
@@ -1,92 +1,169 @@
/**
* 应用页面类型定义
*/
export type PageType = export type PageType =
| 'dashboard' | 'dashboard' // 仪表盘/首页
| 'timestamp' | 'timestamp' // 时间戳转换工具
| 'storageCleaner' | 'storageCleaner' // 存储清理工具
| 'openUrl' | 'openUrl' // 快捷链接工具
| 'qrCode' | 'qrCode' // 二维码工具
| 'formRecognizer' | 'formRecognizer' // 表单识别工具
| 'formMapping' | 'formMapping' // 表单映射配置
| 'formFill' | 'formFill' // 表单填充工具
| 'openUrlViewer'; | 'openUrlViewer'; // 快捷链接查看页面
/**
* 表单映射条目定义
*/
export interface FormMapEntry { export interface FormMapEntry {
/** 条目唯一 ID */
id: string; id: string;
/** 在 UI 中显示的名称 */
label_display: string; label_display: string;
/** 字段特征,用于在页面中定位字段 */
fingerprint: { fingerprint: {
/** CSS 选择器 */
selector: string; selector: string;
/** name 属性 */
name_attr: string; name_attr: string;
/** 占位符文本 */
placeholder: string; placeholder: string;
}; };
/** 填充逻辑配置 */
action_logic: { action_logic: {
/** 字段类型 */
type: 'text' | 'select' | 'checkbox'; type: 'text' | 'select' | 'checkbox';
/** 填充策略:固定值、随机值或序列值 */
strategy: 'fixed' | 'random' | 'sequence'; strategy: 'fixed' | 'random' | 'sequence';
/** 填充的具体值或配置 */
value: string; value: string;
}; };
/** UI 状态 */
ui_state: { ui_state: {
/** 是否被选中 */
is_selected: boolean; is_selected: boolean;
}; };
} }
/**
* Chrome Storage 存储模式定义
* 定义了所有持久化在客户端的数据结构
*/
export interface StorageSchema { export interface StorageSchema {
/** 全局当前路由 */
'app/currentRoute': PageType; 'app/currentRoute': PageType;
/** Popup 窗口的当前路由 */
'app/popupRoute': PageType; 'app/popupRoute': PageType;
/** 侧边栏的当前路由 */
'app/sidepanelRoute': PageType; 'app/sidepanelRoute': PageType;
/** 在菜单中可见的页面列表 */
'app/visiblePages': PageType[]; 'app/visiblePages': PageType[];
/** 菜单页面的显示顺序 */
'app/pageOrder': PageType[]; 'app/pageOrder': PageType[];
/** 上一次访问的路由路径(备用) */
'app/lastRoute': string; 'app/lastRoute': string;
/** 应用主题配置 */
'app/theme': string; 'app/theme': string;
/** 表单映射工具是否正处于“元素拾取”模式 */
'app/formMapping/isPicking': boolean; 'app/formMapping/isPicking': boolean;
/** 当前激活的表单映射条目列表 */
active_form_map: FormMapEntry[]; active_form_map: FormMapEntry[];
/** 存储清理工具的偏好设置 */
'storageCleaner/preferences': StorageCleanerPreferences; 'storageCleaner/preferences': StorageCleanerPreferences;
/** 快捷链接工具的偏好设置 */
'openUrl/preferences': OpenUrlPreferences; 'openUrl/preferences': OpenUrlPreferences;
/** 快捷链接工具当前操作的 URL */
'openUrl/currentUrl': string; 'openUrl/currentUrl': string;
/** 二维码工具中二维码部分是否展开 */
'qrCode/qrExpanded': boolean; 'qrCode/qrExpanded': boolean;
/** 二维码工具中 URL 部分是否展开 */
'qrCode/urlExpanded': boolean; 'qrCode/urlExpanded': boolean;
/** 表单识别工具的字段类型偏好(按域名存储) */
'formRecognizer/fieldTypePreferences': FieldTypePreferences; 'formRecognizer/fieldTypePreferences': FieldTypePreferences;
} }
/**
* 字段类型偏好定义
* 结构:{ [域名]: { [字段标识符]: 类型名称 } }
*/
export interface FieldTypePreferences { export interface FieldTypePreferences {
[domain: string]: { [domain: string]: {
[fieldIdentifier: string]: string; [fieldIdentifier: string]: string;
}; };
} }
/**
* 存储清理工具偏好设置
*/
export interface StorageCleanerPreferences { export interface StorageCleanerPreferences {
/** 是否在清理后自动刷新页面 */
autoRefresh: boolean; autoRefresh: boolean;
/** 默认勾选的清理类型 */
selectedTypes: StorageCleanerOptions; selectedTypes: StorageCleanerOptions;
} }
/**
* 快捷链接条目定义
*/
export interface OpenUrlEntry { export interface OpenUrlEntry {
/** 链接名称 */
name: string; name: string;
/** 链接地址 */
url: string; url: string;
} }
/**
* 快捷链接工具偏好设置
*/
export interface OpenUrlPreferences { export interface OpenUrlPreferences {
/** 链接列表 */
entries: OpenUrlEntry[]; entries: OpenUrlEntry[];
} }
/**
* 存储清理选项配置
*/
export interface StorageCleanerOptions { export interface StorageCleanerOptions {
/** Local Storage */
localStorage: boolean; localStorage: boolean;
/** Session Storage */
sessionStorage: boolean; sessionStorage: boolean;
/** IndexedDB */
indexedDB: boolean; indexedDB: boolean;
/** Cookies */
cookies: boolean; cookies: boolean;
/** Cache Storage */
cacheStorage: boolean; cacheStorage: boolean;
/** Service Workers */
serviceWorkers: boolean; serviceWorkers: boolean;
} }
/**
* 单项存储清理结果
*/
export type StorageCleanResult = export type StorageCleanResult =
| { | {
/** 是否清理成功 */
success: true; success: true;
/** 清理的数量/条数 */
count: number; count: number;
} }
| { | {
/** 是否清理成功 */
success: false; success: false;
/** 错误信息 */
error: string; error: string;
}; };
/**
* 存储清理任务汇总结果
*/
export interface CleaningResult { export interface CleaningResult {
/** 整体操作是否成功 */
success: boolean; success: boolean;
/** 整体错误信息(如果有) */
error?: string; error?: string;
/** 各项清理的具体结果 */
localStorage?: StorageCleanResult; localStorage?: StorageCleanResult;
sessionStorage?: StorageCleanResult; sessionStorage?: StorageCleanResult;
indexedDB?: StorageCleanResult; indexedDB?: StorageCleanResult;
+61
View File
@@ -0,0 +1,61 @@
/**
* Chrome 标签页相关工具函数
*/
/**
* 获取当前活动的标签页
*/
export async function getActiveTab(): Promise<chrome.tabs.Tab | null> {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab || null;
} catch (error) {
console.error('获取活动标签页失败:', error);
return null;
}
}
/**
* 获取当前活动的标签页域名
*/
export async function getActiveTabDomain(): Promise<string> {
const tab = await getActiveTab();
if (tab?.url) {
try {
const url = new URL(tab.url);
return url.hostname;
} catch (e) {
console.error('解析域名失败:', e);
}
}
return '';
}
/**
* 确保内容脚本已注入
*/
export async function ensureContentScriptInjected(): Promise<boolean> {
try {
const tab = await getActiveTab();
if (!tab?.id) return false;
// 尝试发送一个简单的探测消息
try {
// 这里可以根据实际情况发送一个简单的 Ping 消息
// 目前暂时保留原有注入逻辑,由调用方决定
return true;
} catch (e) {
// 如果报错,说明没注入,执行注入
console.log('内容脚本未注入,尝试注入...');
console.error('注入内容脚本失败:', e);
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['/content-scripts/content.js'],
});
return true;
}
} catch (error) {
console.error('注入内容脚本失败:', error);
return false;
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { SnackbarOptions } from '@/components/GlobalSnackbar'; import type { SnackbarOptions } from '@/components/GlobalSnackbar';
import { useSnackbar } from '@/components/GlobalSnackbar'; import { useSnackbarState } from '@/components/GlobalSnackbar';
/** /**
* 复制文本到剪贴板 * 复制文本到剪贴板
@@ -27,7 +27,7 @@ export const copyToClipboard = async (
* @returns 包含复制函数和 snackbarProps 的对象 * @returns 包含复制函数和 snackbarProps 的对象
*/ */
export const useClipboard = () => { export const useClipboard = () => {
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 }); const { snackbarProps, showMessage } = useSnackbarState({ autoHideDuration: 1500 });
const copy = async (text: string): Promise<boolean> => { const copy = async (text: string): Promise<boolean> => {
return copyToClipboard(text, showMessage); return copyToClipboard(text, showMessage);
+125 -312
View File
@@ -207,151 +207,75 @@ export enum FillMode {
INVALID = 'invalid', INVALID = 'invalid',
} }
/**
* 关键词与字段类型映射
*/
const FIELD_TYPE_KEYWORDS: Record<
Exclude<
FieldType,
| FieldType.UNKNOWN
| FieldType.TEXT
| FieldType.TEXTarea
| FieldType.SELECT
| FieldType.RADIO
| FieldType.CHECKBOX
>,
string[]
> = {
[FieldType.EMAIL]: ['email', 'mail', '邮箱'],
[FieldType.PHONE]: ['phone', 'tel', 'mobile', '手机', '电话'],
[FieldType.NAME]: ['name', 'user', 'username', '姓名', '名字'],
[FieldType.ID_CARD]: ['id', 'card', 'identity', '身份证'],
[FieldType.PASSWORD]: ['password', 'pass', '密码'],
[FieldType.NUMBER]: ['number', 'num', '数字'],
[FieldType.DATE]: ['date', 'time', '日期', '时间'],
};
/**
* 根据文本识别字段类型
*/
function detectTypeFromText(text: string): FieldType | null {
const lowerText = text.toLowerCase();
for (const [type, keywords] of Object.entries(FIELD_TYPE_KEYWORDS)) {
if (keywords.some((kw) => lowerText.includes(kw))) {
return type as FieldType;
}
}
return null;
}
/** /**
* 识别表单字段类型 * 识别表单字段类型
*/ */
export function recognizeFieldType( export function recognizeFieldType(
element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement,
): FieldType { ): FieldType {
// 基于 type 属性识别 // 1. 基于 HTML5 type 属性识别
if (element instanceof HTMLInputElement) { if (element instanceof HTMLInputElement) {
switch (element.type) { const typeMap: Record<string, FieldType> = {
case 'email': email: FieldType.EMAIL,
return FieldType.EMAIL; tel: FieldType.PHONE,
case 'tel': number: FieldType.NUMBER,
return FieldType.PHONE; date: FieldType.DATE,
case 'number': password: FieldType.PASSWORD,
return FieldType.NUMBER; radio: FieldType.RADIO,
case 'date': checkbox: FieldType.CHECKBOX,
return FieldType.DATE; };
case 'password': if (typeMap[element.type]) return typeMap[element.type];
return FieldType.PASSWORD;
case 'radio':
return FieldType.RADIO;
case 'checkbox':
return FieldType.CHECKBOX;
case 'text':
break;
}
} }
// 基于 name 或 id 识别 // 2. 基于 name/id, placeholder, label 文本识别
const name = (element.name || element.id || '').toLowerCase(); const name = element.name || element.id || '';
if (name.includes('email') || name.includes('mail')) { const placeholder = 'placeholder' in element ? element.placeholder || '' : '';
return FieldType.EMAIL; const label = getFieldLabel(element) || '';
}
if (name.includes('phone') || name.includes('tel') || name.includes('mobile')) {
return FieldType.PHONE;
}
if (name.includes('name') || name.includes('user') || name.includes('username')) {
return FieldType.NAME;
}
if (name.includes('id') || name.includes('card') || name.includes('identity')) {
return FieldType.ID_CARD;
}
if (name.includes('password') || name.includes('pass')) {
return FieldType.PASSWORD;
}
if (name.includes('number') || name.includes('num')) {
return FieldType.NUMBER;
}
if (name.includes('date') || name.includes('time')) {
return FieldType.DATE;
}
// 基于 placeholder 识别 const detected =
if ('placeholder' in element) { detectTypeFromText(name) || detectTypeFromText(placeholder) || detectTypeFromText(label);
const placeholder = (element.placeholder || '').toLowerCase(); if (detected) return detected;
if (placeholder.includes('email') || placeholder.includes('mail')) {
return FieldType.EMAIL;
}
if (
placeholder.includes('phone') ||
placeholder.includes('tel') ||
placeholder.includes('mobile')
) {
return FieldType.PHONE;
}
if (
placeholder.includes('name') ||
placeholder.includes('user') ||
placeholder.includes('username')
) {
return FieldType.NAME;
}
if (
placeholder.includes('id') ||
placeholder.includes('card') ||
placeholder.includes('identity')
) {
return FieldType.ID_CARD;
}
if (placeholder.includes('password') || placeholder.includes('pass')) {
return FieldType.PASSWORD;
}
if (placeholder.includes('number') || placeholder.includes('num')) {
return FieldType.NUMBER;
}
if (placeholder.includes('date') || placeholder.includes('time')) {
return FieldType.DATE;
}
}
// 基于标签识别 // 3. 基于元素标签识别
const label = getFieldLabel(element); if (element instanceof HTMLTextAreaElement) return FieldType.TEXTarea;
if (label) { if (element instanceof HTMLSelectElement) return FieldType.SELECT;
const labelText = label.toLowerCase();
if (labelText.includes('邮箱') || labelText.includes('email') || labelText.includes('mail')) {
return FieldType.EMAIL;
}
if (
labelText.includes('手机') ||
labelText.includes('电话') ||
labelText.includes('tel') ||
labelText.includes('mobile')
) {
return FieldType.PHONE;
}
if (
labelText.includes('姓名') ||
labelText.includes('名字') ||
labelText.includes('name') ||
labelText.includes('user') ||
labelText.includes('username')
) {
return FieldType.NAME;
}
if (
labelText.includes('身份证') ||
labelText.includes('id') ||
labelText.includes('card') ||
labelText.includes('identity')
) {
return FieldType.ID_CARD;
}
if (labelText.includes('密码') || labelText.includes('pass')) {
return FieldType.PASSWORD;
}
if (labelText.includes('数字') || labelText.includes('number') || labelText.includes('num')) {
return FieldType.NUMBER;
}
if (
labelText.includes('日期') ||
labelText.includes('时间') ||
labelText.includes('date') ||
labelText.includes('time')
) {
return FieldType.DATE;
}
}
// 基于元素类型识别
if (element instanceof HTMLTextAreaElement) {
return FieldType.TEXTarea;
}
if (element instanceof HTMLSelectElement) {
return FieldType.SELECT;
}
return FieldType.TEXT; return FieldType.TEXT;
} }
@@ -381,6 +305,49 @@ function getFieldLabel(element: HTMLElement): string | null {
return null; return null;
} }
/**
* 批量遍历并过滤表单元素
*/
function forEachFormElement(
container: ParentNode,
callback: (element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) => void,
options: { includeHidden?: boolean } = {},
): void {
const inputs = container.querySelectorAll('input, textarea, select');
inputs.forEach((el) => {
if (
(el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el instanceof HTMLSelectElement) &&
isElementValidForFill(el)
) {
if (!options.includeHidden && el instanceof HTMLInputElement && el.type === 'hidden') {
return;
}
callback(el);
}
});
}
/**
* 清空单个字段
*/
export function clearField(
element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement,
): void {
if (
element instanceof HTMLInputElement &&
(element.type === 'checkbox' || element.type === 'radio')
) {
element.checked = false;
} else if (element instanceof HTMLSelectElement) {
element.selectedIndex = 0;
} else {
setInputValue(element, '');
}
triggerEvents(element);
}
/** /**
* 填充表单字段 * 填充表单字段
*/ */
@@ -389,74 +356,8 @@ export function fillField(
mode: FillMode, mode: FillMode,
): void { ): void {
const fieldType = recognizeFieldType(element); const fieldType = recognizeFieldType(element);
let value: string | number | boolean = ''; const value = generateValueByFieldType(fieldType, mode);
fillFieldWithInjector(element, value);
switch (fieldType) {
case FieldType.NAME:
value =
Math.random() > 0.5
? DummyDataGenerator.generateChineseName()
: DummyDataGenerator.generateEnglishName();
break;
case FieldType.EMAIL:
value =
mode === FillMode.VALID
? DummyDataGenerator.generateValidEmail()
: DummyDataGenerator.generateInvalidEmail();
break;
case FieldType.PHONE:
value = DummyDataGenerator.generatePhoneNumber();
break;
case FieldType.NUMBER:
value =
mode === FillMode.VALID
? DummyDataGenerator.generateNumber()
: DummyDataGenerator.generateNegativeNumber();
break;
case FieldType.DATE:
value = DummyDataGenerator.generateDate();
break;
case FieldType.TEXTarea:
value =
mode === FillMode.VALID
? DummyDataGenerator.generateLongText()
: DummyDataGenerator.generateBoundaryText();
break;
case FieldType.PASSWORD:
value = 'password123';
break;
case FieldType.ID_CARD:
value = DummyDataGenerator.generateIdCard();
break;
case FieldType.TEXT:
default:
value =
mode === FillMode.VALID
? DummyDataGenerator.generateShortText()
: DummyDataGenerator.generateBoundaryText();
break;
}
// 填充值
if (element instanceof HTMLInputElement) {
if (element.type === 'checkbox' || element.type === 'radio') {
element.checked = Math.random() > 0.5;
} else {
element.value = String(value);
}
} else if (element instanceof HTMLTextAreaElement) {
element.value = String(value);
} else if (element instanceof HTMLSelectElement) {
// 随机选择一个非禁用的选项
const options = Array.from(element.options).filter((option) => !option.disabled);
if (options.length > 0) {
const randomIndex = Math.floor(Math.random() * options.length);
element.selectedIndex = randomIndex;
}
}
// 触发事件,确保前端框架能够监听到变化
triggerEvents(element);
} }
/** /**
@@ -512,16 +413,12 @@ function isElementVisible(element: HTMLElement): boolean {
// 3. 检查计算样式 (兜底检查 css 隐藏手段) // 3. 检查计算样式 (兜底检查 css 隐藏手段)
const style = window.getComputedStyle(element); const style = window.getComputedStyle(element);
if ( return !(
style.display === 'none' || style.display === 'none' ||
style.visibility === 'hidden' || style.visibility === 'hidden' ||
style.opacity === '0' || style.opacity === '0' ||
style.visibility === 'collapse' style.visibility === 'collapse'
) { );
return false;
}
return true;
} }
/** /**
@@ -605,11 +502,7 @@ function isElementValidForFill(element: HTMLElement): boolean {
} }
// 5. Z轴穿透验证(最后一步,最耗时,放最后) // 5. Z轴穿透验证(最后一步,最耗时,放最后)
if (!isElementNotObscured(element)) { return isElementNotObscured(element);
return false;
}
return true;
} }
/** /**
@@ -667,7 +560,7 @@ export function scanFormFields(): ScanResult {
const name = input.name || input.id || ''; const name = input.name || input.id || '';
fields.push({ fields.push({
id: `field-${Math.random().toString(36).substr(2, 9)}`, id: `field-${Math.random().toString(36).substring(2, 9)}`,
element: input, element: input,
fieldType, fieldType,
label, label,
@@ -888,29 +781,15 @@ export function unhighlightAllFields(fields: FormFieldInfo[]): void {
* 填充所有表单字段 * 填充所有表单字段
*/ */
export function fillAllFields(mode: FillMode, includeHidden: boolean = false): void { export function fillAllFields(mode: FillMode, includeHidden: boolean = false): void {
const inputs = document.querySelectorAll('input, textarea, select'); forEachFormElement(
document,
inputs.forEach((element) => { (el) => {
if ( const fieldType = recognizeFieldType(el);
element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement ||
element instanceof HTMLSelectElement
) {
if (!isElementValidForFill(element)) {
return;
}
if (!includeHidden) {
if (element instanceof HTMLInputElement && element.type === 'hidden') {
return;
}
}
const fieldType = recognizeFieldType(element);
const value = generateValueByFieldType(fieldType, mode); const value = generateValueByFieldType(fieldType, mode);
fillFieldWithInjector(element, value); fillFieldWithInjector(el, value);
} },
}); { includeHidden },
);
} }
/** /**
@@ -921,29 +800,15 @@ export function fillFieldsInContainer(
container: HTMLElement, container: HTMLElement,
includeHidden: boolean = false, includeHidden: boolean = false,
): void { ): void {
const inputs = container.querySelectorAll('input, textarea, select'); forEachFormElement(
container,
inputs.forEach((element) => { (el) => {
if ( const fieldType = recognizeFieldType(el);
element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement ||
element instanceof HTMLSelectElement
) {
if (!isElementValidForFill(element)) {
return;
}
if (!includeHidden) {
if (element instanceof HTMLInputElement && element.type === 'hidden') {
return;
}
}
const fieldType = recognizeFieldType(element);
const value = generateValueByFieldType(fieldType, mode); const value = generateValueByFieldType(fieldType, mode);
fillFieldWithInjector(element, value); fillFieldWithInjector(el, value);
} },
}); { includeHidden },
);
} }
/** /**
@@ -962,64 +827,12 @@ export function fillFieldsInActiveModal(mode: FillMode, includeHidden: boolean =
* 清空所有表单字段 * 清空所有表单字段
*/ */
export function clearAllFields(): void { export function clearAllFields(): void {
const inputs = document.querySelectorAll('input, textarea, select'); forEachFormElement(document, (el) => clearField(el));
inputs.forEach((element) => {
if (
element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement ||
element instanceof HTMLSelectElement
) {
if (!isElementValidForFill(element)) {
return;
}
if (element instanceof HTMLInputElement) {
if (element.type === 'checkbox' || element.type === 'radio') {
element.checked = false;
} else {
setInputValue(element, '');
}
} else if (element instanceof HTMLTextAreaElement) {
setInputValue(element, '');
} else if (element instanceof HTMLSelectElement) {
element.selectedIndex = 0;
}
triggerEvents(element);
}
});
} }
/** /**
* 清空指定容器内的表单字段 * 清空指定容器内的表单字段
*/ */
export function clearFieldsInContainer(container: HTMLElement): void { export function clearFieldsInContainer(container: HTMLElement): void {
const inputs = container.querySelectorAll('input, textarea, select'); forEachFormElement(container, (el) => clearField(el));
inputs.forEach((element) => {
if (
element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement ||
element instanceof HTMLSelectElement
) {
if (!isElementValidForFill(element)) {
return;
}
if (element instanceof HTMLInputElement) {
if (element.type === 'checkbox' || element.type === 'radio') {
element.checked = false;
} else {
setInputValue(element, '');
}
} else if (element instanceof HTMLTextAreaElement) {
setInputValue(element, '');
} else if (element instanceof HTMLSelectElement) {
element.selectedIndex = 0;
}
triggerEvents(element);
}
});
} }
+40 -9
View File
@@ -7,9 +7,12 @@ export class VisualHighlighter {
private canvas: HTMLCanvasElement | null = null; private canvas: HTMLCanvasElement | null = null;
private ctx: CanvasRenderingContext2D | null = null; private ctx: CanvasRenderingContext2D | null = null;
private isVisible = false; private isVisible = false;
private currentEntries: FormMapEntry[] = [];
private animationFrameId: number | null = null;
constructor() { constructor() {
this.handleResize = this.handleResize.bind(this); this.handleResize = this.handleResize.bind(this);
this.render = this.render.bind(this);
} }
public init() { public init() {
@@ -37,29 +40,47 @@ export class VisualHighlighter {
if (!this.canvas) this.init(); if (!this.canvas) this.init();
this.isVisible = true; this.isVisible = true;
this.canvas!.style.display = 'block'; this.canvas!.style.display = 'block';
this.handleResize(); this.requestUpdate();
} }
public hide() { public hide() {
this.isVisible = false; this.isVisible = false;
if (this.canvas) this.canvas.style.display = 'none'; if (this.canvas) this.canvas.style.display = 'none';
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
} }
private handleResize() { private handleResize() {
if (!this.isVisible || !this.canvas || !this.ctx) return; if (!this.isVisible || !this.canvas || !this.ctx) return;
this.canvas.width = window.innerWidth; this.requestUpdate();
this.canvas.height = window.innerHeight;
this.draw();
} }
/** /**
* 核心渲染循环 * 核心更新请求,使用 requestAnimationFrame 节流
*/ */
public draw(entries: FormMapEntry[] = []) { private requestUpdate() {
if (!this.ctx || !this.isVisible) return; if (this.animationFrameId !== null) return;
this.ctx.clearRect(0, 0, this.canvas!.width, this.canvas!.height); this.animationFrameId = requestAnimationFrame(this.render);
}
entries.forEach((entry) => { /**
* 核心渲染逻辑
*/
private render() {
this.animationFrameId = null;
if (!this.ctx || !this.isVisible || !this.canvas) return;
// 适配分辨率
if (this.canvas.width !== window.innerWidth || this.canvas.height !== window.innerHeight) {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.currentEntries.forEach((entry) => {
const el = document.querySelector<HTMLElement>(entry.fingerprint.selector); const el = document.querySelector<HTMLElement>(entry.fingerprint.selector);
if (!el) return; if (!el) return;
@@ -103,6 +124,16 @@ export class VisualHighlighter {
}); });
} }
/**
* 公共 draw 方法,仅更新数据并触发渲染请求
*/
public draw(entries: FormMapEntry[] = []) {
this.currentEntries = entries;
if (this.isVisible) {
this.requestUpdate();
}
}
/** /**
* 开启拾取模式:拦截点击事件 * 开启拾取模式:拦截点击事件
*/ */
+2 -2
View File
@@ -205,6 +205,7 @@ export class SmartInjectionEngine {
* 注入数据到目标元素 * 注入数据到目标元素
* @param element - 目标 DOM 元素 * @param element - 目标 DOM 元素
* @param entry - 表单映射条目 * @param entry - 表单映射条目
* @param mockValue - mock数据
* @returns 注入结果 * @returns 注入结果
*/ */
public static inject(element: HTMLElement, entry: FormMapEntry, mockValue: string): InjectResult { public static inject(element: HTMLElement, entry: FormMapEntry, mockValue: string): InjectResult {
@@ -274,8 +275,7 @@ export class SmartInjectionEngine {
// 随机选择 // 随机选择
const options = Array.from(element.options).filter((opt) => !opt.disabled); const options = Array.from(element.options).filter((opt) => !opt.disabled);
if (options.length > 0) { if (options.length > 0) {
const randomIndex = Math.floor(Math.random() * options.length); element.selectedIndex = Math.floor(Math.random() * options.length);
element.selectedIndex = randomIndex;
} }
} else { } else {
// 使用固定值 // 使用固定值
+54
View File
@@ -0,0 +1,54 @@
import { SmartDetector } from './scanner';
import { highlighter } from './highlighter';
import { storageUtil } from '@/utils/chromeStorage';
import { FormMapEntry } from '@/types/storage';
/**
* 更新表单映射辅助 UI
*/
export async function updateMappingUI() {
const entries = ((await storageUtil.get('active_form_map')) as FormMapEntry[]) || [];
const isPicking = ((await storageUtil.get('app/formMapping/isPicking')) as boolean) || false;
if (entries.length > 0 || isPicking) {
highlighter.show();
highlighter.draw(entries);
if (isPicking) {
highlighter.enablePicker(async (el) => {
const fingerprint = SmartDetector.generateFingerprint(el);
const label = SmartDetector.extractSemanticLabel(el);
const newEntry: FormMapEntry = {
id: Math.random().toString(36).substring(2, 9),
label_display: label,
fingerprint,
action_logic: { type: 'text', strategy: 'fixed', value: '' },
ui_state: { is_selected: true },
};
const currentMap = ((await storageUtil.get('active_form_map')) as FormMapEntry[]) || [];
await storageUtil.set('active_form_map', [...currentMap, newEntry]);
await storageUtil.set('app/formMapping/isPicking', false);
});
} else {
highlighter.disablePicker();
}
} else {
highlighter.hide();
}
}
/**
* 初始化表单映射助手
*/
export function initFormMappingHelper() {
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && (changes['active_form_map'] || changes['app/formMapping/isPicking'])) {
updateMappingUI().catch(console.error);
}
});
// 初始加载
updateMappingUI().catch(console.error);
}
+63 -21
View File
@@ -1,9 +1,10 @@
import { FormFieldInfo, FillMode } from './dummyDataGenerator'; import { FormFieldInfo, FillMode } from './dummyDataGenerator';
import { defineExtensionMessaging } from '@webext-core/messaging';
/** /**
* 字段数据接口(用于消息传递) * 字段数据接口(用于消息传递)
*/ */
interface MessageFieldData extends Omit<FormFieldInfo, 'element'> { export interface MessageFieldData extends Omit<FormFieldInfo, 'element'> {
useInvalidData?: boolean; useInvalidData?: boolean;
} }
@@ -29,10 +30,16 @@ export enum MessageAction {
// 字段定位/闪烁 // 字段定位/闪烁
FLASH_FIELD = 'flashField', FLASH_FIELD = 'flashField',
// 智能表单注入
FORM_INJECT = 'FORM_INJECT',
// 侧边栏状态变化
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
} }
/** /**
* 消息载荷接口 * 消息载荷接口 (保留兼容性)
*/ */
export interface MessagePayload { export interface MessagePayload {
action: MessageAction | string; action: MessageAction | string;
@@ -44,11 +51,22 @@ export interface MessagePayload {
fieldId?: string; fieldId?: string;
fieldIds?: string[]; fieldIds?: string[];
data?: unknown; data?: unknown;
isOpen?: boolean;
} }
/** /**
* 消息响应接口 * 消息响应接口
*/ */
export interface FormInjectItem {
entry: import('@/types/storage').FormMapEntry;
mockValue: string;
}
export interface FormInjectResult {
id: string;
success: boolean;
}
export interface MessageResponse { export interface MessageResponse {
success: boolean; success: boolean;
message?: string; message?: string;
@@ -56,36 +74,60 @@ export interface MessageResponse {
totalCount?: number; totalCount?: number;
validCount?: number; validCount?: number;
hasModal?: boolean; hasModal?: boolean;
results?: unknown[]; results?: FormInjectResult[];
error?: string; error?: string;
} }
/** /**
* 发送消息到内容脚本 * 协议映射定义(用于类型安全的消息通信)
*/ */
export async function sendMessageToContent( export interface ProtocolMap {
action: MessageAction, // 基础消息格式,用于逐步迁移
payload?: Omit<MessagePayload, 'action'>, [MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): MessageResponse;
): Promise<MessageResponse> { [MessageAction.SCAN_FORM_FIELDS](): MessageResponse;
[MessageAction.FILL_VALID_DATA](data: { includeHidden?: boolean }): MessageResponse;
[MessageAction.FILL_INVALID_DATA](data: { includeHidden?: boolean }): MessageResponse;
[MessageAction.FILL_SELECTED_FIELDS](data: {
fields: MessageFieldData[];
mode?: FillMode;
includeHidden?: boolean;
}): MessageResponse;
[MessageAction.CLEAR_ALL_FIELDS](): MessageResponse;
[MessageAction.HIGHLIGHT_FIELD](data: { fieldId: string }): MessageResponse;
[MessageAction.UNHIGHLIGHT_FIELD](data: { fieldId: string }): MessageResponse;
[MessageAction.HIGHLIGHT_ALL_FIELDS](data: { fieldIds: string[] }): MessageResponse;
[MessageAction.UNHIGHLIGHT_ALL_FIELDS](): MessageResponse;
[MessageAction.FLASH_FIELD](data: { fieldId: string }): MessageResponse;
[MessageAction.FORM_INJECT](data: { data: FormInjectItem[] }): MessageResponse;
[MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void;
}
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
/**
* 发送消息到内容脚本 (旧版包装器,内部使用新机制)
* @deprecated 建议直接使用 sendMessage
*/
type ProtocolData<K extends keyof ProtocolMap> = Parameters<ProtocolMap[K]>[0];
type ProtocolReturn<K extends keyof ProtocolMap> = ReturnType<ProtocolMap[K]>;
export async function sendMessageToContent<K extends keyof ProtocolMap>(
action: K,
...args: ProtocolData<K> extends undefined ? [] : [data: ProtocolData<K>]
): Promise<ProtocolReturn<K>> {
try { try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) { if (!tab?.id) {
return { success: false, message: '无法获取当前标签页' }; return { success: false, message: '无法获取当前标签页' } as ProtocolReturn<K>;
} }
return new Promise((resolve) => { const data = args.length > 0 ? args[0] : undefined;
chrome.tabs.sendMessage(tab.id!, { action, ...payload }, (response) => { return await (
if (chrome.runtime.lastError) { sendMessage as (type: K, data: ProtocolData<K>, arg?: number) => Promise<ProtocolReturn<K>>
console.error('消息发送失败:', chrome.runtime.lastError); )(action, data as ProtocolData<K>, tab.id);
resolve({ success: false, message: '无法连接到页面,请确保页面已加载' });
} else {
resolve((response as MessageResponse) || { success: false, message: '未收到响应' });
}
});
});
} catch (error) { } catch (error) {
console.error('获取标签页失败:', error); console.error('发送消息失败:', error);
return { success: false, message: '无法获取当前标签页' }; return { success: false, message: '发送消息失败' } as ProtocolReturn<K>;
} }
} }
+3 -3
View File
@@ -28,8 +28,8 @@ export const useStorageState = <K extends keyof StorageSchema>(
} }
}; };
loadState(); loadState().catch(console.error);
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps -- defaultValue intentionally excluded to prevent infinite loops }, [defaultValue, key]);
// Save to storage when value changes (after initial load) // Save to storage when value changes (after initial load)
useEffect(() => { useEffect(() => {
@@ -43,7 +43,7 @@ export const useStorageState = <K extends keyof StorageSchema>(
} }
}; };
saveState(); saveState().catch(console.error);
}, [value, isInitialized, key]); }, [value, isInitialized, key]);
return [value, setValue, isInitialized] as const; return [value, setValue, isInitialized] as const;
+1 -1
View File
@@ -23,7 +23,7 @@ export const useUrlPreferences = () => {
setIsLoaded(true); setIsLoaded(true);
} }
}; };
loadPreferences(); loadPreferences().catch(console.error);
}, []); }, []);
const savePreferences = useCallback(() => { const savePreferences = useCallback(() => {
+1 -1
View File
@@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react';
import { resolve } from 'path'; import { resolve } from 'path';
export default defineConfig({ export default defineConfig({
plugins: [react()] as any, plugins: [react()] as never,
test: { test: {
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,