diff --git a/.gitignore b/.gitignore index 5e1b2f4..1887fd6 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ stats-*.json .trae/* .workbuddy/* +dev/* diff --git a/AGENTS.md b/AGENTS.md index 7eed845..742c1fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,315 +1,122 @@ # AGENTS.md -This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. +This file provides guidance to AI agents (such as Gemini, Codex, etc.) when working with the code in this repository. ## 项目概述 -这是一个基于 WXT 框架的浏览器扩展项目,提供多种测试工具功能,包括时间戳转换、存储管理、URL 管理、二维码生成、表单识别与填充等。 +**Testing Tools** 是一个基于 WXT (Web Extension Toolkit) 框架的现代化浏览器扩展项目. 它提供了一系列实用的开发和测试工具,包括时间戳转换、存储管理、文本统计、JWT 解析及二维码工具. ## 核心命令 -### 开发相关 +### 开发与构建 -- `npm run dev` - 启动 Chrome 浏览器的开发模式 +- `npm run dev` - 启动 Chrome 浏览器的开发模式(支持 HMR) - `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式 - `npm run build` - 构建 Chrome 浏览器的生产版本 - `npm run build:firefox` - 构建 Firefox 浏览器的生产版本 -- `npm run zip` - 打包 Chrome 扩展 -- `npm run zip:firefox` - 打包 Firefox 扩展 -- `npm run compile` - TypeScript 类型检查(不生成文件) -- `npm run lint` - 运行 ESLint 检查 +- `npm run zip` - 打包 Chrome 扩展为 ZIP 文件 +- `npm run zip:firefox` - 打包 Firefox 扩展为 ZIP 文件 +- `npm run compile` - 执行 TypeScript 类型检查(`tsc --noEmit`) +- `npm run lint` - 运行 ESLint 静态代码检查 -### 测试相关 +### 测试 -- `npm run test` - 运行所有测试(单次执行) -- `npm run test:watch` - 运行测试并监听文件变化 -- `npm run test:coverage` - 运行测试并生成覆盖率报告 +- `npm run test` - 运行所有单元测试(单次执行) +- `npm run test:watch` - 启动 Vitest 交互式监视模式 +- `npm run test:coverage` - 运行测试并生成代码覆盖率报告 **运行单个测试文件:** ```bash -npx vitest run components/__tests__/CopyButton.test.tsx +npx vitest run path/to/your.test.ts ``` -**测试技术栈:** +### 依赖管理 -- Vitest v2 - 测试框架 -- @testing-library/react v16 - React 组件测试 -- @testing-library/user-event v14 - 用户交互模拟 -- jsdom v25 - 浏览器环境模拟 +- `npm install` - 安装项目依赖 +- `postinstall` 钩子会自动运行 `wxt prepare` 以生成必要的类型定义和入口点. +- `prepare` 钩子会自动初始化 Husky 以进行 Git 提交前检查. -### 依赖与准备 - -- `npm install` - 安装依赖 -- `postinstall` 会自动运行 `wxt prepare` 准备开发环境 -- `prepare` 钩子会初始化 Husky Git 钩子 - -## 项目架构 +## 项目架构与目录结构 ### 技术栈 -- **框架**: WXT v0.20.6 (Web Extension Toolkit) - 浏览器扩展开发框架 +- **框架**: WXT v0.20.6 (Web Extension Toolkit) - **前端**: React 19 + TypeScript 5 -- **UI 库**: Material UI (MUI) v7 + Emotion -- **状态管理**: React Hooks + 自定义 Hooks -- **路由**: 自定义路由系统(支持 popup/sidepanel/detached 三种模式) -- **测试**: Vitest + Testing Library -- **代码质量**: ESLint v9 + Prettier + Husky + lint-staged +- **UI 库**: Material UI (MUI) @7.x + Emotion +- **日期处理**: dayjs (集成 UTC 和 Timezone 插件) +- **通信**: `@webext-core/messaging` (用于 Entrypoints 间通信) +- **测试**: Vitest + Testing Library (jsdom 环境) +- **代码规范**: ESLint v9 + Prettier + Husky + lint-staged ### 目录结构 -``` -├── components/ # 可复用 UI 组件 -│ ├── __tests__/ # 组件测试文件 -│ ├── Button.tsx # 按钮组件 -│ ├── CopyButton.tsx # 复制按钮组件 -│ ├── DashboardCard.tsx # 仪表盘卡片组件 -│ ├── FieldList.tsx # 字段列表组件 -│ ├── GlobalSnackbar.tsx # 全局提示消息组件 -│ ├── PageHeader.tsx # 页面头部组件 -│ ├── QrCodeToUrlSection.tsx # 二维码解析为 URL 组件 -│ ├── QrCodeUploader.tsx # 二维码上传组件 -│ ├── RouterContainer.tsx # 路由容器组件 -│ ├── StorageCleanerConfirm.tsx # 存储清理确认组件 -│ ├── ToolCard.tsx # 工具卡片组件 -│ ├── TopBar.tsx # 顶部导航栏组件 -│ ├── UrlEntryForm.tsx # URL 录入表单组件 -│ ├── UrlEntryItem.tsx # URL 条目组件 -│ ├── UrlEntryList.tsx # URL 列表组件 -│ └── UrlToQrCodeSection.tsx # URL 转二维码组件 -├── config/ # 配置文件 -│ ├── __tests__/ # 配置测试文件 -│ ├── dashboardCards.tsx # 仪表盘卡片配置 -│ ├── pageTheme.ts # 页面主题配置 -│ ├── routes.ts # 路由配置 -│ └── theme.ts # 全局主题配置 -├── entrypoints/ # 浏览器扩展入口点 -│ ├── background.ts # 后台脚本(主进程) -│ ├── content.ts # 内容脚本(注入到页面) -│ ├── content/ -│ │ └── messageHandler.ts # 消息处理器 -│ ├── options/ # 选项页面 -│ │ ├── App.tsx # 选项应用 -│ │ ├── index.html # 选项页面 HTML -│ │ └── main.tsx # 选项页面入口 -│ ├── popup/ # 扩展弹窗界面 -│ │ ├── App.tsx # 弹窗主应用 -│ │ ├── main.tsx # 弹窗入口 -│ │ ├── index.html # 弹窗 HTML -│ │ ├── pages/ # 弹窗页面 -│ │ │ ├── components/ # 页面级组件 -│ │ │ │ ├── AutoRefreshToggle.tsx # 自动刷新开关 -│ │ │ │ ├── CleaningResult.tsx # 清理结果展示 -│ │ │ │ ├── DomainHeader.tsx # 域名头部 -│ │ │ │ ├── ErrorDisplay.tsx # 错误显示 -│ │ │ │ ├── LiveClock.tsx # 实时时钟 -│ │ │ │ ├── OptionItem.tsx # 选项条目 -│ │ │ │ ├── ResultView.tsx # 结果视图 -│ │ │ │ └── StorageOptionsGrid.tsx # 存储选项网格 -│ │ │ ├── hooks/ # 自定义 Hooks -│ │ │ │ ├── useActiveTabDomain.ts # 当前标签页域名 -│ │ │ │ ├── useFormRecognizer.ts # 表单识别 -│ │ │ │ ├── useSidePanelState.ts # 侧边栏状态 -│ │ │ │ └── useTimestampConverter.ts # 时间戳转换 -│ │ │ ├── DashboardPage.tsx # 仪表盘页面 -│ │ │ ├── FormFillPage.tsx # 表单填充页面 -│ │ │ ├── FormMappingPage.tsx # 表单映射页面 -│ │ │ ├── FormRecognizerPage.tsx # 表单识别页面 -│ │ │ ├── OpenUrlPage.tsx # 打开 URL 页面 -│ │ │ ├── OpenUrlViewerPage.tsx # URL 查看页面 -│ │ │ ├── QrCodePage.tsx # 二维码页面 -│ │ │ ├── StorageCleanerPage.tsx # 存储清理页面 -│ │ │ ├── TimestampPage.tsx # 时间戳页面 -│ │ │ └── useStorageCleaner.ts # 存储清理 Hook -│ └── sidepanel/ # 侧边栏界面 -│ ├── App.tsx # 侧边栏应用 -│ ├── index.html # 侧边栏 HTML -│ └── main.tsx # 侧边栏入口 -├── providers/ # React Providers -│ └── RouterProvider.tsx # 路由 Provider -├── utils/ # 工具函数 -│ ├── __tests__/ # 工具测试文件 -│ ├── formMapping/ # 表单映射工具 -│ │ ├── highlighter.ts # 表单高亮器 -│ │ ├── scanner.ts # 表单扫描器 -│ │ ├── smartInjector.ts # 智能注入器 -│ │ └── ui.ts # UI 工具 -│ ├── chromeStorage.ts # Chrome 存储工具 -│ ├── chromeTabs.ts # Chrome 标签页工具 -│ ├── clipboard.ts # 剪贴板工具 -│ ├── dataTemplate.ts # 数据模板 -│ ├── dataValidator.ts # 数据验证器 -│ ├── dayjs.ts # 日期处理工具 -│ ├── dummyDataGenerator.ts # 虚拟数据生成器(基于 Faker) -│ ├── messages.ts # 消息通信工具 -│ ├── qrCodeParser.ts # 二维码解析器 -│ ├── storageCleaner.ts # 存储清理工具 -│ ├── useStorageState.ts # 存储状态 Hook -│ └── useUrlPreferences.ts # URL 偏好设置 Hook -├── types/ # 类型定义 -│ └── storage.d.ts # 存储相关类型 -├── docs/ # 文档 -│ └── plans/ # 计划文档 -├── public/ # 静态资源 -│ └── icon/ # 扩展图标 -└── .github/ # GitHub 配置 - └── workflows/ # CI/CD 工作流 - ├── ci.yml # 持续集成 - └── release.yml # 发布流程 +```text +├── components/ # 原子级 UI 组件 +│ ├── __tests__/ # 组件单元测试 +│ ├── PageHeader.tsx # 标准页面头部 +│ ├── ToolCard.tsx # 仪表盘卡片基础 +│ └── ... +├── config/ # 核心配置与元数据 +│ ├── features.tsx # 功能特性定义(路由与元数据的单一事实来源) +│ ├── pageTheme.ts # 页面级主题与样式常量 +│ └── theme.ts # MUI 全局主题配置 +├── entrypoints/ # 浏览器扩展入口点 +│ ├── background.ts # 后台 Service Worker (消息中转与生命周期) +│ ├── content.ts # 注入页面的内容脚本 +│ ├── popup/ # 弹窗界面主入口 +│ ├── options/ # 选项页面主入口 +│ └── sidepanel/ # 侧边栏界面主入口 +├── pages/ # 功能模块页面组件 +│ ├── DashboardPage.tsx # 仪表盘/首页 +│ ├── JwtPage.tsx # JWT 解析工具 +│ ├── QrCodePage.tsx # 二维码工具 +│ ├── StorageCleanerPage.tsx # 存储清理工具 +│ ├── TextStatisticsPage.tsx # 文本统计工具 +│ └── TimestampPage.tsx # 时间戳转换工具 +├── providers/ # React Context Providers (Router, Theme 等) +├── utils/ # 业务逻辑与工具函数 +│ ├── chromeStorage.ts # 类型安全的 Chrome Storage 封装 +│ ├── jwt.ts # JWT 解析逻辑 +│ ├── textStatistics.ts # 文本分析逻辑 +│ └── ... +├── types/ # 全局 TypeScript 类型声明 +└── public/ # 静态资源 (图标等) ``` -### 核心功能模块 +## 核心功能说明 -#### 1. 时间戳转换工具 +### 1. 路由与功能发现 -- 位置: `entrypoints/popup/pages/TimestampPage.tsx` -- Hook: `entrypoints/popup/pages/hooks/useTimestampConverter.ts` -- 依赖: dayjs 库进行日期处理 -- 功能: 支持日期与时间戳的双向转换,支持多种格式,实时时钟显示 +项目不使用传统的 React Router,而是通过 `config/features.tsx` 中的 `FEATURES` 数组统一管理. -#### 2. 存储清理工具 +- 每个功能都有一个唯一的 `PageType` (如 `timestamp`, `jwt`). +- `RouterProvider` 负责维护当前的页面状态,并根据 `FEATURES` 配置渲染对应的组件. -- 位置: `entrypoints/popup/pages/StorageCleanerPage.tsx` -- Hook: `entrypoints/popup/pages/useStorageCleaner.ts` -- 工具: `utils/storageCleaner.ts` -- 功能: 清理缓存、Cookies、本地存储,支持按域名筛选,自动刷新功能 +### 2. 存储管理 (Chrome Storage) -#### 3. URL 管理工具 +- 统一使用 `utils/chromeStorage.ts` 及其对应的 Hook. +- 所有的存储键值必须在 `types/storage.d.ts` 的 `StorageSchema` 中定义,以确保存储的类型安全. -- 打开 URL: `entrypoints/popup/pages/OpenUrlPage.tsx` -- 查看 URL: `entrypoints/popup/pages/OpenUrlViewerPage.tsx` -- 组件: `components/UrlEntryForm.tsx`, `components/UrlEntryList.tsx` -- 功能: 批量打开多个 URL,URL 列表管理 +### 3. 消息通信 (Messaging) -#### 4. 二维码工具 +- 使用 `@webext-core/messaging` 进行 Popup, Sidepanel, Background 和 Content Script 之间的通信. +- 消息协议定义在 `utils/messages.ts` 中. -- 位置: `entrypoints/popup/pages/QrCodePage.tsx` -- 组件: `components/QrCodeUploader.tsx`, `components/QrCodeToUrlSection.tsx`, `components/UrlToQrCodeSection.tsx` -- 工具: `utils/qrCodeParser.ts` -- 依赖: qrcode, jsqr 库 -- 功能: URL 转二维码生成,二维码图片解析为 URL +### 4. 样式系统 -#### 5. 表单工具套件 +- 基于 MUI v7 的 `Box`, `Stack`, `Paper` 等组件构建. +- 页面特定的复杂样式应在 `config/pageTheme.ts` 中统一定义,以保持视觉一致性. -**表单识别 (Form Recognizer)** +## AI 代理开发准则 -- 位置: `entrypoints/popup/pages/FormRecognizerPage.tsx` -- Hook: `entrypoints/popup/pages/hooks/useFormRecognizer.ts` -- 功能: 智能识别页面表单指纹 +1. **类型安全**: 始终优先使用 TypeScript 接口和类型. 不要使用 `any`. +2. **组件化**: 新功能应拆分为 `pages/` 中的页面组件和 `components/` 中的通用组件. +3. **单元测试**: 每次修改逻辑或添加新功能后,必须在对应的 `__tests__` 目录下增加测试用例. +4. **单一事实来源**: 功能的添加、修改或删除应首先从 `config/features.tsx` 开始. +5. **跨浏览器兼容**: WXT 处理了大部分差异,但涉及原生 API (如 `chrome.cookies`) 时,请确保逻辑在 Firefox 和 Chrome 下均有效. +6. **i18n**: 目前主要使用中文 UI,但在开发时请注意提取硬编码字符串,以便未来国际化. -**表单映射 (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` 库实现 -- 内容脚本消息处理: `entrypoints/content/messageHandler.ts` -- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗/侧边栏 - -#### 9. 数据存储 - -- Chrome Storage API: `utils/chromeStorage.ts` -- 存储状态 Hook: `utils/useStorageState.ts` -- URL 偏好设置: `utils/useUrlPreferences.ts` -- 类型定义: `types/storage.d.ts` - -### 关键配置文件 - -#### wxt.config.ts - -- 配置 WXT 框架参数 -- 启用 React 模块 -- 配置浏览器扩展权限(storage, unlimitedStorage, clipboardWrite, activeTab, scripting, tabs, cookies, sidePanel) -- Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码) -- 配置侧边栏和选项页面 - -#### manifest 权限 - -```typescript -permissions: [ - 'storage', // 存储权限 - 'unlimitedStorage', // 无限制存储 - 'clipboardWrite', // 剪贴板写入 - 'activeTab', // 当前标签页 - 'scripting', // 脚本注入 - 'tabs', // 标签页管理 - 'cookies', // Cookies 管理 - 'sidePanel', // 侧边栏 -], -host_permissions:[''] // 访问所有网站 -``` - -#### CI/CD 配置 - -- `.github/workflows/ci.yml` - 持续集成工作流 -- `.github/workflows/release.yml` - 发布工作流 - -## 开发注意事项 - -### 扩展入口点 - -- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务 -- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互 -- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示 -- **侧边栏**: `entrypoints/sidepanel/main.tsx` - 浏览器侧边栏界面 -- **选项页面**: `entrypoints/options/main.tsx` - 扩展设置页面 - -### 路由系统 - -- 使用自定义路由系统,支持多种显示模式 -- 路由配置在 `config/features.tsx` -- 通过 `getEntryPointType()` 判断当前入口点类型 -- 支持页面可见性配置(`defaultVisible`) - -### 浏览器兼容性 - -- 支持 Chrome 和 Firefox 浏览器 -- 使用 WXT 框架抽象浏览器差异 -- 使用 `@types/chrome` 和 `@types/webextension-polyfill` 提供类型支持 - -### 代码质量 - -- 使用 ESLint v9 进行代码检查(基于 typescript-eslint) -- Prettier 进行代码格式化 -- Husky v9 用于 Git 钩子管理 -- Lint-staged 确保暂存文件符合规范 -- GitHub Actions CI/CD 自动化测试和构建 - -### 测试策略 - -- 组件测试: `components/__tests__/` 目录 -- 工具函数测试: `utils/__tests__/` 目录 -- 配置测试: `config/__tests__/` 目录 -- 使用 Vitest 作为测试框架 -- 使用 Testing Library 进行 React 组件测试 +所有新申请的浏览器权限必须同步更新至 `wxt.config.ts` 的 `manifest.permissions` 中. diff --git a/README.md b/README.md index 9c961cf..0841b9b 100644 --- a/README.md +++ b/README.md @@ -1,258 +1,127 @@ # Testing Tools Browser Extension -这是一个基于 WXT 框架的浏览器扩展项目,提供实用的测试工具功能。 +这是一个基于 WXT 框架的浏览器扩展项目,为开发者和测试人员提供实用的效率工具. ## 项目概述 -Testing Tools 是一个轻量级的浏览器扩展,提供多种实用的测试工具功能。项目采用现代化的技术栈,包括 React 19、TypeScript 和 Material UI,并利用 WXT 框架简化浏览器扩展的开发流程。 +**Testing Tools** 是一个轻量级、功能丰富的浏览器扩展,采用现代化的技术栈构建. 它旨在简化日常开发和测试任务,如时间戳转换、存储管理、JWT 解析等. 项目利用 [WXT (Web Extension Toolkit)](https://wxt.dev/) 框架,提供了卓越的开发体验和跨浏览器支持. ## 功能特性 -### Dashboard 首页 +### 🚀 Dashboard 首页 -- 卡片式工具展示 -- 支持自定义工具排序和可见性 -- 实时数据预览(时间戳等) +- **工具导航**: 快速访问所有可用工具. +- **个性化定制**: 支持自定义工具的排序和可见性. +- **实时预览**: 在卡片上直接查看实时数据(如当前时间戳). -### 时间戳转换工具 +### ⏰ 时间戳转换工具 -- 实时显示当前时间戳(毫秒/秒可切换) -- 日期与时间戳之间的双向转换 -- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦) -- 一键复制转换结果 -- 输入验证和错误提示 +- **实时显示**: 毫秒级精度显示当前系统时间. +- **双向转换**: 日期字符串与 Unix 时间戳(秒/毫秒)之间的无缝转换. +- **多时区支持**: 预设常用时区(亚洲/上海、美洲/纽约、欧洲/伦敦),支持快速切换. +- **快捷操作**: 一键复制转换结果,支持多种格式. -### 存储清理工具 +### 🧹 存储清理工具 -- 自动读取当前域名 -- 支持清理多种存储类型: - - localStorage - - sessionStorage - - IndexedDB - - Cookies - - Cache Storage - - Service Workers -- 可选择的清理类型(默认全选) -- 确认对话框防止误操作 -- 清理结果统计 -- 自动刷新页面选项 +- **智能识别**: 自动检测并显示当前活动标签页的域名. +- **全面清理**: 支持一键清理 localStorage、sessionStorage、IndexedDB、Cookies、Cache Storage 和 Service Workers. +- **细粒度控制**: 可根据需要选择特定的清理项. +- **自动刷新**: 提供清理后自动刷新页面的选项,确保状态同步. -### URL 工具 +### 📝 文本统计工具 -- 保存常用 URL 列表 -- 快速打开保存的 URL -- 支持 URL 验证和安全检查 -- 内置 URL 查看器(iframe 沙箱模式) +- **实时分析**: 键入即统计,无需额外操作. +- **多维指标**: 统计字符数、单词数、行数以及精确的字节大小. +- **性能优化**: 采用高性能分词算法,支持大文本处理. -### 二维码工具 +### 🔑 JWT 解析工具 -- URL 转二维码(生成器) -- 二维码转 URL(解析器) -- 支持上传二维码图片解析 -- 生成的二维码可下载 -- 一键复制转换结果 -- 卡片式布局,节省空间 +- **快速解码**: 自动解析 JSON Web Token 的 Header 和 Payload. +- **格式化显示**: 以着色和格式化的 JSON 视图展示数据,方便阅读. +- **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示. +- **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性. + +### 🖼️ 二维码工具 + +- **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载. +- **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容. ## 技术栈 -- **框架**: WXT (Web Extension Toolkit) +- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/) - **前端**: React 19 + TypeScript -- **UI 库**: Material UI -- **日期处理**: dayjs (含 UTC 和时区插件) +- **UI 组件**: Material UI (MUI) @7.x +- **样式**: Emotion (Styled Components) +- **日期处理**: dayjs (集成 UTC 和 Timezone 插件) - **通信**: @webext-core/messaging - **存储**: Chrome Storage API (类型安全封装) -- **二维码**: qrcode (生成) + jsqr (解析) +- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成) - **测试**: Vitest + Testing Library ## 项目结构 -``` -├── components/ # 可复用 UI 组件 -│ ├── Button.tsx -│ ├── CopyButton.tsx -│ ├── DashboardCard.tsx # 仪表盘卡片组件(React.memo 优化) -│ ├── GlobalSnackbar.tsx -│ ├── PageHeader.tsx # 页面标题栏组件 -│ ├── RouterContainer.tsx -│ ├── StorageCleanerConfirm.tsx -│ ├── ToolCard.tsx -│ └── TopBar.tsx -├── config/ # 配置文件 -│ ├── dashboardCards.tsx # 仪表盘卡片配置数据 -│ └── routes.ts # 页面路由定义 -├── entrypoints/ # 浏览器扩展入口点 -│ ├── popup/ # 扩展弹窗界面 -│ │ ├── App.tsx -│ │ ├── main.tsx -│ │ └── pages/ # 页面组件 -│ │ ├── DashboardPage.tsx -│ │ ├── OpenUrlPage.tsx -│ │ ├── OpenUrlViewerPage.tsx -│ │ ├── QrCodePage.tsx -│ │ ├── StorageCleanerPage.tsx -│ │ └── TimestampPage.tsx -│ ├── options/ # 选项页面 -│ ├── sidepanel/ # 侧边栏 -│ ├── background.ts # 后台脚本 -│ └── content.ts # 内容脚本 -├── providers/ # React Context providers -│ └── RouterProvider.tsx # 路由状态管理 -├── types/ # TypeScript 类型定义 -│ └── storage.d.ts -├── utils/ # 工具函数 -│ ├── chromeStorage.ts -│ ├── clipboard.ts -│ ├── dayjs.ts -│ ├── messages.tsx -│ └── storageCleaner.ts -├── public/ # 静态资源 -├── wxt.config.ts # WXT 配置文件 -├── package.json -└── README.md +```text +├── components/ # 可复用 React 组件 +├── config/ # 应用配置(路由、功能元数据、主题) +│ ├── features.tsx # 功能定义与路由映射 +│ └── pageTheme.ts # 各功能页面的视觉风格配置 +├── entrypoints/ # 扩展程序入口点 +│ ├── popup/ # 点击图标弹出的主界面 +│ ├── options/ # 扩展程序设置页面 +│ ├── sidepanel/ # 浏览器侧边栏集成 +│ ├── background.ts # 后台 Service Worker +│ └── content.ts # 网页注入脚本 +├── pages/ # 各功能模块的页面组件 +├── providers/ # 全局状态提供者 (Router, Snackbar 等) +├── types/ # TypeScript 类型声明 +├── utils/ # 工具函数与服务抽象 +├── public/ # 静态资源 (图标、 manifest 资源等) +├── wxt.config.ts # WXT 框架核心配置 +└── package.json # 项目元数据与依赖管理 ``` -## 路由系统 +## 开发与部署 -项目实现了灵活的路由系统,支持: +### 开发环境要求 -- **页面导航**: 在不同工具页面之间切换 -- **路由同步**: 通过 Chrome Storage 同步路由状态 -- **可见性控制**: 可配置显示哪些页面 -- **页面排序**: 自定义工具卡片的显示顺序 +- Node.js >= 18.x +- npm 或 pnpm -### 页面类型 (PageType) +### 常用命令 -| 页面 | 说明 | 默认可见 | -| ---------------- | ---------- | -------- | -| `dashboard` | 首页 | ✓ | -| `timestamp` | 时间戳转换 | ✓ | -| `storageCleaner` | 存储清理 | ✓ | -| `openUrl` | URL 工具 | ✓ | -| `qrCode` | 二维码工具 | ✓ | -| `openUrlViewer` | URL 查看器 | ✗ | +| 命令 | 说明 | +| ----------------------- | -------------------------------- | +| `npm run dev` | 启动 Chrome 开发模式(支持 HMR) | +| `npm run dev:firefox` | 启动 Firefox 开发模式 | +| `npm run build` | 构建 Chrome 生产版本 | +| `npm run compile` | 执行 TypeScript 类型检查 | +| `npm run lint` | 执行 ESLint 代码规范检查 | +| `npm run test` | 运行单元测试 | +| `npm run test:coverage` | 生成测试覆盖率报告 | -## 扩展入口点 +### 自动化流程 -| 入口点 | 说明 | -| -------------- | ------------------------ | -| **popup** | 点击扩展图标弹出的界面 | -| **options** | 扩展选项页面 | -| **sidepanel** | 浏览器侧边栏 | -| **background** | 后台脚本(生命周期管理) | -| **content** | 内容脚本(注入到网页) | +项目通过 GitHub Actions 实现了完善的 CI/CD 流程: -## 开发环境要求 - -- Node.js >= 18 -- npm 或 yarn - -## 安装与运行 - -### 1. 安装依赖 - -```bash -npm install -``` - -### 2. 开发模式 - -```bash -# Chrome 浏览器 -npm run dev - -# Firefox 浏览器 -npm run dev:firefox -``` - -### 3. 构建生产版本 - -```bash -# Chrome 浏览器 -npm run build - -# Firefox 浏览器 -npm run build:firefox -``` - -### 4. 打包分发 - -```bash -# Chrome 浏览器 -npm run zip - -# Firefox 浏览器 -npm run zip:firefox -``` - -### 5. 代码质量 - -```bash -npm run compile # TypeScript 类型检查 -npm run lint # ESLint 代码检查 -``` - -### 6. 测试 - -```bash -npm run test # 运行所有测试 -npm run test:watch # 运行测试并监听文件变化 -npm run test:coverage # 运行测试并生成覆盖率报告 -``` - -## 持续集成与发布 - -项目使用 GitHub Actions 实现自动化 CI/CD,无需手动操作。 - -### CI — 持续集成 - -在以下场景自动触发: - -- push 到 `main` / `develop` / `develop-*` 分支 -- 所有 PR(合并到 `main` 或 `develop`) - -自动执行:ESLint 检查 → TypeScript 类型检查 → 单元测试 → Chrome & Firefox 构建验证。 - -### 发布版本 - -只需推送符合 `v*` 格式的 Git tag,即可自动完成全量 CI 检查、打包并发布到 GitHub Release: - -```bash -git tag v1.0.0 -git push origin v1.0.0 -``` - -> 含 `-` 的 tag(如 `v1.0.0-beta.1`)会自动标记为预发布版本(prerelease)。 - -工作流文件位于 `.github/workflows/`: - -- `ci.yml` — 持续集成 -- `release.yml` — 自动发布 +- **CI**: 每次推送或 PR 都会自动执行 Lint、类型检查、测试和构建验证. +- **Release**: 推送以 `v*` 开头的 Tag 会自动打包并创建 GitHub Release. ## 权限说明 -扩展请求以下权限: +本扩展根据功能需要申请了以下权限: -- `storage` 和 `unlimitedStorage` - 本地数据存储 -- `clipboardWrite` - 剪贴板写入(复制功能) -- `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入 -- `cookies` - Cookie 访问 -- `sidePanel` - 侧边栏支持 -- `` - 访问所有网站内容(内容脚本注入) +- `storage`: 存储用户设置和工具配置. +- `activeTab` & `tabs`: 获取当前页面 URL 及其元数据. +- `scripting`: 在网页中执行清理脚本. +- `cookies`: 管理和清理网站 Cookie. +- `sidePanel`: 支持在浏览器侧边栏中运行. +- `clipboardWrite`: 提供一键复制功能. -## 主要依赖 +## 浏览器支持 -- `react`, `react-dom` - 前端框架 -- `@mui/material` - UI 组件库 -- `dayjs` - 日期处理 -- `@webext-core/messaging` - 扩展消息通信 -- `vitest` - 测试框架 -- `@testing-library/react` - React 组件测试 - -## 浏览器兼容性 - -- Chrome (推荐) +- Chrome (及其它 Chromium 内核浏览器) - Firefox ## 许可证 -此项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。 +基于 [MIT License](LICENSE) 开源. diff --git a/components/FieldList.tsx b/components/FieldList.tsx deleted file mode 100644 index 4dcdcac..0000000 --- a/components/FieldList.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import React from 'react'; -import { - Box, - Typography, - Paper, - List, - ListItem, - ListItemIcon, - Collapse, - FormControl, - InputLabel, - Select, - MenuItem, - SelectChangeEvent, - Checkbox, - Button, -} from '@mui/material'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import ExpandLessIcon from '@mui/icons-material/ExpandLess'; -import { FieldType } from '@/utils/dummyDataGenerator'; - -// 字段数据接口 -interface FieldData { - id: string; - fieldType: string; - label: string | null; - placeholder: string; - name: string; - value: string; - isSelected: boolean; - generatedValue: string; - useInvalidData?: boolean; -} - -// 字段类型显示名称映射 -const FIELD_TYPE_NAMES: Record = { - [FieldType.TEXT]: '文本', - [FieldType.EMAIL]: '邮箱', - [FieldType.PHONE]: '手机号', - [FieldType.NUMBER]: '数字', - [FieldType.DATE]: '日期', - [FieldType.TEXTarea]: '文本域', - [FieldType.RADIO]: '单选框', - [FieldType.CHECKBOX]: '复选框', - [FieldType.SELECT]: '下拉框', - [FieldType.PASSWORD]: '密码', - [FieldType.NAME]: '姓名', - [FieldType.ID_CARD]: '身份证号', - [FieldType.UNKNOWN]: '未知', -}; - -interface FieldListProps { - fields: FieldData[]; - showFields: boolean; - onToggleShowFields: () => void; - onFieldTypeChange: (fieldId: string, newType: string) => void; - onLocateField: (fieldId: string) => void; - onHoverField: (fieldId: string | null) => void; - onToggleFieldSelection: (fieldId: string) => void; - onToggleAllFields: () => void; - hoveredFieldId: string | null; -} - -const FieldList: React.FC = ({ - fields, - showFields, - onToggleShowFields, - onFieldTypeChange, - onHoverField, - onToggleFieldSelection, - onToggleAllFields, - hoveredFieldId, -}) => { - if (fields.length === 0) return null; - - const handleTypeChange = (fieldId: string, event: SelectChangeEvent) => { - onFieldTypeChange(fieldId, event.target.value); - }; - - const allSelected = fields.every((f) => f.isSelected); - const selectedCount = fields.filter((f) => f.isSelected).length; - - return ( - - - - - 已识别字段 ({fields.length}) - - 0 ? 'primary.main' : 'grey.300', - color: selectedCount > 0 ? 'white' : 'text.secondary', - px: 1, - py: 0.25, - borderRadius: 1, - }} - > - {selectedCount} 已选择 - - - - - {showFields ? : } - - - - - {fields.map((field, index) => ( - onHoverField(field.id)} - onMouseLeave={() => onHoverField(null)} - > - - { - e.stopPropagation(); - onToggleFieldSelection(field.id); - }} - /> - - - - - {field.label || field.name || field.placeholder || `字段 ${index + 1}`} - - - - - - 类型 - - - - - {field.placeholder && ( - - 占位符: {field.placeholder} - - )} - - - ))} - - - - ); -}; - -export default FieldList; diff --git a/components/GlobalSnackbar.tsx b/components/GlobalSnackbar.tsx index caf04c7..023319e 100644 --- a/components/GlobalSnackbar.tsx +++ b/components/GlobalSnackbar.tsx @@ -1,12 +1,13 @@ /** - * GlobalSnackbar - 全局 Snackbar 消息提示组件 + * GlobalSnackbar - 全局 Snackbar 消息提示组件及 Provider * - * 提供可复用的 Toast 消息提示功能,支持两种使用方式: + * 提供可复用的 Toast 消息提示功能,支持三种使用方式: * 1. 作为受控组件使用:通过 props 控制显示状态 - * 2. 通过 useSnackbarState Hook 使用:自动管理状态 + * 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态 + * 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式 * * @module GlobalSnackbar - * @version 1.0.0 + * @version 1.1.0 * * @example * ```tsx @@ -18,13 +19,23 @@ * severity="success" * /> * - * // 方式二:Hook 方式 + * // 方式二:Hook 方式 (局部状态) * const { snackbarProps, showMessage } = useSnackbarState(); * showMessage('Hello!', { severity: 'info' }); + * + * // 方式三:Context 方式 (全局状态) + * // 在根组件包裹 Provider + * + * + * + * + * // 在子组件中使用 + * const { showMessage } = useSnackbar(); + * showMessage('Global Message'); * ``` */ -import React, { JSX, useState } from 'react'; +import React, { JSX, useState, createContext, useContext, type ReactNode } from 'react'; import { Snackbar, Alert, type SxProps, type Theme, alpha, Portal } from '@mui/material'; /** @@ -37,12 +48,6 @@ import { Snackbar, Alert, type SxProps, type Theme, alpha, Portal } from '@mui/m */ export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error'; -/** - * 重新导出 SnackbarProvider 组件 - * @description 提供 Context 方式的全局 Snackbar 功能 - */ -export { SnackbarProvider } from './SnackbarProvider'; - /** * GlobalSnackbar 组件的属性接口 * @interface GlobalSnackbarProps @@ -126,23 +131,6 @@ const defaultProps: Required< * * @param {GlobalSnackbarProps} props - 组件属性 * @returns {JSX.Element} - * - * @remarks - * - 使用 Portal 组件将 Snackbar 渲染到 body 末尾,避免 z-index 问题 - * - 默认位置在屏幕底部居中 - * - 自动设置高 z-index 确保显示在其他内容之上 - * - * @example - * ```tsx - * // 受控模式 - * const [open, setOpen] = useState(false); - * setOpen(false)} - * severity="success" - * /> - * ``` */ export function GlobalSnackbar({ message, @@ -153,15 +141,6 @@ export function GlobalSnackbar({ showAlert = defaultProps.showAlert, hideIcon = defaultProps.hideIcon, }: GlobalSnackbarProps): JSX.Element { - /** - * 使用 Portal 将 Snackbar 传送到 DOM 顶层 (body 标签下) - * - * @description - * Portal 的优势: - * - 避免父容器 overflow、z-index 等样式影响 - * - 确保 Snackbar 始终显示在最顶层 - * - 避免与其他组件的样式冲突 - */ return ( `0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`, - // 图标样式:白色、稍大 '& .MuiAlert-icon': { mr: 0.5, fontSize: '1.1rem', color: '#fff' }, - // 消息文字样式:白色、适当内边距 '& .MuiAlert-message': { color: '#fff', padding: '6px 0' }, }} > @@ -218,97 +188,33 @@ export function GlobalSnackbar({ } /** - * useSnackbarState - 消息提示的 Hook 方式 + * 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 ( - * <> - * - * - * - * ); - * } - * ``` */ export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarStateResult { - // Snackbar 显示状态 const [open, setOpen] = useState(false); - // 当前显示的消息内容 const [message, setMessage] = useState(''); - // 消息配置选项 const [options, setOptions] = useState(initialOptions || {}); - /** - * 显示消息 - * - * @param {string} newMessage - 要显示的消息文本 - * @param {SnackbarOptions} [newOptions={}] - 新的配置选项 - * - * @description - * - 合并初始选项和新的调用选项 - * - 新选项会覆盖初始选项 - */ const showMessage = (newMessage: string, newOptions: SnackbarOptions = {}) => { setMessage(newMessage); setOptions({ ...initialOptions, ...newOptions }); setOpen(true); }; - /** - * 关闭消息 - * - * @description - * - 直接将 open 状态设置为 false - */ const closeMessage = () => { 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) => { if (reason === 'clickaway') return; closeMessage(); }; - /** - * 传递给 GlobalSnackbar 组件的属性 - * - * @description - * - 组合当前状态和选项为完整的组件 props - * - onClose 使用 handleClose 包装后的版本 - */ const snackbarProps: GlobalSnackbarProps = { message, open, @@ -325,8 +231,76 @@ export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarS }; } +// --- Context & Provider --- + /** - * GlobalSnackbar 组件的默认导出 - * @description 方便使用 `import GlobalSnackbar from './GlobalSnackbar'` 方式导入 + * Snackbar Context 的值类型定义 */ +interface SnackbarContextValue { + showMessage: (message: string, options?: SnackbarOptions) => void; + closeMessage: () => void; +} + +const SnackbarContext = createContext(null); + +/** + * SnackbarProvider 组件的 props 类型 + */ +interface SnackbarProviderProps { + children: ReactNode; + initialOptions?: SnackbarOptions; +} + +/** + * SnackbarProvider 组件 + * + * 全局消息提示的 Provider 组件,需要包裹在应用根组件外层。 + */ +export function SnackbarProvider({ children, initialOptions }: SnackbarProviderProps): JSX.Element { + const { snackbarProps, showMessage, closeMessage } = useSnackbarState(initialOptions); + + return ( + + {children} + + + ); +} + +/** + * useSnackbar - 在子组件中获取 Snackbar 上下文的 Hook + * + * @param {SnackbarOptions} [options] - 钩子级别的默认配置(如 autoHideDuration) + * @returns {SnackbarContextValue} - 包含 showMessage 和 closeMessage 的对象 + * @throws {Error} - 如果不在 SnackbarProvider 内部调用,抛出错误 + * + * @description + * 选项合并策略: + * 1. 调用 showMessage 时传入的 callOptions 优先级最高 + * 2. useSnackbar(options) 传入的 Hook 级别配置次之 + * 3. SnackbarProvider(initialOptions) 传入的全局配置优先级最低 + */ +export function useSnackbar(options?: SnackbarOptions): SnackbarContextValue { + const context = useContext(SnackbarContext); + if (!context) { + throw new Error('useSnackbar must be used within SnackbarProvider'); + } + + // 包装 showMessage 以支持 Hook 级别的 initialOptions + const wrappedShowMessage = (message: string, callOptions?: SnackbarOptions) => { + // 采用防御性编程,确保 options 和 callOptions 为空时也能正常工作 + // 优先级:callOptions > options + const mergedOptions: SnackbarOptions = { + ...(options || {}), + ...(callOptions || {}), + }; + context.showMessage(message, mergedOptions); + }; + + return { + ...context, + showMessage: wrappedShowMessage, + }; +} + export default GlobalSnackbar; diff --git a/components/QrCodeToUrlSection.tsx b/components/QrCodeToUrlSection.tsx index e1e2efa..7de059d 100644 --- a/components/QrCodeToUrlSection.tsx +++ b/components/QrCodeToUrlSection.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Box, Typography, diff --git a/components/QrCodeUploader.tsx b/components/QrCodeUploader.tsx index f0f3134..90c2261 100644 --- a/components/QrCodeUploader.tsx +++ b/components/QrCodeUploader.tsx @@ -31,7 +31,6 @@ const QrCodeUploader: React.FC = ({ onQrCodeDetected, supportedFormats = ['image/png', 'image/jpeg', 'image/webp'], maxFileSize = 5 * 1024 * 1024, // 5MB - timeout = 10000, // 10 seconds showPreview = true, showProgress = true, className, @@ -77,7 +76,7 @@ const QrCodeUploader: React.FC = ({ }); }, 200); - const result = await parseQrCodeFromFile(file, timeout); + const result = await parseQrCodeFromFile(file); clearInterval(progressInterval); setProgress(100); @@ -102,7 +101,7 @@ const QrCodeUploader: React.FC = ({ setTimeout(() => setProgress(0), 500); } }, - [timeout, showMessage, onQrCodeDetected], + [showMessage, onQrCodeDetected], ); // 处理文件 diff --git a/components/RouterContainer.tsx b/components/RouterContainer.tsx index 2957bcd..092ac91 100644 --- a/components/RouterContainer.tsx +++ b/components/RouterContainer.tsx @@ -1,7 +1,7 @@ -import { Box } from '@mui/material'; +import { Box, CircularProgress } from '@mui/material'; import { FEATURES, getEntryPointType } from '@/config/features'; import { useRouter } from '@/providers/RouterProvider'; -import { useMemo } from 'react'; +import { useMemo, Suspense } from 'react'; export default function RouterContainer() { const { currentPage, isLoaded } = useRouter(); @@ -15,7 +15,18 @@ export default function RouterContainer() { }, []); if (!isLoaded) { - return
Loading...
; + return ( + + + + ); } const currentFeature = FEATURES.find((f) => f.key === currentPage); @@ -34,7 +45,23 @@ export default function RouterContainer() { flexDirection: 'column', }} > - {Component && } + + + + } + > + {Component && } + ); } diff --git a/components/SnackbarProvider.tsx b/components/SnackbarProvider.tsx deleted file mode 100644 index 042b5cf..0000000 --- a/components/SnackbarProvider.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/** - * SnackbarProvider - 全局 Snackbar 消息提示 Provider - * - * 提供全局的 Toast 消息功能,支持成功、错误、警告、信息四种提示类型。 - * 通过 React Context 向下传递消息显示方法,子组件可通过 useSnackbar hook 调用。 - * - * @description - * - 基于 GlobalSnackbar 组件实现,复用其状态管理逻辑 - * - 使用 MUI Snackbar 组件实现消息提示 - * - 支持自定义自动隐藏时长 - * - 消息会显示在页面底部居中位置 - * - 使用 Portal 将 Snackbar 渲染到 body 末尾,避免 z-index 层级问题 - */ - -import { createContext, useContext, type ReactNode } from 'react'; -import GlobalSnackbar, { useSnackbarState } from './GlobalSnackbar'; -import type { SnackbarOptions } from './GlobalSnackbar'; - -/** - * Snackbar Context 的值类型定义 - * @interface SnackbarContextValue - * @property showMessage - 显示消息的方法 - * @property closeMessage - 关闭消息的方法 - */ -interface SnackbarContextValue { - showMessage: (message: string, options?: SnackbarOptions) => void; - closeMessage: () => void; -} - -/** - * React Context,用于在组件树中传递 Snackbar 操作方法 - * @description - * - 初始值为 null,表示未包裹在 Provider 中 - * - 通过 SnackbarProvider 包裹后提供实际值 - */ -const SnackbarContext = createContext(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 - * - * - * - * ``` - * - * @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 ( - - {children} - - - ); -} - -/** - * 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 ( - *
- * - *
- * ); - * } - * ``` - */ -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; diff --git a/components/ToolCard.tsx b/components/ToolCard.tsx index ed2ceab..14ccd4a 100644 --- a/components/ToolCard.tsx +++ b/components/ToolCard.tsx @@ -1,20 +1,52 @@ +/** + * ToolCard 组件 - 工具卡片 + * + * 用于在仪表盘中展示各个工具功能的卡片组件,支持图标、标题、描述、 + * AI 标识和快照内容展示,具备悬停动画效果。 + */ import { Box, Typography, Stack } from '@mui/material'; import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; // Sparkles for AI import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; import React from 'react'; +/** + * ToolCard 组件属性接口 + */ interface ToolCardProps { + /** 工具卡片标题 */ title: string; + /** 工具卡片描述文本(可选) */ description?: string; + /** 快照内容,用于在卡片底部展示额外信息(可选) */ snapshot?: React.ReactNode; + /** 主题色代码,用于图标背景和悬停效果 */ colorCode: string; + /** 工具图标元素 */ icon: React.ReactNode; + /** 卡片点击事件处理函数 */ onClick: () => void; + /** 是否显示 AI 标识(可选) */ hasAI?: boolean; + /** 卡片背景色,默认为 'background.paper' */ cardBackgroundColor?: string; } -export default function ToolCard({ title, description, snapshot, colorCode, icon, onClick, hasAI, cardBackgroundColor = 'background.paper' }: ToolCardProps) { +/** + * ToolCard 组件 + * + * @param props - ToolCardProps 属性对象 + * @returns 工具卡片 JSX 元素 + */ +export default function ToolCard({ + title, + description, + snapshot, + colorCode, + icon, + onClick, + hasAI, + cardBackgroundColor = 'background.paper', +}: ToolCardProps) { return ( - {icon} - {title} {hasAI && } {description && ( - {description} @@ -87,24 +126,24 @@ export default function ToolCard({ title, description, snapshot, colorCode, icon )} - {snapshot && ( - {snapshot} diff --git a/components/TopBar.tsx b/components/TopBar.tsx index ade99fb..3481043 100644 --- a/components/TopBar.tsx +++ b/components/TopBar.tsx @@ -1,4 +1,3 @@ -import { useMemo } from 'react'; import { Box, IconButton, Typography, Stack, Tooltip } from '@mui/material'; import SettingsIcon from '@mui/icons-material/Settings'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; @@ -8,47 +7,36 @@ import { useRouter } from '@/providers/RouterProvider'; export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) { const { currentPage, goBack } = useRouter(); - const isDetachedMode = useMemo(() => { - return new URLSearchParams(window.location.search).get('mode') === 'detached'; - }, []); - - const handleDetach = () => { - // 弹出脱离窗口 (以独立面板形式打开当前 URL,并标记 mode=detached) - const url = new URL(window.location.href); - url.searchParams.set('mode', 'detached'); - - chrome.windows.create({ - url: url.toString(), - type: 'panel', - width: 420, - height: 600 - }); + const handleOpenInTab = () => { + // 在新标签页中打开扩展页面 + chrome.tabs.create({ url: chrome.runtime.getURL('popup.html?mode=tab') }).catch(console.error); + window.close(); }; const isDashboard = currentPage === 'dashboard'; return ( - - + {!isDashboard && ( - @@ -56,27 +44,29 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) )} - Testing Tools - - {!isDetachedMode && ( - - - - - - )} + + + + + + diff --git a/components/UrlEntryForm.tsx b/components/UrlEntryForm.tsx deleted file mode 100644 index 8cb2bb4..0000000 --- a/components/UrlEntryForm.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useState } from 'react'; -import { Box, TextField, Alert, Stack } from '@mui/material'; -import AddIcon from '@mui/icons-material/Add'; -import Button from '@/components/Button'; -import type { OpenUrlEntry } from '@/types/storage'; -import type { SnackbarOptions } from '@/components/GlobalSnackbar'; -import { openUrlPageStyles } from '@/config/pageTheme'; - -interface UrlEntryFormProps { - onAddEntry: (entry: OpenUrlEntry) => void; - showMessage: (message: string, options?: SnackbarOptions) => void; -} - -const UrlEntryForm = ({ onAddEntry, showMessage }: UrlEntryFormProps) => { - const [newName, setNewName] = useState(''); - const [newUrl, setNewUrl] = useState(''); - - const showMixedContentWarning = - newUrl.startsWith('http://') && !newUrl.includes('localhost') && !newUrl.includes('127.0.0.1'); - - const isValidUrl = (url: string) => { - if (!url.trim()) return false; - try { - new URL(url); - return true; - } catch { - return false; - } - }; - - const handleAddEntry = () => { - if (!newName.trim()) { - showMessage('请输入名称', { severity: 'error' }); - return; - } - if (!isValidUrl(newUrl)) { - showMessage('请输入有效的 URL', { severity: 'error' }); - return; - } - - onAddEntry({ name: newName.trim(), url: newUrl.trim() }); - setNewName(''); - setNewUrl(''); - showMessage('添加成功', { severity: 'success' }); - }; - - return ( - - - setNewName(e.target.value)} - fullWidth - variant="outlined" - sx={openUrlPageStyles.INPUT_STYLE} - /> - setNewUrl(e.target.value)} - fullWidth - variant="outlined" - sx={openUrlPageStyles.INPUT_STYLE} - /> - - {showMixedContentWarning && ( - - 混合内容警告:当前 HTTPS 页面无法加载 HTTP 资源。 - - )} - - - - - ); -}; - -export default UrlEntryForm; diff --git a/components/UrlEntryItem.tsx b/components/UrlEntryItem.tsx deleted file mode 100644 index 5abe549..0000000 --- a/components/UrlEntryItem.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { Fragment } from 'react'; -import { Box, ListItem, Typography, Stack, Divider, Tooltip, IconButton } from '@mui/material'; -import DeleteIcon from '@mui/icons-material/Delete'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import VisibilityIcon from '@mui/icons-material/Visibility'; -import { alpha } from '@mui/material/styles'; -import { storageUtil } from '@/utils/chromeStorage'; -import type { OpenUrlEntry } from '@/types/storage'; -import type { SnackbarOptions } from '@/components/GlobalSnackbar'; -import { openUrlPageStyles } from '@/config/pageTheme'; - -interface UrlEntryItemProps { - entry: OpenUrlEntry; - index: number; - isLast: boolean; - onDelete: (index: number) => void; - showMessage: (message: string, options?: SnackbarOptions) => void; -} - -const UrlEntryItem = ({ entry, index, isLast, onDelete, showMessage }: UrlEntryItemProps) => { - const handleOpenInSidebar = async (entry: OpenUrlEntry) => { - try { - // 存储目标 URL - await storageUtil.set('openUrl/currentUrl', entry.url); - // 直接设置侧边栏的路由,而不是通过 syncNavigation 影响弹窗路由 - await storageUtil.set('app/sidepanelRoute', 'openUrlViewer'); - - const [currentTab] = await chrome.tabs.query({ - active: true, - currentWindow: true, - }); - const tabId = currentTab.id; - if (!tabId) { - showMessage('无法获取当前标签页', { severity: 'error' }); - return; - } - - await chrome.sidePanel.setOptions({ - tabId, - path: 'sidepanel.html', - enabled: true, - }); - await chrome.sidePanel.open({ windowId: currentTab.windowId }); - - // 仅当在 Popup 中时才关闭窗口,防止在侧边栏内点击预览时导致侧边栏关闭 - if (window.location.pathname.includes('popup.html')) { - window.close(); - } - } catch (error) { - console.error('Failed to open side panel:', error); - showMessage(`打开失败: ${(error as Error).message}`, { severity: 'error' }); - } - }; - - const handleOpenInNewTab = (entry: OpenUrlEntry) => { - chrome.tabs.create({ url: entry.url }).catch(console.error); - window.close(); - }; - - const handleDelete = () => { - onDelete(index); - }; - - return ( - - - - - {entry.name} - - - {entry.url} - - - - - handleOpenInSidebar(entry)} - sx={{ - color: openUrlPageStyles.themeColor, - bgcolor: alpha(openUrlPageStyles.themeColor, 0.05), - '&:hover': { bgcolor: openUrlPageStyles.themeColor, color: '#fff' }, - }} - > - - - - - handleOpenInNewTab(entry)} - sx={{ - color: 'grey.500', - bgcolor: 'grey.100', - '&:hover': { bgcolor: 'grey.600', color: '#fff' }, - }} - > - - - - - - - - - - - {!isLast && } - - ); -}; - -export default UrlEntryItem; diff --git a/components/UrlEntryList.tsx b/components/UrlEntryList.tsx deleted file mode 100644 index 6a195ea..0000000 --- a/components/UrlEntryList.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Box, List, Typography } from '@mui/material'; -import LinkIcon from '@mui/icons-material/Link'; -import UrlEntryItem from './UrlEntryItem'; -import type { OpenUrlEntry } from '@/types/storage'; -import type { SnackbarOptions } from '@/components/GlobalSnackbar'; - -interface UrlEntryListProps { - entries: OpenUrlEntry[]; - onDeleteEntry: (index: number) => void; - showMessage: (message: string, options?: SnackbarOptions) => void; -} - -const UrlEntryList = ({ entries, onDeleteEntry, showMessage }: UrlEntryListProps) => { - if (entries.length === 0) { - return ( - - - - 暂无快捷方式,请在上方添加 - - - ); - } - - return ( - - {entries.map((entry, index) => ( - - ))} - - ); -}; - -export default UrlEntryList; diff --git a/components/UrlToQrCodeSection.tsx b/components/UrlToQrCodeSection.tsx index ca6f16b..8a3a392 100644 --- a/components/UrlToQrCodeSection.tsx +++ b/components/UrlToQrCodeSection.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import React, { useState } from 'react'; import { Box, Typography, @@ -14,7 +14,7 @@ import QrCodeIcon from '@mui/icons-material/QrCode'; import DownloadIcon from '@mui/icons-material/Download'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import qrcode from 'qrcode'; +import QRious from 'qrious'; import { qrCodePageStyles } from '@/config/pageTheme'; import type { SnackbarOptions } from '@/components/GlobalSnackbar'; @@ -54,16 +54,16 @@ const UrlToQrCodeSection = ({ url = 'https://' + url; } - const dataUrl = await qrcode.toDataURL(url, { - width: 200, - margin: 2, - color: { - dark: qrCodePageStyles.black, - light: qrCodePageStyles.white, - }, + // 使用 QRious 替代 qrcode 库,体积更小 + const qr = new QRious({ + value: url, + size: 250, + level: 'H', + foreground: qrCodePageStyles.black, + background: qrCodePageStyles.white, }); - setQrCodeDataUrl(dataUrl); + setQrCodeDataUrl(qr.toDataURL()); showMessage('二维码生成成功', { severity: 'success', autoHideDuration: 1000 }); } catch (error) { console.error('生成二维码失败:', error); diff --git a/components/__tests__/GlobalSnackbar.test.tsx b/components/__tests__/GlobalSnackbar.test.tsx index 5a59d03..4176ae4 100644 --- a/components/__tests__/GlobalSnackbar.test.tsx +++ b/components/__tests__/GlobalSnackbar.test.tsx @@ -1,6 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, act, renderHook } from '@testing-library/react'; -import { GlobalSnackbar, useSnackbarState, type GlobalSnackbarProps } from '../GlobalSnackbar'; +import React from 'react'; +import { + GlobalSnackbar, + useSnackbarState, + useSnackbar, + SnackbarProvider, + type GlobalSnackbarProps, +} from '../GlobalSnackbar'; describe('GlobalSnackbar 组件系统', () => { const mockOnClose = vi.fn(); @@ -105,4 +112,51 @@ describe('GlobalSnackbar 组件系统', () => { // 源码中 handleClose 拦截了 clickaway,所以 open 不会变为 false。 }); }); + + describe('useSnackbar Context Hook 优先级', () => { + it('优先级验证: Call Options > Hook Options > Provider Options', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + // 1. 测试 Hook Options 覆盖 Provider Options + const { result: hookResult } = renderHook(() => useSnackbar({ severity: 'warning' }), { + wrapper, + }); + + act(() => { + hookResult.current.showMessage('消息 1'); + }); + + // 我们需要通过某种方式检查当前活跃的 Snackbar 属性 + // 由于 GlobalSnackbar 是在 Provider 内部渲染的,我们可以检查 DOM + expect(screen.getByText('消息 1')).toBeInTheDocument(); + const alert1 = document.querySelector('.MuiAlert-filledWarning'); + expect(alert1).toBeInTheDocument(); // Hook 配置 (warning) 覆盖了 Provider 配置 (error) + + // 2. 测试 Call Options 覆盖 Hook Options + act(() => { + hookResult.current.showMessage('消息 2', { severity: 'success' }); + }); + + expect(screen.getByText('消息 2')).toBeInTheDocument(); + const alert2 = document.querySelector('.MuiAlert-filledSuccess'); + expect(alert2).toBeInTheDocument(); // Call 配置 (success) 覆盖了 Hook 配置 (warning) + }); + + it('防御性测试: 当 options 为 undefined 时不应崩溃', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useSnackbar(), { wrapper }); + + act(() => { + expect(() => result.current.showMessage('测试')).not.toThrow(); + }); + expect(screen.getByText('测试')).toBeInTheDocument(); + }); + }); }); diff --git a/components/__tests__/RouterContainer.test.tsx b/components/__tests__/RouterContainer.test.tsx index 55a0d40..09f457e 100644 --- a/components/__tests__/RouterContainer.test.tsx +++ b/components/__tests__/RouterContainer.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import RouterContainer from '../RouterContainer'; import { RouterProvider } from '@/providers/RouterProvider'; -import { SnackbarProvider } from '@/components/SnackbarProvider'; +import { SnackbarProvider } from '@/components/GlobalSnackbar'; import type { PageType } from '@/types/storage'; import React from 'react'; @@ -41,7 +41,7 @@ describe('RouterContainer 组件', () => { it('isLoaded 为 false 时应渲染加载状态', () => { mockRouterValue.isLoaded = false; renderWithProvider(); - expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); }); it('isLoaded 为 true 时应渲染页面内容', () => { diff --git a/components/__tests__/StorageCleanerConfirm.test.tsx b/components/__tests__/StorageCleanerConfirm.test.tsx index 4217dde..e9e4782 100644 --- a/components/__tests__/StorageCleanerConfirm.test.tsx +++ b/components/__tests__/StorageCleanerConfirm.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { StorageCleanerConfirm } from '../StorageCleanerConfirm'; import type { StorageCleanerOptions } from '@/types/storage'; +import React from 'react'; describe('StorageCleanerConfirm 组件', () => { const mockOnClose = vi.fn(); diff --git a/components/__tests__/TopBar.test.tsx b/components/__tests__/TopBar.test.tsx index c5aea7f..fa379bc 100644 --- a/components/__tests__/TopBar.test.tsx +++ b/components/__tests__/TopBar.test.tsx @@ -3,6 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react'; import TopBar from '../TopBar'; import { RouterProvider } from '@/providers/RouterProvider'; import type { PageType } from '@/types/storage'; +import React from 'react'; const mockRouterValue = { currentPage: 'dashboard' as PageType, diff --git a/config/__tests__/features.test.ts b/config/__tests__/features.test.ts index 982510c..7d2562e 100644 --- a/config/__tests__/features.test.ts +++ b/config/__tests__/features.test.ts @@ -1,16 +1,16 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { FEATURES, - getFeatureByKey, - getDefaultVisibleFeatureKeys, getAllFeatureKeys, getDefaultPageOrder, + getDefaultVisibleFeatureKeys, + getFeatureByKey, } from '../features'; describe('features', () => { describe('FEATURES', () => { - it('should have 9 features defined', () => { - expect(FEATURES).toHaveLength(9); + it('should have 6 features defined', () => { + expect(FEATURES).toHaveLength(6); }); it('should have all required properties for each feature', () => { @@ -27,10 +27,10 @@ describe('features', () => { expect(typeof feature.components).toBe('object'); expect(feature.components).toHaveProperty('popup'); expect(feature.components).toHaveProperty('sidepanel'); - expect(feature.components).toHaveProperty('detached'); + expect(feature.components).toHaveProperty('tab'); // Optional UI properties for non-hidden features - if (feature.key !== 'dashboard' && feature.key !== 'openUrlViewer') { + if (feature.key !== 'dashboard') { expect(feature).toHaveProperty('icon'); expect(feature).toHaveProperty('themeColor'); expect(typeof feature.themeColor).toBe('string'); @@ -83,31 +83,25 @@ describe('features', () => { }); }); - it('should include dashboard, timestamp, storageCleaner, openUrl', () => { + it('should include dashboard, timestamp, storageCleaner, qrCode', () => { const visibleKeys = getDefaultVisibleFeatureKeys(); expect(visibleKeys).toContain('dashboard'); expect(visibleKeys).toContain('timestamp'); expect(visibleKeys).toContain('storageCleaner'); - expect(visibleKeys).toContain('openUrl'); - }); - - it('should not include openUrlViewer (not visible by default)', () => { - const visibleKeys = getDefaultVisibleFeatureKeys(); - expect(visibleKeys).not.toContain('openUrlViewer'); + expect(visibleKeys).toContain('qrCode'); }); }); describe('getAllFeatureKeys', () => { it('should return all feature keys', () => { const allKeys = getAllFeatureKeys(); - expect(allKeys).toHaveLength(9); + expect(allKeys).toHaveLength(6); expect(allKeys).toContain('dashboard'); expect(allKeys).toContain('timestamp'); expect(allKeys).toContain('storageCleaner'); - expect(allKeys).toContain('openUrl'); expect(allKeys).toContain('qrCode'); - expect(allKeys).toContain('formRecognizer'); - expect(allKeys).toContain('openUrlViewer'); + expect(allKeys).toContain('textStatistics'); + expect(allKeys).toContain('jwt'); }); }); @@ -117,23 +111,16 @@ describe('features', () => { expect(pageOrder).not.toContain('dashboard'); }); - it('should exclude openUrlViewer from page order', () => { - const pageOrder = getDefaultPageOrder(); - expect(pageOrder).not.toContain('openUrlViewer'); - }); - - it('should include timestamp, storageCleaner, openUrl, qrCode, formRecognizer in page order', () => { + it('should include timestamp, storageCleaner, qrCode in page order', () => { const pageOrder = getDefaultPageOrder(); expect(pageOrder).toContain('timestamp'); expect(pageOrder).toContain('storageCleaner'); - expect(pageOrder).toContain('openUrl'); expect(pageOrder).toContain('qrCode'); - expect(pageOrder).toContain('formRecognizer'); }); - it('should have 7 items in page order', () => { + it('should have 5 items in page order', () => { const pageOrder = getDefaultPageOrder(); - expect(pageOrder).toHaveLength(7); + expect(pageOrder).toHaveLength(5); }); }); }); diff --git a/config/features.tsx b/config/features.tsx index 98e0f8c..0977994 100644 --- a/config/features.tsx +++ b/config/features.tsx @@ -1,23 +1,21 @@ -import React, { ReactNode } from 'react'; +import React, { ReactNode, lazy } from 'react'; import type { PageType } from '@/types/storage'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import StorageIcon from '@mui/icons-material/Storage'; -import LanguageIcon from '@mui/icons-material/Language'; import QrCodeIcon from '@mui/icons-material/QrCode'; import DescriptionIcon from '@mui/icons-material/Description'; - -import DashboardPage from '@/entrypoints/popup/pages/DashboardPage'; -import TimestampPage from '@/entrypoints/popup/pages/TimestampPage'; -import StorageCleanerPage from '@/entrypoints/popup/pages/StorageCleanerPage'; -import OpenUrlPage from '@/entrypoints/popup/pages/OpenUrlPage'; -import OpenUrlViewerPage from '@/entrypoints/popup/pages/OpenUrlViewerPage'; -import QrCodePage from '@/entrypoints/popup/pages/QrCodePage'; -import FormRecognizerPage from '@/entrypoints/popup/pages/FormRecognizerPage'; -import FormMappingPage from '@/entrypoints/popup/pages/FormMappingPage'; -import FormFillPage from '@/entrypoints/popup/pages/FormFillPage'; +import VpnKeyIcon from '@mui/icons-material/VpnKey'; import { THEME_COLORS } from './pageTheme'; +// 懒加载页面组件 +const DashboardPage = lazy(() => import('@/pages/DashboardPage')); +const TimestampPage = lazy(() => import('@/pages/TimestampPage')); +const StorageCleanerPage = lazy(() => import('@/pages/StorageCleanerPage')); +const QrCodePage = lazy(() => import('@/pages/QrCodePage')); +const TextStatisticsPage = lazy(() => import('@/pages/TextStatisticsPage')); +const JwtPage = lazy(() => import('@/pages/JwtPage')); + /** * 功能配置接口 * @@ -42,8 +40,8 @@ export interface FeatureConfig { popup: React.ComponentType; /** 侧边栏模式组件 */ sidepanel: React.ComponentType; - /** 独立窗口模式组件 */ - detached: React.ComponentType; + /** 标签页模式组件 */ + tab: React.ComponentType; }; } @@ -56,7 +54,7 @@ export const FEATURES: FeatureConfig[] = [ components: { popup: DashboardPage, sidepanel: DashboardPage, - detached: DashboardPage, + tab: DashboardPage, }, }, { @@ -69,7 +67,7 @@ export const FEATURES: FeatureConfig[] = [ components: { popup: TimestampPage, sidepanel: TimestampPage, - detached: TimestampPage, + tab: TimestampPage, }, }, { @@ -82,20 +80,7 @@ export const FEATURES: FeatureConfig[] = [ components: { popup: StorageCleanerPage, sidepanel: StorageCleanerPage, - detached: StorageCleanerPage, - }, - }, - { - key: 'openUrl', - label: 'Open Url', - description: '打开当前选中的 URL', - themeColor: THEME_COLORS.purple, - icon: , - defaultVisible: true, - components: { - popup: OpenUrlPage, - sidepanel: OpenUrlPage, - detached: OpenUrlPage, + tab: StorageCleanerPage, }, }, { @@ -108,57 +93,33 @@ export const FEATURES: FeatureConfig[] = [ components: { popup: QrCodePage, sidepanel: QrCodePage, - detached: QrCodePage, + tab: QrCodePage, }, }, { - key: 'formMapping', - label: '表单映射', - description: '智能识别表单指纹,自定义填充逻辑', - themeColor: THEME_COLORS.primary, + key: 'textStatistics', + label: '文本统计', + description: '实时分析文本字符、单词及字节', + themeColor: THEME_COLORS.purple, icon: , defaultVisible: true, components: { - popup: FormMappingPage, - sidepanel: FormMappingPage, - detached: FormMappingPage, + popup: TextStatisticsPage, + sidepanel: TextStatisticsPage, + tab: TextStatisticsPage, }, }, { - key: 'formFill', - label: '智能填充', - description: '根据表单指纹填充表单数据', - themeColor: THEME_COLORS.primary, - icon: , + key: 'jwt', + label: 'JWT 解析', + description: 'JSON Web Token 解码与查看', + themeColor: THEME_COLORS.indigo, + icon: , defaultVisible: true, components: { - popup: FormFillPage, - sidepanel: FormFillPage, - detached: FormFillPage, - }, - }, - { - key: 'formRecognizer', - label: '表单识别', - description: '智能识别表单指纹', - themeColor: THEME_COLORS.primary, - icon: , - defaultVisible: true, - components: { - popup: FormRecognizerPage, - sidepanel: FormRecognizerPage, - detached: FormRecognizerPage, - }, - }, - { - key: 'openUrlViewer', - label: '查看', - description: '', - defaultVisible: false, - components: { - popup: OpenUrlViewerPage, - sidepanel: OpenUrlViewerPage, - detached: OpenUrlViewerPage, + popup: JwtPage, + sidepanel: JwtPage, + tab: JwtPage, }, }, ]; @@ -176,18 +137,16 @@ export function getAllFeatureKeys(): PageType[] { } export function getDefaultPageOrder(): PageType[] { - return FEATURES.filter((f) => f.key !== 'dashboard' && f.key !== 'openUrlViewer').map( - (f) => f.key, - ); + return FEATURES.filter((f) => f.key !== 'dashboard').map((f) => f.key); } -export function getEntryPointType(): 'popup' | 'sidepanel' | 'detached' { +export function getEntryPointType(): 'popup' | 'sidepanel' | 'tab' { const pathname = window.location.pathname; if (pathname.includes('sidepanel')) { return 'sidepanel'; } - if (new URLSearchParams(window.location.search).get('mode') === 'detached') { - return 'detached'; + if (new URLSearchParams(window.location.search).get('mode') === 'tab') { + return 'tab'; } return 'popup'; } diff --git a/config/pageTheme.ts b/config/pageTheme.ts index 056970b..aa86c54 100644 --- a/config/pageTheme.ts +++ b/config/pageTheme.ts @@ -43,6 +43,12 @@ export const THEME_COLORS = { purpleDark: '#4a148c', purpleLight: '#9c27b0', + // 靛蓝色系 + // #303f9f 在白底对比度 7.01:1 ✓ + indigo: '#303f9f', + indigoDark: '#1a237e', + indigoLight: '#7986cb', + // 中性色 white: '#FFFFFF', black: '#000000', @@ -107,57 +113,6 @@ export const timestampPageStyles = { buttonHover: `0 8px 24px ${alpha(THEME_COLORS.primary, 0.2)}`, } as const; -/** - * 打开 URL 页面样式 - */ -export const openUrlPageStyles = { - primaryColor: THEME_COLORS.purple, - primaryDark: THEME_COLORS.purpleDark, - INPUT_STYLE: { - '& .MuiOutlinedInput-root': { - bgcolor: 'background.paper', - borderRadius: 4, - transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', - '&:hover fieldset': { - borderColor: 'grey.300', - }, - '&:hover': { bgcolor: 'grey.50' }, - '&.Mui-focused fieldset': { - borderColor: THEME_COLORS.purple, - }, - '&.Mui-focused': { - bgcolor: '#fff', - }, - }, - '& .MuiInputBase-input': { - py: '14px', - px: 2, - fontSize: '0.85rem', - fontWeight: 600, - lineHeight: 1.4, - }, - '& .MuiInputLabel-root': { - fontSize: '0.85rem', - fontWeight: 700, - color: 'text.secondary', - '&.Mui-focused': { color: THEME_COLORS.purple }, - }, - }, - themeColor: THEME_COLORS.purple, - themeBg: alpha(THEME_COLORS.purple, 0.1), - buttonBg: alpha(THEME_COLORS.purple, 0.85), - buttonHover: `0 8px 24px ${alpha(THEME_COLORS.purple, 0.2)}`, - errorColor: THEME_COLORS.error, - errorBg: alpha(THEME_COLORS.error, 0.05), -} as const; - -/** - * 查看 URL 页面样式 - */ -export const openUrlViewerPageStyles = { - backgroundColor: '#ffffff', -} as const; - /** * 存储清理页面样式 */ @@ -219,3 +174,32 @@ export const formRecognizerPageStyles = { export const formMappingPageStyles = { secondaryColor: THEME_COLORS.purple, } as const; + +/** + * 文本统计页面样式 + */ +export const textStatisticsPageStyles = { + primaryColor: THEME_COLORS.purple, + cardBg: alpha(THEME_COLORS.purple, 0.04), + cardBorder: alpha(THEME_COLORS.purple, 0.1), +} as const; + +/** + * JWT 解析工具页面样式 + */ +export const jwtPageStyles = { + primaryColor: THEME_COLORS.indigo, + cardBg: alpha(THEME_COLORS.indigo, 0.04), + cardBorder: alpha(THEME_COLORS.indigo, 0.1), + INPUT_STYLE: { + '& .MuiOutlinedInput-root': { + bgcolor: 'background.paper', + borderRadius: 4, + fontSize: '0.85rem', + fontFamily: 'monospace', + transition: 'all 0.2s', + '&:hover': { bgcolor: 'grey.50' }, + '&.Mui-focused': { bgcolor: '#fff' }, + }, + }, +} as const; diff --git a/config/theme.ts b/config/theme.ts index aa097bb..f785f60 100644 --- a/config/theme.ts +++ b/config/theme.ts @@ -56,14 +56,19 @@ const theme = createTheme({ '--sb-thumb-hover': 'rgba(0, 0, 0, 0.2)', '--sb-track-color': 'transparent', }, - 'html, body, #root': { + html: { margin: 0, padding: 0, - minWidth: '400px', - minHeight: '600px', - overflow: 'hidden', + width: '100%', + minHeight: '100%', backgroundColor: '#f5f5f5', }, + 'body, #root': { + margin: 0, + padding: 0, + width: '100%', + minHeight: '100%', + }, // 针对 Popup 的特殊处理(如果需要固定宽高,可以在具体入口点或容器中处理, // 这里提供全局基础,具体尺寸在 App 容器中限制) body: { diff --git a/entrypoints/content.ts b/entrypoints/content.ts index 8156f70..c6e8255 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -1,15 +1,10 @@ import '../.wxt/types/imports.d.ts'; -import { initFormMappingHelper } from '@/utils/formMapping/ui'; import { initMessageHandler } from './content/messageHandler'; export default defineContentScript({ matches: [''], runAt: 'document_end', main() { - // 初始化表单映射助手逻辑 (UI, Picker, Highlighter) - initFormMappingHelper(); - - // 初始化消息处理器 (Scan, Fill, Clear, Highlight, Flash, Inject) initMessageHandler(); }, }); diff --git a/entrypoints/content/messageHandler.ts b/entrypoints/content/messageHandler.ts index 42398e9..7993b0c 100644 --- a/entrypoints/content/messageHandler.ts +++ b/entrypoints/content/messageHandler.ts @@ -1,160 +1 @@ -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 : '注入失败', - }; - } - }); -} +export function initMessageHandler() {} diff --git a/entrypoints/options/App.tsx b/entrypoints/options/App.tsx index 6df2b3d..791bd12 100644 --- a/entrypoints/options/App.tsx +++ b/entrypoints/options/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Box, Typography, @@ -8,11 +8,17 @@ import { CircularProgress, Stack, IconButton, + Tabs, + Tab, + alpha, + Divider, } from '@mui/material'; +import SettingsIcon from '@mui/icons-material/Settings'; import RefreshIcon from '@mui/icons-material/Refresh'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import type { PageType } from '@/types/storage'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import type { PageType, StorageSchema } from '@/types/storage'; import { storageUtil } from '@/utils/chromeStorage'; import { getFeatureByKey, @@ -21,34 +27,88 @@ import { } from '@/config/features'; import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar'; import ErrorBoundary from '@/components/ErrorBoundary'; +import PageHeader from '@/components/PageHeader'; +import { THEME_COLORS } from '@/config/pageTheme'; +type WindowType = 'popup' | 'sidepanel' | 'tab'; + +/** + * Options 设置页面主组件 + * 支持对不同窗口入口的功能显示和排序进行独立配置 + */ export default function App() { + // 从 URL 参数中初始化当前的 Tab 类型 + const initialWindowType = useMemo(() => { + if (typeof window === 'undefined') return 'popup'; + const params = new URLSearchParams(window.location.search); + const tab = params.get('tab'); + if (tab === 'popup' || tab === 'sidepanel' || tab === 'tab') { + return tab as WindowType; + } + return 'popup'; + }, []); + + const [windowType, setWindowType] = useState(initialWindowType); const [visiblePages, setVisiblePages] = useState([]); const [pageOrder, setPageOrder] = useState([]); const [isLoaded, setIsLoaded] = useState(false); const { snackbarProps, showMessage } = useSnackbarState(); - useEffect(() => { - loadConfig().catch(console.error); - }, []); - - const loadConfig = async () => { - try { - const [savedVisible, savedOrder] = await Promise.all([ - storageUtil.get('app/visiblePages', getDefaultVisibleFeatureKeys()), - storageUtil.get('app/pageOrder', getDefaultPageOrder()), - ]); - setVisiblePages(savedVisible ?? getDefaultVisibleFeatureKeys()); - setPageOrder(savedOrder && savedOrder.length > 0 ? savedOrder : getDefaultPageOrder()); - } catch (error) { - console.error('Failed to load config:', error); - setVisiblePages(getDefaultVisibleFeatureKeys()); - setPageOrder(getDefaultPageOrder()); - } finally { - setIsLoaded(true); + // 根据当前选择的窗口类型确定对应的 Storage Key + const configKeys = useMemo(() => { + switch (windowType) { + case 'sidepanel': + return { + visible: 'app/sidepanelVisiblePages' as keyof StorageSchema, + order: 'app/sidepanelPageOrder' as keyof StorageSchema, + }; + case 'tab': + return { + visible: 'app/tabVisiblePages' as keyof StorageSchema, + order: 'app/tabPageOrder' as keyof StorageSchema, + }; + case 'popup': + default: + return { + visible: 'app/popupVisiblePages' as keyof StorageSchema, + order: 'app/popupPageOrder' as keyof StorageSchema, + }; } - }; + }, [windowType]); + // 当 windowType 改变时,同步更新 URL 参数 + useEffect(() => { + const url = new URL(window.location.href); + url.searchParams.set('tab', windowType); + window.history.replaceState({}, '', url.toString()); + }, [windowType]); + + // 加载配置数据 + useEffect(() => { + const loadConfig = async () => { + setIsLoaded(false); + try { + const [savedVisible, savedOrder] = await Promise.all([ + storageUtil.get(configKeys.visible, getDefaultVisibleFeatureKeys()), + storageUtil.get(configKeys.order, getDefaultPageOrder()), + ]); + setVisiblePages((savedVisible as PageType[]) ?? getDefaultVisibleFeatureKeys()); + setPageOrder((savedOrder as PageType[]) ?? getDefaultPageOrder()); + } catch (error) { + console.error('Failed to load config:', error); + setVisiblePages(getDefaultVisibleFeatureKeys()); + setPageOrder(getDefaultPageOrder()); + } finally { + setIsLoaded(true); + } + }; + + loadConfig().catch(console.error); + }, [configKeys]); + + /** + * 切换页面可见性 + */ const handlePageToggle = async (page: PageType) => { const isCurrentlyVisible = visiblePages.includes(page); let newPages: PageType[]; @@ -64,7 +124,7 @@ export default function App() { } try { - await storageUtil.set('app/visiblePages', newPages); + await storageUtil.set(configKeys.visible, newPages); setVisiblePages(newPages); const feature = getFeatureByKey(page); showToast(`已${isCurrentlyVisible ? '隐藏' : '显示'} ${feature?.label || page}`, 'success'); @@ -74,6 +134,9 @@ export default function App() { } }; + /** + * 调整页面显示顺序 + */ const handleMove = async (index: number, direction: 'up' | 'down') => { if (direction === 'up' && index === 0) return; if (direction === 'down' && index === pageOrder.length - 1) return; @@ -83,7 +146,7 @@ export default function App() { [newOrder[index], newOrder[swapIndex]] = [newOrder[swapIndex], newOrder[index]]; try { - await storageUtil.set('app/pageOrder', newOrder); + await storageUtil.set(configKeys.order, newOrder); setPageOrder(newOrder); } catch (error) { console.error('Failed to save order:', error); @@ -91,133 +154,286 @@ export default function App() { } }; + /** + * 恢复默认设置 + */ const handleRestoreDefaults = async () => { try { - const { getDefaultVisibleFeatureKeys } = await import('@/config/features'); const defaults = getDefaultVisibleFeatureKeys(); const defaultOrder = getDefaultPageOrder(); await Promise.all([ - storageUtil.set('app/visiblePages', defaults), - storageUtil.set('app/pageOrder', defaultOrder), + storageUtil.set(configKeys.visible, defaults), + storageUtil.set(configKeys.order, defaultOrder), ]); setVisiblePages(defaults); setPageOrder(defaultOrder); - showToast('已恢复默认', 'success'); + showToast('已恢复当前模式默认设置', 'success'); } catch (error) { console.error('Failed to restore defaults:', error); showToast('恢复失败', 'warning'); } }; + const handleWindowTypeChange = (_event: React.SyntheticEvent, newType: WindowType) => { + if (newType !== null) { + setWindowType(newType); + } + }; + const showToast = (message: string, severity: 'success' | 'info' | 'warning') => { showMessage(message, { severity }); }; - if (!isLoaded) { - return ( - - - - ); - } - return ( - - - - + + + + + + - - - {pageOrder.map((key, index, array) => { - const feature = getFeatureByKey(key); - if (!feature) return null; + {/* 主内容区域 */} + + + + + - const isChecked = visiblePages.includes(key); - const isDisabled = isChecked && visiblePages.length === 1; + {!isLoaded ? ( + + + + ) : ( + + + {pageOrder.map((key, index, array) => { + const feature = getFeatureByKey(key); + if (!feature) return null; - return ( - - - - {feature.label} - - - {isChecked ? '已在 Dashboard 启用' : '已在 Dashboard 隐藏'} - - - - handleMove(index, 'up')} - disabled={index === 0} - sx={{ color: 'text.secondary' }} + const isChecked = visiblePages.includes(key); + const isDisabled = isChecked && visiblePages.length === 1; + + return ( + - - - handleMove(index, 'down')} - disabled={index === array.length - 1} - sx={{ color: 'text.secondary' }} - > - - - handlePageToggle(key)} - disabled={isDisabled} - /> - - - ); - })} - - + + {/* 拖拽/排序暗示图标 */} + + + + + {/* 功能图标容器 */} + + {feature.icon} + + + {/* 文本信息 */} + + + {feature.label} + + + {feature.description || '暂无描述'} + + + + + + {/* 移动操作按钮 */} + + handleMove(index, 'up')} + disabled={index === 0} + sx={{ + color: 'grey.400', + p: { xs: 0.5, sm: 1 }, + '&:hover': { + color: 'primary.main', + bgcolor: alpha(THEME_COLORS.primary, 0.08), + }, + }} + > + + + handleMove(index, 'down')} + disabled={index === array.length - 1} + sx={{ + color: 'grey.400', + p: { xs: 0.5, sm: 1 }, + '&:hover': { + color: 'primary.main', + bgcolor: alpha(THEME_COLORS.primary, 0.08), + }, + }} + > + + + + + + + {/* 显示切换开关 */} + handlePageToggle(key)} + disabled={isDisabled} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { + color: feature.themeColor || THEME_COLORS.primary, + }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { + backgroundColor: feature.themeColor || THEME_COLORS.primary, + }, + }} + /> + + + ); + })} + + + )} + diff --git a/entrypoints/popup/App.tsx b/entrypoints/popup/App.tsx index 2eaf49b..34256c0 100644 --- a/entrypoints/popup/App.tsx +++ b/entrypoints/popup/App.tsx @@ -5,30 +5,58 @@ import ErrorBoundary from '@/components/ErrorBoundary'; import { globalStyles } from '@/config/pageTheme'; import { SnackbarProvider } from '@/components/GlobalSnackbar'; import { Box } from '@mui/material'; +import { getEntryPointType } from '@/config/features'; +import { useMemo } from 'react'; export default function App() { // 打开Chrome扩展选项页面,需确保manifest中已配置options_page或options_ui const handleOpenOptions = () => { - chrome.runtime.openOptionsPage().catch((r) => console.error(r)); + chrome.runtime.openOptionsPage().catch(console.error); }; + const entryType = useMemo(() => getEntryPointType(), []); + + const routerConfig = useMemo(() => { + if (entryType === 'tab') { + return { + syncKey: 'app/tabRoute' as const, + visiblePagesKey: 'app/tabVisiblePages' as const, + pageOrderKey: 'app/tabPageOrder' as const, + }; + } + return { + syncKey: 'app/popupRoute' as const, + visiblePagesKey: 'app/popupVisiblePages' as const, + pageOrderKey: 'app/popupPageOrder' as const, + }; + }, [entryType]); + return ( - - + + diff --git a/entrypoints/popup/index.html b/entrypoints/popup/index.html index 42d17e8..c65b971 100644 --- a/entrypoints/popup/index.html +++ b/entrypoints/popup/index.html @@ -3,8 +3,30 @@ - 我是独立窗口 + Testing Tools - 标签页 +
diff --git a/entrypoints/popup/pages/FormFillPage.tsx b/entrypoints/popup/pages/FormFillPage.tsx deleted file mode 100644 index 2e41c3a..0000000 --- a/entrypoints/popup/pages/FormFillPage.tsx +++ /dev/null @@ -1,364 +0,0 @@ -import { - Box, - Typography, - Container, - List, - ListItem, - ListItemText, - IconButton, - Switch, - Divider, - Paper, - Chip, -} from '@mui/material'; -import PlayArrowIcon from '@mui/icons-material/PlayArrow'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import VisibilityIcon from '@mui/icons-material/Visibility'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import CancelIcon from '@mui/icons-material/Cancel'; -import { useEffect, useState, useCallback } from 'react'; -import { storageUtil } from '@/utils/chromeStorage'; -import { FormMapEntry } from '@/types/storage'; -import PageHeader from '@/components/PageHeader'; -import { formMappingPageStyles } from '@/config/pageTheme.ts'; -import { MockDataGenerator } from '@/utils/formMapping/smartInjector'; -import { Button } from '@/components/Button'; -import { FormInjectResult, MessageAction, sendMessage } from '@/utils/messages'; -import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider'; - -export default function FormFillPage() { - const [entries, setEntries] = useState([]); - const [previewData, setPreviewData] = useState>(new Map()); - const [injectResults, setInjectResults] = useState>(new Map()); - const [isInjecting, setIsInjecting] = useState(false); - const { showMessage } = useGlobalSnackbar({ autoHideDuration: 3000 }); - - const generatePreviewData = useCallback((items: FormMapEntry[]) => { - const preview = new Map(); - items.forEach((entry) => { - const value = MockDataGenerator.generate(entry.action_logic, entry); - preview.set(entry.id, value); - }); - setPreviewData(preview); - setInjectResults(new Map()); - }, []); - - useEffect(() => { - const loadEntries = async () => { - const data = (await storageUtil.get('active_form_map')) as FormMapEntry[]; - setEntries(data || []); - generatePreviewData(data || []); - }; - - loadEntries().catch((r) => console.error(r)); - const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => { - if (area === 'local' && changes['active_form_map']) { - loadEntries().catch((r) => console.error(r)); - } - }; - chrome.storage.onChanged.addListener(listener); - return () => chrome.storage.onChanged.removeListener(listener); - }, [generatePreviewData]); - - const refreshPreview = () => { - generatePreviewData(entries); - }; - - const injectAllFields = async () => { - if (entries.length === 0) { - showMessage('没有可填充的字段', { severity: 'error' }); - return; - } - - setIsInjecting(true); - const results = new Map(); - - try { - // 发送消息到 content script 执行注入 - const response = await chrome.tabs.query({ active: true, currentWindow: true }); - if (response.length === 0) { - throw new Error('无法获取当前标签页'); - } - - const tabId = response[0].id; - if (!tabId) { - throw new Error('标签页ID无效'); - } - - // 准备注入数据 - const injectData = entries.map((entry) => ({ - entry, - mockValue: previewData.get(entry.id) || '', - })); - - // 执行注入 (使用 type-safe sendMessage) - const result = await sendMessage(MessageAction.FORM_INJECT, { data: injectData }, tabId); - - if (result && result.success) { - result.results?.forEach((r: FormInjectResult) => { - results.set(r.id, r.success); - }); - setInjectResults(results); - showMessage('填充成功!', { severity: 'success' }); - } else { - throw new Error(result?.error || result?.message || '注入失败'); - } - } catch (error) { - console.error('注入失败:', error); - showMessage(error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单'); - showMessage(error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单', { - severity: 'error', - }); - } finally { - setIsInjecting(false); - } - }; - - const getFieldTypeLabel = (type: string) => { - const labels: Record = { - text: '文本', - select: '下拉框', - checkbox: '复选框', - radio: '单选框', - }; - return labels[type] || type; - }; - - const getStrategyLabel = (strategy: string) => { - const labels: Record = { - fixed: '固定值', - random: '随机', - sequence: '序列', - }; - return labels[strategy] || strategy; - }; - - return ( - - - } - /> - - {/* 操作区域 */} - - - - 填充控制 - - - - - - - - 点击"开始填充"后,将根据映射配置向网页表单注入数据。 - - - - {/* 字段列表 */} - - - 映射字段 ({entries.length}) - - - - - {entries.length === 0 ? ( - - - - ) : ( - entries.map((entry, index) => ( - - {index > 0 && } - - - - } - sx={{ py: 1.5 }} - > - - - {entry.label_display} - - - - } - secondary={ - - - {entry.fingerprint.selector} - - - - 预览: {previewData.get(entry.id) || '---'} - - {injectResults.has(entry.id) && - (injectResults.get(entry.id) ? ( - - ) : ( - - ))} - - - } - /> - - - )) - )} - - - {/* 统计信息 */} - {injectResults.size > 0 && ( - - - - - - {Array.from(injectResults.values()).filter(Boolean).length} - - - 成功注入 - - - - - - {Array.from(injectResults.values()).filter((v) => !v).length} - - - 注入失败 - - - - - - )} - - -
- ); -} diff --git a/entrypoints/popup/pages/FormMappingPage.tsx b/entrypoints/popup/pages/FormMappingPage.tsx deleted file mode 100644 index e8c4865..0000000 --- a/entrypoints/popup/pages/FormMappingPage.tsx +++ /dev/null @@ -1,285 +0,0 @@ -import { - Box, - Typography, - Container, - List, - ListItem, - ListItemText, - IconButton, - Switch, - Divider, - Paper, -} from '@mui/material'; -import DeleteIcon from '@mui/icons-material/Delete'; -import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; -import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; -import FileDownloadIcon from '@mui/icons-material/FileDownload'; -import { useEffect, useState } from 'react'; -import { storageUtil } from '@/utils/chromeStorage'; -import { FormMapEntry } from '@/types/storage'; -import PageHeader from '@/components/PageHeader'; -import Button from '@/components/Button'; -import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider'; - -export default function FormMappingPage() { - const [entries, setEntries] = useState([]); - const [isPicking, setIsPicking] = useState(false); - const { showMessage } = useGlobalSnackbar({ autoHideDuration: 3000 }); - - useEffect(() => { - const loadData = async () => { - const data = (await storageUtil.get('active_form_map')) as FormMapEntry[]; - setEntries(data || []); - const picking = (await storageUtil.get('app/formMapping/isPicking')) as boolean; - setIsPicking(picking || false); - }; - - loadData().catch((r) => console.error(r)); - - const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => { - if (area === 'local') { - if (changes['active_form_map']) { - setEntries((changes['active_form_map'].newValue as FormMapEntry[]) || []); - } - if (changes['app/formMapping/isPicking']) { - setIsPicking((changes['app/formMapping/isPicking'].newValue as boolean) || false); - } - } - }; - - chrome.storage.onChanged.addListener(listener); - return () => chrome.storage.onChanged.removeListener(listener); - }, []); - - const togglePicking = async () => { - await storageUtil.set('app/formMapping/isPicking', !isPicking); - }; - - const deleteEntry = async (id: string) => { - const newEntries = entries.filter((e) => e.id !== id); - await storageUtil.set('active_form_map', newEntries); - }; - - const toggleSelection = async (id: string) => { - const newEntries = entries.map((e) => - e.id === id ? { ...e, ui_state: { ...e.ui_state, is_selected: !e.ui_state.is_selected } } : e, - ); - await storageUtil.set('active_form_map', newEntries); - }; - - const clearAll = async () => { - await storageUtil.set('active_form_map', []); - await storageUtil.set('app/formMapping/isPicking', false); - }; - - const exportConfig = () => { - try { - if (entries.length === 0) { - showMessage('没有可导出的配置数据', { severity: 'warning' }); - return; - } - - const jsonStr = JSON.stringify(entries, null, 2); - const blob = new Blob([jsonStr], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - - const date = new Date(); - const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; - const filename = `form-mapping-config-${dateStr}.json`; - - const link = document.createElement('a'); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - - showMessage('配置导出成功!', { severity: 'success' }); - } catch (error) { - console.error('导出配置失败:', error); - showMessage(error instanceof Error ? error.message : '导出失败,请重试', { - severity: 'error', - }); - } - }; - - return ( - - - - } - /> - - - - - 状态控制 - - - - - 点击“开始拾取”后,直接在网页上点击想要映射的表单元素。 - - - - - - 已拾取字段 ({entries.length}) - - - - - - {entries.length === 0 ? ( - - - - ) : ( - entries.map((entry, index) => ( - - {index > 0 && } - deleteEntry(entry.id)} - sx={{ color: 'error.light' }} - > - - - } - sx={{ py: 1.5 }} - > - toggleSelection(entry.id)} - /> - - - - )) - )} - - - {entries.length > 0 && ( - - - - 映射配置导出 (JSON) - - - - -
{JSON.stringify(entries, null, 2)}
-
-
- )} -
-
-
-
- ); -} diff --git a/entrypoints/popup/pages/FormRecognizerPage.tsx b/entrypoints/popup/pages/FormRecognizerPage.tsx deleted file mode 100644 index 6c12ec8..0000000 --- a/entrypoints/popup/pages/FormRecognizerPage.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { Box, Container, CircularProgress, FormControlLabel, Switch } from '@mui/material'; -import Button from '@/components/Button'; -import InputIcon from '@mui/icons-material/Input'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import { formRecognizerPageStyles } from '@/config/pageTheme'; -import FieldList from '@/components/FieldList'; -import PageHeader from '@/components/PageHeader'; -import { useFormRecognizer } from './hooks/useFormRecognizer'; - -export default function FormRecognizerPage() { - const { - fillLoading, - clearLoading, - includeHidden, - setIncludeHidden, - fields, - scanning, - showFields, - setShowFields, - hoveredFieldId, - sidePanelOpen, - handleScanFields, - handleFieldTypeChange, - handleToggleFieldSelection, - handleToggleAllFields, - handleLocateField, - handleHoverField, - handleFillSelectedFields, - handleClearAllFields, - handleOpenSidePanel, - selectedCount, - } = useFormRecognizer(); - - return ( - - - {/* Header */} - } - iconColor={formRecognizerPageStyles.primaryColor} - sx={{ mb: 2.5 }} - /> - - - - {/* 扫描按钮 */} - - - setShowFields(!showFields)} - onFieldTypeChange={handleFieldTypeChange} - onLocateField={handleLocateField} - onHoverField={handleHoverField} - onToggleFieldSelection={handleToggleFieldSelection} - onToggleAllFields={handleToggleAllFields} - hoveredFieldId={hoveredFieldId} - /> - - {/* 操作按钮 */} - {fields.length > 0 && ( - - - - - - )} - - - setIncludeHidden(e.target.checked)} - /> - } - label="包含隐藏字段" - /> - - - - ); -} diff --git a/entrypoints/popup/pages/OpenUrlPage.tsx b/entrypoints/popup/pages/OpenUrlPage.tsx deleted file mode 100644 index ed9a7e1..0000000 --- a/entrypoints/popup/pages/OpenUrlPage.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Box, Typography, Container } from '@mui/material'; -import LanguageIcon from '@mui/icons-material/Language'; -import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider'; -import UrlEntryForm from '@/components/UrlEntryForm'; -import UrlEntryList from '@/components/UrlEntryList'; -import { useUrlPreferences } from '@/utils/useUrlPreferences'; -import type { OpenUrlEntry } from '@/types/storage'; -import { openUrlPageStyles } from '@/config/pageTheme'; -import PageHeader from '@/components/PageHeader'; - -export default function OpenUrlPage() { - const { entries, setEntries, isLoaded } = useUrlPreferences(); - const { showMessage } = useGlobalSnackbar(); - - const handleAddEntry = (entry: OpenUrlEntry) => { - setEntries([...entries, entry]); - }; - - const handleDeleteEntry = (index: number) => { - const newEntries = [...entries]; - newEntries.splice(index, 1); - setEntries(newEntries); - showMessage('删除成功', { severity: 'success' }); - }; - - if (!isLoaded) { - return ( - - - 加载中... - - - ); - } - - return ( - - - {/* Header */} - } - iconColor={openUrlPageStyles.primaryColor} - sx={{ mb: 2.5 }} - /> - - {/* Form Section */} - - - {/* List Section */} - - - 已保存的快捷方式 ({entries.length}) - - - - - - - ); -} diff --git a/entrypoints/popup/pages/OpenUrlViewerPage.tsx b/entrypoints/popup/pages/OpenUrlViewerPage.tsx deleted file mode 100644 index 3ce1571..0000000 --- a/entrypoints/popup/pages/OpenUrlViewerPage.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { Box, Typography, CircularProgress, Alert } from '@mui/material'; -import { storageUtil } from '@/utils/chromeStorage'; - -// 只允许 HTTP/HTTPS 协议,阻止危险协议 -const ALLOWED_PROTOCOLS = ['http:', 'https:']; - -export default function OpenUrlViewerPage() { - const [currentUrl, setCurrentUrl] = useState(''); - const [isLoaded, setIsLoaded] = useState(false); - const [iframeLoading, setIframeLoading] = useState(true); - const [error, setError] = useState(null); - - // 验证 URL 是否安全 - const validateUrl = (url: string): string | null => { - try { - const urlObj = new URL(url); - if (!ALLOWED_PROTOCOLS.includes(urlObj.protocol)) { - return `不支持的 URL 协议: ${urlObj.protocol}。仅允许 HTTP 和 HTTPS。`; - } - return null; - } catch { - return '无效的 URL 格式'; - } - }; - - // 从存储加载当前选中的 URL - const loadCurrentUrl = useCallback(async () => { - try { - const saved = await storageUtil.get('openUrl/currentUrl', ''); - if (saved) { - const validationError = validateUrl(saved); - if (validationError) { - setError(validationError); - } else { - setCurrentUrl(saved); - setError(null); - setIframeLoading(true); // 重置 iframe 加载状态 - } - } else { - setError(null); - setCurrentUrl(''); - } - } catch (error) { - console.error('Failed to load current URL:', error); - setError('加载 URL 失败'); - } finally { - setIsLoaded(true); - } - }, []); - - // 初始加载 - useEffect(() => { - loadCurrentUrl(); - }, [loadCurrentUrl]); - - // 监听存储变化,确保 URL 变更时能及时更新 - useEffect(() => { - const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => { - if (changes['openUrl/currentUrl']) { - loadCurrentUrl(); - } - }; - - chrome.storage.onChanged.addListener(handleStorageChange); - return () => chrome.storage.onChanged.removeListener(handleStorageChange); - }, [loadCurrentUrl]); - - if (!isLoaded) { - return ( - - - - ); - } - - if (error) { - return ( - - - {error} - - 请返回 OpenUrl 页面选择有效的 URL。 - - ); - } - - if (!currentUrl) { - return ( - - - 没有选中的 URL - - 请先在 OpenUrl 页面选择一个 URL 打开。 - - ); - } - - return ( - - {/* 加载状态指示器 */} - {iframeLoading && ( - - - - 加载中... - - - )} -