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**: 更新单元测试以覆盖新增的工具函数及功能特性。
@@ -28,3 +28,4 @@ stats-*.json
|
|||||||
|
|
||||||
.trae/*
|
.trae/*
|
||||||
.workbuddy/*
|
.workbuddy/*
|
||||||
|
dev/*
|
||||||
|
|||||||
@@ -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 # 二维码上传组件
|
├── entrypoints/ # 浏览器扩展入口点
|
||||||
│ ├── RouterContainer.tsx # 路由容器组件
|
│ ├── background.ts # 后台 Service Worker (消息中转与生命周期)
|
||||||
│ ├── StorageCleanerConfirm.tsx # 存储清理确认组件
|
│ ├── content.ts # 注入页面的内容脚本
|
||||||
│ ├── ToolCard.tsx # 工具卡片组件
|
│ ├── popup/ # 弹窗界面主入口
|
||||||
│ ├── TopBar.tsx # 顶部导航栏组件
|
│ ├── options/ # 选项页面主入口
|
||||||
│ ├── UrlEntryForm.tsx # URL 录入表单组件
|
│ └── sidepanel/ # 侧边栏界面主入口
|
||||||
│ ├── UrlEntryItem.tsx # URL 条目组件
|
├── pages/ # 功能模块页面组件
|
||||||
│ ├── UrlEntryList.tsx # URL 列表组件
|
│ ├── DashboardPage.tsx # 仪表盘/首页
|
||||||
│ └── UrlToQrCodeSection.tsx # URL 转二维码组件
|
│ ├── JwtPage.tsx # JWT 解析工具
|
||||||
├── config/ # 配置文件
|
│ ├── QrCodePage.tsx # 二维码工具
|
||||||
│ ├── __tests__/ # 配置测试文件
|
│ ├── StorageCleanerPage.tsx # 存储清理工具
|
||||||
│ ├── dashboardCards.tsx # 仪表盘卡片配置
|
│ ├── TextStatisticsPage.tsx # 文本统计工具
|
||||||
│ ├── pageTheme.ts # 页面主题配置
|
│ └── TimestampPage.tsx # 时间戳转换工具
|
||||||
│ ├── routes.ts # 路由配置
|
├── providers/ # React Context Providers (Router, Theme 等)
|
||||||
│ └── theme.ts # 全局主题配置
|
├── utils/ # 业务逻辑与工具函数
|
||||||
├── entrypoints/ # 浏览器扩展入口点
|
│ ├── chromeStorage.ts # 类型安全的 Chrome Storage 封装
|
||||||
│ ├── background.ts # 后台脚本(主进程)
|
│ ├── jwt.ts # JWT 解析逻辑
|
||||||
│ ├── content.ts # 内容脚本(注入到页面)
|
│ ├── textStatistics.ts # 文本分析逻辑
|
||||||
│ ├── content/
|
│ └── ...
|
||||||
│ │ └── messageHandler.ts # 消息处理器
|
├── types/ # 全局 TypeScript 类型声明
|
||||||
│ ├── options/ # 选项页面
|
└── public/ # 静态资源 (图标等)
|
||||||
│ │ ├── App.tsx # 选项应用
|
|
||||||
│ │ ├── index.html # 选项页面 HTML
|
|
||||||
│ │ └── main.tsx # 选项页面入口
|
|
||||||
│ ├── popup/ # 扩展弹窗界面
|
|
||||||
│ │ ├── App.tsx # 弹窗主应用
|
|
||||||
│ │ ├── main.tsx # 弹窗入口
|
|
||||||
│ │ ├── index.html # 弹窗 HTML
|
|
||||||
│ │ ├── pages/ # 弹窗页面
|
|
||||||
│ │ │ ├── components/ # 页面级组件
|
|
||||||
│ │ │ │ ├── AutoRefreshToggle.tsx # 自动刷新开关
|
|
||||||
│ │ │ │ ├── CleaningResult.tsx # 清理结果展示
|
|
||||||
│ │ │ │ ├── DomainHeader.tsx # 域名头部
|
|
||||||
│ │ │ │ ├── ErrorDisplay.tsx # 错误显示
|
|
||||||
│ │ │ │ ├── LiveClock.tsx # 实时时钟
|
|
||||||
│ │ │ │ ├── OptionItem.tsx # 选项条目
|
|
||||||
│ │ │ │ ├── ResultView.tsx # 结果视图
|
|
||||||
│ │ │ │ └── StorageOptionsGrid.tsx # 存储选项网格
|
|
||||||
│ │ │ ├── hooks/ # 自定义 Hooks
|
|
||||||
│ │ │ │ ├── useActiveTabDomain.ts # 当前标签页域名
|
|
||||||
│ │ │ │ ├── useFormRecognizer.ts # 表单识别
|
|
||||||
│ │ │ │ ├── useSidePanelState.ts # 侧边栏状态
|
|
||||||
│ │ │ │ └── useTimestampConverter.ts # 时间戳转换
|
|
||||||
│ │ │ ├── DashboardPage.tsx # 仪表盘页面
|
|
||||||
│ │ │ ├── FormFillPage.tsx # 表单填充页面
|
|
||||||
│ │ │ ├── FormMappingPage.tsx # 表单映射页面
|
|
||||||
│ │ │ ├── FormRecognizerPage.tsx # 表单识别页面
|
|
||||||
│ │ │ ├── OpenUrlPage.tsx # 打开 URL 页面
|
|
||||||
│ │ │ ├── OpenUrlViewerPage.tsx # URL 查看页面
|
|
||||||
│ │ │ ├── QrCodePage.tsx # 二维码页面
|
|
||||||
│ │ │ ├── StorageCleanerPage.tsx # 存储清理页面
|
|
||||||
│ │ │ ├── TimestampPage.tsx # 时间戳页面
|
|
||||||
│ │ │ └── useStorageCleaner.ts # 存储清理 Hook
|
|
||||||
│ └── sidepanel/ # 侧边栏界面
|
|
||||||
│ ├── App.tsx # 侧边栏应用
|
|
||||||
│ ├── index.html # 侧边栏 HTML
|
|
||||||
│ └── main.tsx # 侧边栏入口
|
|
||||||
├── providers/ # React Providers
|
|
||||||
│ └── RouterProvider.tsx # 路由 Provider
|
|
||||||
├── utils/ # 工具函数
|
|
||||||
│ ├── __tests__/ # 工具测试文件
|
|
||||||
│ ├── formMapping/ # 表单映射工具
|
|
||||||
│ │ ├── highlighter.ts # 表单高亮器
|
|
||||||
│ │ ├── scanner.ts # 表单扫描器
|
|
||||||
│ │ ├── smartInjector.ts # 智能注入器
|
|
||||||
│ │ └── ui.ts # UI 工具
|
|
||||||
│ ├── chromeStorage.ts # Chrome 存储工具
|
|
||||||
│ ├── chromeTabs.ts # Chrome 标签页工具
|
|
||||||
│ ├── clipboard.ts # 剪贴板工具
|
|
||||||
│ ├── dataTemplate.ts # 数据模板
|
|
||||||
│ ├── dataValidator.ts # 数据验证器
|
|
||||||
│ ├── dayjs.ts # 日期处理工具
|
|
||||||
│ ├── dummyDataGenerator.ts # 虚拟数据生成器(基于 Faker)
|
|
||||||
│ ├── messages.ts # 消息通信工具
|
|
||||||
│ ├── qrCodeParser.ts # 二维码解析器
|
|
||||||
│ ├── storageCleaner.ts # 存储清理工具
|
|
||||||
│ ├── useStorageState.ts # 存储状态 Hook
|
|
||||||
│ └── useUrlPreferences.ts # URL 偏好设置 Hook
|
|
||||||
├── types/ # 类型定义
|
|
||||||
│ └── storage.d.ts # 存储相关类型
|
|
||||||
├── docs/ # 文档
|
|
||||||
│ └── plans/ # 计划文档
|
|
||||||
├── public/ # 静态资源
|
|
||||||
│ └── icon/ # 扩展图标
|
|
||||||
└── .github/ # GitHub 配置
|
|
||||||
└── workflows/ # CI/CD 工作流
|
|
||||||
├── ci.yml # 持续集成
|
|
||||||
└── release.yml # 发布流程
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 核心功能模块
|
## 核心功能说明
|
||||||
|
|
||||||
#### 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 组件测试
|
|
||||||
|
|||||||
@@ -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) 开源.
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 处理文件
|
// 处理文件
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -34,7 +45,23 @@ export default function RouterContainer() {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Component && <Component />}
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={32} />
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Component && <Component />}
|
||||||
|
</Suspense>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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' }}
|
||||||
<OpenInNewIcon sx={{ fontSize: 18 }} />
|
>
|
||||||
</IconButton>
|
<Tooltip title="在标签页打开">
|
||||||
</Tooltip>
|
<IconButton size="small" onClick={handleOpenInTab}>
|
||||||
)}
|
<OpenInNewIcon sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip title="设置">
|
<Tooltip title="设置">
|
||||||
<IconButton size="small" onClick={onOpenOptions}>
|
<IconButton size="small" onClick={onOpenOptions}>
|
||||||
<SettingsIcon sx={{ fontSize: 18 }} />
|
<SettingsIcon sx={{ fontSize: 18 }} />
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 +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 : '注入失败',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,34 +27,88 @@ 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':
|
||||||
const loadConfig = async () => {
|
return {
|
||||||
try {
|
visible: 'app/sidepanelVisiblePages' as keyof StorageSchema,
|
||||||
const [savedVisible, savedOrder] = await Promise.all([
|
order: 'app/sidepanelPageOrder' as keyof StorageSchema,
|
||||||
storageUtil.get('app/visiblePages', getDefaultVisibleFeatureKeys()),
|
};
|
||||||
storageUtil.get('app/pageOrder', getDefaultPageOrder()),
|
case 'tab':
|
||||||
]);
|
return {
|
||||||
setVisiblePages(savedVisible ?? getDefaultVisibleFeatureKeys());
|
visible: 'app/tabVisiblePages' as keyof StorageSchema,
|
||||||
setPageOrder(savedOrder && savedOrder.length > 0 ? savedOrder : getDefaultPageOrder());
|
order: 'app/tabPageOrder' as keyof StorageSchema,
|
||||||
} catch (error) {
|
};
|
||||||
console.error('Failed to load config:', error);
|
case 'popup':
|
||||||
setVisiblePages(getDefaultVisibleFeatureKeys());
|
default:
|
||||||
setPageOrder(getDefaultPageOrder());
|
return {
|
||||||
} finally {
|
visible: 'app/popupVisiblePages' as keyof StorageSchema,
|
||||||
setIsLoaded(true);
|
order: 'app/popupPageOrder' as keyof StorageSchema,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
}, [windowType]);
|
||||||
|
|
||||||
|
// 当 windowType 改变时,同步更新 URL 参数
|
||||||
|
useEffect(() => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('tab', windowType);
|
||||||
|
window.history.replaceState({}, '', url.toString());
|
||||||
|
}, [windowType]);
|
||||||
|
|
||||||
|
// 加载配置数据
|
||||||
|
useEffect(() => {
|
||||||
|
const loadConfig = async () => {
|
||||||
|
setIsLoaded(false);
|
||||||
|
try {
|
||||||
|
const [savedVisible, savedOrder] = await Promise.all([
|
||||||
|
storageUtil.get(configKeys.visible, getDefaultVisibleFeatureKeys()),
|
||||||
|
storageUtil.get(configKeys.order, getDefaultPageOrder()),
|
||||||
|
]);
|
||||||
|
setVisiblePages((savedVisible as PageType[]) ?? getDefaultVisibleFeatureKeys());
|
||||||
|
setPageOrder((savedOrder as PageType[]) ?? getDefaultPageOrder());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load config:', error);
|
||||||
|
setVisiblePages(getDefaultVisibleFeatureKeys());
|
||||||
|
setPageOrder(getDefaultPageOrder());
|
||||||
|
} finally {
|
||||||
|
setIsLoaded(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadConfig().catch(console.error);
|
||||||
|
}, [configKeys]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换页面可见性
|
||||||
|
*/
|
||||||
const handlePageToggle = async (page: PageType) => {
|
const 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,133 +154,286 @@ 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',
|
||||||
<Button
|
pt: { xs: 3, sm: 5 },
|
||||||
variant="text"
|
pb: 0,
|
||||||
size="small"
|
px: { xs: 2, sm: 4 },
|
||||||
onClick={handleRestoreDefaults}
|
}}
|
||||||
startIcon={<RefreshIcon sx={{ fontSize: 16 }} />}
|
>
|
||||||
sx={{ color: 'text.secondary', fontWeight: 600 }}
|
<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 窗口" />
|
||||||
</Button>
|
<Tab value="sidepanel" label="侧边栏" />
|
||||||
</Stack>
|
<Tab value="tab" label="标签页" />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Paper
|
{/* 主内容区域 */}
|
||||||
elevation={0}
|
<Box sx={{ flex: 1, p: { xs: 2, sm: 4 } }}>
|
||||||
sx={{
|
<Box sx={{ maxWidth: 800, mx: 'auto' }}>
|
||||||
borderRadius: 4,
|
<Stack direction="row" justifyContent="flex-end" sx={{ mb: 2.5 }}>
|
||||||
border: '1px solid',
|
<Button
|
||||||
borderColor: 'grey.200',
|
variant="outlined"
|
||||||
overflow: 'hidden',
|
color="primary"
|
||||||
bgcolor: 'background.paper',
|
size="small"
|
||||||
}}
|
onClick={handleRestoreDefaults}
|
||||||
>
|
startIcon={<RefreshIcon sx={{ fontSize: 18 }} />}
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
sx={{
|
||||||
{pageOrder.map((key, index, array) => {
|
borderRadius: 2.5,
|
||||||
const feature = getFeatureByKey(key);
|
fontWeight: 700,
|
||||||
if (!feature) return null;
|
textTransform: 'none',
|
||||||
|
borderColor: 'grey.200',
|
||||||
|
color: 'text.secondary',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
color: 'primary.main',
|
||||||
|
bgcolor: alpha(THEME_COLORS.primary, 0.04),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
恢复当前模式默认
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
const isChecked = visiblePages.includes(key);
|
{!isLoaded ? (
|
||||||
const isDisabled = isChecked && visiblePages.length === 1;
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 12 }}>
|
||||||
|
<CircularProgress size={32} thickness={5} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.200',
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
boxShadow: '0 4px 24px rgba(0,0,0,0.03)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{pageOrder.map((key, index, array) => {
|
||||||
|
const feature = getFeatureByKey(key);
|
||||||
|
if (!feature) return null;
|
||||||
|
|
||||||
return (
|
const isChecked = visiblePages.includes(key);
|
||||||
<Box
|
const isDisabled = isChecked && visiblePages.length === 1;
|
||||||
key={key}
|
|
||||||
sx={{
|
return (
|
||||||
display: 'flex',
|
<Box
|
||||||
alignItems: 'center',
|
key={key}
|
||||||
justifyContent: 'space-between',
|
sx={{
|
||||||
p: 2.5,
|
display: 'flex',
|
||||||
borderBottom: index === array.length - 1 ? 'none' : '1px solid',
|
alignItems: 'center',
|
||||||
borderColor: 'grey.100',
|
justifyContent: 'space-between',
|
||||||
transition: 'all 0.2s',
|
p: { xs: 2, sm: 2.5 },
|
||||||
'&:hover': { bgcolor: 'grey.50' },
|
borderBottom: index === array.length - 1 ? 'none' : '1px solid',
|
||||||
}}
|
borderColor: 'grey.100',
|
||||||
>
|
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
<Box>
|
'&:hover': {
|
||||||
<Typography variant="body1" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
bgcolor: alpha(feature.themeColor || THEME_COLORS.primary, 0.02),
|
||||||
{feature.label}
|
},
|
||||||
</Typography>
|
}}
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{isChecked ? '已在 Dashboard 启用' : '已在 Dashboard 隐藏'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleMove(index, 'up')}
|
|
||||||
disabled={index === 0}
|
|
||||||
sx={{ color: 'text.secondary' }}
|
|
||||||
>
|
>
|
||||||
<KeyboardArrowUpIcon fontSize="small" />
|
<Stack
|
||||||
</IconButton>
|
direction="row"
|
||||||
<IconButton
|
spacing={{ xs: 1.5, sm: 2.5 }}
|
||||||
size="small"
|
alignItems="center"
|
||||||
onClick={() => handleMove(index, 'down')}
|
sx={{ flex: 1, minWidth: 0 }}
|
||||||
disabled={index === array.length - 1}
|
>
|
||||||
sx={{ color: 'text.secondary' }}
|
{/* 拖拽/排序暗示图标 */}
|
||||||
>
|
<Box sx={{ color: 'grey.300', display: 'flex' }}>
|
||||||
<KeyboardArrowDownIcon fontSize="small" />
|
<DragIndicatorIcon fontSize="small" />
|
||||||
</IconButton>
|
</Box>
|
||||||
<Switch
|
|
||||||
size="small"
|
{/* 功能图标容器 */}
|
||||||
checked={isChecked}
|
<Box
|
||||||
onChange={() => handlePageToggle(key)}
|
sx={{
|
||||||
disabled={isDisabled}
|
p: 1.2,
|
||||||
/>
|
borderRadius: 2.5,
|
||||||
</Box>
|
bgcolor: alpha(feature.themeColor || THEME_COLORS.primary, 0.1),
|
||||||
</Box>
|
color: feature.themeColor || THEME_COLORS.primary,
|
||||||
);
|
display: 'flex',
|
||||||
})}
|
flexShrink: 0,
|
||||||
</Box>
|
}}
|
||||||
</Paper>
|
>
|
||||||
|
{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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{feature.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
display: 'block',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{feature.description || '暂无描述'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', ml: { xs: 1, sm: 2 } }}>
|
||||||
|
{/* 移动操作按钮 */}
|
||||||
|
<Stack direction="row" sx={{ display: 'flex', mr: { xs: 0, sm: 1 } }}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleMove(index, 'up')}
|
||||||
|
disabled={index === 0}
|
||||||
|
sx={{
|
||||||
|
color: 'grey.400',
|
||||||
|
p: { xs: 0.5, sm: 1 },
|
||||||
|
'&:hover': {
|
||||||
|
color: 'primary.main',
|
||||||
|
bgcolor: alpha(THEME_COLORS.primary, 0.08),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KeyboardArrowUpIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleMove(index, 'down')}
|
||||||
|
disabled={index === array.length - 1}
|
||||||
|
sx={{
|
||||||
|
color: 'grey.400',
|
||||||
|
p: { xs: 0.5, sm: 1 },
|
||||||
|
'&:hover': {
|
||||||
|
color: 'primary.main',
|
||||||
|
bgcolor: alpha(THEME_COLORS.primary, 0.08),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KeyboardArrowDownIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider
|
||||||
|
orientation="vertical"
|
||||||
|
flexItem
|
||||||
|
sx={{
|
||||||
|
mx: { xs: 0.5, sm: 1 },
|
||||||
|
display: 'block',
|
||||||
|
height: 24,
|
||||||
|
alignSelf: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 显示切换开关 */}
|
||||||
|
<Switch
|
||||||
|
color="primary"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={() => handlePageToggle(key)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
sx={{
|
||||||
|
'& .MuiSwitch-switchBase.Mui-checked': {
|
||||||
|
color: feature.themeColor || THEME_COLORS.primary,
|
||||||
|
},
|
||||||
|
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
|
||||||
|
backgroundColor: feature.themeColor || THEME_COLORS.primary,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,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,21 +23,23 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
|
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
|
||||||
<Box
|
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
|
||||||
className="app"
|
<Box
|
||||||
sx={{
|
className="app"
|
||||||
display: 'flex',
|
sx={{
|
||||||
flexDirection: 'column',
|
display: 'flex',
|
||||||
height: '100vh',
|
flexDirection: 'column',
|
||||||
width: '100%',
|
height: '100vh',
|
||||||
overflow: 'hidden',
|
width: '100%',
|
||||||
}}
|
overflow: 'hidden',
|
||||||
>
|
}}
|
||||||
<TopBar onOpenOptions={handleOpenOptions} />
|
>
|
||||||
<ErrorBoundary>
|
<TopBar onOpenOptions={handleOpenOptions} />
|
||||||
<RouterContainer />
|
<ErrorBoundary>
|
||||||
</ErrorBoundary>
|
<RouterContainer />
|
||||||
</Box>
|
</ErrorBoundary>
|
||||||
|
</Box>
|
||||||
|
</SnackbarProvider>
|
||||||
</RouterProvider>
|
</RouterProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export default [
|
|||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
'dist',
|
'dist',
|
||||||
|
'.output',
|
||||||
'.wxt',
|
'.wxt',
|
||||||
'node_modules',
|
'node_modules',
|
||||||
'eslint.config.ts',
|
'eslint.config.ts',
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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 ? '正在清理...' : '立即清理'}
|
||||||
@@ -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);
|
||||||
@@ -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]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 跳转到指定页面
|
* 跳转到指定页面
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 669 B |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 716 KiB After Width: | Height: | Size: 10 KiB |
@@ -31,6 +31,7 @@
|
|||||||
"include": [
|
"include": [
|
||||||
"vite-env.d.ts",
|
"vite-env.d.ts",
|
||||||
"entrypoints/**/*",
|
"entrypoints/**/*",
|
||||||
|
"pages/**/*",
|
||||||
"components/**/*",
|
"components/**/*",
|
||||||
"utils/**/*",
|
"utils/**/*",
|
||||||
"types/**/*",
|
"types/**/*",
|
||||||
|
|||||||
@@ -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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 存储清理选项配置
|
* 存储清理选项配置
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>>
|
|
||||||
)(action, data as ProtocolData<K>, tab.id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('发送消息失败:', error);
|
|
||||||
return { success: false, message: '发送消息失败' } as ProtocolReturn<K>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const response = await (
|
||||||
* 注入内容脚本
|
sendMessage as (
|
||||||
*/
|
type: K,
|
||||||
export async function injectContentScript(): Promise<boolean> {
|
data: Parameters<ProtocolMap[K]>[0],
|
||||||
try {
|
arg?: number,
|
||||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
) => Promise<ReturnType<ProtocolMap[K]>>
|
||||||
if (!tab?.id) {
|
)(action, data as Parameters<ProtocolMap[K]>[0], tab.id);
|
||||||
return false;
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
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]>;
|
||||||
await chrome.scripting.executeScript({
|
|
||||||
target: { tabId: tab.id },
|
|
||||||
files: ['/content-scripts/content.js'],
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('注入内容脚本失败:', error);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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 };
|
|
||||||
};
|
|
||||||