Enhance form recognition, optimize UI, and unify components (#19)

- **docs**: 完善组件注释、README 目录结构及 AGENTS.md 文档。
- **refactor**:
  - 提取通用 `PageHeader`、`Button`、`DashboardCard` 及 `ErrorBoundary` 组件。
  - 重构消息通信机制,采用 `@webext-core/messaging` 实现类型安全。
  - 将全局通知系统重构为 `SnackbarProvider` (后合并至 `GlobalSnackbar`)。
  - 迁移样式系统至 MUI 主题,移除冗余 CSS。
  - 优化路由配置,支持独立标签页模式及页面懒加载。
  - 移除未使用文件、URL 工具及表单映射相关功能。
- **feat**:
  - 新增配置导出功能(JSON)及状态提示。
  - 新增侧边栏状态变化通知机制。
  - 新增文本统计及 JWT 解析工具。
  - 优化二维码生成与解析逻辑,换用更轻量的 `qrious` 和 `qr-scanner`。
  - 增强高亮器功能,支持闪烁效果及 Shadow DOM 穿透。
- **style**: 优化仪表盘响应式网格布局及 UI 细节。
- **fix**: 修复 `useStorageState` 依赖缺失及路由初始化性能问题。
- **test**: 更新单元测试以覆盖新增的工具函数及功能特性。
This commit is contained in:
LingandRX
2026-05-03 17:06:32 +08:00
committed by GitHub
parent 8d36f21f1b
commit 57ea4d9858
80 changed files with 1909 additions and 15265 deletions
+1
View File
@@ -28,3 +28,4 @@ stats-*.json
.trae/* .trae/*
.workbuddy/* .workbuddy/*
dev/*
+77 -270
View File
@@ -1,315 +1,122 @@
# AGENTS.md # 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 dev:firefox` - 启动 Firefox 浏览器的开发模式
- `npm run build` - 构建 Chrome 浏览器的生产版本 - `npm run build` - 构建 Chrome 浏览器的生产版本
- `npm run build:firefox` - 构建 Firefox 浏览器的生产版本 - `npm run build:firefox` - 构建 Firefox 浏览器的生产版本
- `npm run zip` - 打包 Chrome 扩展 - `npm run zip` - 打包 Chrome 扩展为 ZIP 文件
- `npm run zip:firefox` - 打包 Firefox 扩展 - `npm run zip:firefox` - 打包 Firefox 扩展为 ZIP 文件
- `npm run compile` - TypeScript 类型检查(不生成文件 - `npm run compile` - 执行 TypeScript 类型检查(`tsc --noEmit`
- `npm run lint` - 运行 ESLint 检查 - `npm run lint` - 运行 ESLint 静态代码检查
### 测试相关 ### 测试
- `npm run test` - 运行所有测试(单次执行) - `npm run test` - 运行所有单元测试(单次执行)
- `npm run test:watch` - 运行测试并监听文件变化 - `npm run test:watch` - 启动 Vitest 交互式监视模式
- `npm run test:coverage` - 运行测试并生成覆盖率报告 - `npm run test:coverage` - 运行测试并生成代码覆盖率报告
**运行单个测试文件:** **运行单个测试文件:**
```bash ```bash
npx vitest run components/__tests__/CopyButton.test.tsx npx vitest run path/to/your.test.ts
``` ```
**测试技术栈:** ### 依赖管理
- Vitest v2 - 测试框架 - `npm install` - 安装项目依赖
- @testing-library/react v16 - React 组件测试 - `postinstall` 钩子会自动运行 `wxt prepare` 以生成必要的类型定义和入口点.
- @testing-library/user-event v14 - 用户交互模拟 - `prepare` 钩子会自动初始化 Husky 以进行 Git 提交前检查.
- jsdom v25 - 浏览器环境模拟
### 依赖与准备 ## 项目架构与目录结构
- `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 - **前端**: React 19 + TypeScript 5
- **UI 库**: Material UI (MUI) v7 + Emotion - **UI 库**: Material UI (MUI) @7.x + Emotion
- **状态管理**: React Hooks + 自定义 Hooks - **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
- **路由**: 自定义路由系统(支持 popup/sidepanel/detached 三种模式) - **通信**: `@webext-core/messaging` (用于 Entrypoints 间通信)
- **测试**: Vitest + Testing Library - **测试**: Vitest + Testing Library (jsdom 环境)
- **代码质量**: ESLint v9 + Prettier + Husky + lint-staged - **代码规范**: ESLint v9 + Prettier + Husky + lint-staged
### 目录结构 ### 目录结构
``` ```text
├── components/ # 可复用 UI 组件 ├── components/ # 原子级 UI 组件
│ ├── __tests__/ # 组件测试文件 │ ├── __tests__/ # 组件单元测试
│ ├── Button.tsx # 按钮组件 │ ├── PageHeader.tsx # 标准页面头部
│ ├── CopyButton.tsx # 复制按钮组件 │ ├── ToolCard.tsx # 仪表盘卡片基础
── DashboardCard.tsx # 仪表盘卡片组件 ── ...
│ ├── FieldList.tsx # 字段列表组件 ├── config/ # 核心配置与元数据
│ ├── GlobalSnackbar.tsx # 全局提示消息组件 │ ├── features.tsx # 功能特性定义(路由与元数据的单一事实来源)
│ ├── PageHeader.tsx # 页面头部组件 │ ├── pageTheme.ts # 页面级主题与样式常量
── QrCodeToUrlSection.tsx # 二维码解析为 URL 组件 ── theme.ts # MUI 全局主题配置
│ ├── 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 # 后台 Service Worker (消息中转与生命周期)
│ ├── content.ts # 内容脚本(注入到页面) │ ├── content.ts # 注入页面的内容脚本
│ ├── content/ │ ├── popup/ # 弹窗界面主入口
│ └── messageHandler.ts # 消息处理器 ├── options/ # 选项页面主入口
── options/ # 选项页面 ── sidepanel/ # 侧边栏界面主入口
│ │ ├── App.tsx # 选项应用 ├── pages/ # 功能模块页面组件
│ ├── index.html # 选项页面 HTML │ ├── DashboardPage.tsx # 仪表盘/首页
│ └── main.tsx # 选项页面入口 ├── JwtPage.tsx # JWT 解析工具
│ ├── popup/ # 扩展弹窗界面 │ ├── QrCodePage.tsx # 二维码工具
│ ├── App.tsx # 弹窗主应用 │ ├── StorageCleanerPage.tsx # 存储清理工具
│ ├── main.tsx # 弹窗入口 │ ├── TextStatisticsPage.tsx # 文本统计工具
│ ├── index.html # 弹窗 HTML └── TimestampPage.tsx # 时间戳转换工具
│ │ ├── pages/ # 弹窗页面 ├── providers/ # React Context Providers (Router, Theme 等)
├── components/ # 页面级组件 ├── utils/ # 业务逻辑与工具函数
│ │ │ ├── AutoRefreshToggle.tsx # 自动刷新开关 ├── chromeStorage.ts # 类型安全的 Chrome Storage 封装
│ │ │ ├── CleaningResult.tsx # 清理结果展示 ├── jwt.ts # JWT 解析逻辑
│ │ │ ├── DomainHeader.tsx # 域名头部 ├── textStatistics.ts # 文本分析逻辑
│ │ │ ├── ErrorDisplay.tsx # 错误显示 └── ...
│ │ │ │ ├── LiveClock.tsx # 实时时钟 ├── types/ # 全局 TypeScript 类型声明
│ │ ├── OptionItem.tsx # 选项条目 └── public/ # 静态资源 (图标等)
│ │ │ │ ├── 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 # 发布流程
``` ```
### 核心功能模块 ## 核心功能说明
#### 1. 时间戳转换工具 ### 1. 路由与功能发现
- 位置: `entrypoints/popup/pages/TimestampPage.tsx` 项目不使用传统的 React Router,而是通过 `config/features.tsx` 中的 `FEATURES` 数组统一管理.
- Hook: `entrypoints/popup/pages/hooks/useTimestampConverter.ts`
- 依赖: dayjs 库进行日期处理
- 功能: 支持日期与时间戳的双向转换,支持多种格式,实时时钟显示
#### 2. 存储清理工具 - 每个功能都有一个唯一的 `PageType` (如 `timestamp`, `jwt`).
- `RouterProvider` 负责维护当前的页面状态,并根据 `FEATURES` 配置渲染对应的组件.
- 位置: `entrypoints/popup/pages/StorageCleanerPage.tsx` ### 2. 存储管理 (Chrome Storage)
- Hook: `entrypoints/popup/pages/useStorageCleaner.ts`
- 工具: `utils/storageCleaner.ts`
- 功能: 清理缓存、Cookies、本地存储,支持按域名筛选,自动刷新功能
#### 3. URL 管理工具 - 统一使用 `utils/chromeStorage.ts` 及其对应的 Hook.
- 所有的存储键值必须在 `types/storage.d.ts``StorageSchema` 中定义,以确保存储的类型安全.
- 打开 URL: `entrypoints/popup/pages/OpenUrlPage.tsx` ### 3. 消息通信 (Messaging)
- 查看 URL: `entrypoints/popup/pages/OpenUrlViewerPage.tsx`
- 组件: `components/UrlEntryForm.tsx`, `components/UrlEntryList.tsx`
- 功能: 批量打开多个 URL,URL 列表管理
#### 4. 二维码工具 - 使用 `@webext-core/messaging` 进行 Popup, Sidepanel, Background 和 Content Script 之间的通信.
- 消息协议定义在 `utils/messages.ts` 中.
- 位置: `entrypoints/popup/pages/QrCodePage.tsx` ### 4. 样式系统
- 组件: `components/QrCodeUploader.tsx`, `components/QrCodeToUrlSection.tsx`, `components/UrlToQrCodeSection.tsx`
- 工具: `utils/qrCodeParser.ts`
- 依赖: qrcode, jsqr 库
- 功能: URL 转二维码生成,二维码图片解析为 URL
#### 5. 表单工具套件 - 基于 MUI v7 的 `Box`, `Stack`, `Paper` 等组件构建.
- 页面特定的复杂样式应在 `config/pageTheme.ts` 中统一定义,以保持视觉一致性.
**表单识别 (Form Recognizer)** ## AI 代理开发准则
- 位置: `entrypoints/popup/pages/FormRecognizerPage.tsx` 1. **类型安全**: 始终优先使用 TypeScript 接口和类型. 不要使用 `any`.
- Hook: `entrypoints/popup/pages/hooks/useFormRecognizer.ts` 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` 所有新申请的浏览器权限必须同步更新至 `wxt.config.ts``manifest.permissions` 中.
- 工具: `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:['<all_urls>'] // 访问所有网站
```
#### CI/CD 配置
- `.github/workflows/ci.yml` - 持续集成工作流
- `.github/workflows/release.yml` - 发布工作流
## 开发注意事项
### 扩展入口点
- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务
- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互
- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示
- **侧边栏**: `entrypoints/sidepanel/main.tsx` - 浏览器侧边栏界面
- **选项页面**: `entrypoints/options/main.tsx` - 扩展设置页面
### 路由系统
- 使用自定义路由系统,支持多种显示模式
- 路由配置在 `config/features.tsx`
- 通过 `getEntryPointType()` 判断当前入口点类型
- 支持页面可见性配置(`defaultVisible`
### 浏览器兼容性
- 支持 Chrome 和 Firefox 浏览器
- 使用 WXT 框架抽象浏览器差异
- 使用 `@types/chrome``@types/webextension-polyfill` 提供类型支持
### 代码质量
- 使用 ESLint v9 进行代码检查(基于 typescript-eslint
- Prettier 进行代码格式化
- Husky v9 用于 Git 钩子管理
- Lint-staged 确保暂存文件符合规范
- GitHub Actions CI/CD 自动化测试和构建
### 测试策略
- 组件测试: `components/__tests__/` 目录
- 工具函数测试: `utils/__tests__/` 目录
- 配置测试: `config/__tests__/` 目录
- 使用 Vitest 作为测试框架
- 使用 Testing Library 进行 React 组件测试
+81 -212
View File
@@ -1,258 +1,127 @@
# Testing Tools Browser Extension # 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 转二维码(生成器) - **快速解码**: 自动解析 JSON Web Token 的 Header 和 Payload.
- 二维码转 URL(解析器) - **格式化显示**: 以着色和格式化的 JSON 视图展示数据,方便阅读.
- 支持上传二维码图片解析 - **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示.
- 生成的二维码可下载 - **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性.
- 一键复制转换结果
- 卡片式布局,节省空间 ### 🖼️ 二维码工具
- **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载.
- **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容.
## 技术栈 ## 技术栈
- **框架**: WXT (Web Extension Toolkit) - **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
- **前端**: React 19 + TypeScript - **前端**: React 19 + TypeScript
- **UI **: Material UI - **UI 组件**: Material UI (MUI) @7.x
- **日期处理**: dayjs (含 UTC 和时区插件) - **样式**: Emotion (Styled Components)
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
- **通信**: @webext-core/messaging - **通信**: @webext-core/messaging
- **存储**: Chrome Storage API (类型安全封装) - **存储**: Chrome Storage API (类型安全封装)
- **二维码**: qrcode (生成) + jsqr (解析) - **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
- **测试**: Vitest + Testing Library - **测试**: Vitest + Testing Library
## 项目结构 ## 项目结构
``` ```text
├── components/ # 可复用 UI 组件 ├── components/ # 可复用 React 组件
│ ├── Button.tsx ├── config/ # 应用配置(路由、功能元数据、主题)
│ ├── CopyButton.tsx │ ├── features.tsx # 功能定义与路由映射
── DashboardCard.tsx # 仪表盘卡片组件(React.memo 优化) ── pageTheme.ts # 各功能页面的视觉风格配置
│ ├── GlobalSnackbar.tsx ├── entrypoints/ # 扩展程序入口点
│ ├── PageHeader.tsx # 页面标题栏组件 │ ├── popup/ # 点击图标弹出的主界面
│ ├── RouterContainer.tsx │ ├── options/ # 扩展程序设置页面
│ ├── StorageCleanerConfirm.tsx │ ├── sidepanel/ # 浏览器侧边栏集成
│ ├── ToolCard.tsx │ ├── background.ts # 后台 Service Worker
│ └── TopBar.tsx │ └── content.ts # 网页注入脚本
├── config/ # 配置文 ├── pages/ # 各功能模块的页面组
│ ├── dashboardCards.tsx # 仪表盘卡片配置数据 ├── providers/ # 全局状态提供者 (Router, Snackbar 等)
│ └── routes.ts # 页面路由定义 ├── types/ # TypeScript 类型声明
├── entrypoints/ # 浏览器扩展入口点 ├── utils/ # 工具函数与服务抽象
│ ├── popup/ # 扩展弹窗界面 ├── public/ # 静态资源 (图标、 manifest 资源等)
│ │ ├── App.tsx ├── wxt.config.ts # WXT 框架核心配置
│ │ ├── main.tsx └── package.json # 项目元数据与依赖管理
│ │ └── 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
``` ```
## 路由系统 ## 开发与部署
项目实现了灵活的路由系统,支持: ### 开发环境要求
- **页面导航**: 在不同工具页面之间切换 - Node.js >= 18.x
- **路由同步**: 通过 Chrome Storage 同步路由状态 - npm 或 pnpm
- **可见性控制**: 可配置显示哪些页面
- **页面排序**: 自定义工具卡片的显示顺序
### 页面类型 (PageType) ### 常用命令
| 页面 | 说明 | 默认可见 | | 命令 | 说明 |
| ---------------- | ---------- | -------- | | ----------------------- | -------------------------------- |
| `dashboard` | 首页 | | | `npm run dev` | 启动 Chrome 开发模式(支持 HMR) |
| `timestamp` | 时间戳转换 | ✓ | | `npm run dev:firefox` | 启动 Firefox 开发模式 |
| `storageCleaner` | 存储清理 | ✓ | | `npm run build` | 构建 Chrome 生产版本 |
| `openUrl` | URL 工具 | ✓ | | `npm run compile` | 执行 TypeScript 类型检查 |
| `qrCode` | 二维码工具 | ✓ | | `npm run lint` | 执行 ESLint 代码规范检查 |
| `openUrlViewer` | URL 查看器 | ✗ | | `npm run test` | 运行单元测试 |
| `npm run test:coverage` | 生成测试覆盖率报告 |
## 扩展入口点 ### 自动化流程
| 入口点 | 说明 | 项目通过 GitHub Actions 实现了完善的 CI/CD 流程:
| -------------- | ------------------------ |
| **popup** | 点击扩展图标弹出的界面 |
| **options** | 扩展选项页面 |
| **sidepanel** | 浏览器侧边栏 |
| **background** | 后台脚本(生命周期管理) |
| **content** | 内容脚本(注入到网页) |
## 开发环境要求 - **CI**: 每次推送或 PR 都会自动执行 Lint、类型检查、测试和构建验证.
- **Release**: 推送以 `v*` 开头的 Tag 会自动打包并创建 GitHub Release.
- 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` — 自动发布
## 权限说明 ## 权限说明
扩展请求以下权限: 扩展根据功能需要申请了以下权限:
- `storage``unlimitedStorage` - 本地数据存储 - `storage`: 存储用户设置和工具配置.
- `clipboardWrite` - 剪贴板写入(复制功能) - `activeTab` & `tabs`: 获取当前页面 URL 及其元数据.
- `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入 - `scripting`: 在网页中执行清理脚本.
- `cookies` - Cookie 访问 - `cookies`: 管理和清理网站 Cookie.
- `sidePanel` - 侧边栏支持 - `sidePanel`: 支持在浏览器侧边栏中运行.
- `<all_urls>` - 访问所有网站内容(内容脚本注入) - `clipboardWrite`: 提供一键复制功能.
## 主要依赖 ## 浏览器支持
- `react`, `react-dom` - 前端框架 - Chrome (及其它 Chromium 内核浏览器)
- `@mui/material` - UI 组件库
- `dayjs` - 日期处理
- `@webext-core/messaging` - 扩展消息通信
- `vitest` - 测试框架
- `@testing-library/react` - React 组件测试
## 浏览器兼容性
- Chrome (推荐)
- Firefox - Firefox
## 许可证 ## 许可证
此项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。 基于 [MIT License](LICENSE) 开源.
-200
View File
@@ -1,200 +0,0 @@
import React from 'react';
import {
Box,
Typography,
Paper,
List,
ListItem,
ListItemIcon,
Collapse,
FormControl,
InputLabel,
Select,
MenuItem,
SelectChangeEvent,
Checkbox,
Button,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { FieldType } from '@/utils/dummyDataGenerator';
// 字段数据接口
interface FieldData {
id: string;
fieldType: string;
label: string | null;
placeholder: string;
name: string;
value: string;
isSelected: boolean;
generatedValue: string;
useInvalidData?: boolean;
}
// 字段类型显示名称映射
const FIELD_TYPE_NAMES: Record<string, string> = {
[FieldType.TEXT]: '文本',
[FieldType.EMAIL]: '邮箱',
[FieldType.PHONE]: '手机号',
[FieldType.NUMBER]: '数字',
[FieldType.DATE]: '日期',
[FieldType.TEXTarea]: '文本域',
[FieldType.RADIO]: '单选框',
[FieldType.CHECKBOX]: '复选框',
[FieldType.SELECT]: '下拉框',
[FieldType.PASSWORD]: '密码',
[FieldType.NAME]: '姓名',
[FieldType.ID_CARD]: '身份证号',
[FieldType.UNKNOWN]: '未知',
};
interface FieldListProps {
fields: FieldData[];
showFields: boolean;
onToggleShowFields: () => void;
onFieldTypeChange: (fieldId: string, newType: string) => void;
onLocateField: (fieldId: string) => void;
onHoverField: (fieldId: string | null) => void;
onToggleFieldSelection: (fieldId: string) => void;
onToggleAllFields: () => void;
hoveredFieldId: string | null;
}
const FieldList: React.FC<FieldListProps> = ({
fields,
showFields,
onToggleShowFields,
onFieldTypeChange,
onHoverField,
onToggleFieldSelection,
onToggleAllFields,
hoveredFieldId,
}) => {
if (fields.length === 0) return null;
const handleTypeChange = (fieldId: string, event: SelectChangeEvent<string>) => {
onFieldTypeChange(fieldId, event.target.value);
};
const allSelected = fields.every((f) => f.isSelected);
const selectedCount = fields.filter((f) => f.isSelected).length;
return (
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 2 }}>
<Box
sx={{
borderBottom: 1,
borderColor: 'divider',
px: 2,
py: 1.5,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
}}
onClick={onToggleShowFields}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
({fields.length})
</Typography>
<Typography
variant="caption"
sx={{
bgcolor: selectedCount > 0 ? 'primary.main' : 'grey.300',
color: selectedCount > 0 ? 'white' : 'text.secondary',
px: 1,
py: 0.25,
borderRadius: 1,
}}
>
{selectedCount}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
size="small"
onClick={(e) => {
e.stopPropagation();
onToggleAllFields();
}}
>
{allSelected ? '取消全选' : '全选'}
</Button>
{showFields ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</Box>
</Box>
<Collapse in={showFields}>
<List dense sx={{ maxHeight: 400, overflow: 'auto' }}>
{fields.map((field, index) => (
<ListItem
key={field.id}
sx={{
py: 1,
px: 2,
bgcolor: hoveredFieldId === field.id ? '#e3f2fd' : 'transparent',
transition: 'background-color 0.2s ease',
}}
onMouseEnter={() => onHoverField(field.id)}
onMouseLeave={() => onHoverField(null)}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<Checkbox
size="small"
checked={field.isSelected}
onChange={(e) => {
e.stopPropagation();
onToggleFieldSelection(field.id);
}}
/>
</ListItemIcon>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
opacity: field.isSelected ? 1 : 0.5,
}}
>
{field.label || field.name || field.placeholder || `字段 ${index + 1}`}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FormControl size="small" sx={{ flex: 1, minWidth: 120 }}>
<InputLabel></InputLabel>
<Select
value={field.fieldType}
label="类型"
onChange={(e) => handleTypeChange(field.id, e)}
>
{Object.values(FieldType).map((type) => (
<MenuItem key={type} value={type}>
{FIELD_TYPE_NAMES[type] || type}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
{field.placeholder && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
: {field.placeholder}
</Typography>
)}
</Box>
</ListItem>
))}
</List>
</Collapse>
</Paper>
);
};
export default FieldList;
+88 -114
View File
@@ -1,12 +1,13 @@
/** /**
* GlobalSnackbar - 全局 Snackbar 消息提示组件 * GlobalSnackbar - 全局 Snackbar 消息提示组件及 Provider
* *
* 提供可复用的 Toast 消息提示功能,支持种使用方式: * 提供可复用的 Toast 消息提示功能,支持种使用方式:
* 1. 作为受控组件使用:通过 props 控制显示状态 * 1. 作为受控组件使用:通过 props 控制显示状态
* 2. 通过 useSnackbarState Hook 使用:自动管理状态 * 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态
* 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式
* *
* @module GlobalSnackbar * @module GlobalSnackbar
* @version 1.0.0 * @version 1.1.0
* *
* @example * @example
* ```tsx * ```tsx
@@ -18,13 +19,23 @@
* severity="success" * severity="success"
* /> * />
* *
* // 方式二:Hook 方式 * // 方式二:Hook 方式 (局部状态)
* const { snackbarProps, showMessage } = useSnackbarState(); * const { snackbarProps, showMessage } = useSnackbarState();
* showMessage('Hello!', { severity: 'info' }); * showMessage('Hello!', { severity: 'info' });
*
* // 方式三:Context 方式 (全局状态)
* // 在根组件包裹 Provider
* <SnackbarProvider>
* <App />
* </SnackbarProvider>
*
* // 在子组件中使用
* 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'; 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'; export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
/**
* 重新导出 SnackbarProvider 组件
* @description 提供 Context 方式的全局 Snackbar 功能
*/
export { SnackbarProvider } from './SnackbarProvider';
/** /**
* GlobalSnackbar 组件的属性接口 * GlobalSnackbar 组件的属性接口
* @interface GlobalSnackbarProps * @interface GlobalSnackbarProps
@@ -126,23 +131,6 @@ const defaultProps: Required<
* *
* @param {GlobalSnackbarProps} props - 组件属性 * @param {GlobalSnackbarProps} props - 组件属性
* @returns {JSX.Element} * @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,
@@ -153,15 +141,6 @@ export function GlobalSnackbar({
showAlert = defaultProps.showAlert, showAlert = defaultProps.showAlert,
hideIcon = defaultProps.hideIcon, hideIcon = defaultProps.hideIcon,
}: GlobalSnackbarProps): JSX.Element { }: GlobalSnackbarProps): JSX.Element {
/**
* 使用 Portal 将 Snackbar 传送到 DOM 顶层 (body 标签下)
*
* @description
* Portal 的优势:
* - 避免父容器 overflow、z-index 等样式影响
* - 确保 Snackbar 始终显示在最顶层
* - 避免与其他组件的样式冲突
*/
return ( return (
<Portal> <Portal>
<Snackbar <Snackbar
@@ -172,9 +151,7 @@ export function GlobalSnackbar({
disableWindowBlurListener disableWindowBlurListener
sx={{ sx={{
zIndex: 999999, zIndex: 999999,
// 确保距离底部的间距,响应式设计适配不同屏幕
bottom: { xs: '24px', sm: '24px' }, bottom: { xs: '24px', sm: '24px' },
// 固定宽度时使用 transform 实现真正的居中
left: '50%', left: '50%',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
minWidth: '140px', minWidth: '140px',
@@ -186,26 +163,19 @@ 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' },
}} }}
> >
@@ -218,97 +188,33 @@ export function GlobalSnackbar({
} }
/** /**
* useSnackbarState - 消息提示的 Hook 方式 * useSnackbarState - 消息提示的状态管理 Hook
* *
* 提供状态管理的 Snackbar 功能,自动处理 open、message 等状态。 * 提供状态管理的 Snackbar 功能,自动处理 open、message 等状态。
* 适合在组件内部使用,无需额外的状态管理代码。
* *
* @param {SnackbarOptions} [initialOptions] - 初始配置选项 * @param {SnackbarOptions} [initialOptions] - 初始配置选项
* @returns {UseSnackbarStateResult} 包含 snackbarProps 和操作方法的对象 * @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 { 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,
@@ -325,8 +231,76 @@ export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarS
}; };
} }
// --- Context & Provider ---
/** /**
* GlobalSnackbar 组件的默认导出 * Snackbar Context 的值类型定义
* @description 方便使用 `import GlobalSnackbar from './GlobalSnackbar'` 方式导入
*/ */
interface SnackbarContextValue {
showMessage: (message: string, options?: SnackbarOptions) => void;
closeMessage: () => void;
}
const SnackbarContext = createContext<SnackbarContextValue | null>(null);
/**
* SnackbarProvider 组件的 props 类型
*/
interface SnackbarProviderProps {
children: ReactNode;
initialOptions?: SnackbarOptions;
}
/**
* SnackbarProvider 组件
*
* 全局消息提示的 Provider 组件,需要包裹在应用根组件外层。
*/
export function SnackbarProvider({ children, initialOptions }: SnackbarProviderProps): JSX.Element {
const { snackbarProps, showMessage, closeMessage } = useSnackbarState(initialOptions);
return (
<SnackbarContext.Provider value={{ showMessage, closeMessage }}>
{children}
<GlobalSnackbar {...snackbarProps} />
</SnackbarContext.Provider>
);
}
/**
* useSnackbar - 在子组件中获取 Snackbar 上下文的 Hook
*
* @param {SnackbarOptions} [options] - 钩子级别的默认配置(如 autoHideDuration
* @returns {SnackbarContextValue} - 包含 showMessage 和 closeMessage 的对象
* @throws {Error} - 如果不在 SnackbarProvider 内部调用,抛出错误
*
* @description
* 选项合并策略:
* 1. 调用 showMessage 时传入的 callOptions 优先级最高
* 2. useSnackbar(options) 传入的 Hook 级别配置次之
* 3. SnackbarProvider(initialOptions) 传入的全局配置优先级最低
*/
export function useSnackbar(options?: SnackbarOptions): SnackbarContextValue {
const context = useContext(SnackbarContext);
if (!context) {
throw new Error('useSnackbar must be used within SnackbarProvider');
}
// 包装 showMessage 以支持 Hook 级别的 initialOptions
const wrappedShowMessage = (message: string, callOptions?: SnackbarOptions) => {
// 采用防御性编程,确保 options 和 callOptions 为空时也能正常工作
// 优先级:callOptions > options
const mergedOptions: SnackbarOptions = {
...(options || {}),
...(callOptions || {}),
};
context.showMessage(message, mergedOptions);
};
return {
...context,
showMessage: wrappedShowMessage,
};
}
export default GlobalSnackbar; export default GlobalSnackbar;
+1 -1
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { import {
Box, Box,
Typography, Typography,
+2 -3
View File
@@ -31,7 +31,6 @@ const QrCodeUploader: React.FC<QrCodeUploaderProps> = ({
onQrCodeDetected, onQrCodeDetected,
supportedFormats = ['image/png', 'image/jpeg', 'image/webp'], supportedFormats = ['image/png', 'image/jpeg', 'image/webp'],
maxFileSize = 5 * 1024 * 1024, // 5MB maxFileSize = 5 * 1024 * 1024, // 5MB
timeout = 10000, // 10 seconds
showPreview = true, showPreview = true,
showProgress = true, showProgress = true,
className, className,
@@ -77,7 +76,7 @@ const QrCodeUploader: React.FC<QrCodeUploaderProps> = ({
}); });
}, 200); }, 200);
const result = await parseQrCodeFromFile(file, timeout); const result = await parseQrCodeFromFile(file);
clearInterval(progressInterval); clearInterval(progressInterval);
setProgress(100); setProgress(100);
@@ -102,7 +101,7 @@ const QrCodeUploader: React.FC<QrCodeUploaderProps> = ({
setTimeout(() => setProgress(0), 500); setTimeout(() => setProgress(0), 500);
} }
}, },
[timeout, showMessage, onQrCodeDetected], [showMessage, onQrCodeDetected],
); );
// 处理文件 // 处理文件
+30 -3
View File
@@ -1,7 +1,7 @@
import { Box } from '@mui/material'; import { Box, CircularProgress } from '@mui/material';
import { FEATURES, getEntryPointType } from '@/config/features'; import { FEATURES, getEntryPointType } from '@/config/features';
import { useRouter } from '@/providers/RouterProvider'; import { useRouter } from '@/providers/RouterProvider';
import { useMemo } from 'react'; import { useMemo, Suspense } from 'react';
export default function RouterContainer() { export default function RouterContainer() {
const { currentPage, isLoaded } = useRouter(); const { currentPage, isLoaded } = useRouter();
@@ -15,7 +15,18 @@ export default function RouterContainer() {
}, []); }, []);
if (!isLoaded) { if (!isLoaded) {
return <div className="app">Loading...</div>; return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
}}
>
<CircularProgress size={32} />
</Box>
);
} }
const currentFeature = FEATURES.find((f) => f.key === currentPage); const currentFeature = FEATURES.find((f) => f.key === currentPage);
@@ -33,8 +44,24 @@ export default function RouterContainer() {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
}} }}
>
<Suspense
fallback={
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flex: 1,
minHeight: 200,
}}
>
<CircularProgress size={32} />
</Box>
}
> >
{Component && <Component />} {Component && <Component />}
</Suspense>
</Box> </Box>
); );
} }
-154
View File
@@ -1,154 +0,0 @@
/**
* SnackbarProvider - 全局 Snackbar 消息提示 Provider
*
* 提供全局的 Toast 消息功能,支持成功、错误、警告、信息四种提示类型。
* 通过 React Context 向下传递消息显示方法,子组件可通过 useSnackbar hook 调用。
*
* @description
* - 基于 GlobalSnackbar 组件实现,复用其状态管理逻辑
* - 使用 MUI Snackbar 组件实现消息提示
* - 支持自定义自动隐藏时长
* - 消息会显示在页面底部居中位置
* - 使用 Portal 将 Snackbar 渲染到 body 末尾,避免 z-index 层级问题
*/
import { createContext, useContext, type ReactNode } from 'react';
import GlobalSnackbar, { useSnackbarState } from './GlobalSnackbar';
import type { SnackbarOptions } from './GlobalSnackbar';
/**
* Snackbar Context 的值类型定义
* @interface SnackbarContextValue
* @property showMessage - 显示消息的方法
* @property closeMessage - 关闭消息的方法
*/
interface SnackbarContextValue {
showMessage: (message: string, options?: SnackbarOptions) => void;
closeMessage: () => void;
}
/**
* React Context,用于在组件树中传递 Snackbar 操作方法
* @description
* - 初始值为 null,表示未包裹在 Provider 中
* - 通过 SnackbarProvider 包裹后提供实际值
*/
const SnackbarContext = createContext<SnackbarContextValue | null>(null);
/**
* SnackbarProvider 组件的 props 类型
* @interface SnackbarProviderProps
* @property children - 子组件
* @property initialOptions - 初始配置选项
*/
interface SnackbarProviderProps {
children: ReactNode;
initialOptions?: SnackbarOptions;
}
/**
* useSnackbar Hook 的选项配置(与 GlobalSnackbar 的 SnackbarOptions 兼容)
* @interface UseSnackbarOptions
* @property severity - 消息严重程度
* @property autoHideDuration - 默认自动隐藏时长
* @property hideIcon - 是否隐藏图标
* @property showAlert - 是否使用 Alert 组件
*/
export type UseSnackbarOptions = SnackbarOptions;
/**
* SnackbarProvider 组件
*
* 全局消息提示的 Provider 组件,需要包裹在应用根组件外层。
* 提供 showMessage 方法用于显示各种类型的提示消息。
*
* @param {SnackbarProviderProps} props - 组件属性
* @returns {JSX.Element}
*
* @remarks
* - 使用 useGlobalSnackbar() hook 复用 GlobalSnackbar 的状态管理逻辑
* - 通过 Context.Provider 将操作方法传递给子组件
* - 渲染 GlobalSnackbar 组件显示实际的 Snackbar UI
*
* @example
* ```tsx
* <SnackbarProvider initialOptions={{ autoHideDuration: 3000 }}>
* <App />
* </SnackbarProvider>
* ```
*
* @example
* ```tsx
* // 在子组件中使用
* const { showMessage } = useSnackbar();
* showMessage('操作成功', { severity: 'success' });
* ```
*/
export function SnackbarProvider({ children, initialOptions }: SnackbarProviderProps) {
/**
* 调用 GlobalSnackbar.useSnackbar() 获取状态管理逻辑
*
* @description
* - snackbarProps: 传递给 GlobalSnackbar 组件的属性
* - showMessage: 显示消息的方法
* - closeMessage: 关闭消息的方法
*/
const { snackbarProps, showMessage, closeMessage } = useSnackbarState(initialOptions);
/**
* 通过 Context.Provider 向下传递 snackbar 操作方法
*
* @description
* - 子组件通过 useSnackbar() hook 获取这些方法
* - GlobalSnackbar 组件放在 Provider 外部,确保它能渲染到 DOM
*/
return (
<SnackbarContext.Provider value={{ showMessage, closeMessage }}>
{children}
<GlobalSnackbar {...snackbarProps} />
</SnackbarContext.Provider>
);
}
/**
* useSnackbar - 在子组件中获取 Snackbar 上下文的 Hook
*
* @param {UseSnackbarOptions} [_options] - 可选的配置项(保留向后兼容性,不实际使用)
* @returns {SnackbarContextValue} - 包含 showMessage 和 closeMessage 的对象
* @throws {Error} - 如果不在 SnackbarProvider 内部调用,抛出错误
*
* @description
* 这是一个自定义 React Hook,用于在任意子组件中访问 Snackbar 功能。
* 必须确保组件被 SnackbarProvider 包裹才能使用。
*
* @remarks
* - 由于 Context 限制,useSnackbar 的 options 参数无法动态传递给 Provider
* - 如需设置全局初始选项,请在 SnackbarProvider 组件上设置 initialOptions
* - 如需为单个消息设置选项,请在 showMessage() 方法中传入
*
* @example
* ```tsx
* function MyComponent() {
* const { showMessage } = useSnackbar();
*
* const handleSuccess = () => {
* showMessage('操作成功!', { severity: 'success', autoHideDuration: 5000 });
* };
*
* return (
* <div>
* <button onClick={handleSuccess}>成功提示</button>
* </div>
* );
* }
* ```
*/
export function useSnackbar(_options?: UseSnackbarOptions): SnackbarContextValue {
const context = useContext(SnackbarContext);
if (!context) {
throw new Error('useSnackbar must be used within SnackbarProvider');
}
return context;
}
export default SnackbarProvider;
+50 -11
View File
@@ -1,20 +1,52 @@
/**
* ToolCard 组件 - 工具卡片
*
* 用于在仪表盘中展示各个工具功能的卡片组件,支持图标、标题、描述、
* AI 标识和快照内容展示,具备悬停动画效果。
*/
import { Box, Typography, Stack } from '@mui/material'; import { Box, Typography, Stack } from '@mui/material';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; // Sparkles for AI import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; // Sparkles for AI
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import React from 'react'; import React from 'react';
/**
* ToolCard 组件属性接口
*/
interface ToolCardProps { interface ToolCardProps {
/** 工具卡片标题 */
title: string; title: string;
/** 工具卡片描述文本(可选) */
description?: string; description?: string;
/** 快照内容,用于在卡片底部展示额外信息(可选) */
snapshot?: React.ReactNode; snapshot?: React.ReactNode;
/** 主题色代码,用于图标背景和悬停效果 */
colorCode: string; colorCode: string;
/** 工具图标元素 */
icon: React.ReactNode; icon: React.ReactNode;
/** 卡片点击事件处理函数 */
onClick: () => void; onClick: () => void;
/** 是否显示 AI 标识(可选) */
hasAI?: boolean; hasAI?: boolean;
/** 卡片背景色,默认为 'background.paper' */
cardBackgroundColor?: string; 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 ( return (
<Box <Box
onClick={onClick} onClick={onClick}
@@ -26,19 +58,25 @@ export default function ToolCard({ title, description, snapshot, colorCode, icon
cursor: 'pointer', cursor: 'pointer',
border: '1px solid', border: '1px solid',
borderColor: 'grey.100', borderColor: 'grey.100',
height: '100%',
boxSizing: 'border-box',
// 使用 cubic-bezier 缓动函数实现平滑的过渡动画
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 1.5, gap: 1.5,
// 悬停效果:边框变色、向上位移、添加阴影
'&:hover': { '&:hover': {
borderColor: colorCode, borderColor: colorCode,
transform: 'translateY(-4px)', transform: 'translateY(-4px)',
boxShadow: `0 12px 24px -10px ${colorCode}33`, // 20% opacity of colorCode // 阴影颜色为主题色的 20% 透明度(十六进制后两位 33 约等于 20%)
boxShadow: `0 12px 24px -10px ${colorCode}33`,
// 悬停时箭头图标右移并变色
'& .arrow-icon': { '& .arrow-icon': {
transform: 'translateX(4px)', transform: 'translateX(4px)',
color: colorCode color: colorCode,
} },
} },
}} }}
> >
<Stack direction="row" justifyContent="space-between" alignItems="flex-start"> <Stack direction="row" justifyContent="space-between" alignItems="flex-start">
@@ -51,8 +89,9 @@ export default function ToolCard({ title, description, snapshot, colorCode, icon
width: 40, width: 40,
height: 40, height: 40,
borderRadius: 3, borderRadius: 3,
bgcolor: `${colorCode}11`, // 7% opacity // 图标背景色为主题色的 7% 透明度(十六进制后两位 11 约等于 7%)
color: colorCode bgcolor: `${colorCode}11`,
color: colorCode,
}} }}
> >
{icon} {icon}
@@ -66,7 +105,7 @@ export default function ToolCard({ title, description, snapshot, colorCode, icon
color: 'text.primary', color: 'text.primary',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 0.5 gap: 0.5,
}} }}
> >
{title} {title}
@@ -79,7 +118,7 @@ export default function ToolCard({ title, description, snapshot, colorCode, icon
color: 'text.secondary', color: 'text.secondary',
fontWeight: 500, fontWeight: 500,
display: 'block', display: 'block',
mt: 0.5 mt: 0.5,
}} }}
> >
{description} {description}
@@ -93,7 +132,7 @@ export default function ToolCard({ title, description, snapshot, colorCode, icon
fontSize: 12, fontSize: 12,
color: 'grey.300', color: 'grey.300',
mt: 0.5, mt: 0.5,
transition: 'all 0.3s ease' transition: 'all 0.3s ease',
}} }}
/> />
</Stack> </Stack>
@@ -104,7 +143,7 @@ export default function ToolCard({ title, description, snapshot, colorCode, icon
mt: 'auto', mt: 'auto',
pt: 1.5, pt: 1.5,
borderTop: '1px dashed', borderTop: '1px dashed',
borderColor: 'grey.100' borderColor: 'grey.100',
}} }}
> >
{snapshot} {snapshot}
+16 -26
View File
@@ -1,4 +1,3 @@
import { useMemo } from 'react';
import { Box, IconButton, Typography, Stack, Tooltip } from '@mui/material'; import { Box, IconButton, Typography, Stack, Tooltip } from '@mui/material';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import OpenInNewIcon from '@mui/icons-material/OpenInNew';
@@ -8,21 +7,10 @@ import { useRouter } from '@/providers/RouterProvider';
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) { export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
const { currentPage, goBack } = useRouter(); const { currentPage, goBack } = useRouter();
const isDetachedMode = useMemo(() => { const handleOpenInTab = () => {
return new URLSearchParams(window.location.search).get('mode') === 'detached'; // 在新标签页中打开扩展页面
}, []); chrome.tabs.create({ url: chrome.runtime.getURL('popup.html?mode=tab') }).catch(console.error);
window.close();
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 isDashboard = currentPage === 'dashboard'; const isDashboard = currentPage === 'dashboard';
@@ -33,22 +21,22 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
justifyContent="space-between" justifyContent="space-between"
alignItems="center" alignItems="center"
sx={{ sx={{
px: 2, px: { xs: 1.5, sm: 2 },
py: 1.5, py: 1.5,
borderBottom: '1px solid', borderBottom: '1px solid',
borderColor: 'grey.100', borderColor: 'grey.100',
bgcolor: 'background.paper', bgcolor: 'background.paper',
zIndex: 1100 zIndex: 1100,
}} }}
> >
<Box sx={{ width: 40 }}> <Box sx={{ width: { xs: 32, sm: 40 } }}>
{!isDashboard && ( {!isDashboard && (
<IconButton <IconButton
size="small" size="small"
onClick={goBack} onClick={goBack}
sx={{ sx={{
bgcolor: 'grey.50', bgcolor: 'grey.50',
'&:hover': { bgcolor: 'grey.200' } '&:hover': { bgcolor: 'grey.200' },
}} }}
> >
<ArrowBackIosNewIcon sx={{ fontSize: 14 }} /> <ArrowBackIosNewIcon sx={{ fontSize: 14 }} />
@@ -63,20 +51,22 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
letterSpacing: '0.5px', letterSpacing: '0.5px',
textTransform: 'uppercase', textTransform: 'uppercase',
fontSize: '0.75rem', fontSize: '0.75rem',
color: 'text.secondary' color: 'text.secondary',
}} }}
> >
Testing Tools Testing Tools
</Typography> </Typography>
<Stack direction="row" spacing={1} sx={{ width: 80, justifyContent: 'flex-end' }}> <Stack
{!isDetachedMode && ( direction="row"
<Tooltip title="独立窗口模式"> spacing={0.5}
<IconButton size="small" onClick={handleDetach}> sx={{ width: { xs: 80, sm: 120 }, justifyContent: 'flex-end' }}
>
<Tooltip title="在标签页打开">
<IconButton size="small" onClick={handleOpenInTab}>
<OpenInNewIcon sx={{ fontSize: 18 }} /> <OpenInNewIcon sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
)}
<Tooltip title="设置"> <Tooltip title="设置">
<IconButton size="small" onClick={onOpenOptions}> <IconButton size="small" onClick={onOpenOptions}>
<SettingsIcon sx={{ fontSize: 18 }} /> <SettingsIcon sx={{ fontSize: 18 }} />
-111
View File
@@ -1,111 +0,0 @@
import { useState } from 'react';
import { Box, TextField, Alert, Stack } from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import Button from '@/components/Button';
import type { OpenUrlEntry } from '@/types/storage';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
import { openUrlPageStyles } from '@/config/pageTheme';
interface UrlEntryFormProps {
onAddEntry: (entry: OpenUrlEntry) => void;
showMessage: (message: string, options?: SnackbarOptions) => void;
}
const UrlEntryForm = ({ onAddEntry, showMessage }: UrlEntryFormProps) => {
const [newName, setNewName] = useState<string>('');
const [newUrl, setNewUrl] = useState<string>('');
const showMixedContentWarning =
newUrl.startsWith('http://') && !newUrl.includes('localhost') && !newUrl.includes('127.0.0.1');
const isValidUrl = (url: string) => {
if (!url.trim()) return false;
try {
new URL(url);
return true;
} catch {
return false;
}
};
const handleAddEntry = () => {
if (!newName.trim()) {
showMessage('请输入名称', { severity: 'error' });
return;
}
if (!isValidUrl(newUrl)) {
showMessage('请输入有效的 URL', { severity: 'error' });
return;
}
onAddEntry({ name: newName.trim(), url: newUrl.trim() });
setNewName('');
setNewUrl('');
showMessage('添加成功', { severity: 'success' });
};
return (
<Box
sx={{
bgcolor: 'background.paper',
p: 2,
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.100',
mb: 3,
boxShadow: '0 4px 12px rgba(0,0,0,0.02)',
}}
>
<Stack spacing={2}>
<TextField
label="环境名称"
placeholder="例如: 本地文档"
value={newName}
onChange={(e) => setNewName(e.target.value)}
fullWidth
variant="outlined"
sx={openUrlPageStyles.INPUT_STYLE}
/>
<TextField
label="目标 URL"
placeholder="例如: http://localhost:8000/docs"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
fullWidth
variant="outlined"
sx={openUrlPageStyles.INPUT_STYLE}
/>
{showMixedContentWarning && (
<Alert
severity="warning"
sx={{
borderRadius: 3,
'& .MuiAlert-message': { fontSize: '0.7rem', fontWeight: 600, lineHeight: 1.4 },
}}
>
HTTPS HTTP
</Alert>
)}
<Button
variant="contained"
onClick={handleAddEntry}
disabled={!newName.trim() || !isValidUrl(newUrl)}
fullWidth
startIcon={<AddIcon />}
sx={{
bgcolor: openUrlPageStyles.themeColor,
'&:hover': {
bgcolor: openUrlPageStyles.primaryDark,
},
}}
>
</Button>
</Stack>
</Box>
);
};
export default UrlEntryForm;
-142
View File
@@ -1,142 +0,0 @@
import { Fragment } from 'react';
import { Box, ListItem, Typography, Stack, Divider, Tooltip, IconButton } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import VisibilityIcon from '@mui/icons-material/Visibility';
import { alpha } from '@mui/material/styles';
import { storageUtil } from '@/utils/chromeStorage';
import type { OpenUrlEntry } from '@/types/storage';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
import { openUrlPageStyles } from '@/config/pageTheme';
interface UrlEntryItemProps {
entry: OpenUrlEntry;
index: number;
isLast: boolean;
onDelete: (index: number) => void;
showMessage: (message: string, options?: SnackbarOptions) => void;
}
const UrlEntryItem = ({ entry, index, isLast, onDelete, showMessage }: UrlEntryItemProps) => {
const handleOpenInSidebar = async (entry: OpenUrlEntry) => {
try {
// 存储目标 URL
await storageUtil.set('openUrl/currentUrl', entry.url);
// 直接设置侧边栏的路由,而不是通过 syncNavigation 影响弹窗路由
await storageUtil.set('app/sidepanelRoute', 'openUrlViewer');
const [currentTab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
const tabId = currentTab.id;
if (!tabId) {
showMessage('无法获取当前标签页', { severity: 'error' });
return;
}
await chrome.sidePanel.setOptions({
tabId,
path: 'sidepanel.html',
enabled: true,
});
await chrome.sidePanel.open({ windowId: currentTab.windowId });
// 仅当在 Popup 中时才关闭窗口,防止在侧边栏内点击预览时导致侧边栏关闭
if (window.location.pathname.includes('popup.html')) {
window.close();
}
} catch (error) {
console.error('Failed to open side panel:', error);
showMessage(`打开失败: ${(error as Error).message}`, { severity: 'error' });
}
};
const handleOpenInNewTab = (entry: OpenUrlEntry) => {
chrome.tabs.create({ url: entry.url }).catch(console.error);
window.close();
};
const handleDelete = () => {
onDelete(index);
};
return (
<Fragment>
<ListItem
sx={{
px: 2,
py: 1.5,
display: 'flex',
alignItems: 'center',
gap: 2,
transition: 'background-color 0.2s',
'&:hover': { bgcolor: 'grey.50' },
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 800, color: 'text.primary' }} noWrap>
{entry.name}
</Typography>
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{
fontSize: '0.65rem',
fontWeight: 500,
display: 'block',
mt: 0.2,
fontFamily: 'monospace',
}}
>
{entry.url}
</Typography>
</Box>
<Stack direction="row" spacing={0.5}>
<Tooltip title="在侧边栏预览">
<IconButton
size="small"
onClick={() => handleOpenInSidebar(entry)}
sx={{
color: openUrlPageStyles.themeColor,
bgcolor: alpha(openUrlPageStyles.themeColor, 0.05),
'&:hover': { bgcolor: openUrlPageStyles.themeColor, color: '#fff' },
}}
>
<VisibilityIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Tooltip title="新标签页打开">
<IconButton
size="small"
onClick={() => handleOpenInNewTab(entry)}
sx={{
color: 'grey.500',
bgcolor: 'grey.100',
'&:hover': { bgcolor: 'grey.600', color: '#fff' },
}}
>
<OpenInNewIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Tooltip title="删除">
<IconButton
size="small"
onClick={handleDelete}
sx={{
color: 'error.main',
'&:hover': { color: 'error.dark', bgcolor: alpha('#f44336', 0.05) },
}}
>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Stack>
</ListItem>
{!isLast && <Divider sx={{ mx: 2, borderColor: 'grey.50' }} />}
</Fragment>
);
};
export default UrlEntryItem;
-63
View File
@@ -1,63 +0,0 @@
import { Box, List, Typography } from '@mui/material';
import LinkIcon from '@mui/icons-material/Link';
import UrlEntryItem from './UrlEntryItem';
import type { OpenUrlEntry } from '@/types/storage';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
interface UrlEntryListProps {
entries: OpenUrlEntry[];
onDeleteEntry: (index: number) => void;
showMessage: (message: string, options?: SnackbarOptions) => void;
}
const UrlEntryList = ({ entries, onDeleteEntry, showMessage }: UrlEntryListProps) => {
if (entries.length === 0) {
return (
<Box
sx={{
textAlign: 'center',
py: 4,
bgcolor: 'grey.50',
borderRadius: 4,
border: '1px dashed',
borderColor: 'grey.200',
}}
>
<LinkIcon sx={{ color: 'grey.300', fontSize: 40, mb: 1 }} />
<Typography
variant="caption"
color="text.disabled"
sx={{ display: 'block', fontWeight: 600 }}
>
</Typography>
</Box>
);
}
return (
<List
disablePadding
sx={{
bgcolor: 'background.paper',
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.100',
overflow: 'hidden',
}}
>
{entries.map((entry, index) => (
<UrlEntryItem
key={index}
entry={entry}
index={index}
isLast={index === entries.length - 1}
onDelete={onDeleteEntry}
showMessage={showMessage}
/>
))}
</List>
);
};
export default UrlEntryList;
+10 -10
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import React, { useState } from 'react';
import { import {
Box, Box,
Typography, Typography,
@@ -14,7 +14,7 @@ import QrCodeIcon from '@mui/icons-material/QrCode';
import DownloadIcon from '@mui/icons-material/Download'; import DownloadIcon from '@mui/icons-material/Download';
import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import qrcode from 'qrcode'; import QRious from 'qrious';
import { qrCodePageStyles } from '@/config/pageTheme'; import { qrCodePageStyles } from '@/config/pageTheme';
import type { SnackbarOptions } from '@/components/GlobalSnackbar'; import type { SnackbarOptions } from '@/components/GlobalSnackbar';
@@ -54,16 +54,16 @@ const UrlToQrCodeSection = ({
url = 'https://' + url; url = 'https://' + url;
} }
const dataUrl = await qrcode.toDataURL(url, { // 使用 QRious 替代 qrcode 库,体积更小
width: 200, const qr = new QRious({
margin: 2, value: url,
color: { size: 250,
dark: qrCodePageStyles.black, level: 'H',
light: qrCodePageStyles.white, foreground: qrCodePageStyles.black,
}, background: qrCodePageStyles.white,
}); });
setQrCodeDataUrl(dataUrl); setQrCodeDataUrl(qr.toDataURL());
showMessage('二维码生成成功', { severity: 'success', autoHideDuration: 1000 }); showMessage('二维码生成成功', { severity: 'success', autoHideDuration: 1000 });
} catch (error) { } catch (error) {
console.error('生成二维码失败:', error); console.error('生成二维码失败:', error);
+55 -1
View File
@@ -1,6 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, act, renderHook } from '@testing-library/react'; 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 组件系统', () => { describe('GlobalSnackbar 组件系统', () => {
const mockOnClose = vi.fn(); const mockOnClose = vi.fn();
@@ -105,4 +112,51 @@ describe('GlobalSnackbar 组件系统', () => {
// 源码中 handleClose 拦截了 clickaway,所以 open 不会变为 false。 // 源码中 handleClose 拦截了 clickaway,所以 open 不会变为 false。
}); });
}); });
describe('useSnackbar Context Hook 优先级', () => {
it('优先级验证: Call Options > Hook Options > Provider Options', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<SnackbarProvider initialOptions={{ severity: 'error', autoHideDuration: 1000 }}>
{children}
</SnackbarProvider>
);
// 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 }) => (
<SnackbarProvider>{children}</SnackbarProvider>
);
const { result } = renderHook(() => useSnackbar(), { wrapper });
act(() => {
expect(() => result.current.showMessage('测试')).not.toThrow();
});
expect(screen.getByText('测试')).toBeInTheDocument();
});
});
}); });
@@ -2,7 +2,7 @@ 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 { SnackbarProvider } from '@/components/GlobalSnackbar';
import type { PageType } from '@/types/storage'; import type { PageType } from '@/types/storage';
import React from 'react'; import React from 'react';
@@ -41,7 +41,7 @@ describe('RouterContainer 组件', () => {
it('isLoaded 为 false 时应渲染加载状态', () => { it('isLoaded 为 false 时应渲染加载状态', () => {
mockRouterValue.isLoaded = false; mockRouterValue.isLoaded = false;
renderWithProvider(<RouterContainer />); renderWithProvider(<RouterContainer />);
expect(screen.getByText('Loading...')).toBeInTheDocument(); expect(screen.getByRole('progressbar')).toBeInTheDocument();
}); });
it('isLoaded 为 true 时应渲染页面内容', () => { it('isLoaded 为 true 时应渲染页面内容', () => {
@@ -2,6 +2,7 @@ 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 { StorageCleanerConfirm } from '../StorageCleanerConfirm'; import { StorageCleanerConfirm } from '../StorageCleanerConfirm';
import type { StorageCleanerOptions } from '@/types/storage'; import type { StorageCleanerOptions } from '@/types/storage';
import React from 'react';
describe('StorageCleanerConfirm 组件', () => { describe('StorageCleanerConfirm 组件', () => {
const mockOnClose = vi.fn(); const mockOnClose = vi.fn();
+1
View File
@@ -3,6 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
import TopBar from '../TopBar'; import TopBar from '../TopBar';
import { RouterProvider } from '@/providers/RouterProvider'; import { RouterProvider } from '@/providers/RouterProvider';
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,
+15 -28
View File
@@ -1,16 +1,16 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
FEATURES, FEATURES,
getFeatureByKey,
getDefaultVisibleFeatureKeys,
getAllFeatureKeys, getAllFeatureKeys,
getDefaultPageOrder, getDefaultPageOrder,
getDefaultVisibleFeatureKeys,
getFeatureByKey,
} from '../features'; } from '../features';
describe('features', () => { describe('features', () => {
describe('FEATURES', () => { describe('FEATURES', () => {
it('should have 9 features defined', () => { it('should have 6 features defined', () => {
expect(FEATURES).toHaveLength(9); expect(FEATURES).toHaveLength(6);
}); });
it('should have all required properties for each feature', () => { it('should have all required properties for each feature', () => {
@@ -27,10 +27,10 @@ describe('features', () => {
expect(typeof feature.components).toBe('object'); expect(typeof feature.components).toBe('object');
expect(feature.components).toHaveProperty('popup'); expect(feature.components).toHaveProperty('popup');
expect(feature.components).toHaveProperty('sidepanel'); expect(feature.components).toHaveProperty('sidepanel');
expect(feature.components).toHaveProperty('detached'); expect(feature.components).toHaveProperty('tab');
// Optional UI properties for non-hidden features // 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('icon');
expect(feature).toHaveProperty('themeColor'); expect(feature).toHaveProperty('themeColor');
expect(typeof feature.themeColor).toBe('string'); 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(); const visibleKeys = getDefaultVisibleFeatureKeys();
expect(visibleKeys).toContain('dashboard'); expect(visibleKeys).toContain('dashboard');
expect(visibleKeys).toContain('timestamp'); expect(visibleKeys).toContain('timestamp');
expect(visibleKeys).toContain('storageCleaner'); expect(visibleKeys).toContain('storageCleaner');
expect(visibleKeys).toContain('openUrl'); expect(visibleKeys).toContain('qrCode');
});
it('should not include openUrlViewer (not visible by default)', () => {
const visibleKeys = getDefaultVisibleFeatureKeys();
expect(visibleKeys).not.toContain('openUrlViewer');
}); });
}); });
describe('getAllFeatureKeys', () => { describe('getAllFeatureKeys', () => {
it('should return all feature keys', () => { it('should return all feature keys', () => {
const allKeys = getAllFeatureKeys(); const allKeys = getAllFeatureKeys();
expect(allKeys).toHaveLength(9); expect(allKeys).toHaveLength(6);
expect(allKeys).toContain('dashboard'); expect(allKeys).toContain('dashboard');
expect(allKeys).toContain('timestamp'); expect(allKeys).toContain('timestamp');
expect(allKeys).toContain('storageCleaner'); expect(allKeys).toContain('storageCleaner');
expect(allKeys).toContain('openUrl');
expect(allKeys).toContain('qrCode'); expect(allKeys).toContain('qrCode');
expect(allKeys).toContain('formRecognizer'); expect(allKeys).toContain('textStatistics');
expect(allKeys).toContain('openUrlViewer'); expect(allKeys).toContain('jwt');
}); });
}); });
@@ -117,23 +111,16 @@ describe('features', () => {
expect(pageOrder).not.toContain('dashboard'); expect(pageOrder).not.toContain('dashboard');
}); });
it('should exclude openUrlViewer from page order', () => { it('should include timestamp, storageCleaner, qrCode in page order', () => {
const pageOrder = getDefaultPageOrder();
expect(pageOrder).not.toContain('openUrlViewer');
});
it('should include timestamp, storageCleaner, openUrl, qrCode, formRecognizer in page order', () => {
const pageOrder = getDefaultPageOrder(); const pageOrder = getDefaultPageOrder();
expect(pageOrder).toContain('timestamp'); expect(pageOrder).toContain('timestamp');
expect(pageOrder).toContain('storageCleaner'); expect(pageOrder).toContain('storageCleaner');
expect(pageOrder).toContain('openUrl');
expect(pageOrder).toContain('qrCode'); 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(); const pageOrder = getDefaultPageOrder();
expect(pageOrder).toHaveLength(7); expect(pageOrder).toHaveLength(5);
}); });
}); });
}); });
+35 -76
View File
@@ -1,23 +1,21 @@
import React, { ReactNode } from 'react'; import React, { ReactNode, lazy } from 'react';
import type { PageType } from '@/types/storage'; import type { PageType } from '@/types/storage';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import StorageIcon from '@mui/icons-material/Storage'; import StorageIcon from '@mui/icons-material/Storage';
import LanguageIcon from '@mui/icons-material/Language';
import QrCodeIcon from '@mui/icons-material/QrCode'; import QrCodeIcon from '@mui/icons-material/QrCode';
import DescriptionIcon from '@mui/icons-material/Description'; import DescriptionIcon from '@mui/icons-material/Description';
import VpnKeyIcon from '@mui/icons-material/VpnKey';
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 { THEME_COLORS } from './pageTheme'; 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; popup: React.ComponentType;
/** 侧边栏模式组件 */ /** 侧边栏模式组件 */
sidepanel: React.ComponentType; sidepanel: React.ComponentType;
/** 独立窗口模式组件 */ /** 标签页模式组件 */
detached: React.ComponentType; tab: React.ComponentType;
}; };
} }
@@ -56,7 +54,7 @@ export const FEATURES: FeatureConfig[] = [
components: { components: {
popup: DashboardPage, popup: DashboardPage,
sidepanel: DashboardPage, sidepanel: DashboardPage,
detached: DashboardPage, tab: DashboardPage,
}, },
}, },
{ {
@@ -69,7 +67,7 @@ export const FEATURES: FeatureConfig[] = [
components: { components: {
popup: TimestampPage, popup: TimestampPage,
sidepanel: TimestampPage, sidepanel: TimestampPage,
detached: TimestampPage, tab: TimestampPage,
}, },
}, },
{ {
@@ -82,20 +80,7 @@ export const FEATURES: FeatureConfig[] = [
components: { components: {
popup: StorageCleanerPage, popup: StorageCleanerPage,
sidepanel: StorageCleanerPage, sidepanel: StorageCleanerPage,
detached: StorageCleanerPage, tab: StorageCleanerPage,
},
},
{
key: 'openUrl',
label: 'Open Url',
description: '打开当前选中的 URL',
themeColor: THEME_COLORS.purple,
icon: <LanguageIcon sx={{ fontSize: 20 }} />,
defaultVisible: true,
components: {
popup: OpenUrlPage,
sidepanel: OpenUrlPage,
detached: OpenUrlPage,
}, },
}, },
{ {
@@ -108,57 +93,33 @@ export const FEATURES: FeatureConfig[] = [
components: { components: {
popup: QrCodePage, popup: QrCodePage,
sidepanel: QrCodePage, sidepanel: QrCodePage,
detached: QrCodePage, tab: QrCodePage,
}, },
}, },
{ {
key: 'formMapping', key: 'textStatistics',
label: '表单映射', label: '文本统计',
description: '智能识别表单指纹,自定义填充逻辑', description: '实时分析文本字符、单词及字节',
themeColor: THEME_COLORS.primary, themeColor: THEME_COLORS.purple,
icon: <DescriptionIcon sx={{ fontSize: 20 }} />, icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: FormMappingPage, popup: TextStatisticsPage,
sidepanel: FormMappingPage, sidepanel: TextStatisticsPage,
detached: FormMappingPage, tab: TextStatisticsPage,
}, },
}, },
{ {
key: 'formFill', key: 'jwt',
label: '智能填充', label: 'JWT 解析',
description: '根据表单指纹填充表单数据', description: 'JSON Web Token 解码与查看',
themeColor: THEME_COLORS.primary, themeColor: THEME_COLORS.indigo,
icon: <DescriptionIcon sx={{ fontSize: 20 }} />, icon: <VpnKeyIcon sx={{ fontSize: 20 }} />,
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: FormFillPage, popup: JwtPage,
sidepanel: FormFillPage, sidepanel: JwtPage,
detached: FormFillPage, tab: JwtPage,
},
},
{
key: 'formRecognizer',
label: '表单识别',
description: '智能识别表单指纹',
themeColor: THEME_COLORS.primary,
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
defaultVisible: true,
components: {
popup: FormRecognizerPage,
sidepanel: FormRecognizerPage,
detached: FormRecognizerPage,
},
},
{
key: 'openUrlViewer',
label: '查看',
description: '',
defaultVisible: false,
components: {
popup: OpenUrlViewerPage,
sidepanel: OpenUrlViewerPage,
detached: OpenUrlViewerPage,
}, },
}, },
]; ];
@@ -176,18 +137,16 @@ export function getAllFeatureKeys(): PageType[] {
} }
export function getDefaultPageOrder(): PageType[] { export function getDefaultPageOrder(): PageType[] {
return FEATURES.filter((f) => f.key !== 'dashboard' && f.key !== 'openUrlViewer').map( return FEATURES.filter((f) => f.key !== 'dashboard').map((f) => f.key);
(f) => f.key,
);
} }
export function getEntryPointType(): 'popup' | 'sidepanel' | 'detached' { export function getEntryPointType(): 'popup' | 'sidepanel' | 'tab' {
const pathname = window.location.pathname; const pathname = window.location.pathname;
if (pathname.includes('sidepanel')) { if (pathname.includes('sidepanel')) {
return 'sidepanel'; return 'sidepanel';
} }
if (new URLSearchParams(window.location.search).get('mode') === 'detached') { if (new URLSearchParams(window.location.search).get('mode') === 'tab') {
return 'detached'; return 'tab';
} }
return 'popup'; return 'popup';
} }
+35 -51
View File
@@ -43,6 +43,12 @@ export const THEME_COLORS = {
purpleDark: '#4a148c', purpleDark: '#4a148c',
purpleLight: '#9c27b0', purpleLight: '#9c27b0',
// 靛蓝色系
// #303f9f 在白底对比度 7.01:1 ✓
indigo: '#303f9f',
indigoDark: '#1a237e',
indigoLight: '#7986cb',
// 中性色 // 中性色
white: '#FFFFFF', white: '#FFFFFF',
black: '#000000', black: '#000000',
@@ -107,57 +113,6 @@ export const timestampPageStyles = {
buttonHover: `0 8px 24px ${alpha(THEME_COLORS.primary, 0.2)}`, buttonHover: `0 8px 24px ${alpha(THEME_COLORS.primary, 0.2)}`,
} as const; } 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 = { export const formMappingPageStyles = {
secondaryColor: THEME_COLORS.purple, secondaryColor: THEME_COLORS.purple,
} as const; } 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;
+9 -4
View File
@@ -56,14 +56,19 @@ const theme = createTheme({
'--sb-thumb-hover': 'rgba(0, 0, 0, 0.2)', '--sb-thumb-hover': 'rgba(0, 0, 0, 0.2)',
'--sb-track-color': 'transparent', '--sb-track-color': 'transparent',
}, },
'html, body, #root': { html: {
margin: 0, margin: 0,
padding: 0, padding: 0,
minWidth: '400px', width: '100%',
minHeight: '600px', minHeight: '100%',
overflow: 'hidden',
backgroundColor: '#f5f5f5', backgroundColor: '#f5f5f5',
}, },
'body, #root': {
margin: 0,
padding: 0,
width: '100%',
minHeight: '100%',
},
// 针对 Popup 的特殊处理(如果需要固定宽高,可以在具体入口点或容器中处理, // 针对 Popup 的特殊处理(如果需要固定宽高,可以在具体入口点或容器中处理,
// 这里提供全局基础,具体尺寸在 App 容器中限制) // 这里提供全局基础,具体尺寸在 App 容器中限制)
body: { body: {
-5
View File
@@ -1,15 +1,10 @@
import '../.wxt/types/imports.d.ts'; import '../.wxt/types/imports.d.ts';
import { initFormMappingHelper } from '@/utils/formMapping/ui';
import { initMessageHandler } from './content/messageHandler'; import { initMessageHandler } from './content/messageHandler';
export default defineContentScript({ export default defineContentScript({
matches: ['<all_urls>'], matches: ['<all_urls>'],
runAt: 'document_end', runAt: 'document_end',
main() { main() {
// 初始化表单映射助手逻辑 (UI, Picker, Highlighter)
initFormMappingHelper();
// 初始化消息处理器 (Scan, Fill, Clear, Highlight, Flash, Inject)
initMessageHandler(); initMessageHandler();
}, },
}); });
+1 -160
View File
@@ -1,160 +1 @@
import { export function initMessageHandler() {}
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 : '注入失败',
};
}
});
}
+263 -47
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { import {
Box, Box,
Typography, Typography,
@@ -8,11 +8,17 @@ import {
CircularProgress, CircularProgress,
Stack, Stack,
IconButton, IconButton,
Tabs,
Tab,
alpha,
Divider,
} from '@mui/material'; } from '@mui/material';
import SettingsIcon from '@mui/icons-material/Settings';
import RefreshIcon from '@mui/icons-material/Refresh'; import RefreshIcon from '@mui/icons-material/Refresh';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; 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 DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import type { PageType, StorageSchema } from '@/types/storage';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import { import {
getFeatureByKey, getFeatureByKey,
@@ -21,25 +27,73 @@ import {
} from '@/config/features'; } from '@/config/features';
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar'; import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
import ErrorBoundary from '@/components/ErrorBoundary'; 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() { 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<WindowType>(initialWindowType);
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 } = useSnackbarState(); const { snackbarProps, showMessage } = useSnackbarState();
useEffect(() => { // 根据当前选择的窗口类型确定对应的 Storage Key
loadConfig().catch(console.error); 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 () => { const loadConfig = async () => {
setIsLoaded(false);
try { try {
const [savedVisible, savedOrder] = await Promise.all([ const [savedVisible, savedOrder] = await Promise.all([
storageUtil.get('app/visiblePages', getDefaultVisibleFeatureKeys()), storageUtil.get(configKeys.visible, getDefaultVisibleFeatureKeys()),
storageUtil.get('app/pageOrder', getDefaultPageOrder()), storageUtil.get(configKeys.order, getDefaultPageOrder()),
]); ]);
setVisiblePages(savedVisible ?? getDefaultVisibleFeatureKeys()); setVisiblePages((savedVisible as PageType[]) ?? getDefaultVisibleFeatureKeys());
setPageOrder(savedOrder && savedOrder.length > 0 ? savedOrder : getDefaultPageOrder()); setPageOrder((savedOrder as PageType[]) ?? getDefaultPageOrder());
} catch (error) { } catch (error) {
console.error('Failed to load config:', error); console.error('Failed to load config:', error);
setVisiblePages(getDefaultVisibleFeatureKeys()); setVisiblePages(getDefaultVisibleFeatureKeys());
@@ -49,6 +103,12 @@ export default function App() {
} }
}; };
loadConfig().catch(console.error);
}, [configKeys]);
/**
* 切换页面可见性
*/
const handlePageToggle = async (page: PageType) => { const handlePageToggle = async (page: PageType) => {
const isCurrentlyVisible = visiblePages.includes(page); const isCurrentlyVisible = visiblePages.includes(page);
let newPages: PageType[]; let newPages: PageType[];
@@ -64,7 +124,7 @@ export default function App() {
} }
try { try {
await storageUtil.set('app/visiblePages', newPages); await storageUtil.set(configKeys.visible, newPages);
setVisiblePages(newPages); setVisiblePages(newPages);
const feature = getFeatureByKey(page); const feature = getFeatureByKey(page);
showToast(`${isCurrentlyVisible ? '隐藏' : '显示'} ${feature?.label || page}`, 'success'); showToast(`${isCurrentlyVisible ? '隐藏' : '显示'} ${feature?.label || page}`, 'success');
@@ -74,6 +134,9 @@ export default function App() {
} }
}; };
/**
* 调整页面显示顺序
*/
const handleMove = async (index: number, direction: 'up' | 'down') => { const handleMove = async (index: number, direction: 'up' | 'down') => {
if (direction === 'up' && index === 0) return; if (direction === 'up' && index === 0) return;
if (direction === 'down' && index === pageOrder.length - 1) 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]]; [newOrder[index], newOrder[swapIndex]] = [newOrder[swapIndex], newOrder[index]];
try { try {
await storageUtil.set('app/pageOrder', newOrder); await storageUtil.set(configKeys.order, newOrder);
setPageOrder(newOrder); setPageOrder(newOrder);
} catch (error) { } catch (error) {
console.error('Failed to save order:', error); console.error('Failed to save order:', error);
@@ -91,64 +154,127 @@ export default function App() {
} }
}; };
/**
* 恢复默认设置
*/
const handleRestoreDefaults = async () => { const handleRestoreDefaults = async () => {
try { try {
const { getDefaultVisibleFeatureKeys } = await import('@/config/features');
const defaults = getDefaultVisibleFeatureKeys(); const defaults = getDefaultVisibleFeatureKeys();
const defaultOrder = getDefaultPageOrder(); const defaultOrder = getDefaultPageOrder();
await Promise.all([ await Promise.all([
storageUtil.set('app/visiblePages', defaults), storageUtil.set(configKeys.visible, defaults),
storageUtil.set('app/pageOrder', defaultOrder), storageUtil.set(configKeys.order, defaultOrder),
]); ]);
setVisiblePages(defaults); setVisiblePages(defaults);
setPageOrder(defaultOrder); setPageOrder(defaultOrder);
showToast('已恢复默认', 'success'); showToast('已恢复当前模式默认设置', 'success');
} catch (error) { } catch (error) {
console.error('Failed to restore defaults:', error); console.error('Failed to restore defaults:', error);
showToast('恢复失败', 'warning'); showToast('恢复失败', 'warning');
} }
}; };
const handleWindowTypeChange = (_event: React.SyntheticEvent, newType: WindowType) => {
if (newType !== null) {
setWindowType(newType);
}
};
const showToast = (message: string, severity: 'success' | 'info' | 'warning') => { const showToast = (message: string, severity: 'success' | 'info' | 'warning') => {
showMessage(message, { severity }); showMessage(message, { severity });
}; };
if (!isLoaded) {
return (
<Box
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}
>
<CircularProgress size={24} />
</Box>
);
}
return ( return (
<Box <Box
className="app" className="app"
sx={{ p: 4, minHeight: '100vh', bgcolor: 'grey.50', display: 'block', overflowY: 'auto' }} sx={{
minHeight: '100vh',
bgcolor: 'grey.50',
display: 'flex',
flexDirection: 'column',
}}
> >
<ErrorBoundary> <ErrorBoundary>
<Box sx={{ maxWidth: 600, mx: 'auto' }}> {/* 顶部标题与导航栏 */}
<Stack <Box
direction="row" sx={{
justifyContent="space-between" width: '100%',
alignItems="flex-start" bgcolor: 'background.paper',
sx={{ mb: 4 }} borderBottom: '1px solid',
borderColor: 'grey.200',
pt: { xs: 3, sm: 5 },
pb: 0,
px: { xs: 2, sm: 4 },
}}
> >
<Box sx={{ maxWidth: 800, mx: 'auto' }}>
<PageHeader
icon={<SettingsIcon />}
iconColor={THEME_COLORS.primary}
title="应用设置"
subtitle="针对不同窗口类型独立配置 Dashboard 中显示的功能及其排序"
sx={{ mb: 4 }}
/>
<Tabs
value={windowType}
onChange={handleWindowTypeChange}
indicatorColor="primary"
textColor="primary"
sx={{
'& .MuiTab-root': {
fontWeight: 700,
fontSize: '0.9rem',
textTransform: 'none',
minWidth: { xs: 100, sm: 140 },
letterSpacing: '0.3px',
},
'& .MuiTabs-indicator': {
height: 3,
borderRadius: '3px 3px 0 0',
},
}}
>
<Tab value="popup" label="Popup 窗口" />
<Tab value="sidepanel" label="侧边栏" />
<Tab value="tab" label="标签页" />
</Tabs>
</Box>
</Box>
{/* 主内容区域 */}
<Box sx={{ flex: 1, p: { xs: 2, sm: 4 } }}>
<Box sx={{ maxWidth: 800, mx: 'auto' }}>
<Stack direction="row" justifyContent="flex-end" sx={{ mb: 2.5 }}>
<Button <Button
variant="text" variant="outlined"
color="primary"
size="small" size="small"
onClick={handleRestoreDefaults} onClick={handleRestoreDefaults}
startIcon={<RefreshIcon sx={{ fontSize: 16 }} />} startIcon={<RefreshIcon sx={{ fontSize: 18 }} />}
sx={{ color: 'text.secondary', fontWeight: 600 }} sx={{
borderRadius: 2.5,
fontWeight: 700,
textTransform: 'none',
borderColor: 'grey.200',
color: 'text.secondary',
'&:hover': {
borderColor: 'primary.main',
color: 'primary.main',
bgcolor: alpha(THEME_COLORS.primary, 0.04),
},
}}
> >
</Button> </Button>
</Stack> </Stack>
{!isLoaded ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 12 }}>
<CircularProgress size={32} thickness={5} />
</Box>
) : (
<Paper <Paper
elevation={0} elevation={0}
sx={{ sx={{
@@ -157,6 +283,7 @@ export default function App() {
borderColor: 'grey.200', borderColor: 'grey.200',
overflow: 'hidden', overflow: 'hidden',
bgcolor: 'background.paper', bgcolor: 'background.paper',
boxShadow: '0 4px 24px rgba(0,0,0,0.03)',
}} }}
> >
<Box sx={{ display: 'flex', flexDirection: 'column' }}> <Box sx={{ display: 'flex', flexDirection: 'column' }}>
@@ -174,27 +301,85 @@ export default function App() {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
p: 2.5, p: { xs: 2, sm: 2.5 },
borderBottom: index === array.length - 1 ? 'none' : '1px solid', borderBottom: index === array.length - 1 ? 'none' : '1px solid',
borderColor: 'grey.100', borderColor: 'grey.100',
transition: 'all 0.2s', transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': { bgcolor: 'grey.50' }, '&:hover': {
bgcolor: alpha(feature.themeColor || THEME_COLORS.primary, 0.02),
},
}}
>
<Stack
direction="row"
spacing={{ xs: 1.5, sm: 2.5 }}
alignItems="center"
sx={{ flex: 1, minWidth: 0 }}
>
{/* 拖拽/排序暗示图标 */}
<Box sx={{ color: 'grey.300', display: 'flex' }}>
<DragIndicatorIcon fontSize="small" />
</Box>
{/* 功能图标容器 */}
<Box
sx={{
p: 1.2,
borderRadius: 2.5,
bgcolor: alpha(feature.themeColor || THEME_COLORS.primary, 0.1),
color: feature.themeColor || THEME_COLORS.primary,
display: 'flex',
flexShrink: 0,
}}
>
{feature.icon}
</Box>
{/* 文本信息 */}
<Box sx={{ minWidth: 0 }}>
<Typography
variant="subtitle2"
sx={{
fontWeight: 800,
color: 'text.primary',
fontSize: '0.95rem',
lineHeight: 1.2,
mb: 0.5,
}} }}
> >
<Box>
<Typography variant="body1" sx={{ fontWeight: 700, color: 'text.primary' }}>
{feature.label} {feature.label}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography
{isChecked ? '已在 Dashboard 启用' : '已在 Dashboard 隐藏'} variant="caption"
color="text.secondary"
sx={{
fontWeight: 600,
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{feature.description || '暂无描述'}
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> </Stack>
<Box sx={{ display: 'flex', alignItems: 'center', ml: { xs: 1, sm: 2 } }}>
{/* 移动操作按钮 */}
<Stack direction="row" sx={{ display: 'flex', mr: { xs: 0, sm: 1 } }}>
<IconButton <IconButton
size="small" size="small"
onClick={() => handleMove(index, 'up')} onClick={() => handleMove(index, 'up')}
disabled={index === 0} disabled={index === 0}
sx={{ color: 'text.secondary' }} sx={{
color: 'grey.400',
p: { xs: 0.5, sm: 1 },
'&:hover': {
color: 'primary.main',
bgcolor: alpha(THEME_COLORS.primary, 0.08),
},
}}
> >
<KeyboardArrowUpIcon fontSize="small" /> <KeyboardArrowUpIcon fontSize="small" />
</IconButton> </IconButton>
@@ -202,15 +387,44 @@ export default function App() {
size="small" size="small"
onClick={() => handleMove(index, 'down')} onClick={() => handleMove(index, 'down')}
disabled={index === array.length - 1} disabled={index === array.length - 1}
sx={{ color: 'text.secondary' }} sx={{
color: 'grey.400',
p: { xs: 0.5, sm: 1 },
'&:hover': {
color: 'primary.main',
bgcolor: alpha(THEME_COLORS.primary, 0.08),
},
}}
> >
<KeyboardArrowDownIcon fontSize="small" /> <KeyboardArrowDownIcon fontSize="small" />
</IconButton> </IconButton>
</Stack>
<Divider
orientation="vertical"
flexItem
sx={{
mx: { xs: 0.5, sm: 1 },
display: 'block',
height: 24,
alignSelf: 'center',
}}
/>
{/* 显示切换开关 */}
<Switch <Switch
size="small" color="primary"
checked={isChecked} checked={isChecked}
onChange={() => handlePageToggle(key)} onChange={() => handlePageToggle(key)}
disabled={isDisabled} 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,
},
}}
/> />
</Box> </Box>
</Box> </Box>
@@ -218,6 +432,8 @@ export default function App() {
})} })}
</Box> </Box>
</Paper> </Paper>
)}
</Box>
</Box> </Box>
</ErrorBoundary> </ErrorBoundary>
+34 -6
View File
@@ -5,30 +5,58 @@ import ErrorBoundary from '@/components/ErrorBoundary';
import { globalStyles } from '@/config/pageTheme'; import { globalStyles } from '@/config/pageTheme';
import { SnackbarProvider } from '@/components/GlobalSnackbar'; import { SnackbarProvider } from '@/components/GlobalSnackbar';
import { Box } from '@mui/material'; import { Box } from '@mui/material';
import { getEntryPointType } from '@/config/features';
import { useMemo } from 'react';
export default function App() { export default function App() {
// 打开Chrome扩展选项页面,需确保manifest中已配置options_page或options_ui // 打开Chrome扩展选项页面,需确保manifest中已配置options_page或options_ui
const handleOpenOptions = () => { 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 ( return (
<RouterProvider syncKey="app/popupRoute"> <RouterProvider
<SnackbarProvider initialOptions={{ autoHideDuration: 1500000 }}> syncKey={routerConfig.syncKey}
visiblePagesKey={routerConfig.visiblePagesKey}
pageOrderKey={routerConfig.pageOrderKey}
>
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
<Box <Box
className="app" className="app"
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
width: '400px', width: '400px',
maxWidth: '400px',
minWidth: '400px',
height: '600px', height: '600px',
minHeight: '600px',
overflow: 'hidden', overflow: 'hidden',
backgroundColor: globalStyles.backgroundColor, backgroundColor: globalStyles.backgroundColor,
'@media screen and (min-width: 401px), screen and (min-height: 601px)': { // 仅在明确的大屏幕(如独立页面或侧边栏拉伸)下才允许扩展
'@media screen and (min-width: 600px)': {
width: '100vw', width: '100vw',
maxWidth: 'none',
minWidth: 'none',
height: '100vh', height: '100vh',
minWidth: '400px', minHeight: 'none',
minHeight: '600px',
}, },
}} }}
> >
+23 -1
View File
@@ -3,8 +3,30 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>我是独立窗口</title> <title>Testing Tools - 标签页</title>
<meta name="manifest.type" content="browser_action" /> <meta name="manifest.type" content="browser_action" />
<style>
/* Force initial popup size before React hydration */
html, body {
width: 400px;
height: 600px;
margin: 0;
padding: 0;
overflow: hidden;
}
/* Ensure full size for the root container */
#root {
width: 100%;
height: 100%;
}
/* If opened in a tab (mode=tab), reset the fixed size */
@media screen and (min-width: 600px) {
html, body {
width: 100%;
height: 100%;
}
}
</style>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
-364
View File
@@ -1,364 +0,0 @@
import {
Box,
Typography,
Container,
List,
ListItem,
ListItemText,
IconButton,
Switch,
Divider,
Paper,
Chip,
} from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RefreshIcon from '@mui/icons-material/Refresh';
import VisibilityIcon from '@mui/icons-material/Visibility';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelIcon from '@mui/icons-material/Cancel';
import { useEffect, useState, useCallback } from 'react';
import { storageUtil } from '@/utils/chromeStorage';
import { FormMapEntry } from '@/types/storage';
import PageHeader from '@/components/PageHeader';
import { formMappingPageStyles } from '@/config/pageTheme.ts';
import { MockDataGenerator } from '@/utils/formMapping/smartInjector';
import { Button } from '@/components/Button';
import { FormInjectResult, MessageAction, sendMessage } from '@/utils/messages';
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
export default function FormFillPage() {
const [entries, setEntries] = useState<FormMapEntry[]>([]);
const [previewData, setPreviewData] = useState<Map<string, string>>(new Map());
const [injectResults, setInjectResults] = useState<Map<string, boolean>>(new Map());
const [isInjecting, setIsInjecting] = useState(false);
const { showMessage } = useGlobalSnackbar({ autoHideDuration: 3000 });
const generatePreviewData = useCallback((items: FormMapEntry[]) => {
const preview = new Map<string, string>();
items.forEach((entry) => {
const value = MockDataGenerator.generate(entry.action_logic, entry);
preview.set(entry.id, value);
});
setPreviewData(preview);
setInjectResults(new Map());
}, []);
useEffect(() => {
const loadEntries = async () => {
const data = (await storageUtil.get('active_form_map')) as FormMapEntry[];
setEntries(data || []);
generatePreviewData(data || []);
};
loadEntries().catch((r) => console.error(r));
const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => {
if (area === 'local' && changes['active_form_map']) {
loadEntries().catch((r) => console.error(r));
}
};
chrome.storage.onChanged.addListener(listener);
return () => chrome.storage.onChanged.removeListener(listener);
}, [generatePreviewData]);
const refreshPreview = () => {
generatePreviewData(entries);
};
const injectAllFields = async () => {
if (entries.length === 0) {
showMessage('没有可填充的字段', { severity: 'error' });
return;
}
setIsInjecting(true);
const results = new Map<string, boolean>();
try {
// 发送消息到 content script 执行注入
const response = await chrome.tabs.query({ active: true, currentWindow: true });
if (response.length === 0) {
throw new Error('无法获取当前标签页');
}
const tabId = response[0].id;
if (!tabId) {
throw new Error('标签页ID无效');
}
// 准备注入数据
const injectData = entries.map((entry) => ({
entry,
mockValue: previewData.get(entry.id) || '',
}));
// 执行注入 (使用 type-safe sendMessage)
const result = await sendMessage(MessageAction.FORM_INJECT, { data: injectData }, tabId);
if (result && result.success) {
result.results?.forEach((r: FormInjectResult) => {
results.set(r.id, r.success);
});
setInjectResults(results);
showMessage('填充成功!', { severity: 'success' });
} else {
throw new Error(result?.error || result?.message || '注入失败');
}
} catch (error) {
console.error('注入失败:', error);
showMessage(error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单');
showMessage(error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单', {
severity: 'error',
});
} finally {
setIsInjecting(false);
}
};
const getFieldTypeLabel = (type: string) => {
const labels: Record<string, string> = {
text: '文本',
select: '下拉框',
checkbox: '复选框',
radio: '单选框',
};
return labels[type] || type;
};
const getStrategyLabel = (strategy: string) => {
const labels: Record<string, string> = {
fixed: '固定值',
random: '随机',
sequence: '序列',
};
return labels[strategy] || strategy;
};
return (
<Box>
<Container sx={{ py: 2 }}>
<PageHeader
title="智能表单填充"
subtitle="基于指纹识别的精准数据注入"
icon={<PlayArrowIcon />}
/>
<Container maxWidth="sm" sx={{ py: 2, px: 0 }}>
{/* 操作区域 */}
<Paper
sx={{
p: 2,
mb: 2.5,
bgcolor: 'background.paper',
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.100',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
}}
>
<Typography variant="subtitle1" fontWeight={800} color="text.primary">
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="outlined"
onClick={refreshPreview}
size="small"
startIcon={<RefreshIcon />}
>
</Button>
<Button
variant="contained"
onClick={injectAllFields}
size="small"
startIcon={<PlayArrowIcon />}
disabled={isInjecting || entries.length === 0}
sx={{
bgcolor: formMappingPageStyles.secondaryColor || '#9c27b0',
}}
>
{isInjecting ? '注入中...' : '开始填充'}
</Button>
</Box>
</Box>
<Typography variant="body2" color="text.secondary">
"开始填充"
</Typography>
</Paper>
{/* 字段列表 */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
px: 0.5,
}}
>
<Typography variant="subtitle2" fontWeight={800} color="text.secondary">
({entries.length})
</Typography>
</Box>
<List
sx={{
bgcolor: 'background.paper',
borderRadius: 4,
overflow: 'hidden',
border: '1px solid',
borderColor: 'grey.100',
}}
>
{entries.length === 0 ? (
<ListItem>
<ListItemText
primary="暂无映射字段"
secondary="请先在表单映射页面配置字段"
slotProps={{
primary: {
align: 'center',
color: 'text.secondary',
},
secondary: {
align: 'center',
},
}}
/>
</ListItem>
) : (
entries.map((entry, index) => (
<Box key={entry.id}>
{index > 0 && <Divider />}
<ListItem
secondaryAction={
<IconButton edge="end" aria-label="preview">
<VisibilityIcon fontSize="small" />
</IconButton>
}
sx={{ py: 1.5 }}
>
<Switch
edge="start"
checked={entry.ui_state.is_selected}
disabled
sx={{ mr: 2 }}
/>
<ListItemText
slotProps={{
primary: {
component: 'div',
},
secondary: {
component: 'div',
},
}}
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<span style={{ fontWeight: 500 }}>{entry.label_display}</span>
<Chip
size="small"
label={getFieldTypeLabel(entry.action_logic.type)}
sx={{
fontSize: '0.65rem',
bgcolor: 'grey.100',
color: 'grey.700',
}}
/>
<Chip
size="small"
label={getStrategyLabel(entry.action_logic.strategy)}
sx={{
fontSize: '0.65rem',
bgcolor: formMappingPageStyles.secondaryColor + '20',
color: formMappingPageStyles.secondaryColor || '#9c27b0',
}}
/>
</Box>
}
secondary={
<Box>
<Typography
sx={{
fontFamily: 'monospace',
fontSize: '0.7rem',
color: 'text.secondary',
mb: 1,
}}
>
{entry.fingerprint.selector}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
sx={{
fontSize: '0.75rem',
color: 'primary.main',
fontStyle: 'italic',
wordBreak: 'break-all',
maxWidth: '250px',
}}
>
: {previewData.get(entry.id) || '---'}
</Typography>
{injectResults.has(entry.id) &&
(injectResults.get(entry.id) ? (
<CheckCircleIcon sx={{ color: '#32CD32', fontSize: '1rem' }} />
) : (
<CancelIcon sx={{ color: '#FF4444', fontSize: '1rem' }} />
))}
</Box>
</Box>
}
/>
</ListItem>
</Box>
))
)}
</List>
{/* 统计信息 */}
{injectResults.size > 0 && (
<Box sx={{ mt: 4 }}>
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: 'grey.50',
borderRadius: 3,
border: '1px solid',
borderColor: 'grey.200',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-around' }}>
<Box textAlign="center">
<Typography variant="h5" fontWeight={800} color="primary.main">
{Array.from(injectResults.values()).filter(Boolean).length}
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
<Box textAlign="center">
<Typography variant="h5" fontWeight={800} color="error.main">
{Array.from(injectResults.values()).filter((v) => !v).length}
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
</Box>
</Paper>
</Box>
)}
</Container>
</Container>
</Box>
);
}
-285
View File
@@ -1,285 +0,0 @@
import {
Box,
Typography,
Container,
List,
ListItem,
ListItemText,
IconButton,
Switch,
Divider,
Paper,
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import { useEffect, useState } from 'react';
import { storageUtil } from '@/utils/chromeStorage';
import { FormMapEntry } from '@/types/storage';
import PageHeader from '@/components/PageHeader';
import Button from '@/components/Button';
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
export default function FormMappingPage() {
const [entries, setEntries] = useState<FormMapEntry[]>([]);
const [isPicking, setIsPicking] = useState(false);
const { showMessage } = useGlobalSnackbar({ autoHideDuration: 3000 });
useEffect(() => {
const loadData = async () => {
const data = (await storageUtil.get('active_form_map')) as FormMapEntry[];
setEntries(data || []);
const picking = (await storageUtil.get('app/formMapping/isPicking')) as boolean;
setIsPicking(picking || false);
};
loadData().catch((r) => console.error(r));
const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => {
if (area === 'local') {
if (changes['active_form_map']) {
setEntries((changes['active_form_map'].newValue as FormMapEntry[]) || []);
}
if (changes['app/formMapping/isPicking']) {
setIsPicking((changes['app/formMapping/isPicking'].newValue as boolean) || false);
}
}
};
chrome.storage.onChanged.addListener(listener);
return () => chrome.storage.onChanged.removeListener(listener);
}, []);
const togglePicking = async () => {
await storageUtil.set('app/formMapping/isPicking', !isPicking);
};
const deleteEntry = async (id: string) => {
const newEntries = entries.filter((e) => e.id !== id);
await storageUtil.set('active_form_map', newEntries);
};
const toggleSelection = async (id: string) => {
const newEntries = entries.map((e) =>
e.id === id ? { ...e, ui_state: { ...e.ui_state, is_selected: !e.ui_state.is_selected } } : e,
);
await storageUtil.set('active_form_map', newEntries);
};
const clearAll = async () => {
await storageUtil.set('active_form_map', []);
await storageUtil.set('app/formMapping/isPicking', false);
};
const exportConfig = () => {
try {
if (entries.length === 0) {
showMessage('没有可导出的配置数据', { severity: 'warning' });
return;
}
const jsonStr = JSON.stringify(entries, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const date = new Date();
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const filename = `form-mapping-config-${dateStr}.json`;
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showMessage('配置导出成功!', { severity: 'success' });
} catch (error) {
console.error('导出配置失败:', error);
showMessage(error instanceof Error ? error.message : '导出失败,请重试', {
severity: 'error',
});
}
};
return (
<Box>
<Box>
<Container sx={{ py: 2 }}>
<PageHeader
title="通用表单映射助手"
subtitle="智能识别表单指纹,自定义填充逻辑"
icon={<AutoFixHighIcon />}
/>
<Container maxWidth="sm" sx={{ py: 2, px: 0 }}>
<Paper
sx={{
p: 2,
mb: 2.5,
bgcolor: 'background.paper',
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.100',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
}}
>
<Typography variant="subtitle1" fontWeight={800} color="text.primary">
</Typography>
<Button
variant={isPicking ? 'contained' : 'outlined'}
onClick={togglePicking}
size="small"
startIcon={<AddCircleOutlineIcon />}
>
{isPicking ? '正在拾取...' : '开始拾取'}
</Button>
</Box>
<Typography variant="body2" color="text.secondary">
</Typography>
</Paper>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
px: 0.5,
}}
>
<Typography variant="subtitle2" fontWeight={800} color="text.secondary">
({entries.length})
</Typography>
<Button size="small" color="error" onClick={clearAll}>
</Button>
</Box>
<List
sx={{
bgcolor: 'background.paper',
borderRadius: 4,
overflow: 'hidden',
border: '1px solid',
borderColor: 'grey.100',
}}
>
{entries.length === 0 ? (
<ListItem>
<ListItemText
primary="暂无数据"
secondary="点击上方按钮开始探测网页表单"
slotProps={{
primary: {
align: 'center',
color: 'text.secondary',
},
secondary: {
align: 'center',
},
}}
/>
</ListItem>
) : (
entries.map((entry, index) => (
<Box key={entry.id}>
{index > 0 && <Divider />}
<ListItem
secondaryAction={
<IconButton
edge="end"
aria-label="delete"
onClick={() => deleteEntry(entry.id)}
sx={{ color: 'error.light' }}
>
<DeleteIcon fontSize="small" />
</IconButton>
}
sx={{ py: 1.5 }}
>
<Switch
edge="start"
checked={entry.ui_state.is_selected}
onChange={() => toggleSelection(entry.id)}
/>
<ListItemText
primary={entry.label_display}
secondary={entry.fingerprint.selector}
slotProps={{
primary: { fontWeight: 500 },
secondary: {
sx: {
fontFamily: 'monospace',
fontSize: '0.7rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '200px',
},
},
}}
/>
</ListItem>
</Box>
))
)}
</List>
{entries.length > 0 && (
<Box sx={{ mt: 4 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
px: 0.5,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'text.secondary' }}>
(JSON)
</Typography>
<Button
size="small"
variant="outlined"
onClick={exportConfig}
startIcon={<FileDownloadIcon />}
>
</Button>
</Box>
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: 'grey.50',
borderRadius: 3,
border: '1px solid',
borderColor: 'grey.200',
fontFamily: 'monospace',
fontSize: '0.7rem',
maxHeight: '180px',
overflow: 'auto',
}}
>
<pre style={{ margin: 0 }}>{JSON.stringify(entries, null, 2)}</pre>
</Paper>
</Box>
)}
</Container>
</Container>
</Box>
</Box>
);
}
@@ -1,127 +0,0 @@
import { Box, Container, CircularProgress, FormControlLabel, Switch } from '@mui/material';
import Button from '@/components/Button';
import InputIcon from '@mui/icons-material/Input';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { formRecognizerPageStyles } from '@/config/pageTheme';
import FieldList from '@/components/FieldList';
import PageHeader from '@/components/PageHeader';
import { useFormRecognizer } from './hooks/useFormRecognizer';
export default function FormRecognizerPage() {
const {
fillLoading,
clearLoading,
includeHidden,
setIncludeHidden,
fields,
scanning,
showFields,
setShowFields,
hoveredFieldId,
sidePanelOpen,
handleScanFields,
handleFieldTypeChange,
handleToggleFieldSelection,
handleToggleAllFields,
handleLocateField,
handleHoverField,
handleFillSelectedFields,
handleClearAllFields,
handleOpenSidePanel,
selectedCount,
} = useFormRecognizer();
return (
<Box>
<Container maxWidth="sm" sx={{ py: 3, px: 2 }}>
{/* Header */}
<PageHeader
title="表单测试数据填充器"
subtitle="一键填充表单测试数据,提升开发和测试效率"
icon={<InputIcon />}
iconColor={formRecognizerPageStyles.primaryColor}
sx={{ mb: 2.5 }}
/>
<Button
size="small"
startIcon={<OpenInNewIcon />}
onClick={handleOpenSidePanel}
sx={{
textTransform: 'none',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
opacity: sidePanelOpen ? 0 : 1,
transform: sidePanelOpen ? 'scale(0.8)' : 'scale(1)',
pointerEvents: sidePanelOpen ? 'none' : 'auto',
visibility: sidePanelOpen ? 'hidden' : 'visible',
position: 'relative',
}}
>
</Button>
{/* 扫描按钮 */}
<Button
variant="outlined"
onClick={handleScanFields}
disabled={scanning}
fullWidth
startIcon={scanning ? <CircularProgress size={16} color="inherit" /> : <InputIcon />}
>
{scanning ? '扫描中...' : '扫描表单字段'}
</Button>
<FieldList
fields={fields}
showFields={showFields}
onToggleShowFields={() => setShowFields(!showFields)}
onFieldTypeChange={handleFieldTypeChange}
onLocateField={handleLocateField}
onHoverField={handleHoverField}
onToggleFieldSelection={handleToggleFieldSelection}
onToggleAllFields={handleToggleAllFields}
hoveredFieldId={hoveredFieldId}
/>
{/* 操作按钮 */}
{fields.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Button
disableElevation
disableRipple
variant="contained"
onClick={handleFillSelectedFields}
disabled={fillLoading || selectedCount === 0}
fullWidth
>
</Button>
<Button
disableElevation
disableRipple
variant="outlined"
onClick={handleClearAllFields}
disabled={clearLoading}
fullWidth
>
</Button>
</Box>
)}
<Box sx={{ mt: 3 }}>
<FormControlLabel
control={
<Switch
checked={includeHidden}
onChange={(e) => setIncludeHidden(e.target.checked)}
/>
}
label="包含隐藏字段"
/>
</Box>
</Container>
</Box>
);
}
-69
View File
@@ -1,69 +0,0 @@
import { Box, Typography, Container } from '@mui/material';
import LanguageIcon from '@mui/icons-material/Language';
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
import UrlEntryForm from '@/components/UrlEntryForm';
import UrlEntryList from '@/components/UrlEntryList';
import { useUrlPreferences } from '@/utils/useUrlPreferences';
import type { OpenUrlEntry } from '@/types/storage';
import { openUrlPageStyles } from '@/config/pageTheme';
import PageHeader from '@/components/PageHeader';
export default function OpenUrlPage() {
const { entries, setEntries, isLoaded } = useUrlPreferences();
const { showMessage } = useGlobalSnackbar();
const handleAddEntry = (entry: OpenUrlEntry) => {
setEntries([...entries, entry]);
};
const handleDeleteEntry = (index: number) => {
const newEntries = [...entries];
newEntries.splice(index, 1);
setEntries(newEntries);
showMessage('删除成功', { severity: 'success' });
};
if (!isLoaded) {
return (
<Box sx={{ minHeight: '100%', pb: 3 }}>
<Container sx={{ py: 2 }}>
<Typography>...</Typography>
</Container>
</Box>
);
}
return (
<Box>
<Container sx={{ py: 2 }}>
{/* Header */}
<PageHeader
title="URL 工具"
subtitle="快速打开 URL 或复制链接"
icon={<LanguageIcon />}
iconColor={openUrlPageStyles.primaryColor}
sx={{ mb: 2.5 }}
/>
{/* Form Section */}
<UrlEntryForm onAddEntry={handleAddEntry} showMessage={showMessage} />
{/* List Section */}
<Box>
<Typography
variant="caption"
sx={{ color: 'text.secondary', fontWeight: 800, px: 1, mb: 1, display: 'block' }}
>
({entries.length})
</Typography>
<UrlEntryList
entries={entries}
onDeleteEntry={handleDeleteEntry}
showMessage={showMessage}
/>
</Box>
</Container>
</Box>
);
}
@@ -1,156 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { Box, Typography, CircularProgress, Alert } from '@mui/material';
import { storageUtil } from '@/utils/chromeStorage';
// 只允许 HTTP/HTTPS 协议,阻止危险协议
const ALLOWED_PROTOCOLS = ['http:', 'https:'];
export default function OpenUrlViewerPage() {
const [currentUrl, setCurrentUrl] = useState<string>('');
const [isLoaded, setIsLoaded] = useState(false);
const [iframeLoading, setIframeLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 验证 URL 是否安全
const validateUrl = (url: string): string | null => {
try {
const urlObj = new URL(url);
if (!ALLOWED_PROTOCOLS.includes(urlObj.protocol)) {
return `不支持的 URL 协议: ${urlObj.protocol}。仅允许 HTTP 和 HTTPS。`;
}
return null;
} catch {
return '无效的 URL 格式';
}
};
// 从存储加载当前选中的 URL
const loadCurrentUrl = useCallback(async () => {
try {
const saved = await storageUtil.get('openUrl/currentUrl', '');
if (saved) {
const validationError = validateUrl(saved);
if (validationError) {
setError(validationError);
} else {
setCurrentUrl(saved);
setError(null);
setIframeLoading(true); // 重置 iframe 加载状态
}
} else {
setError(null);
setCurrentUrl('');
}
} catch (error) {
console.error('Failed to load current URL:', error);
setError('加载 URL 失败');
} finally {
setIsLoaded(true);
}
}, []);
// 初始加载
useEffect(() => {
loadCurrentUrl();
}, [loadCurrentUrl]);
// 监听存储变化,确保 URL 变更时能及时更新
useEffect(() => {
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
if (changes['openUrl/currentUrl']) {
loadCurrentUrl();
}
};
chrome.storage.onChanged.addListener(handleStorageChange);
return () => chrome.storage.onChanged.removeListener(handleStorageChange);
}, [loadCurrentUrl]);
if (!isLoaded) {
return (
<Box
sx={{
p: 2,
flex: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<CircularProgress size={40} />
</Box>
);
}
if (error) {
return (
<Box sx={{ p: 2, flex: 1 }}>
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
<Typography color="text.secondary"> OpenUrl URL</Typography>
</Box>
);
}
if (!currentUrl) {
return (
<Box sx={{ p: 2, flex: 1 }}>
<Alert severity="info" sx={{ mb: 2 }}>
URL
</Alert>
<Typography color="text.secondary"> OpenUrl URL </Typography>
</Box>
);
}
return (
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* 加载状态指示器 */}
{iframeLoading && (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
bgcolor: 'rgba(255, 255, 255, 0.8)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
}}
>
<Box sx={{ textAlign: 'center' }}>
<CircularProgress size={60} />
<Typography sx={{ mt: 2 }}>...</Typography>
</Box>
</Box>
)}
<iframe
src={currentUrl}
title="OpenUrl Viewer"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-navigation"
style={{
flex: 1,
width: '100%',
border: 'none',
display: 'block',
}}
onLoad={() => setIframeLoading(false)}
onError={() => {
setIframeLoading(false);
setError('URL 加载失败,请检查网络连接或 URL 是否正确');
}}
/>
</Box>
);
}
@@ -1,292 +0,0 @@
import { useState, useRef, useCallback } from 'react';
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
import { MessageAction, sendMessageToContent, injectContentScript } from '@/utils/messages';
import { FillMode } from '@/utils/dummyDataGenerator';
import { useStorageState } from '@/utils/useStorageState';
import { FieldTypePreferences } from '@/types/storage';
import { useActiveTabDomain } from './useActiveTabDomain';
import { useSidePanelState } from './useSidePanelState';
// 字段数据接口
export interface FieldData {
id: string;
fieldType: string;
label: string | null;
placeholder: string;
name: string;
value: string;
isSelected: boolean;
generatedValue: string;
useInvalidData?: boolean;
}
const DEFAULT_FIELD_TYPE_PREFERENCES: FieldTypePreferences = {};
export function useFormRecognizer() {
const { showMessage } = useGlobalSnackbar({ autoHideDuration: 1500 });
const [fillLoading, setFillLoading] = useState(false);
const [clearLoading, setClearLoading] = useState(false);
const [includeHidden, setIncludeHidden] = useState(false);
const isProcessingRef = useRef(false);
const [fields, setFields] = useState<FieldData[]>([]);
const [scanning, setScanning] = useState(false);
const [showFields, setShowFields] = useState(false);
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
const currentDomain = useActiveTabDomain();
const { sidePanelOpen, handleOpenSidePanel } = useSidePanelState();
const [fieldTypePreferences, setFieldTypePreferences] = useStorageState(
'formRecognizer/fieldTypePreferences',
DEFAULT_FIELD_TYPE_PREFERENCES,
);
// 生成字段标识符
const getFieldIdentifier = useCallback(
(field: Pick<FieldData, 'label' | 'name' | 'placeholder'>): string => {
return field.label || field.name || field.placeholder || 'unknown';
},
[],
);
// 应用保存的类型偏好
const applySavedPreferences = useCallback(
(fields: FieldData[], domain: string, preferences: FieldTypePreferences): FieldData[] => {
if (!domain || !preferences[domain]) {
return fields;
}
const prefs = preferences[domain];
return fields.map((field) => {
const identifier = getFieldIdentifier(field);
if (prefs && prefs[identifier]) {
return { ...field, fieldType: prefs[identifier] };
}
return field;
});
},
[getFieldIdentifier],
);
// 保存类型偏好
const saveTypePreference = useCallback(
(field: FieldData, newType: string) => {
if (!currentDomain) return;
const identifier = getFieldIdentifier(field);
setFieldTypePreferences((prev) => {
const prevPrefs = prev as FieldTypePreferences;
const currentDomainPrefs = prevPrefs[currentDomain] || {};
return {
...prevPrefs,
[currentDomain]: {
...currentDomainPrefs,
[identifier]: newType,
},
} as FieldTypePreferences;
});
},
[currentDomain, getFieldIdentifier, setFieldTypePreferences],
);
// 扫描表单字段
const handleScanFields = async () => {
setScanning(true);
try {
let response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
if (!response.success && response.message && response.message.includes('无法连接')) {
showMessage('正在注入内容脚本...', { severity: 'info' });
const injected = await injectContentScript();
if (injected) {
response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
}
}
if (response.success && response.fields) {
const fieldsWithPreferences = applySavedPreferences(
response.fields as FieldData[],
currentDomain,
fieldTypePreferences,
);
setFields(fieldsWithPreferences);
setShowFields(true);
showMessage(`扫描完成,发现 ${response.totalCount} 个可填充字段`, { severity: 'success' });
} else {
showMessage(response.message || '扫描失败', { severity: 'error' });
}
} catch (error) {
console.error('扫描失败:', error);
showMessage('扫描失败,请确保页面已加载', { severity: 'error' });
} finally {
setScanning(false);
}
};
// 更新字段类型
const handleFieldTypeChange = (fieldId: string, newType: string) => {
setFields((prev) =>
prev.map((field) => {
if (field.id === fieldId) {
const updatedField = {
...field,
fieldType: newType,
generatedValue: '',
};
saveTypePreference(field, newType);
return updatedField;
}
return field;
}),
);
};
// 切换单个字段的选中状态
const handleToggleFieldSelection = (fieldId: string) => {
setFields((prev) =>
prev.map((field) =>
field.id === fieldId ? { ...field, isSelected: !field.isSelected } : field,
),
);
};
// 全选/取消全选
const handleToggleAllFields = () => {
setFields((prev) => {
const allSelected = prev.every((f) => f.isSelected);
return prev.map((f) => ({ ...f, isSelected: !allSelected }));
});
};
// 定位字段(闪烁)
const handleLocateField = async (fieldId: string) => {
try {
const response = await sendMessageToContent(MessageAction.FLASH_FIELD, { fieldId });
if (!response.success) {
showMessage(response.message || '定位字段失败', { severity: 'error' });
}
} catch (error) {
console.error('定位字段失败:', error);
}
};
// 悬停高亮
const handleHoverField = async (fieldId: string | null) => {
setHoveredFieldId(fieldId);
try {
if (fieldId) {
await sendMessageToContent(MessageAction.HIGHLIGHT_FIELD, { fieldId });
} else {
await sendMessageToContent(MessageAction.UNHIGHLIGHT_ALL_FIELDS);
}
} catch (error) {
console.error('高亮字段失败:', error);
}
};
// 填充选中字段
const handleFillSelectedFields = async () => {
if (isProcessingRef.current) {
showMessage('操作进行中,请稍候...', { severity: 'warning' });
return;
}
const selectedCount = fields.filter((f) => f.isSelected).length;
if (selectedCount === 0) {
showMessage('请先选择要填充的字段', { severity: 'warning' });
return;
}
setFillLoading(true);
isProcessingRef.current = true;
try {
const messageFields = fields as MessageFieldData[];
let response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
fields: messageFields,
mode: FillMode.VALID,
includeHidden,
});
if (!response.success && response.message && response.message.includes('无法连接')) {
showMessage('正在注入内容脚本...', { severity: 'info' });
const injected = await injectContentScript();
if (injected) {
response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
fields: messageFields,
mode: FillMode.VALID,
includeHidden,
});
}
}
if (response.success) {
showMessage(response.message || '填充成功', { severity: 'success' });
} else {
showMessage(response.message || '填充失败', { severity: 'error' });
}
} catch (error) {
console.error('填充失败:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
showMessage(`填充失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
} finally {
setFillLoading(false);
isProcessingRef.current = false;
}
};
// 清空所有字段
const handleClearAllFields = async () => {
if (isProcessingRef.current) {
showMessage('操作进行中,请稍候...', { severity: 'warning' });
return;
}
setClearLoading(true);
isProcessingRef.current = true;
try {
let response = await sendMessageToContent(MessageAction.CLEAR_ALL_FIELDS);
if (!response.success && response.message && response.message.includes('无法连接')) {
showMessage('正在注入内容脚本...', { severity: 'info' });
const injected = await injectContentScript();
if (injected) {
response = await sendMessageToContent(MessageAction.CLEAR_ALL_FIELDS);
}
}
if (response.success) {
showMessage(response.message || '清空成功', { severity: 'success' });
} else {
showMessage(response.message || '清空失败', { severity: 'error' });
}
} catch (error) {
console.error('清空失败:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
showMessage(`清空失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
} finally {
setClearLoading(false);
isProcessingRef.current = false;
}
};
return {
fillLoading,
clearLoading,
includeHidden,
setIncludeHidden,
fields,
scanning,
showFields,
setShowFields,
hoveredFieldId,
sidePanelOpen,
handleScanFields,
handleFieldTypeChange,
handleToggleFieldSelection,
handleToggleAllFields,
handleLocateField,
handleHoverField,
handleFillSelectedFields,
handleClearAllFields,
handleOpenSidePanel,
selectedCount: fields.filter((f) => f.isSelected).length,
};
}
+3
View File
@@ -3,6 +3,7 @@ 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 ErrorBoundary from '@/components/ErrorBoundary';
import { SnackbarProvider } from '@/components/GlobalSnackbar';
import { MessageAction, sendMessage } from '@/utils/messages'; import { MessageAction, sendMessage } from '@/utils/messages';
import { Box } from '@mui/material'; import { Box } from '@mui/material';
@@ -22,6 +23,7 @@ export default function App() {
return ( return (
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute"> <RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
<Box <Box
className="app" className="app"
sx={{ sx={{
@@ -37,6 +39,7 @@ export default function App() {
<RouterContainer /> <RouterContainer />
</ErrorBoundary> </ErrorBoundary>
</Box> </Box>
</SnackbarProvider>
</RouterProvider> </RouterProvider>
); );
} }
+1
View File
@@ -8,6 +8,7 @@ export default [
{ {
ignores: [ ignores: [
'dist', 'dist',
'.output',
'.wxt', '.wxt',
'node_modules', 'node_modules',
'eslint.config.ts', 'eslint.config.ts',
+23 -276
View File
@@ -12,13 +12,12 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@faker-js/faker": "^10.4.0",
"@mui/icons-material": "^7.3.8", "@mui/icons-material": "^7.3.8",
"@mui/material": "^7.3.8", "@mui/material": "^7.3.8",
"@webext-core/messaging": "^2.3.0", "@webext-core/messaging": "^2.3.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"jsqr": "^1.4.0", "qr-scanner": "^1.4.2",
"qrcode": "^1.5.4", "qrious": "^4.0.2",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3" "react-dom": "^19.2.3"
}, },
@@ -1449,22 +1448,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@faker-js/faker": {
"version": "10.4.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/@faker-js/faker/-/faker-10.4.0.tgz",
"integrity": "sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0",
"npm": ">=10"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz",
@@ -2512,6 +2495,12 @@
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
"license": "MIT"
},
"node_modules/@types/parse-json": { "node_modules/@types/parse-json": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmmirror.com/@types/parse-json/-/parse-json-4.0.2.tgz", "resolved": "https://registry.npmmirror.com/@types/parse-json/-/parse-json-4.0.2.tgz",
@@ -4120,6 +4109,7 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@@ -4132,6 +4122,7 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/colorette": { "node_modules/colorette": {
@@ -4517,15 +4508,6 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": { "node_modules/decimal.js": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -4673,12 +4655,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/doctrine": { "node_modules/doctrine": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz", "resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz",
@@ -5862,6 +5838,7 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
@@ -7163,12 +7140,6 @@
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
}, },
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/jsx-ast-utils": { "node_modules/jsx-ast-utils": {
"version": "3.3.5", "version": "3.3.5",
"resolved": "https://registry.npmmirror.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", "resolved": "https://registry.npmmirror.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -8267,15 +8238,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json": { "node_modules/package-json": {
"version": "10.0.1", "version": "10.0.1",
"resolved": "https://registry.npmmirror.com/package-json/-/package-json-10.0.1.tgz", "resolved": "https://registry.npmmirror.com/package-json/-/package-json-10.0.1.tgz",
@@ -8377,6 +8339,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -8515,15 +8478,6 @@
"pathe": "^2.0.3" "pathe": "^2.0.3"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -8763,211 +8717,20 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/qrcode": { "node_modules/qr-scanner": {
"version": "1.5.4", "version": "1.4.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/qrcode/-/qrcode-1.5.4.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/qr-scanner/-/qr-scanner-1.4.2.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", "integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"dijkstrajs": "^1.0.1", "@types/offscreencanvas": "^2019.6.4"
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
} }
}, },
"node_modules/qrcode/node_modules/ansi-regex": { "node_modules/qrious": {
"version": "5.0.1", "version": "4.0.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/qrious/-/qrious-4.0.2.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-xWPJIrK1zu5Ypn898fBp8RHkT/9ibquV2Kv24S/JY9VYEhMBMKur1gHVsOiNUh7PHP9uCgejjpZUHUIXXKoU/g==",
"license": "MIT", "license": "GPL-3.0"
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/qrcode/node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
}, },
"node_modules/quansync": { "node_modules/quansync": {
"version": "0.2.11", "version": "0.2.11",
@@ -9223,17 +8986,12 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "2.0.0-next.5", "version": "2.0.0-next.5",
"resolved": "https://registry.npmmirror.com/resolve/-/resolve-2.0.0-next.5.tgz", "resolved": "https://registry.npmmirror.com/resolve/-/resolve-2.0.0-next.5.tgz",
@@ -9516,12 +9274,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -11644,11 +11396,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
},
"node_modules/which-typed-array": { "node_modules/which-typed-array": {
"version": "1.1.20", "version": "1.1.20",
"resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz", "resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz",
+2 -3
View File
@@ -23,13 +23,12 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@faker-js/faker": "^10.4.0",
"@mui/icons-material": "^7.3.8", "@mui/icons-material": "^7.3.8",
"@mui/material": "^7.3.8", "@mui/material": "^7.3.8",
"@webext-core/messaging": "^2.3.0", "@webext-core/messaging": "^2.3.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"jsqr": "^1.4.0", "qr-scanner": "^1.4.2",
"qrcode": "^1.5.4", "qrious": "^4.0.2",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3" "react-dom": "^19.2.3"
}, },
@@ -20,7 +20,17 @@ export default function DashboardPage() {
); );
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, p: 2 }}> <Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr', // 弹出窗口或小屏幕保持单列
sm: 'repeat(auto-fill, minmax(300px, 1fr))', // 标签页大屏幕自适应多列
},
gap: 2,
p: 2,
}}
>
{pageOrder.map((key) => { {pageOrder.map((key) => {
if (!isVisible(key)) return null; if (!isVisible(key)) return null;
+171
View File
@@ -0,0 +1,171 @@
import { useState, useMemo } from 'react';
import { TextField, Stack, Box, Container, Typography, Paper } from '@mui/material';
import { useSnackbar } from '@/components/GlobalSnackbar';
import VpnKeyIcon from '@mui/icons-material/VpnKey';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import PageHeader from '@/components/PageHeader';
import { jwtPageStyles } from '@/config/pageTheme';
import { parseJwt, formatJson } from '@/utils/jwt';
import CopyButton from '@/components/CopyButton';
interface SectionProps {
title: string;
content: unknown;
raw: string;
color: string;
}
const Section = ({ title, content, color }: SectionProps) => (
<Paper
variant="outlined"
sx={{
p: 2,
borderRadius: 3,
borderColor: `${color}40`,
bgcolor: `${color}05`,
position: 'relative',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: color, letterSpacing: 0.5 }}>
{title}
</Typography>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<CopyButton text={JSON.stringify(content)} size="small" />
</Box>
</Box>
<Box
component="pre"
sx={{
m: 0,
p: 1.5,
bgcolor: 'rgba(255,255,255,0.6)',
borderRadius: 2,
fontSize: '0.8rem',
fontFamily: 'monospace',
overflowX: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
border: '1px solid rgba(0,0,0,0.05)',
}}
>
{content ? formatJson(content) : '无法解析'}
</Box>
</Paper>
);
export default function JwtPage() {
useSnackbar();
const [jwtInput, setJwtInput] = useState('');
const result = useMemo(() => {
if (!jwtInput.trim()) {
return null;
}
return parseJwt(jwtInput);
}, [jwtInput]);
return (
<Box>
<Container sx={{ p: 2 }}>
<PageHeader title="JWT 解析" subtitle="JSON Web Token 解码与查看" icon={<VpnKeyIcon />} />
<Stack spacing={2.5}>
{/* Input Area */}
<TextField
multiline
rows={4}
placeholder="在此粘贴 JWT 令牌 (Encoded JWT)..."
value={jwtInput}
onChange={(e) => {
// 自动去除 Bearer 前缀及首尾空白字符/换行
const val = e.target.value.replace(/^Bearer\s*/i, '').trim();
setJwtInput(val);
}}
fullWidth
sx={jwtPageStyles.INPUT_STYLE}
/>
{result?.error && (
<Paper
sx={{
p: 2,
bgcolor: 'error.lighter',
color: 'error.main',
borderRadius: 3,
display: 'flex',
alignItems: 'flex-start',
gap: 1.5,
border: '1px solid',
borderColor: 'error.light',
}}
>
<ErrorOutlineIcon sx={{ mt: 0.2 }} fontSize="small" />
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{result.error}
</Typography>
</Paper>
)}
{result && !result.error && (
<Stack spacing={2}>
<Section
title="HEADER: 算法 & 令牌类型"
content={result.header}
raw={result.raw.header}
color="#fb015b" // JWT.io Header Color
/>
<Section
title="PAYLOAD: 数据"
content={result.payload}
raw={result.raw.payload}
color="#d63aff" // JWT.io Payload Color
/>
<Paper
variant="outlined"
sx={{
p: 2,
borderRadius: 3,
borderColor: 'primary.light',
bgcolor: 'primary.lighter',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1,
}}
>
<Typography
variant="subtitle2"
sx={{ fontWeight: 800, color: '#00b9f1', letterSpacing: 0.5 }}
>
</Typography>
<CopyButton text={JSON.stringify(result.raw.signature)} size="small" />
</Box>
<Typography
variant="body2"
sx={{
fontFamily: 'monospace',
fontSize: '0.8rem',
wordBreak: 'break-all',
color: 'text.secondary',
bgcolor: 'rgba(255,255,255,0.6)',
p: 1.5,
borderRadius: 2,
border: '1px solid rgba(0,0,0,0.05)',
}}
>
{result.signature || 'No Signature'}
</Typography>
</Paper>
</Stack>
)}
</Stack>
</Container>
</Box>
);
}
@@ -1,6 +1,6 @@
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 { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider'; import { useSnackbar as useGlobalSnackbar } from '@/components/GlobalSnackbar';
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';
@@ -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 { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider'; import { useSnackbar as useGlobalSnackbar } from '@/components/GlobalSnackbar';
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';
@@ -32,6 +32,8 @@ export default function StorageCleanerPage() {
handleClean, handleClean,
} = useStorageCleaner({ showMessage }); } = useStorageCleaner({ showMessage });
const isDisabled = (!someSelected && !allSelected) || loading;
if (isInitializing) { if (isInitializing) {
return ( return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}> <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
@@ -69,7 +71,7 @@ export default function StorageCleanerPage() {
bgcolor: storageCleanerPageStyles.warningDark, bgcolor: storageCleanerPageStyles.warningDark,
}, },
}} }}
disabled={loading} disabled={isDisabled}
fullWidth fullWidth
> >
{loading ? '正在清理...' : '立即清理'} {loading ? '正在清理...' : '立即清理'}
+125
View File
@@ -0,0 +1,125 @@
import { useState, useMemo } from 'react';
import { Box, Container, TextField, Grid, Paper, Typography, alpha } from '@mui/material';
import PageHeader from '@/components/PageHeader';
import DescriptionIcon from '@mui/icons-material/Description';
import { getTextStats, formatByteSize } from '@/utils/textStatistics';
import { textStatisticsPageStyles } from '@/config/pageTheme';
/**
* 文本统计页面组件
*
* 提供实时的文本分析功能,包括字符数、单词数、行数和字节大小。
*/
export default function TextStatisticsPage() {
const [text, setText] = useState('');
// 实时计算统计信息,使用 useMemo 优化性能
// 对于 10,000 字符以上的文本,Intl.Segmenter 也能保持良好的性能
const stats = useMemo(() => getTextStats(text), [text]);
const statItems = [
{ label: '字符数', value: stats.characters },
{ label: '单词数', value: stats.words },
{ label: '行数', value: stats.lines },
{ label: '字节大小', value: formatByteSize(stats.bytes) },
];
return (
<Box>
<Container sx={{ p: 2 }}>
{/* 头部区域 */}
<PageHeader
title="文本统计"
subtitle="实时分析文本的字符、单词、行数及字节大小"
icon={<DescriptionIcon />}
iconColor={textStatisticsPageStyles.primaryColor}
/>
{/* 文本输入区域 */}
<TextField
multiline
fullWidth
minRows={8}
maxRows={15}
placeholder="在此输入或粘贴文本..."
value={text}
onChange={(e) => setText(e.target.value)}
sx={{
mb: 3,
'& .MuiOutlinedInput-root': {
borderRadius: 4,
bgcolor: 'grey.50',
transition: 'all 0.2s',
'& fieldset': {
borderColor: 'grey.200',
},
'&:hover fieldset': {
borderColor: 'grey.300',
},
'&.Mui-focused fieldset': {
borderColor: textStatisticsPageStyles.primaryColor,
},
},
'& .MuiInputBase-input': {
fontSize: '0.9rem',
lineHeight: 1.6,
},
}}
/>
{/* 统计结果展示区域 */}
<Grid container spacing={2} sx={{ justifyContent: 'center', alignItems: 'center' }}>
{statItems.map((item) => (
<Grid sx={{ xs: 12, md: 3 }} key={item.label}>
<Paper
elevation={0}
sx={{
p: 2,
textAlign: 'center',
borderRadius: 4,
bgcolor: textStatisticsPageStyles.cardBg,
border: '1px solid',
borderColor: textStatisticsPageStyles.cardBorder,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', // 平滑的切换动画
minHeight: { xs: '64px', md: '90px' },
display: 'flex',
flexDirection: { xs: 'row', md: 'column' }, // 小屏幕横向排列提高空间利用率
alignItems: 'center',
justifyContent: { xs: 'space-between', md: 'center' },
px: { xs: 3, md: 2 },
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: () =>
`0 4px 12px ${alpha(textStatisticsPageStyles.primaryColor, 0.15)}`,
borderColor: textStatisticsPageStyles.primaryColor,
},
lineHeight: 1.6,
fontSize: '0.9rem',
fontWeight: 600,
}}
>
<Typography
color="text.secondary"
sx={{
mb: { xs: 0, md: 0.5 },
whiteSpace: 'nowrap',
}}
>
{item.label}
</Typography>
<Typography
sx={{
color: textStatisticsPageStyles.primaryColor, // 高亮显示核心数值
wordBreak: 'break-all',
}}
>
{item.value}
</Typography>
</Paper>
</Grid>
))}
</Grid>
</Container>
</Box>
);
}
@@ -1,5 +1,5 @@
import { TextField, Select, MenuItem, Stack, Box, Container } from '@mui/material'; import { TextField, Select, MenuItem, Stack, Box, Container } from '@mui/material';
import { useSnackbar } from '@/components/SnackbarProvider'; import { useSnackbar } from '@/components/GlobalSnackbar';
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';
@@ -13,7 +13,7 @@ export default function ErrorDisplay({ error }: ErrorDisplayProps) {
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
minHeight: '400px', minHeight: { xs: 'auto', sm: '400px' },
textAlign: 'center', textAlign: 'center',
}} }}
> >
@@ -44,6 +44,8 @@ const LiveClock = React.memo(({ unit, onUseNow, onUnitChange, showMessage }: Liv
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 1.5,
p: 1.8, p: 1.8,
mb: 2.5, mb: 2.5,
bgcolor: alpha(timestampPageStyles.primaryColor, 0.04), bgcolor: alpha(timestampPageStyles.primaryColor, 0.04),
@@ -52,7 +54,7 @@ const LiveClock = React.memo(({ unit, onUseNow, onUnitChange, showMessage }: Liv
borderColor: alpha(timestampPageStyles.primaryColor, 0.1), borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
}} }}
> >
<Stack spacing={0.5}> <Stack spacing={0.5} sx={{ minWidth: { xs: 100, sm: 120 } }}>
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
@@ -71,7 +73,7 @@ const LiveClock = React.memo(({ unit, onUseNow, onUnitChange, showMessage }: Liv
fontWeight: 800, fontWeight: 800,
color: timestampPageStyles.primaryColor, color: timestampPageStyles.primaryColor,
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: '1.2rem', fontSize: { xs: '1.1rem', sm: '1.2rem' },
letterSpacing: '-0.5px', letterSpacing: '-0.5px',
lineHeight: 1.2, lineHeight: 1.2,
}} }}
@@ -80,7 +82,7 @@ const LiveClock = React.memo(({ unit, onUseNow, onUnitChange, showMessage }: Liv
</Typography> </Typography>
</Stack> </Stack>
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center" sx={{ flexShrink: 0 }}>
{/* 胶囊式单位切换器 */} {/* 胶囊式单位切换器 */}
<Box <Box
sx={{ sx={{
@@ -97,7 +99,7 @@ const LiveClock = React.memo(({ unit, onUseNow, onUnitChange, showMessage }: Liv
key={u} key={u}
onClick={() => onUnitChange(u)} onClick={() => onUnitChange(u)}
sx={{ sx={{
px: 1.2, px: { xs: 1, sm: 1.2 },
py: 0.35, py: 0.35,
borderRadius: 2, borderRadius: 2,
cursor: 'pointer', cursor: 'pointer',
@@ -24,7 +24,7 @@ export default function OptionItem({
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
py: 1, py: 1,
px: 1.5, px: { xs: 1, sm: 1.5 },
borderRadius: 3, borderRadius: 3,
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
bgcolor: checked ? 'rgba(255, 152, 0, 0.05)' : 'transparent', bgcolor: checked ? 'rgba(255, 152, 0, 0.05)' : 'transparent',
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { MessageAction, onMessage } from '@/utils/messages'; import { MessageAction, onMessage } from '@/utils/messages';
import { useSnackbar } from '@/components/SnackbarProvider'; import { useSnackbar } from '@/components/GlobalSnackbar';
/** /**
* Hook * Hook
@@ -146,13 +146,13 @@ export function useStorageCleaner({
clearTimeout(debounceTimerRef.current); clearTimeout(debounceTimerRef.current);
} }
debounceTimerRef.current = setTimeout(() => { debounceTimerRef.current = setTimeout(() => {
loadInfoRef.current().then((r) => console.info(r)); loadInfoRef.current().catch(console.error);
}, 300); }, 300);
}, []); }, []);
useEffect(() => { useEffect(() => {
// 首次加载不防抖 // 首次加载不防抖
loadInfoRef.current().then((r) => console.info(r)); loadInfoRef.current().catch(console.error);
const handleTabChange = () => debouncedLoadInfo(); const handleTabChange = () => debouncedLoadInfo();
const handleTabUpdated = (_tabId: number, changeInfo: { status?: string; url?: string }) => { const handleTabUpdated = (_tabId: number, changeInfo: { status?: string; url?: string }) => {
@@ -220,6 +220,9 @@ export function useStorageCleaner({
); );
const handleClean = useCallback(async () => { const handleClean = useCallback(async () => {
if (loading) {
return;
}
const tab = await getCurrentTab(); const tab = await getCurrentTab();
if (!tab || !tab.id || !tab.url) { if (!tab || !tab.id || !tab.url) {
showMessage('无法获取当前标签页', { severity: 'warning' }); showMessage('无法获取当前标签页', { severity: 'warning' });
@@ -242,11 +245,8 @@ export function useStorageCleaner({
setLoading(false); setLoading(false);
setShowConfirm(false); setShowConfirm(false);
} }
}, [options, autoRefresh, showMessage, loadInfo]); }, [loading, options, autoRefresh, showMessage, loadInfo]);
// Computed values
// Note: sizes.indexedDB contains navigator.storage.estimate().usage
// which includes IndexedDB, Cache, etc.
const totalSize = (sizes.cookies || 0) + (sizes.indexedDB || 0); const totalSize = (sizes.cookies || 0) + (sizes.indexedDB || 0);
const allSelected = Object.values(options).every(Boolean); const allSelected = Object.values(options).every(Boolean);
+22 -16
View File
@@ -64,6 +64,10 @@ interface RouterProviderProps {
syncRoute?: boolean; syncRoute?: boolean;
/** 存储路由状态的键名,默认为 'app/currentRoute' */ /** 存储路由状态的键名,默认为 'app/currentRoute' */
syncKey?: keyof StorageSchema; syncKey?: keyof StorageSchema;
/** 可见页面列表的键名,默认为 'app/visiblePages' */
visiblePagesKey?: keyof StorageSchema;
/** 页面排序的键名,默认为 'app/pageOrder' */
pageOrderKey?: keyof StorageSchema;
} }
/** /**
@@ -97,6 +101,8 @@ export function RouterProvider({
defaultRoute = 'dashboard', defaultRoute = 'dashboard',
syncRoute = true, syncRoute = true,
syncKey = 'app/currentRoute', syncKey = 'app/currentRoute',
visiblePagesKey = 'app/visiblePages',
pageOrderKey = 'app/pageOrder',
}: RouterProviderProps) { }: RouterProviderProps) {
// 当前页面状态:优先从同步快照加载,并进行合法性校验 // 当前页面状态:优先从同步快照加载,并进行合法性校验
const [currentPage, setCurrentPage] = useState<PageType>(() => const [currentPage, setCurrentPage] = useState<PageType>(() =>
@@ -104,11 +110,11 @@ export function RouterProvider({
); );
// 可见页面列表状态 // 可见页面列表状态
const [visiblePages, setVisiblePages] = useState<PageType[]>(() => const [visiblePages, setVisiblePages] = useState<PageType[]>(() =>
getSyncSnapshot('app/visiblePages', getDefaultVisibleFeatureKeys(), isValidPageList), getSyncSnapshot(visiblePagesKey as string, getDefaultVisibleFeatureKeys(), isValidPageList),
); );
// 页面排序状态 // 页面排序状态
const [pageOrder, setPageOrder] = useState<PageType[]>(() => const [pageOrder, setPageOrder] = useState<PageType[]>(() =>
getSyncSnapshot('app/pageOrder', getDefaultPageOrder(), isValidPageList), getSyncSnapshot(pageOrderKey as string, getDefaultPageOrder(), isValidPageList),
); );
// 加载完成标识 // 加载完成标识
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
@@ -119,10 +125,10 @@ export function RouterProvider({
try { try {
const savedRoute = await storageUtil.get(syncKey, defaultRoute); const savedRoute = await storageUtil.get(syncKey, defaultRoute);
const savedVisiblePages = await storageUtil.get( const savedVisiblePages = await storageUtil.get(
'app/visiblePages', visiblePagesKey,
getDefaultVisibleFeatureKeys(), getDefaultVisibleFeatureKeys(),
); );
const savedPageOrder = await storageUtil.get('app/pageOrder', getDefaultPageOrder()); const savedPageOrder = await storageUtil.get(pageOrderKey, getDefaultPageOrder());
// 增加数据合法性校验并进行类型收窄 // 增加数据合法性校验并进行类型收窄
if (isValidPage(savedRoute) && syncRoute) { if (isValidPage(savedRoute) && syncRoute) {
@@ -139,7 +145,7 @@ export function RouterProvider({
} finally { } finally {
setIsLoaded(true); setIsLoaded(true);
} }
}, [defaultRoute, syncKey, syncRoute]); }, [defaultRoute, syncKey, syncRoute, visiblePagesKey, pageOrderKey]);
// 组件挂载时加载初始数据 // 组件挂载时加载初始数据
useEffect(() => { useEffect(() => {
@@ -157,18 +163,18 @@ export function RouterProvider({
// 持久化可见页面列表 // 持久化可见页面列表
useEffect(() => { useEffect(() => {
if (isLoaded) { if (isLoaded) {
storageUtil.set('app/visiblePages', visiblePages).catch(console.error); storageUtil.set(visiblePagesKey, visiblePages).catch(console.error);
localStorage.setItem('snapshot/app/visiblePages', JSON.stringify(visiblePages)); localStorage.setItem(`snapshot/${visiblePagesKey}`, JSON.stringify(visiblePages));
} }
}, [visiblePages, isLoaded]); }, [visiblePages, isLoaded, visiblePagesKey]);
// 持久化页面排序 // 持久化页面排序
useEffect(() => { useEffect(() => {
if (isLoaded) { if (isLoaded) {
storageUtil.set('app/pageOrder', pageOrder).catch(console.error); storageUtil.set(pageOrderKey, pageOrder).catch(console.error);
localStorage.setItem('snapshot/app/pageOrder', JSON.stringify(pageOrder)); localStorage.setItem(`snapshot/${pageOrderKey}`, JSON.stringify(pageOrder));
} }
}, [pageOrder, isLoaded]); }, [pageOrder, isLoaded, pageOrderKey]);
/** /**
* 监听存储变化,以便在多个入口(如 Popup 和 Options)之间同步路由和设置 * 监听存储变化,以便在多个入口(如 Popup 和 Options)之间同步路由和设置
@@ -185,15 +191,15 @@ export function RouterProvider({
} }
} }
// 同步可见页面列表 // 同步可见页面列表
if (changes['app/visiblePages']) { if (changes[visiblePagesKey as string]) {
const newPages = changes['app/visiblePages'].newValue; const newPages = changes[visiblePagesKey as string].newValue;
if (isValidPageList(newPages)) { if (isValidPageList(newPages)) {
setVisiblePages(newPages); setVisiblePages(newPages);
} }
} }
// 同步页面排序 // 同步页面排序
if (changes['app/pageOrder']) { if (changes[pageOrderKey as string]) {
const newOrder = changes['app/pageOrder'].newValue; const newOrder = changes[pageOrderKey as string].newValue;
if (isValidPageList(newOrder)) { if (isValidPageList(newOrder)) {
setPageOrder(newOrder); setPageOrder(newOrder);
} }
@@ -202,7 +208,7 @@ export function RouterProvider({
chrome.storage.onChanged.addListener(handleStorageChange); chrome.storage.onChanged.addListener(handleStorageChange);
return () => chrome.storage.onChanged.removeListener(handleStorageChange); return () => chrome.storage.onChanged.removeListener(handleStorageChange);
}, [syncRoute, currentPage, syncKey]); }, [syncRoute, currentPage, syncKey, visiblePagesKey, pageOrderKey]);
/** /**
* 跳转到指定页面 * 跳转到指定页面
+141
View File
@@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, act } from '@testing-library/react';
import { RouterProvider, useRouter } from '../RouterProvider';
import { storageUtil } from '@/utils/chromeStorage';
import React from 'react';
// Mock storageUtil
vi.mock('@/utils/chromeStorage', () => ({
storageUtil: {
get: vi.fn(),
set: vi.fn(() => Promise.resolve()),
},
}));
// Helper component to test useRouter
const TestComponent = () => {
const { currentPage, navigateTo, visiblePages, pageOrder } = useRouter();
return (
<div>
<div data-testid="current-page">{currentPage}</div>
<div data-testid="visible-pages">{visiblePages.join(',')}</div>
<div data-testid="page-order">{pageOrder.join(',')}</div>
<button onClick={() => navigateTo('timestamp')} data-testid="navigate-btn">
Navigate
</button>
</div>
);
};
describe('RouterProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
// Mock chrome.storage.onChanged
(global as any).chrome = {
storage: {
onChanged: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
},
};
});
it('应该使用默认值初始化路由', async () => {
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) =>
Promise.resolve(defaultValue),
);
render(
<RouterProvider defaultRoute="dashboard">
<TestComponent />
</RouterProvider>,
);
await waitFor(() => {
expect(screen.getByTestId('current-page')).toHaveTextContent('dashboard');
});
});
it('应该从指定的 syncKey 加载路由', async () => {
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
if (key === 'app/sidepanelRoute') return Promise.resolve('timestamp');
return Promise.resolve(defaultValue);
});
render(
<RouterProvider syncKey="app/sidepanelRoute">
<TestComponent />
</RouterProvider>,
);
await waitFor(() => {
expect(screen.getByTestId('current-page')).toHaveTextContent('timestamp');
});
});
it('导航时应该更新指定的 syncKey', async () => {
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) =>
Promise.resolve(defaultValue),
);
render(
<RouterProvider syncKey="app/popupRoute">
<TestComponent />
</RouterProvider>,
);
await waitFor(() => {
expect(screen.getByTestId('current-page')).toHaveTextContent('dashboard');
});
const btn = screen.getByTestId('navigate-btn');
await act(async () => {
btn.click();
});
expect(screen.getByTestId('current-page')).toHaveTextContent('timestamp');
expect(storageUtil.set).toHaveBeenCalledWith('app/popupRoute', 'timestamp');
});
it('应该支持独立的标签页路由同步', async () => {
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
if (key === 'app/tabRoute') return Promise.resolve('qrCode');
return Promise.resolve(defaultValue);
});
render(
<RouterProvider syncKey="app/tabRoute">
<TestComponent />
</RouterProvider>,
);
await waitFor(() => {
expect(screen.getByTestId('current-page')).toHaveTextContent('qrCode');
});
});
it('应该独立支持 visiblePagesKey 和 pageOrderKey', async () => {
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
if (key === 'app/sidepanelVisiblePages')
return Promise.resolve(['timestamp', 'storageCleaner']);
if (key === 'app/sidepanelPageOrder') return Promise.resolve(['storageCleaner', 'timestamp']);
return Promise.resolve(defaultValue);
});
render(
<RouterProvider
visiblePagesKey="app/sidepanelVisiblePages"
pageOrderKey="app/sidepanelPageOrder"
>
<TestComponent />
</RouterProvider>,
);
await waitFor(() => {
expect(screen.getByTestId('visible-pages')).toHaveTextContent('timestamp,storageCleaner');
expect(screen.getByTestId('page-order')).toHaveTextContent('storageCleaner,timestamp');
});
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 669 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

+54 -9523
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 716 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+1
View File
@@ -31,6 +31,7 @@
"include": [ "include": [
"vite-env.d.ts", "vite-env.d.ts",
"entrypoints/**/*", "entrypoints/**/*",
"pages/**/*",
"components/**/*", "components/**/*",
"utils/**/*", "utils/**/*",
"types/**/*", "types/**/*",
+18 -35
View File
@@ -5,12 +5,9 @@ export type PageType =
| 'dashboard' // 仪表盘/首页 | 'dashboard' // 仪表盘/首页
| 'timestamp' // 时间戳转换工具 | 'timestamp' // 时间戳转换工具
| 'storageCleaner' // 存储清理工具 | 'storageCleaner' // 存储清理工具
| 'openUrl' // 快捷链接工具
| 'qrCode' // 二维码工具 | 'qrCode' // 二维码工具
| 'formRecognizer' // 表单识别工具 | 'textStatistics' // 文本统计工具
| 'formMapping' // 表单映射配置 | 'jwt'; // JWT 解析工具
| 'formFill' // 表单填充工具
| 'openUrlViewer'; // 快捷链接查看页面
/** /**
* 表单映射条目定义 * 表单映射条目定义
@@ -56,30 +53,34 @@ export interface StorageSchema {
'app/popupRoute': PageType; 'app/popupRoute': PageType;
/** 侧边栏的当前路由 */ /** 侧边栏的当前路由 */
'app/sidepanelRoute': PageType; 'app/sidepanelRoute': PageType;
/** 在菜单中可见的页面列表 */ /** 标签页的当前路由 */
'app/tabRoute': PageType;
/** 在菜单中可见的页面列表 (通用/旧版) */
'app/visiblePages': PageType[]; 'app/visiblePages': PageType[];
/** 菜单页面的显示顺序 */ /** 菜单页面的显示顺序 (通用/旧版) */
'app/pageOrder': PageType[]; 'app/pageOrder': PageType[];
/** Popup 窗口可见的页面列表 */
'app/popupVisiblePages': PageType[];
/** Popup 窗口页面的显示顺序 */
'app/popupPageOrder': PageType[];
/** 侧边栏可见的页面列表 */
'app/sidepanelVisiblePages': PageType[];
/** 侧边栏页面的显示顺序 */
'app/sidepanelPageOrder': PageType[];
/** 标签页可见的页面列表 */
'app/tabVisiblePages': PageType[];
/** 标签页页面的显示顺序 */
'app/tabPageOrder': PageType[];
/** 上一次访问的路由路径(备用) */ /** 上一次访问的路由路径(备用) */
'app/lastRoute': string; 'app/lastRoute': string;
/** 应用主题配置 */ /** 应用主题配置 */
'app/theme': string; 'app/theme': string;
/** 表单映射工具是否正处于“元素拾取”模式 */
'app/formMapping/isPicking': boolean;
/** 当前激活的表单映射条目列表 */
active_form_map: FormMapEntry[];
/** 存储清理工具的偏好设置 */ /** 存储清理工具的偏好设置 */
'storageCleaner/preferences': StorageCleanerPreferences; 'storageCleaner/preferences': StorageCleanerPreferences;
/** 快捷链接工具的偏好设置 */
'openUrl/preferences': OpenUrlPreferences;
/** 快捷链接工具当前操作的 URL */
'openUrl/currentUrl': string;
/** 二维码工具中二维码部分是否展开 */ /** 二维码工具中二维码部分是否展开 */
'qrCode/qrExpanded': boolean; 'qrCode/qrExpanded': boolean;
/** 二维码工具中 URL 部分是否展开 */ /** 二维码工具中 URL 部分是否展开 */
'qrCode/urlExpanded': boolean; 'qrCode/urlExpanded': boolean;
/** 表单识别工具的字段类型偏好(按域名存储) */
'formRecognizer/fieldTypePreferences': FieldTypePreferences;
} }
/** /**
@@ -102,24 +103,6 @@ export interface StorageCleanerPreferences {
selectedTypes: StorageCleanerOptions; selectedTypes: StorageCleanerOptions;
} }
/**
* 快捷链接条目定义
*/
export interface OpenUrlEntry {
/** 链接名称 */
name: string;
/** 链接地址 */
url: string;
}
/**
* 快捷链接工具偏好设置
*/
export interface OpenUrlPreferences {
/** 链接列表 */
entries: OpenUrlEntry[];
}
/** /**
* 存储清理选项配置 * 存储清理选项配置
*/ */
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { parseJwt, decodeBase64Url } from '../jwt';
describe('jwt utils', () => {
describe('decodeBase64Url', () => {
it('should decode standard base64url', () => {
// "test" -> "dGVzdA"
expect(decodeBase64Url('dGVzdA')).toBe('test');
});
it('should handle padding correctly', () => {
// "a" -> "YQ" (needs ==)
expect(decodeBase64Url('YQ')).toBe('a');
// "ab" -> "YWI" (needs =)
expect(decodeBase64Url('YWI')).toBe('ab');
});
it('should handle - and _ correctly', () => {
// Validating base64url specific chars
// standard base64 of binary 0xFF 0xEF is "/+8="
// base64url should be "_-8"
// Wait, let's use a simpler one.
// 0xFB 0xFF -> "+/8=" in base64, "-_8=" in base64url? No.
// + -> -
// / -> _
// let's try to encode something that results in + and /
// binary 0xFB 0xFF 0xBE -> "+/++" in base64 -> "-_--" in base64url
expect(decodeBase64Url('-_--')).toBeDefined();
});
it('should decode UTF-8 characters correctly', () => {
// "你好" -> "5L2g5aW9"
expect(decodeBase64Url('5L2g5aW9')).toBe('你好');
});
});
describe('parseJwt', () => {
it('should return error for invalid format', () => {
const result = parseJwt('invalid-token');
expect(result.error).toContain('格式错误');
});
it('should parse a valid JWT structure', () => {
// Header: {"alg":"HS256","typ":"JWT"}
// Payload: {"sub":"1234567890","name":"John Doe","iat":1516239022}
const token =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const result = parseJwt(token);
expect(result.error).toBeUndefined();
expect(result.header?.alg).toBe('HS256');
expect(result.payload?.name).toBe('John Doe');
expect(result.signature).toBe('SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c');
});
it('should handle malformed json in header/payload', () => {
// Base64 of "{"
const token = 'ew.ew.signature';
const result = parseJwt(token);
expect(result.error).toBeDefined();
});
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { getTextStats, formatByteSize } from '../textStatistics';
describe('textStatistics utils', () => {
describe('getTextStats', () => {
it('should return zeros for empty text', () => {
const stats = getTextStats('');
expect(stats).toEqual({ characters: 0, words: 0, lines: 0, bytes: 0 });
});
it('should count characters correctly', () => {
expect(getTextStats('abc').characters).toBe(3);
expect(getTextStats('a b c').characters).toBe(5);
expect(getTextStats('你好').characters).toBe(2);
});
it('should count English words correctly', () => {
expect(getTextStats('hello world').words).toBe(2);
expect(getTextStats(' hello world ').words).toBe(2);
expect(getTextStats('hello, world!').words).toBe(2);
});
it('should count Chinese words correctly', () => {
// "你好世界" 在 Intl.Segmenter 中通常被识别为 "你好" 和 "世界" 两个词
const stats = getTextStats('你好世界');
expect(stats.words).toBe(2);
});
it('should count mixed language words correctly', () => {
const stats = getTextStats('Hello 你好');
// "Hello" (1) + "你好" (1) = 2
expect(stats.words).toBe(2);
});
it('should count lines correctly', () => {
expect(getTextStats('line1\nline2').lines).toBe(2);
expect(getTextStats('line1\nline2\n').lines).toBe(3);
});
it('should count bytes correctly (UTF-8)', () => {
expect(getTextStats('abc').bytes).toBe(3);
expect(getTextStats('你好').bytes).toBe(6); // UTF-8 中每个常用汉字占 3 字节
});
it('should handle special cases', () => {
expect(getTextStats(' ').words).toBe(0);
expect(getTextStats('\n\n\n').lines).toBe(4);
expect(getTextStats('\n\n\n').words).toBe(0);
});
});
describe('formatByteSize', () => {
it('should format bytes correctly', () => {
expect(formatByteSize(100)).toBe('100 Bytes');
expect(formatByteSize(0)).toBe('0 Bytes');
});
});
});
-164
View File
@@ -1,164 +0,0 @@
/**
* 数据模板管理工具
* 用于创建、编辑、保存和管理自定义测试数据模板
*/
import { FieldType } from './dummyDataGenerator';
/**
* 模板字段接口
*/
export interface TemplateField {
id: string;
name: string;
label: string;
fieldType: FieldType;
defaultValue: string;
rules: TemplateRule[];
}
/**
* 模板规则接口
*/
export interface TemplateRule {
type: 'required' | 'pattern' | 'minLength' | 'maxLength' | 'min' | 'max' | 'custom';
value: string | number;
message?: string;
}
/**
* 数据模板接口
*/
export interface DataTemplate {
id: string;
name: string;
description: string;
fields: TemplateField[];
createdAt: string;
updatedAt: string;
}
/**
* 模板存储键名
*/
const TEMPLATE_STORAGE_KEY = 'dataTemplates';
/**
* 数据模板管理类
*/
export class DataTemplateManager {
/**
* 获取所有模板
*/
static async getAllTemplates(): Promise<DataTemplate[]> {
try {
const stored = await chrome.storage.local.get(TEMPLATE_STORAGE_KEY);
return (stored[TEMPLATE_STORAGE_KEY] as DataTemplate[]) || [];
} catch (error) {
console.error('获取模板失败:', error);
return [];
}
}
/**
* 保存模板
*/
static async saveTemplate(template: DataTemplate): Promise<boolean> {
try {
const templates = await this.getAllTemplates();
const existingIndex = templates.findIndex((t) => t.id === template.id);
if (existingIndex >= 0) {
templates[existingIndex] = {
...template,
updatedAt: new Date().toISOString(),
};
} else {
templates.push({
...template,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
await chrome.storage.local.set({ [TEMPLATE_STORAGE_KEY]: templates });
return true;
} catch (error) {
console.error('保存模板失败:', error);
return false;
}
}
/**
* 删除模板
*/
static async deleteTemplate(templateId: string): Promise<boolean> {
try {
const templates = await this.getAllTemplates();
const filtered = templates.filter((t) => t.id !== templateId);
await chrome.storage.local.set({ [TEMPLATE_STORAGE_KEY]: filtered });
return true;
} catch (error) {
console.error('删除模板失败:', error);
return false;
}
}
/**
* 导出模板
*/
static exportTemplates(templates: DataTemplate[]): string {
return JSON.stringify(templates, null, 2);
}
/**
* 导入模板
*/
static async importTemplates(jsonString: string): Promise<boolean> {
try {
const importedTemplates = JSON.parse(jsonString) as DataTemplate[];
if (!Array.isArray(importedTemplates)) {
return false;
}
const existingTemplates = await this.getAllTemplates();
const mergedTemplates = [...existingTemplates];
for (const template of importedTemplates) {
const existingIndex = mergedTemplates.findIndex((t) => t.id === template.id);
if (existingIndex >= 0) {
mergedTemplates[existingIndex] = template;
} else {
mergedTemplates.push(template);
}
}
await chrome.storage.local.set({ [TEMPLATE_STORAGE_KEY]: mergedTemplates });
return true;
} catch (error) {
console.error('导入模板失败:', error);
return false;
}
}
/**
* 生成唯一ID
*/
static generateId(): string {
return `template_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* 创建空模板
*/
static createEmptyTemplate(name: string, description: string = ''): DataTemplate {
return {
id: this.generateId(),
name,
description,
fields: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
}
-166
View File
@@ -1,166 +0,0 @@
/**
* 数据验证工具
* 用于在数据填充前进行格式验证
*/
import { FieldType } from './dummyDataGenerator';
/**
* 验证结果接口
*/
export interface ValidationResult {
isValid: boolean;
errors: string[];
}
/**
* 数据验证工具类
*/
export class DataValidator {
/**
* 验证邮箱格式
*/
private static validateEmail(value: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value);
}
/**
* 验证手机号格式(中国大陆)
*/
private static validatePhone(value: string): boolean {
const phoneRegex = /^1[3-9]\d{9}$/;
return phoneRegex.test(value);
}
/**
* 验证身份证号格式(中国大陆)
*/
private static validateIdCard(value: string): boolean {
const idCardRegex =
/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/;
return idCardRegex.test(value);
}
/**
* 验证日期格式
*/
private static validateDate(value: string): boolean {
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(value)) return false;
const date = new Date(value);
return !isNaN(date.getTime());
}
/**
* 验证数字格式
*/
private static validateNumber(value: string): boolean {
return !isNaN(Number(value)) && value.trim() !== '';
}
/**
* 验证字段值
*/
static validateField(fieldType: FieldType, value: string): ValidationResult {
const errors: string[] = [];
switch (fieldType) {
case FieldType.EMAIL:
if (!this.validateEmail(value)) {
errors.push('邮箱格式不正确');
}
break;
case FieldType.PHONE:
if (!this.validatePhone(value)) {
errors.push('手机号格式不正确,应为11位数字');
}
break;
case FieldType.ID_CARD:
if (!this.validateIdCard(value)) {
errors.push('身份证号格式不正确');
}
break;
case FieldType.DATE:
if (!this.validateDate(value)) {
errors.push('日期格式不正确,应为YYYY-MM-DD');
}
break;
case FieldType.NUMBER:
if (!this.validateNumber(value)) {
errors.push('数字格式不正确');
}
break;
case FieldType.NAME:
if (value.length < 2 || value.length > 50) {
errors.push('姓名长度应在2-50个字符之间');
}
break;
case FieldType.PASSWORD:
if (value.length < 6) {
errors.push('密码长度不能少于6个字符');
}
break;
case FieldType.TEXT:
case FieldType.TEXTarea:
if (value.length > 10000) {
errors.push('文本长度不能超过10000个字符');
}
break;
default:
// 未知类型不做验证
break;
}
return {
isValid: errors.length === 0,
errors,
};
}
/**
* 批量验证字段
*/
static validateFields(fields: Array<{ fieldType: FieldType; value: string }>): ValidationResult {
const allErrors: string[] = [];
fields.forEach((field, index) => {
const result = this.validateField(field.fieldType, field.value);
if (!result.isValid) {
allErrors.push(`字段 ${index + 1}: ${result.errors.join(', ')}`);
}
});
return {
isValid: allErrors.length === 0,
errors: allErrors,
};
}
/**
* 获取字段类型的验证规则描述
*/
static getValidationRules(fieldType: FieldType): string[] {
switch (fieldType) {
case FieldType.EMAIL:
return ['格式: user@domain.com'];
case FieldType.PHONE:
return ['格式: 11位中国大陆手机号', '以1开头,第二位为3-9'];
case FieldType.ID_CARD:
return ['格式: 18位身份证号', '前6位为地区码', '中间8位为生日', '最后1位为校验码'];
case FieldType.DATE:
return ['格式: YYYY-MM-DD', '例如: 2024-01-01'];
case FieldType.NUMBER:
return ['格式: 整数或浮点数'];
case FieldType.NAME:
return ['长度: 2-50个字符'];
case FieldType.PASSWORD:
return ['长度: 至少6个字符'];
case FieldType.TEXT:
case FieldType.TEXTarea:
return ['长度: 不超过10000个字符'];
default:
return [];
}
}
}
-838
View File
@@ -1,838 +0,0 @@
import { fakerZH_CN as faker } from '@faker-js/faker';
/**
* 表单字段信息接口
*/
export interface FormFieldInfo {
id: string;
element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
fieldType: FieldType;
label: string | null;
placeholder: string;
name: string;
value: string;
isSelected: boolean;
generatedValue: string;
}
/**
* 扫描结果接口
*/
export interface ScanResult {
fields: FormFieldInfo[];
totalCount: number;
validCount: number;
modalContainer: HTMLElement | null;
}
/**
* 数据生成器工具类
* 用于生成各种类型的测试数据
*/
export class DummyDataGenerator {
/**
* 生成随机中文姓名
*/
static generateChineseName(): string {
return faker.person.fullName();
}
/**
* 生成随机英文姓名
*/
static generateEnglishName(): string {
return faker.person.fullName();
}
/**
* 生成随机手机号(中国格式)
*/
static generatePhoneNumber(): string {
const prefix =
'1' + faker.string.numeric({ length: 1, allowLeadingZeros: false, exclude: ['0', '1', '2'] });
const suffix = faker.string.numeric({ length: 9, allowLeadingZeros: true });
return prefix + suffix;
}
/**
* 生成有效邮箱
*/
static generateValidEmail(): string {
return faker.internet.email();
}
/**
* 生成无效邮箱
*/
static generateInvalidEmail(): string {
const invalidEmails = [
'testexample.com', // 缺失 @
'test@@example.com', // 多个 @
'test@', // 缺失域名
'test@.com', // 域名为空
'test@example', // 缺失顶级域名
];
return invalidEmails[Math.floor(Math.random() * invalidEmails.length)];
}
/**
* 生成短文本
*/
static generateShortText(): string {
return faker.lorem.sentence({ min: 3, max: 6 });
}
/**
* 生成长文本
*/
static generateLongText(): string {
return faker.lorem.paragraphs(5);
}
/**
* 生成边界测试文本
*/
static generateBoundaryText(): string {
const specialChars = '!@#$%^&*()_+[]{}|;:,.<>?';
const emoji = '😀😃😄😁😆😅😂🤣';
let text = '';
for (let i = 0; i < 100; i++) {
text += specialChars + emoji + '测试文本';
}
return text;
}
/**
* 生成随机数字
*/
static generateNumber(): number {
return faker.number.int(10000);
}
/**
* 生成随机浮点数
*/
static generateFloat(): number {
return faker.number.float({ max: 10000 });
}
/**
* 生成随机负数
*/
static generateNegativeNumber(): number {
return -faker.number.int(10000);
}
/**
* 生成随机日期
*/
static generateDate(): string {
return faker.date.recent({ days: 365 }).toISOString().split('T')[0];
}
/**
* 生成过去的日期
*/
static generatePastDate(): string {
return faker.date.past({ years: 1 }).toISOString().split('T')[0];
}
/**
* 生成未来的日期
*/
static generateFutureDate(): string {
return faker.date.future({ years: 1 }).toISOString().split('T')[0];
}
/**
* 生成随机身份证号
*/
static generateIdCard(): string {
const areaCodes = [
'110101',
'110102',
'110103',
'110104',
'110105',
'310101',
'310102',
'310103',
'310104',
'310105',
'440101',
'440102',
'440103',
'440104',
'440105',
];
const areaCode = areaCodes[Math.floor(Math.random() * areaCodes.length)];
const year = (1950 + Math.floor(Math.random() * 50)).toString();
const month = String(1 + Math.floor(Math.random() * 12)).padStart(2, '0');
const day = String(1 + Math.floor(Math.random() * 28)).padStart(2, '0');
const random = Math.floor(Math.random() * 10000)
.toString()
.padStart(4, '0');
return areaCode + year + month + day + random;
}
}
/**
* 表单字段类型
*/
export enum FieldType {
TEXT = 'text',
EMAIL = 'email',
PHONE = 'phone',
NUMBER = 'number',
DATE = 'date',
TEXTarea = 'textarea',
RADIO = 'radio',
CHECKBOX = 'checkbox',
SELECT = 'select',
PASSWORD = 'password',
NAME = 'name',
ID_CARD = 'id_card',
UNKNOWN = 'unknown',
}
/**
* 填充模式
*/
export enum FillMode {
VALID = 'valid',
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(
element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement,
): FieldType {
// 1. 基于 HTML5 type 属性识别
if (element instanceof HTMLInputElement) {
const typeMap: Record<string, FieldType> = {
email: FieldType.EMAIL,
tel: FieldType.PHONE,
number: FieldType.NUMBER,
date: FieldType.DATE,
password: FieldType.PASSWORD,
radio: FieldType.RADIO,
checkbox: FieldType.CHECKBOX,
};
if (typeMap[element.type]) return typeMap[element.type];
}
// 2. 基于 name/id, placeholder, label 文本识别
const name = element.name || element.id || '';
const placeholder = 'placeholder' in element ? element.placeholder || '' : '';
const label = getFieldLabel(element) || '';
const detected =
detectTypeFromText(name) || detectTypeFromText(placeholder) || detectTypeFromText(label);
if (detected) return detected;
// 3. 基于元素标签识别
if (element instanceof HTMLTextAreaElement) return FieldType.TEXTarea;
if (element instanceof HTMLSelectElement) return FieldType.SELECT;
return FieldType.TEXT;
}
/**
* 获取字段的标签
*/
function getFieldLabel(element: HTMLElement): string | null {
// 查找相邻的 label 元素
const labels = document.querySelectorAll('label');
for (const label of labels) {
const forAttr = label.getAttribute('for');
if (forAttr === element.id) {
return label.textContent || null;
}
}
// 查找父元素中的 label
let parent = element.parentElement;
while (parent) {
if (parent.tagName === 'LABEL') {
return parent.textContent || null;
}
parent = parent.parentElement;
}
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);
}
/**
* 填充表单字段
*/
export function fillField(
element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement,
mode: FillMode,
): void {
const fieldType = recognizeFieldType(element);
const value = generateValueByFieldType(fieldType, mode);
fillFieldWithInjector(element, value);
}
/**
* 触发事件
*/
function triggerEvents(element: HTMLElement): void {
// 触发 input 事件
const inputEvent = new Event('input', {
bubbles: true,
cancelable: true,
});
element.dispatchEvent(inputEvent);
// 触发 change 事件
const changeEvent = new Event('change', {
bubbles: true,
cancelable: true,
});
element.dispatchEvent(changeEvent);
// 触发 blur 事件
const blurEvent = new Event('blur', {
bubbles: true,
cancelable: true,
});
element.dispatchEvent(blurEvent);
}
/**
* 判断元素是否真正可见且允许输入
*/
function isElementVisible(element: HTMLElement): boolean {
// 1. 排除隐藏域、禁用和只读状态
if (element instanceof HTMLInputElement) {
if (element.type === 'hidden' || element.disabled || element.readOnly) {
return false;
}
} else if (element instanceof HTMLTextAreaElement) {
if (element.disabled || element.readOnly) {
return false;
}
} else if (element instanceof HTMLSelectElement) {
if (element.disabled) {
return false;
}
}
// 2. 检查空间尺寸 (能有效过滤大部分 display: none 或未渲染完毕的组件)
const rect = element.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
return false;
}
// 3. 检查计算样式 (兜底检查 css 隐藏手段)
const style = window.getComputedStyle(element);
return !(
style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0' ||
style.visibility === 'collapse'
);
}
/**
* Z轴穿透验证 (Raycasting)
* 通过 document.elementFromPoint(x, y) 向元素中心点发射坐标射线
* 如果获取到的顶层元素不是输入框本身或其子元素,则判定为"视觉遮挡"
*/
function isElementNotObscured(element: HTMLElement): boolean {
const rect = element.getBoundingClientRect();
// 计算元素中心点坐标
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// 向元素中心点发射坐标射线,获取最顶层的元素
const topElement = document.elementFromPoint(centerX, centerY);
if (!topElement) {
return false;
}
// 检查获取到的顶层元素是否是输入框本身或其子元素
return element.contains(topElement);
}
/**
* 判断元素是否在视口范围内
*/
function isElementInViewport(element: HTMLElement): boolean {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
/**
* 判断元素是否真正可见且允许输入(漏斗式检测)
*/
function isElementValidForFill(element: HTMLElement): boolean {
// 1. 基础过滤:排除隐藏域、禁用和只读状态
if (element instanceof HTMLInputElement) {
if (element.type === 'hidden' || element.disabled || element.readOnly) {
return false;
}
} else if (element instanceof HTMLTextAreaElement) {
if (element.disabled || element.readOnly) {
return false;
}
} else if (element instanceof HTMLSelectElement) {
if (element.disabled) {
return false;
}
} else {
return false;
}
// 2. 空间尺寸检测:排除宽高为0的元素
const rect = element.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
return false;
}
// 3. CSS样式检测:排除通过CSS隐藏的元素
const style = window.getComputedStyle(element);
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0' ||
style.visibility === 'collapse'
) {
return false;
}
// 4. 视口检测:只处理当前屏幕滚动范围内的元素
if (!isElementInViewport(element)) {
return false;
}
// 5. Z轴穿透验证(最后一步,最耗时,放最后)
return isElementNotObscured(element);
}
/**
* 查找最上层的弹窗容器
*/
function findActiveModalContainer(): HTMLElement | null {
const modalSelectors = [
'.ant-modal-content',
'.el-dialog',
'[role="dialog"]',
'.MuiDialog-content',
'.modal-content',
'.dialog-content',
'.popup-content',
];
let topModal: HTMLElement | null = null;
let highestZIndex = 0;
modalSelectors.forEach((selector) => {
const modals = document.querySelectorAll(selector);
modals.forEach((modal) => {
if (modal instanceof HTMLElement) {
const style = window.getComputedStyle(modal);
const zIndex = parseInt(style.zIndex, 10) || 0;
if (zIndex > highestZIndex && isElementVisible(modal)) {
highestZIndex = zIndex;
topModal = modal;
}
}
});
});
return topModal;
}
/**
* 扫描页面中所有可见的表单字段
*/
export function scanFormFields(): ScanResult {
const inputs = document.querySelectorAll('input, textarea, select');
const fields: FormFieldInfo[] = [];
const modalContainer = findActiveModalContainer();
inputs.forEach((input) => {
if (
input instanceof HTMLInputElement ||
input instanceof HTMLTextAreaElement ||
input instanceof HTMLSelectElement
) {
if (isElementValidForFill(input)) {
const fieldType = recognizeFieldType(input);
const label = getFieldLabel(input);
const placeholder = 'placeholder' in input ? input.placeholder : '';
const name = input.name || input.id || '';
fields.push({
id: `field-${Math.random().toString(36).substring(2, 9)}`,
element: input,
fieldType,
label,
placeholder,
name,
value: input.value,
isSelected: true,
generatedValue: generateValueByFieldType(fieldType, FillMode.VALID),
});
}
}
});
return {
fields,
totalCount: fields.length,
validCount: fields.filter((f) => f.isSelected).length,
modalContainer,
};
}
/**
* 根据字段类型生成对应的值
*/
export function generateValueByFieldType(fieldType: FieldType, mode: FillMode): string {
switch (fieldType) {
case FieldType.NAME:
return Math.random() > 0.5
? DummyDataGenerator.generateChineseName()
: DummyDataGenerator.generateEnglishName();
case FieldType.EMAIL:
return mode === FillMode.VALID
? DummyDataGenerator.generateValidEmail()
: DummyDataGenerator.generateInvalidEmail();
case FieldType.PHONE:
return DummyDataGenerator.generatePhoneNumber();
case FieldType.NUMBER:
return String(
mode === FillMode.VALID
? DummyDataGenerator.generateNumber()
: DummyDataGenerator.generateNegativeNumber(),
);
case FieldType.DATE:
return DummyDataGenerator.generateDate();
case FieldType.TEXTarea:
return mode === FillMode.VALID
? DummyDataGenerator.generateLongText()
: DummyDataGenerator.generateBoundaryText();
case FieldType.PASSWORD:
return 'password123';
case FieldType.ID_CARD:
return DummyDataGenerator.generateIdCard();
case FieldType.TEXT:
default:
return mode === FillMode.VALID
? DummyDataGenerator.generateShortText()
: DummyDataGenerator.generateBoundaryText();
}
}
/**
* 框架级数据注入器
* 破解 React/Vue 的 input setter 劫持
*/
function setInputValue(
element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement,
value: string,
): void {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
element instanceof HTMLInputElement
? window.HTMLInputElement.prototype
: element instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLSelectElement.prototype,
'value',
)?.set;
if (nativeInputValueSetter) {
nativeInputValueSetter.call(element, value);
} else {
element.value = value;
}
}
/**
* 填充指定字段(使用框架级注入)
*/
export function fillFieldWithInjector(
element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement,
value: string,
): void {
if (element instanceof HTMLInputElement) {
if (element.type === 'checkbox' || element.type === 'radio') {
element.checked = value === 'true' || value === '1';
} else {
setInputValue(element, value);
}
} else if (element instanceof HTMLTextAreaElement) {
setInputValue(element, value);
} else if (element instanceof HTMLSelectElement) {
// 查找匹配的选项
const options = Array.from(element.options);
const matchingOption = options.find((opt) => opt.value === value || opt.text === value);
if (matchingOption) {
element.value = matchingOption.value;
} else if (options.length > 0) {
element.selectedIndex = 0;
}
}
triggerEvents(element);
}
/**
* 批量填充选中的字段(支持单字段模式覆盖)
*/
export function fillSelectedFields(
fields: Array<FormFieldInfo & { useInvalidData?: boolean }>,
defaultMode: FillMode,
): number {
let filledCount = 0;
fields.forEach((field) => {
if (field.isSelected) {
const mode = field.useInvalidData
? FillMode.INVALID
: field.useInvalidData === false
? FillMode.VALID
: defaultMode;
// 始终根据当前 fieldType 重新生成值,确保类型变更生效
const value = generateValueByFieldType(field.fieldType, mode);
fillFieldWithInjector(field.element, value);
filledCount++;
}
});
return filledCount;
}
/**
* 闪烁字段(用于定位)
*/
export function flashField(element: HTMLElement): void {
let flashCount = 0;
const maxFlashes = 4;
const originalStyle =
element.getAttribute('data-original-style') || element.getAttribute('style') || '';
element.setAttribute('data-original-style', originalStyle);
const flash = () => {
if (flashCount >= maxFlashes) {
unhighlightField(element);
return;
}
if (flashCount % 2 === 0) {
element.style.outline = '3px solid #4caf50';
element.style.outlineOffset = '2px';
element.style.transition = 'outline 0.3s ease-in-out';
} else {
element.style.outline = '';
}
flashCount++;
setTimeout(flash, 300);
};
flash();
}
/**
* 高亮指定字段
*/
export function highlightField(element: HTMLElement): void {
const originalStyle =
element.getAttribute('data-original-style') || element.getAttribute('style') || '';
element.setAttribute('data-original-style', originalStyle);
element.style.outline = '3px solid #2196f3';
element.style.outlineOffset = '2px';
element.style.transition = 'outline 0.2s ease-in-out';
}
/**
* 取消高亮指定字段
*/
export function unhighlightField(element: HTMLElement): void {
const originalStyle = element.getAttribute('data-original-style') || '';
if (originalStyle) {
element.setAttribute('style', originalStyle);
element.removeAttribute('data-original-style');
} else {
element.style.outline = '';
element.style.outlineOffset = '';
}
}
/**
* 高亮所有指定字段
*/
export function highlightAllFields(fieldIds: string[], fields: FormFieldInfo[]): void {
fieldIds.forEach((id) => {
const field = fields.find((f) => f.id === id);
if (field) {
highlightField(field.element);
}
});
}
/**
* 取消高亮所有字段
*/
export function unhighlightAllFields(fields: FormFieldInfo[]): void {
fields.forEach((field) => {
unhighlightField(field.element);
});
}
/**
* 填充所有表单字段
*/
export function fillAllFields(mode: FillMode, includeHidden: boolean = false): void {
forEachFormElement(
document,
(el) => {
const fieldType = recognizeFieldType(el);
const value = generateValueByFieldType(fieldType, mode);
fillFieldWithInjector(el, value);
},
{ includeHidden },
);
}
/**
* 填充指定容器内的表单字段
*/
export function fillFieldsInContainer(
mode: FillMode,
container: HTMLElement,
includeHidden: boolean = false,
): void {
forEachFormElement(
container,
(el) => {
const fieldType = recognizeFieldType(el);
const value = generateValueByFieldType(fieldType, mode);
fillFieldWithInjector(el, value);
},
{ includeHidden },
);
}
/**
* 填充弹窗内的表单字段
*/
export function fillFieldsInActiveModal(mode: FillMode, includeHidden: boolean = false): boolean {
const modal = findActiveModalContainer();
if (modal) {
fillFieldsInContainer(mode, modal, includeHidden);
return true;
}
return false;
}
/**
* 清空所有表单字段
*/
export function clearAllFields(): void {
forEachFormElement(document, (el) => clearField(el));
}
/**
* 清空指定容器内的表单字段
*/
export function clearFieldsInContainer(container: HTMLElement): void {
forEachFormElement(container, (el) => clearField(el));
}
-170
View File
@@ -1,170 +0,0 @@
import { FormMapEntry } from '@/types/storage';
/**
* 可视化交互模块:负责在网页上绘制非破坏性的高亮遮罩
*/
export class VisualHighlighter {
private canvas: HTMLCanvasElement | null = null;
private ctx: CanvasRenderingContext2D | null = null;
private isVisible = false;
private currentEntries: FormMapEntry[] = [];
private animationFrameId: number | null = null;
constructor() {
this.handleResize = this.handleResize.bind(this);
this.render = this.render.bind(this);
}
public init() {
if (this.canvas) return;
this.canvas = document.createElement('canvas');
this.canvas.id = 'form-mapping-highlighter';
Object.assign(this.canvas.style, {
position: 'fixed',
top: '0',
left: '0',
width: '100vw',
height: '100vh',
pointerEvents: 'none',
zIndex: '2147483647',
display: 'none',
});
document.body.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d');
window.addEventListener('resize', this.handleResize);
window.addEventListener('scroll', this.handleResize);
}
public show() {
if (!this.canvas) this.init();
this.isVisible = true;
this.canvas!.style.display = 'block';
this.requestUpdate();
}
public hide() {
this.isVisible = false;
if (this.canvas) this.canvas.style.display = 'none';
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
}
private handleResize() {
if (!this.isVisible || !this.canvas || !this.ctx) return;
this.requestUpdate();
}
/**
* 核心更新请求,使用 requestAnimationFrame 节流
*/
private requestUpdate() {
if (this.animationFrameId !== null) return;
this.animationFrameId = requestAnimationFrame(this.render);
}
/**
* 核心渲染逻辑
*/
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);
if (!el) return;
const rect = el.getBoundingClientRect();
// 检查元素是否在视口内
if (
rect.bottom < 0 ||
rect.top > window.innerHeight ||
rect.right < 0 ||
rect.left > window.innerWidth
) {
return;
}
// 设置样式
if (entry.ui_state.is_selected) {
// 选中状态:亮黄色边框
this.ctx!.strokeStyle = '#FFD700';
this.ctx!.lineWidth = 3;
this.ctx!.fillStyle = 'rgba(255, 215, 0, 0.2)';
} else {
// 未选中状态:浅蓝色半透明
this.ctx!.strokeStyle = 'rgba(173, 216, 230, 0.8)';
this.ctx!.lineWidth = 1;
this.ctx!.fillStyle = 'rgba(173, 216, 230, 0.4)';
}
// 绘制矩形
this.ctx!.beginPath();
this.ctx!.rect(rect.left, rect.top, rect.width, rect.height);
this.ctx!.fill();
this.ctx!.stroke();
// 如果被选中,绘制一个小标签
if (entry.ui_state.is_selected) {
this.ctx!.fillStyle = '#FFD700';
this.ctx!.font = '12px sans-serif';
this.ctx!.fillText(entry.label_display, rect.left, rect.top - 5);
}
});
}
/**
* 公共 draw 方法,仅更新数据并触发渲染请求
*/
public draw(entries: FormMapEntry[] = []) {
this.currentEntries = entries;
if (this.isVisible) {
this.requestUpdate();
}
}
/**
* 开启拾取模式:拦截点击事件
*/
public enablePicker(onPick: (element: HTMLElement) => void) {
if (!this.canvas) this.init();
this.canvas!.style.pointerEvents = 'auto';
this.canvas!.style.cursor = 'crosshair';
const handleClick = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// 暂时禁用 canvas pointer-events 以便探测下方的真实元素
this.canvas!.style.pointerEvents = 'none';
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement;
this.canvas!.style.pointerEvents = 'auto';
if (el) {
onPick(el);
}
};
this.canvas!.addEventListener('click', handleClick, { capture: true, once: true });
}
public disablePicker() {
if (this.canvas) {
this.canvas.style.pointerEvents = 'none';
this.canvas.style.cursor = 'default';
}
}
}
export const highlighter = new VisualHighlighter();
-100
View File
@@ -1,100 +0,0 @@
import { FormMapEntry } from '@/types/storage';
/**
* 智能探测引擎:负责扫描 DOM 并生成唯一指纹
*/
export class SmartDetector {
/**
* 扫描页面中符合条件的表单元素
*/
public static scanFormElements(): HTMLElement[] {
const selector =
'input:not([type="hidden"]):not([type="submit"]):not([type="button"]), textarea, select, [contenteditable="true"]';
const elements = Array.from(document.querySelectorAll<HTMLElement>(selector));
return elements.filter((el) => {
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && window.getComputedStyle(el).display !== 'none';
});
}
/**
* 生成元素的唯一性指纹
*/
public static generateFingerprint(element: HTMLElement): FormMapEntry['fingerprint'] {
return {
selector: this.getUniqueSelector(element),
name_attr: element.getAttribute('name') || element.getAttribute('id') || '',
placeholder: element.getAttribute('placeholder') || '',
};
}
/**
* 提取元素的语义标签 (核心算法)
* 优先查找 label[for],其次在物理位置上方或左侧 50px 范围内寻找文本
*/
public static extractSemanticLabel(element: HTMLElement): string {
// 1. 尝试查找关联的 label 元素
if (element.id) {
const label = document.querySelector(`label[for="${element.id}"]`);
if (label?.textContent) return label.textContent.trim();
}
// 2. 尝试向上查找父级中的 label
const parentLabel = element.closest('label');
if (parentLabel?.textContent) return parentLabel.textContent.trim();
// 3. 物理位置探测算法 (getBoundingClientRect)
const rect = element.getBoundingClientRect();
// 探测左侧 50px
const leftText = this.getTextNearby(rect.left - 25, rect.top + rect.height / 2);
if (leftText) return leftText;
// 探测上方 50px
const topText = this.getTextNearby(rect.left + rect.width / 2, rect.top - 25);
if (topText) return topText;
// 4. 降级:使用 placeholder 或 name
return element.getAttribute('placeholder') || element.getAttribute('name') || '未知字段';
}
/**
* 在指定坐标附近寻找最可能的文本节点
*/
private static getTextNearby(x: number, y: number): string | null {
if (x < 0 || y < 0) return null;
const el = document.elementFromPoint(x, y);
if (!el) return null;
// 如果命中了文本容器
const text = el.textContent?.trim();
if (text && text.length < 30) return text; // 避免抓到太长的段落
return null;
}
/**
* 计算元素的相对短且唯一的 CSS 选择器
*/
private static getUniqueSelector(el: HTMLElement): string {
if (el.id) return `#${el.id}`;
let path = el.tagName.toLowerCase();
// 尝试添加类名以增加唯一性
if (el.classList.length > 0) {
path += `.${Array.from(el.classList).join('.')}`;
}
// 如果当前路径在文档中不是唯一的,则增加 nth-child
if (document.querySelectorAll(path).length > 1) {
const parent = el.parentElement;
if (parent) {
const index = Array.from(parent.children).indexOf(el) + 1;
path = `${this.getUniqueSelector(parent as HTMLElement)} > ${el.tagName.toLowerCase()}:nth-child(${index})`;
}
}
return path;
}
}
-579
View File
@@ -1,579 +0,0 @@
import { FormMapEntry } from '@/types/storage';
/**
* 模糊匹配引擎结果接口
*/
export interface MatchResult {
element: HTMLElement | null;
score: number;
}
/**
* 注入结果接口
*/
export interface InjectResult {
success: boolean;
entry: FormMapEntry;
error?: string;
}
/**
* 模糊匹配引擎
* 根据 JSON 指纹在页面中精准定位目标 DOM 元素
*/
export class FuzzyMatcher {
private static readonly MATCH_THRESHOLD = 75;
private static readonly SCORE_SELECTOR = 50;
private static readonly SCORE_NAME_ATTR = 25;
private static readonly SCORE_PLACEHOLDER = 15;
private static readonly SCORE_NEIGHBOR_TEXT = 10;
/**
* 根据指纹查找目标元素
* @param fingerprint - 表单字段指纹
* @returns 匹配结果(包含元素和得分)
*/
public static findTargetElement(fingerprint: FormMapEntry['fingerprint']): MatchResult {
const candidates: Array<{ element: HTMLElement; score: number }> = [];
// 1. 首先尝试精确选择器匹配
if (fingerprint.selector) {
const exactMatch = document.querySelector<HTMLElement>(fingerprint.selector);
if (exactMatch) {
const score = this.calculateScore(exactMatch, fingerprint);
candidates.push({ element: exactMatch, score });
}
}
// 2. 收集所有可能的候选元素
const potentialElements = this.collectPotentialElements(fingerprint);
for (const element of potentialElements) {
const score = this.calculateScore(element, fingerprint);
if (score > 0) {
candidates.push({ element, score });
}
}
// 3. 找到最高分的候选
if (candidates.length === 0) {
return { element: null, score: 0 };
}
const bestMatch = candidates.reduce((prev, curr) => (curr.score > prev.score ? curr : prev));
return bestMatch.score >= this.MATCH_THRESHOLD
? { element: bestMatch.element, score: bestMatch.score }
: { element: null, score: bestMatch.score };
}
/**
* 计算元素匹配得分
*/
private static calculateScore(
element: HTMLElement,
fingerprint: FormMapEntry['fingerprint'],
): number {
let score = 0;
// 选择器精确匹配
if (fingerprint.selector) {
const matched = document.querySelector(fingerprint.selector);
if (matched === element) {
score += this.SCORE_SELECTOR;
}
}
// name 或 id 属性匹配
if (fingerprint.name_attr) {
const elementName = element.getAttribute('name') || '';
const elementId = element.getAttribute('id') || '';
if (elementName === fingerprint.name_attr || elementId === fingerprint.name_attr) {
score += this.SCORE_NAME_ATTR;
}
}
// placeholder 匹配
if (fingerprint.placeholder) {
const elementPlaceholder =
'placeholder' in element && (element as HTMLInputElement).placeholder;
if (elementPlaceholder === fingerprint.placeholder) {
score += this.SCORE_PLACEHOLDER;
}
}
// 邻近文本(label)匹配
if (fingerprint.name_attr || fingerprint.placeholder) {
const neighborText = this.getNeighborText(element);
const searchText = fingerprint.name_attr || fingerprint.placeholder || '';
if (neighborText.includes(searchText)) {
score += this.SCORE_NEIGHBOR_TEXT;
}
}
return score;
}
/**
* 收集潜在的候选元素
*/
private static collectPotentialElements(
_fingerprint: FormMapEntry['fingerprint'],
): HTMLElement[] {
const elements: HTMLElement[] = [];
// 获取所有表单元素
const selectors = [
'input:not([type="hidden"])',
'textarea',
'select',
'[contenteditable="true"]',
];
for (const selector of selectors) {
const found = document.querySelectorAll<HTMLElement>(selector);
found.forEach((el) => {
if (this.isVisibleElement(el)) {
elements.push(el);
}
});
}
return elements;
}
/**
* 获取元素附近的文本内容
*/
private static getNeighborText(element: HTMLElement): string {
const texts: string[] = [];
// 查找关联的 label
const id = element.getAttribute('id');
if (id) {
const label = document.querySelector(`label[for="${id}"]`);
if (label) {
texts.push(label.textContent || '');
}
}
// 查找父级 label
const parentLabel = element.closest('label');
if (parentLabel) {
texts.push(parentLabel.textContent || '');
}
// 查找相邻元素的文本
const prevSibling = element.previousElementSibling;
const nextSibling = element.nextElementSibling;
if (prevSibling) {
texts.push(prevSibling.textContent || '');
}
if (nextSibling) {
texts.push(nextSibling.textContent || '');
}
// 查找父级内的文本节点
const parent = element.parentElement;
if (parent) {
const textNodes = parent.querySelectorAll('span, div, p');
textNodes.forEach((node) => {
texts.push(node.textContent || '');
});
}
return texts.join(' ').toLowerCase();
}
/**
* 检查元素是否可见
*/
private static isVisibleElement(element: HTMLElement): boolean {
const rect = element.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;
const style = window.getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
}
}
/**
* 智能注入引擎
* 突破 React/Vue 等现代框架的表单状态绑定
*/
export class SmartInjectionEngine {
/**
* 注入数据到目标元素
* @param element - 目标 DOM 元素
* @param entry - 表单映射条目
* @param mockValue - mock数据
* @returns 注入结果
*/
public static inject(element: HTMLElement, entry: FormMapEntry, mockValue: string): InjectResult {
try {
const { action_logic } = entry;
switch (action_logic.type) {
case 'text':
this.injectText(element as HTMLInputElement | HTMLTextAreaElement, mockValue);
break;
case 'select':
this.injectSelect(element as HTMLSelectElement, action_logic);
break;
case 'checkbox':
this.injectCheckbox(element as HTMLInputElement, action_logic);
break;
default:
this.injectText(element as HTMLInputElement | HTMLTextAreaElement, mockValue);
}
return { success: true, entry };
} catch (error) {
return {
success: false,
entry,
error: error instanceof Error ? error.message : '注入失败',
};
}
}
/**
* 注入文本类输入框
*/
private static injectText(element: HTMLInputElement | HTMLTextAreaElement, value: string): void {
// 获取原生 setter
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
element instanceof HTMLInputElement
? window.HTMLInputElement.prototype
: window.HTMLTextAreaElement.prototype,
'value',
)?.set;
if (nativeInputValueSetter) {
nativeInputValueSetter.call(element, value);
} else {
element.value = value;
}
// 连续触发事件
element.dispatchEvent(new Event('focus', { bubbles: true }));
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
element.dispatchEvent(new Event('blur', { bubbles: true }));
}
/**
* 注入下拉框
*/
private static injectSelect(
element: HTMLSelectElement,
actionLogic: FormMapEntry['action_logic'],
): void {
if (actionLogic.strategy === 'random') {
// 随机选择
const options = Array.from(element.options).filter((opt) => !opt.disabled);
if (options.length > 0) {
element.selectedIndex = Math.floor(Math.random() * options.length);
}
} else {
// 使用固定值
const value = actionLogic.value;
const matchingOption = Array.from(element.options).find(
(opt) => opt.value === value || opt.text === value,
);
if (matchingOption) {
element.value = matchingOption.value;
} else if (element.options.length > 0) {
element.selectedIndex = 0;
}
}
element.dispatchEvent(new Event('change', { bubbles: true }));
}
/**
* 注入复选框/单选框
*/
private static injectCheckbox(
element: HTMLInputElement,
actionLogic: FormMapEntry['action_logic'],
): void {
if (actionLogic.strategy === 'random') {
// 随机选择
const isChecked = Math.random() > 0.5;
if (element.type === 'checkbox') {
element.checked = isChecked;
} else if (element.type === 'radio') {
// 对于单选框,找到同 name 的所有选项并随机选择一个
const radioGroup = document.querySelectorAll<HTMLInputElement>(
`input[type="radio"][name="${element.name}"]`,
);
if (radioGroup.length > 0) {
const randomIndex = Math.floor(Math.random() * radioGroup.length);
radioGroup[randomIndex].click();
}
}
} else {
// 使用固定值
const shouldCheck = actionLogic.value === 'true' || actionLogic.value === '1';
element.checked = shouldCheck;
if (shouldCheck) {
element.click();
}
}
element.dispatchEvent(new Event('change', { bubbles: true }));
}
}
/**
* Mock 数据生成器
*/
export class MockDataGenerator {
/**
* 根据策略生成随机数据
*/
public static generate(actionLogic: FormMapEntry['action_logic'], entry: FormMapEntry): string {
const { strategy, value, type } = actionLogic;
if (strategy === 'fixed') {
return value;
}
// 根据字段类型和策略生成数据
switch (type) {
case 'text':
return this.generateText(entry);
case 'select':
return this.generateSelectValue();
case 'checkbox':
return this.generateBoolean();
default:
return this.generateText(entry);
}
}
/**
* 生成文本数据
*/
private static generateText(entry: FormMapEntry): string {
const { fingerprint, action_logic } = entry;
const { strategy, value: pattern } = action_logic;
// 根据模式生成数据
if (pattern) {
return this.generateByPattern(pattern);
}
// 根据指纹特征推断数据类型
const name = fingerprint.name_attr.toLowerCase();
const placeholder = fingerprint.placeholder.toLowerCase();
if (name.includes('phone') || placeholder.includes('phone')) {
return this.generatePhoneNumber();
}
if (name.includes('email') || placeholder.includes('email')) {
return this.generateEmail();
}
if (name.includes('name') || placeholder.includes('name')) {
return this.generateName();
}
if (name.includes('id') || name.includes('card')) {
return this.generateIdCard();
}
if (name.includes('date') || placeholder.includes('date')) {
return this.generateDate();
}
if (name.includes('number') || placeholder.includes('number')) {
return this.generateNumber();
}
// 默认生成随机文本
return strategy === 'random' ? this.generateRandomText() : '测试数据';
}
/**
* 根据模式生成数据
*/
private static generateByPattern(pattern: string): string {
if (pattern.includes('phone') || pattern.includes('mobile')) {
return this.generatePhoneNumber();
}
if (pattern.includes('email')) {
return this.generateEmail();
}
if (pattern.includes('name')) {
return this.generateName();
}
if (pattern.includes('date')) {
return this.generateDate();
}
if (pattern.includes('idcard') || pattern.includes('身份证')) {
return this.generateIdCard();
}
if (/^\d+$/.test(pattern)) {
return this.generateNumber(pattern.length);
}
return pattern;
}
/**
* 生成手机号
*/
private static generatePhoneNumber(): string {
const prefix = '1' + ['3', '4', '5', '6', '7', '8', '9'][Math.floor(Math.random() * 7)];
const suffix = Math.floor(Math.random() * 1000000000)
.toString()
.padStart(9, '0');
return prefix + suffix;
}
/**
* 生成邮箱
*/
private static generateEmail(): string {
const names = ['test', 'user', 'admin', 'guest', 'demo'];
const domains = ['example.com', 'test.com', 'gmail.com', 'outlook.com'];
const name = names[Math.floor(Math.random() * names.length)];
const domain = domains[Math.floor(Math.random() * domains.length)];
const num = Math.floor(Math.random() * 1000);
return `${name}${num}@${domain}`;
}
/**
* 生成姓名
*/
private static generateName(): string {
const surnames = ['张', '李', '王', '刘', '陈', '杨', '赵', '黄'];
const givenNames = ['伟', '芳', '强', '英', '华', '建', '明', '娜'];
return (
surnames[Math.floor(Math.random() * surnames.length)] +
givenNames[Math.floor(Math.random() * givenNames.length)]
);
}
/**
* 生成身份证号
*/
private static generateIdCard(): string {
const areaCodes = ['110101', '310101', '440101', '120101', '320101'];
const areaCode = areaCodes[Math.floor(Math.random() * areaCodes.length)];
const year = (1980 + Math.floor(Math.random() * 30)).toString();
const month = String(1 + Math.floor(Math.random() * 12)).padStart(2, '0');
const day = String(1 + Math.floor(Math.random() * 28)).padStart(2, '0');
const random = Math.floor(Math.random() * 10000)
.toString()
.padStart(4, '0');
return areaCode + year + month + day + random;
}
/**
* 生成日期
*/
private static generateDate(): string {
const date = new Date();
date.setDate(date.getDate() - Math.floor(Math.random() * 365));
return date.toISOString().split('T')[0];
}
/**
* 生成数字
*/
private static generateNumber(length: number = 6): string {
return Math.floor(Math.random() * Math.pow(10, length))
.toString()
.padStart(length, '0');
}
/**
* 生成随机文本
*/
private static generateRandomText(): string {
const texts = ['测试内容', '示例文本', 'Lorem ipsum', '随机数据', 'Sample Text'];
return texts[Math.floor(Math.random() * texts.length)];
}
/**
* 生成下拉框值
*/
private static generateSelectValue(): string {
return '选项' + (Math.floor(Math.random() * 5) + 1);
}
/**
* 生成布尔值
*/
private static generateBoolean(): string {
return Math.random() > 0.5 ? 'true' : 'false';
}
}
/**
* 视觉反馈渲染器
*/
export class FeedbackRenderer {
private static readonly SUCCESS_COLOR = '#32CD32';
private static readonly ERROR_COLOR = '#FF4444';
private static readonly HIGHLIGHT_DURATION = 3000;
/**
* 渲染成功反馈
*/
public static renderSuccess(element: HTMLElement): void {
this.applyHighlight(element, this.SUCCESS_COLOR);
}
/**
* 渲染失败反馈
*/
public static renderError(element: HTMLElement | null): void {
if (!element) return;
this.applyHighlight(element, this.ERROR_COLOR);
}
/**
* 应用高亮样式
*/
private static applyHighlight(element: HTMLElement, color: string): void {
// 保存原始样式
const originalStyle = element.getAttribute('style') || '';
element.setAttribute('data-original-style', originalStyle);
// 应用高亮
element.style.outline = `3px solid ${color}`;
element.style.outlineOffset = '2px';
element.style.transition = 'outline 0.3s ease';
// 自动移除高亮
setTimeout(() => {
const savedStyle = element.getAttribute('data-original-style');
if (savedStyle) {
element.setAttribute('style', savedStyle);
element.removeAttribute('data-original-style');
} else {
element.style.outline = '';
element.style.outlineOffset = '';
}
}, this.HIGHLIGHT_DURATION);
}
/**
* 清除所有高亮
*/
public static clearAllHighlights(): void {
const highlightedElements = document.querySelectorAll('[data-original-style]');
highlightedElements.forEach((element) => {
const savedStyle = element.getAttribute('data-original-style');
if (savedStyle) {
element.setAttribute('style', savedStyle);
element.removeAttribute('data-original-style');
}
});
}
}
-54
View File
@@ -1,54 +0,0 @@
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);
}
+125
View File
@@ -0,0 +1,125 @@
/**
* JWT 解析工具
*/
export interface JwtHeader {
alg: string;
typ?: string;
[key: string]: unknown;
}
export interface JwtPayload {
iss?: string;
sub?: string;
aud?: string | string[];
exp?: number;
nbf?: number;
iat?: number;
jti?: string;
[key: string]: unknown;
}
export interface JwtResult {
header: JwtHeader | null;
payload: JwtPayload | null;
signature: string;
raw: {
header: string;
payload: string;
signature: string;
};
error?: string;
}
/**
* Base64URL 解码
* @param str Base64URL 编码字符串
*/
export function decodeBase64Url(str: string): string {
// 将 Base64URL 转换为 标准 Base64
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
// 添加填充
const pad = base64.length % 4;
if (pad) {
if (pad === 1) {
throw new Error('Invalid base64url string');
}
base64 += new Array(5 - pad).join('=');
}
try {
// 使用 TextDecoder 处理 UTF-8 字符
const binStr = atob(base64);
const binLen = binStr.length;
const bytes = new Uint8Array(binLen);
for (let i = 0; i < binLen; i++) {
bytes[i] = binStr.charCodeAt(i);
}
const decoder = new TextDecoder('utf-8');
return decoder.decode(bytes);
} catch (e) {
throw new Error('Failed to decode base64url: ' + (e instanceof Error ? e.message : String(e)));
}
}
/**
* 解析 JWT 字符串
* @param token JWT 字符串
*/
export function parseJwt(token: string): JwtResult {
const parts = token.trim().split('.');
if (parts.length !== 3) {
return {
header: null,
payload: null,
signature: '',
raw: { header: '', payload: '', signature: '' },
error: 'JWT 格式错误:必须包含三个由 "." 分隔的部分',
};
}
const [headerB64, payloadB64, signatureB64] = parts;
const result: JwtResult = {
header: null,
payload: null,
signature: signatureB64,
raw: {
header: headerB64,
payload: payloadB64,
signature: signatureB64,
},
};
try {
const headerJson = decodeBase64Url(headerB64);
result.header = JSON.parse(headerJson);
} catch (e) {
result.error = '解析 Header 失败:' + (e instanceof Error ? e.message : String(e));
return result;
}
try {
const payloadJson = decodeBase64Url(payloadB64);
result.payload = JSON.parse(payloadJson);
} catch (e) {
result.error = '解析 Payload 失败:' + (e instanceof Error ? e.message : String(e));
return result;
}
return result;
}
/**
* 格式化 JSON
* @param obj 对象
*/
export function formatJson(obj: unknown): string {
try {
return JSON.stringify(obj, null, 2);
} catch (e) {
console.error('格式化 JSON 失败:', e);
return String(obj);
}
}
+26 -120
View File
@@ -1,154 +1,60 @@
import { FormFieldInfo, FillMode } from './dummyDataGenerator';
import { defineExtensionMessaging } from '@webext-core/messaging'; import { defineExtensionMessaging } from '@webext-core/messaging';
/**
* 字段数据接口(用于消息传递)
*/
export interface MessageFieldData extends Omit<FormFieldInfo, 'element'> {
useInvalidData?: boolean;
}
/**
* 消息动作类型
*/
export enum MessageAction { export enum MessageAction {
// 标签页操作
RELOAD_TAB = 'reloadTab', RELOAD_TAB = 'reloadTab',
// 表单相关操作
SCAN_FORM_FIELDS = 'scanFormFields',
FILL_VALID_DATA = 'fillValidData',
FILL_INVALID_DATA = 'fillInvalidData',
FILL_SELECTED_FIELDS = 'fillSelectedFields',
CLEAR_ALL_FIELDS = 'clearAllFields',
// 字段高亮操作
HIGHLIGHT_FIELD = 'highlightField',
UNHIGHLIGHT_FIELD = 'unhighlightField',
HIGHLIGHT_ALL_FIELDS = 'highlightAllFields',
UNHIGHLIGHT_ALL_FIELDS = 'unhighlightAllFields',
// 字段定位/闪烁
FLASH_FIELD = 'flashField',
// 智能表单注入
FORM_INJECT = 'FORM_INJECT',
// 侧边栏状态变化
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged', SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
} }
/**
* 消息载荷接口 (保留兼容性)
*/
export interface MessagePayload {
action: MessageAction | string;
tabId?: number;
delay?: number;
fields?: MessageFieldData[];
mode?: FillMode;
includeHidden?: boolean;
fieldId?: string;
fieldIds?: string[];
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;
fields?: Omit<FormFieldInfo, 'element'>[];
totalCount?: number;
validCount?: number;
hasModal?: boolean;
results?: FormInjectResult[];
error?: string; error?: string;
} }
/**
* 协议映射定义(用于类型安全的消息通信)
*/
export interface ProtocolMap { export interface ProtocolMap {
// 基础消息格式,用于逐步迁移
[MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): MessageResponse; [MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): 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; [MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void;
} }
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>(); 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>( export async function sendMessageToContent<K extends keyof ProtocolMap>(
action: K, action: K,
...args: ProtocolData<K> extends undefined ? [] : [data: ProtocolData<K>] ...args: Parameters<ProtocolMap[K]>[0] extends undefined
): Promise<ProtocolReturn<K>> { ? []
: [data: Parameters<ProtocolMap[K]>[0]]
): Promise<ReturnType<ProtocolMap[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: '无法获取当前标签页' } as ProtocolReturn<K>; console.warn(`[Messaging] 无法获取当前标签页,无法发送动作: ${action}`);
return { success: false, message: '无法获取当前标签页' } as ReturnType<ProtocolMap[K]>;
} }
const data = args.length > 0 ? args[0] : undefined; const data = args.length > 0 ? args[0] : undefined;
return await (
sendMessage as (type: K, data: ProtocolData<K>, arg?: number) => Promise<ProtocolReturn<K>> const response = await (
)(action, data as ProtocolData<K>, tab.id); sendMessage as (
type: K,
data: Parameters<ProtocolMap[K]>[0],
arg?: number,
) => Promise<ReturnType<ProtocolMap[K]>>
)(action, data as Parameters<ProtocolMap[K]>[0], tab.id);
return response;
} catch (error) { } catch (error) {
console.error('发送消息失败:', error); const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, message: '发送消息失败' } as ProtocolReturn<K>; console.error(`[Messaging] 向内容脚本发送消息失败 [Action: ${action}]:`, errorMsg);
if (errorMsg.includes('Could not establish connection')) {
return { success: false, message: '无法连接到网页,请刷新页面后再试' } as ReturnType<
ProtocolMap[K]
>;
} }
if (errorMsg.includes('No response')) {
return { success: false, message: '网页响应超时,请重试' } as ReturnType<ProtocolMap[K]>;
} }
/** return { success: false, message: `通信失败: ${errorMsg}` } as ReturnType<ProtocolMap[K]>;
* 注入内容脚本
*/
export async function injectContentScript(): Promise<boolean> {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) {
return false;
}
// 尝试注入内容脚本
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['/content-scripts/content.js'],
});
return true;
} catch (error) {
console.error('注入内容脚本失败:', error);
return false;
} }
} }
+21 -38
View File
@@ -1,4 +1,4 @@
import jsQR from 'jsqr'; import QrScanner from 'qr-scanner';
export interface QrCodeParseResult { export interface QrCodeParseResult {
success: boolean; success: boolean;
@@ -6,49 +6,32 @@ export interface QrCodeParseResult {
error?: string; error?: string;
} }
export async function parseQrCodeFromFile( /**
file: File, * 从文件中解析二维码
timeout: number = 10000, * 使用 qr-scanner 替代 jsqr 以减小体积并提高性能
): Promise<QrCodeParseResult> { */
export async function parseQrCodeFromFile(file: File): Promise<QrCodeParseResult> {
try { try {
const canvas = document.createElement('canvas'); // qr-scanner 的 scanImage 方法支持直接传入 File 对象
const ctx = canvas.getContext('2d'); // 它会自动处理图片加载、Canvas 绘制和解析过程
// 并且在支持的浏览器中会优先使用原生的 BarcodeDetector API
if (!ctx) { const result = await QrScanner.scanImage(file, {
return { success: false, error: '无法创建 canvas 上下文' }; returnDetailedScanResult: true,
}
const image = new Image();
image.src = URL.createObjectURL(file);
await new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('图片加载超时'));
}, timeout);
image.onload = () => {
clearTimeout(timeoutId);
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0);
resolve();
};
image.onerror = () => {
clearTimeout(timeoutId);
reject(new Error('图片加载失败'));
};
}); });
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); if (result && result.data) {
const code = jsQR(imageData.data, imageData.width, imageData.height); return { success: true, data: result.data };
if (code) {
return { success: true, data: code.data };
} else { } else {
return { success: false, error: '未检测到二维码' }; return { success: false, error: '未检测到二维码' };
} }
} catch (err) { } catch (err) {
return { success: false, error: err instanceof Error ? err.message : '解析失败' }; // qr-scanner 在未发现二维码时会抛出 "No QR code found"
const errorMsg =
err === 'No QR code found'
? '未检测到二维码'
: err instanceof Error
? err.message
: String(err);
return { success: false, error: errorMsg };
} }
} }
+67
View File
@@ -0,0 +1,67 @@
/**
* 文本统计信息接口
*/
export interface TextStats {
/** 字符数(包含空格和特殊字符) */
characters: number;
/** 单词数 */
words: number;
/** 行数 */
lines: number;
/** 字节大小 */
bytes: number;
}
/**
* 计算文本统计信息
*
* @param text 输入的文本内容
* @returns 统计结果对象
*/
export function getTextStats(text: string): TextStats {
if (!text) {
return { characters: 0, words: 0, lines: 0, bytes: 0 };
}
// 1. 字符数:统计总字符数量
const characters = text.length;
// 2. 单词数:使用 Intl.Segmenter 识别单词边界
// 这能很好地处理中英文混合文本。中文会按词组切分,英文按单词切分。
let words = 0;
try {
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
const segments = segmenter.segment(text);
for (const segment of segments) {
// isWordLike 为 true 表示该片段是“类词”的(非空格、非标点)
if (segment.isWordLike) {
words++;
}
}
} catch {
// 降级方案:如果不支持 Intl.Segmenter,使用正则匹配英文单词
// 但对中文支持较差
const englishWords = text.match(/\b\w+\b/g) || [];
const chineseChars = text.match(/[\u4e00-\u9fa5]/g) || [];
words = englishWords.length + chineseChars.length;
}
// 3. 行数:统计换行符数量
// 空字符串已在上方处理。非空文本至少有一行。
const lines = text.split('\n').length;
// 4. 字节大小:计算文本内容的字节数 (UTF-8)
const bytes = new TextEncoder().encode(text).length;
return { characters, words, lines, bytes };
}
/**
* 格式化字节大小显示
*
* @param bytes 字节数
* @returns 格式化后的字符串,例如 "100 Bytes"
*/
export function formatByteSize(bytes: number): string {
return `${bytes} Bytes`;
}
-45
View File
@@ -1,45 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { storageUtil } from '@/utils/chromeStorage';
import type { OpenUrlPreferences, OpenUrlEntry } from '@/types/storage';
const DEFAULT_PREFERENCES: OpenUrlPreferences = {
entries: [],
};
export const useUrlPreferences = () => {
const [entries, setEntries] = useState<OpenUrlEntry[]>(DEFAULT_PREFERENCES.entries);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const loadPreferences = async () => {
try {
const saved = await storageUtil.get('openUrl/preferences', DEFAULT_PREFERENCES);
if (saved && saved.entries) {
setEntries(saved.entries);
}
} catch (error) {
console.error('Failed to load Open Url preferences:', error);
} finally {
setIsLoaded(true);
}
};
loadPreferences().catch(console.error);
}, []);
const savePreferences = useCallback(() => {
const preferences: OpenUrlPreferences = { entries };
storageUtil.set('openUrl/preferences', preferences).catch((error) => {
console.error('Failed to save Open Url preferences:', error);
});
}, [entries]);
useEffect(() => {
if (!isLoaded) return;
const timer = setTimeout(() => {
savePreferences();
}, 500);
return () => clearTimeout(timer);
}, [entries, isLoaded, savePreferences]);
return { entries, setEntries, isLoaded };
};