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:
@@ -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 组件测试
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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,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)}`,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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。
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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()} />);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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;
|
||||||
@@ -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
@@ -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: '执行操作失败' });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 : '注入失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 加载状态指示器 */}
|
{/* 加载状态指示器 */}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
Vendored
+86
-9
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 开启拾取模式:拦截点击事件
|
* 开启拾取模式:拦截点击事件
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 {
|
||||||
// 使用固定值
|
// 使用固定值
|
||||||
|
|||||||
@@ -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
@@ -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>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const useUrlPreferences = () => {
|
|||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadPreferences();
|
loadPreferences().catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const savePreferences = useCallback(() => {
|
const savePreferences = useCallback(() => {
|
||||||
|
|||||||
+1
-1
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user