Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d36f21f1b | |||
| 60537f6e2e | |||
| c039475119 | |||
| fb5f98dd3b | |||
| af0089c980 | |||
| 4850c92365 | |||
| 0379f96e80 | |||
| be5e2f02ee | |||
| bbd0507bcd | |||
| 9a947d7431 | |||
| 84ffd2a132 | |||
| 2cd21973f3 | |||
| 979b45a898 | |||
| 8bc11eb696 | |||
| 71b9dcc35c | |||
| f4c2f6d374 |
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"WebSearch",
|
|
||||||
"Bash(npm install:*)",
|
|
||||||
"Bash(git add:*)",
|
|
||||||
"Bash(git commit:*)",
|
|
||||||
"Bash(npm run:*)",
|
|
||||||
"Bash(git show-ref:*)",
|
|
||||||
"Bash(git checkout:*)",
|
|
||||||
"mcp__plugin_playwright_playwright__browser_navigate"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run ESLint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
name: TypeScript Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run TypeScript type check
|
||||||
|
run: npm run compile
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build (${{ matrix.browser }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, typecheck, test]
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
browser: [chrome]
|
||||||
|
fail-fast: false
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build (Chrome)
|
||||||
|
if: matrix.browser == 'chrome'
|
||||||
|
run: npm run build
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
|
|
||||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
|
|
||||||
|
|
||||||
name: Node.js CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "main" ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ "main" ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [22.x]
|
|
||||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node-version }}
|
|
||||||
cache: 'npm'
|
|
||||||
- run: npm install
|
|
||||||
- run: npm run build --if-present
|
|
||||||
# - run: npm test
|
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── Phase 1: 全量 CI 检查 ────────────────────────────────────────────
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run ESLint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
name: TypeScript Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run TypeScript type check
|
||||||
|
run: npm run compile
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
# ── Phase 2: 打包 & 发布 ─────────────────────────────────────────────
|
||||||
|
release:
|
||||||
|
name: Package & Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, typecheck, test]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Package Chrome extension
|
||||||
|
run: npm run zip
|
||||||
|
|
||||||
|
- name: Package Firefox extension
|
||||||
|
run: npm run zip:firefox
|
||||||
|
|
||||||
|
- name: Find zip artifacts
|
||||||
|
id: find_zips
|
||||||
|
run: |
|
||||||
|
CHROME_ZIP=$(find .output -name "*.zip" | grep -v firefox | head -1)
|
||||||
|
FIREFOX_ZIP=$(find .output -name "*.zip" | grep firefox | head -1)
|
||||||
|
echo "chrome_zip=$CHROME_ZIP" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "firefox_zip=$FIREFOX_ZIP" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Found Chrome zip: $CHROME_ZIP"
|
||||||
|
echo "Found Firefox zip: $FIREFOX_ZIP"
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
name: "v${{ steps.version.outputs.version }}"
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
draft: false
|
||||||
|
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||||
|
generate_release_notes: true
|
||||||
|
files: |
|
||||||
|
${{ steps.find_zips.outputs.chrome_zip }}
|
||||||
|
${{ steps.find_zips.outputs.firefox_zip }}
|
||||||
@@ -25,3 +25,6 @@ stats-*.json
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
.trae/*
|
||||||
|
.workbuddy/*
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t
|
|||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
这是一个基于 WXT 框架的浏览器扩展项目,提供测试工具功能,包括时间戳转换等。
|
这是一个基于 WXT 框架的浏览器扩展项目,提供多种测试工具功能,包括时间戳转换、存储管理、URL 管理、二维码生成、表单识别与填充等。
|
||||||
|
|
||||||
## 核心命令
|
## 核心命令
|
||||||
|
|
||||||
@@ -33,10 +33,10 @@ npx vitest run components/__tests__/CopyButton.test.tsx
|
|||||||
|
|
||||||
**测试技术栈:**
|
**测试技术栈:**
|
||||||
|
|
||||||
- Vitest - 测试框架
|
- Vitest v2 - 测试框架
|
||||||
- @testing-library/react - React 组件测试
|
- @testing-library/react v16 - React 组件测试
|
||||||
- @testing-library/user-event v13 - 用户交互模拟(注意:v13 不支持 setup(),使用 fireEvent)
|
- @testing-library/user-event v14 - 用户交互模拟
|
||||||
- jsdom - 浏览器环境模拟
|
- jsdom v25 - 浏览器环境模拟
|
||||||
|
|
||||||
### 依赖与准备
|
### 依赖与准备
|
||||||
|
|
||||||
@@ -48,56 +48,201 @@ npx vitest run components/__tests__/CopyButton.test.tsx
|
|||||||
|
|
||||||
### 技术栈
|
### 技术栈
|
||||||
|
|
||||||
- **框架**: WXT (Web Extension Toolkit) - 浏览器扩展开发框架
|
- **框架**: WXT v0.20.6 (Web Extension Toolkit) - 浏览器扩展开发框架
|
||||||
- **前端**: React 19 + TypeScript
|
- **前端**: React 19 + TypeScript 5
|
||||||
- **UI 库**: Material UI (MUI)
|
- **UI 库**: Material UI (MUI) v7 + Emotion
|
||||||
- **状态管理**: React Hooks
|
- **状态管理**: React Hooks + 自定义 Hooks
|
||||||
- **路由**: React Router DOM
|
- **路由**: 自定义路由系统(支持 popup/sidepanel/detached 三种模式)
|
||||||
|
- **测试**: Vitest + Testing Library
|
||||||
|
- **代码质量**: ESLint v9 + Prettier + Husky + lint-staged
|
||||||
|
|
||||||
### 目录结构
|
### 目录结构
|
||||||
|
|
||||||
```
|
```
|
||||||
├── components/ # 可复用 UI 组件
|
├── components/ # 可复用 UI 组件
|
||||||
│ ├── CopyButton.tsx # 复制按钮组件
|
│ ├── __tests__/ # 组件测试文件
|
||||||
│ ├── DatetimeToTimestamp.tsx # 日期转时间戳组件
|
│ ├── Button.tsx # 按钮组件
|
||||||
│ ├── Navbar.tsx # 导航栏组件
|
│ ├── CopyButton.tsx # 复制按钮组件
|
||||||
│ ├── RoutePersistence.tsx # 路由持久化组件
|
│ ├── DashboardCard.tsx # 仪表盘卡片组件
|
||||||
│ ├── TimestampExecution.tsx # 时间戳执行组件
|
│ ├── FieldList.tsx # 字段列表组件
|
||||||
│ └── TimestampToDatetime.tsx # 时间戳转日期组件
|
│ ├── GlobalSnackbar.tsx # 全局提示消息组件
|
||||||
├── entrypoints/ # 浏览器扩展入口点
|
│ ├── PageHeader.tsx # 页面头部组件
|
||||||
│ ├── background.ts # 后台脚本(主进程)
|
│ ├── QrCodeToUrlSection.tsx # 二维码解析为 URL 组件
|
||||||
│ ├── content.ts # 内容脚本(注入到页面)
|
│ ├── QrCodeUploader.tsx # 二维码上传组件
|
||||||
│ └── popup/ # 扩展弹窗界面
|
│ ├── RouterContainer.tsx # 路由容器组件
|
||||||
│ ├── App.tsx # 弹窗主应用
|
│ ├── StorageCleanerConfirm.tsx # 存储清理确认组件
|
||||||
│ ├── main.tsx # 弹窗入口
|
│ ├── ToolCard.tsx # 工具卡片组件
|
||||||
│ └── pages/ # 弹窗页面
|
│ ├── TopBar.tsx # 顶部导航栏组件
|
||||||
│ ├── TestPage.tsx # 测试页面
|
│ ├── UrlEntryForm.tsx # URL 录入表单组件
|
||||||
│ └── TimestampPage.tsx # 时间戳工具页面
|
│ ├── UrlEntryItem.tsx # URL 条目组件
|
||||||
├── utils/ # 工具函数
|
│ ├── UrlEntryList.tsx # URL 列表组件
|
||||||
│ ├── chromeStorage.ts # Chrome 存储工具
|
│ └── UrlToQrCodeSection.tsx # URL 转二维码组件
|
||||||
│ ├── dayjs.ts # 日期处理工具
|
├── config/ # 配置文件
|
||||||
│ └── messages.tsx # 消息通信工具
|
│ ├── __tests__/ # 配置测试文件
|
||||||
├── types/ # 类型定义
|
│ ├── dashboardCards.tsx # 仪表盘卡片配置
|
||||||
│ └── storage.d.ts # 存储相关类型
|
│ ├── pageTheme.ts # 页面主题配置
|
||||||
|
│ ├── routes.ts # 路由配置
|
||||||
|
│ └── theme.ts # 全局主题配置
|
||||||
|
├── entrypoints/ # 浏览器扩展入口点
|
||||||
|
│ ├── background.ts # 后台脚本(主进程)
|
||||||
|
│ ├── content.ts # 内容脚本(注入到页面)
|
||||||
|
│ ├── content/
|
||||||
|
│ │ └── messageHandler.ts # 消息处理器
|
||||||
|
│ ├── options/ # 选项页面
|
||||||
|
│ │ ├── App.tsx # 选项应用
|
||||||
|
│ │ ├── index.html # 选项页面 HTML
|
||||||
|
│ │ └── main.tsx # 选项页面入口
|
||||||
|
│ ├── popup/ # 扩展弹窗界面
|
||||||
|
│ │ ├── App.tsx # 弹窗主应用
|
||||||
|
│ │ ├── main.tsx # 弹窗入口
|
||||||
|
│ │ ├── index.html # 弹窗 HTML
|
||||||
|
│ │ ├── pages/ # 弹窗页面
|
||||||
|
│ │ │ ├── components/ # 页面级组件
|
||||||
|
│ │ │ │ ├── AutoRefreshToggle.tsx # 自动刷新开关
|
||||||
|
│ │ │ │ ├── CleaningResult.tsx # 清理结果展示
|
||||||
|
│ │ │ │ ├── DomainHeader.tsx # 域名头部
|
||||||
|
│ │ │ │ ├── ErrorDisplay.tsx # 错误显示
|
||||||
|
│ │ │ │ ├── LiveClock.tsx # 实时时钟
|
||||||
|
│ │ │ │ ├── OptionItem.tsx # 选项条目
|
||||||
|
│ │ │ │ ├── ResultView.tsx # 结果视图
|
||||||
|
│ │ │ │ └── StorageOptionsGrid.tsx # 存储选项网格
|
||||||
|
│ │ │ ├── hooks/ # 自定义 Hooks
|
||||||
|
│ │ │ │ ├── useActiveTabDomain.ts # 当前标签页域名
|
||||||
|
│ │ │ │ ├── useFormRecognizer.ts # 表单识别
|
||||||
|
│ │ │ │ ├── useSidePanelState.ts # 侧边栏状态
|
||||||
|
│ │ │ │ └── useTimestampConverter.ts # 时间戳转换
|
||||||
|
│ │ │ ├── DashboardPage.tsx # 仪表盘页面
|
||||||
|
│ │ │ ├── FormFillPage.tsx # 表单填充页面
|
||||||
|
│ │ │ ├── FormMappingPage.tsx # 表单映射页面
|
||||||
|
│ │ │ ├── FormRecognizerPage.tsx # 表单识别页面
|
||||||
|
│ │ │ ├── OpenUrlPage.tsx # 打开 URL 页面
|
||||||
|
│ │ │ ├── OpenUrlViewerPage.tsx # URL 查看页面
|
||||||
|
│ │ │ ├── QrCodePage.tsx # 二维码页面
|
||||||
|
│ │ │ ├── StorageCleanerPage.tsx # 存储清理页面
|
||||||
|
│ │ │ ├── TimestampPage.tsx # 时间戳页面
|
||||||
|
│ │ │ └── useStorageCleaner.ts # 存储清理 Hook
|
||||||
|
│ └── sidepanel/ # 侧边栏界面
|
||||||
|
│ ├── App.tsx # 侧边栏应用
|
||||||
|
│ ├── index.html # 侧边栏 HTML
|
||||||
|
│ └── main.tsx # 侧边栏入口
|
||||||
|
├── providers/ # React Providers
|
||||||
|
│ └── RouterProvider.tsx # 路由 Provider
|
||||||
|
├── utils/ # 工具函数
|
||||||
|
│ ├── __tests__/ # 工具测试文件
|
||||||
|
│ ├── formMapping/ # 表单映射工具
|
||||||
|
│ │ ├── highlighter.ts # 表单高亮器
|
||||||
|
│ │ ├── scanner.ts # 表单扫描器
|
||||||
|
│ │ ├── smartInjector.ts # 智能注入器
|
||||||
|
│ │ └── ui.ts # UI 工具
|
||||||
|
│ ├── chromeStorage.ts # Chrome 存储工具
|
||||||
|
│ ├── chromeTabs.ts # Chrome 标签页工具
|
||||||
|
│ ├── clipboard.ts # 剪贴板工具
|
||||||
|
│ ├── dataTemplate.ts # 数据模板
|
||||||
|
│ ├── dataValidator.ts # 数据验证器
|
||||||
|
│ ├── dayjs.ts # 日期处理工具
|
||||||
|
│ ├── dummyDataGenerator.ts # 虚拟数据生成器(基于 Faker)
|
||||||
|
│ ├── messages.ts # 消息通信工具
|
||||||
|
│ ├── qrCodeParser.ts # 二维码解析器
|
||||||
|
│ ├── storageCleaner.ts # 存储清理工具
|
||||||
|
│ ├── useStorageState.ts # 存储状态 Hook
|
||||||
|
│ └── useUrlPreferences.ts # URL 偏好设置 Hook
|
||||||
|
├── types/ # 类型定义
|
||||||
|
│ └── storage.d.ts # 存储相关类型
|
||||||
|
├── docs/ # 文档
|
||||||
|
│ └── plans/ # 计划文档
|
||||||
|
├── public/ # 静态资源
|
||||||
|
│ └── icon/ # 扩展图标
|
||||||
|
└── .github/ # GitHub 配置
|
||||||
|
└── workflows/ # CI/CD 工作流
|
||||||
|
├── ci.yml # 持续集成
|
||||||
|
└── release.yml # 发布流程
|
||||||
```
|
```
|
||||||
|
|
||||||
### 核心功能实现
|
### 核心功能模块
|
||||||
|
|
||||||
#### 1. 时间戳转换工具
|
#### 1. 时间戳转换工具
|
||||||
|
|
||||||
- 位置: `components/` 目录下的时间戳相关组件
|
- 位置: `entrypoints/popup/pages/TimestampPage.tsx`
|
||||||
|
- Hook: `entrypoints/popup/pages/hooks/useTimestampConverter.ts`
|
||||||
- 依赖: dayjs 库进行日期处理
|
- 依赖: dayjs 库进行日期处理
|
||||||
- 功能: 支持日期与时间戳的双向转换,支持多种格式
|
- 功能: 支持日期与时间戳的双向转换,支持多种格式,实时时钟显示
|
||||||
|
|
||||||
#### 2. 通信系统
|
#### 2. 存储清理工具
|
||||||
|
|
||||||
- 位置: `utils/messages.tsx`
|
- 位置: `entrypoints/popup/pages/StorageCleanerPage.tsx`
|
||||||
|
- Hook: `entrypoints/popup/pages/useStorageCleaner.ts`
|
||||||
|
- 工具: `utils/storageCleaner.ts`
|
||||||
|
- 功能: 清理缓存、Cookies、本地存储,支持按域名筛选,自动刷新功能
|
||||||
|
|
||||||
|
#### 3. URL 管理工具
|
||||||
|
|
||||||
|
- 打开 URL: `entrypoints/popup/pages/OpenUrlPage.tsx`
|
||||||
|
- 查看 URL: `entrypoints/popup/pages/OpenUrlViewerPage.tsx`
|
||||||
|
- 组件: `components/UrlEntryForm.tsx`, `components/UrlEntryList.tsx`
|
||||||
|
- 功能: 批量打开多个 URL,URL 列表管理
|
||||||
|
|
||||||
|
#### 4. 二维码工具
|
||||||
|
|
||||||
|
- 位置: `entrypoints/popup/pages/QrCodePage.tsx`
|
||||||
|
- 组件: `components/QrCodeUploader.tsx`, `components/QrCodeToUrlSection.tsx`, `components/UrlToQrCodeSection.tsx`
|
||||||
|
- 工具: `utils/qrCodeParser.ts`
|
||||||
|
- 依赖: qrcode, jsqr 库
|
||||||
|
- 功能: URL 转二维码生成,二维码图片解析为 URL
|
||||||
|
|
||||||
|
#### 5. 表单工具套件
|
||||||
|
|
||||||
|
**表单识别 (Form Recognizer)**
|
||||||
|
|
||||||
|
- 位置: `entrypoints/popup/pages/FormRecognizerPage.tsx`
|
||||||
|
- Hook: `entrypoints/popup/pages/hooks/useFormRecognizer.ts`
|
||||||
|
- 功能: 智能识别页面表单指纹
|
||||||
|
|
||||||
|
**表单映射 (Form Mapping)**
|
||||||
|
|
||||||
|
- 位置: `entrypoints/popup/pages/FormMappingPage.tsx`
|
||||||
|
- 工具: `utils/formMapping/` 目录
|
||||||
|
- `scanner.ts` - 表单扫描器
|
||||||
|
- `highlighter.ts` - 表单高亮器
|
||||||
|
- `smartInjector.ts` - 智能注入器
|
||||||
|
- `ui.ts` - UI 工具
|
||||||
|
- 功能: 表单指纹识别与自定义映射规则配置
|
||||||
|
|
||||||
|
**表单填充 (Form Fill)**
|
||||||
|
|
||||||
|
- 位置: `entrypoints/popup/pages/FormFillPage.tsx`
|
||||||
|
- 工具: `utils/dummyDataGenerator.ts` (基于 @faker-js/faker)
|
||||||
|
- 功能: 根据表单指纹智能填充表单数据
|
||||||
|
|
||||||
|
#### 6. 仪表盘系统
|
||||||
|
|
||||||
|
- 位置: `entrypoints/popup/pages/DashboardPage.tsx`
|
||||||
|
- 配置: `config/features.tsx`
|
||||||
|
- 组件: `components/DashboardCard.tsx`, `components/ToolCard.tsx`
|
||||||
|
- 功能: 统一工具入口,可自定义显示的工具卡片
|
||||||
|
|
||||||
|
#### 7. 多模式显示系统
|
||||||
|
|
||||||
|
- 支持三种显示模式:
|
||||||
|
- **popup** - 扩展弹窗(点击图标显示)
|
||||||
|
- **sidepanel** - 浏览器侧边栏
|
||||||
|
- **detached** - 独立窗口模式
|
||||||
|
- 路由配置: `config/features.tsx`
|
||||||
|
- 路由容器: `components/RouterContainer.tsx`
|
||||||
|
- Provider: `providers/RouterProvider.tsx`
|
||||||
|
|
||||||
|
#### 8. 通信系统
|
||||||
|
|
||||||
|
- 位置: `utils/messages.ts`
|
||||||
- 机制: 使用 `@webext-core/messaging` 库实现
|
- 机制: 使用 `@webext-core/messaging` 库实现
|
||||||
- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗
|
- 内容脚本消息处理: `entrypoints/content/messageHandler.ts`
|
||||||
|
- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗/侧边栏
|
||||||
|
|
||||||
#### 3. 数据存储
|
#### 9. 数据存储
|
||||||
|
|
||||||
- Chrome Storage API: `utils/chromeStorage.ts` (用于配置等小数据)
|
- Chrome Storage API: `utils/chromeStorage.ts`
|
||||||
|
- 存储状态 Hook: `utils/useStorageState.ts`
|
||||||
|
- URL 偏好设置: `utils/useUrlPreferences.ts`
|
||||||
|
- 类型定义: `types/storage.d.ts`
|
||||||
|
|
||||||
### 关键配置文件
|
### 关键配置文件
|
||||||
|
|
||||||
@@ -105,8 +250,9 @@ npx vitest run components/__tests__/CopyButton.test.tsx
|
|||||||
|
|
||||||
- 配置 WXT 框架参数
|
- 配置 WXT 框架参数
|
||||||
- 启用 React 模块
|
- 启用 React 模块
|
||||||
- 配置浏览器扩展权限
|
- 配置浏览器扩展权限(storage, unlimitedStorage, clipboardWrite, activeTab, scripting, tabs, cookies, sidePanel)
|
||||||
- Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码)
|
- Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码)
|
||||||
|
- 配置侧边栏和选项页面
|
||||||
|
|
||||||
#### manifest 权限
|
#### manifest 权限
|
||||||
|
|
||||||
@@ -118,11 +264,17 @@ permissions: [
|
|||||||
'activeTab', // 当前标签页
|
'activeTab', // 当前标签页
|
||||||
'scripting', // 脚本注入
|
'scripting', // 脚本注入
|
||||||
'tabs', // 标签页管理
|
'tabs', // 标签页管理
|
||||||
'debugger', // 调试器
|
'cookies', // Cookies 管理
|
||||||
|
'sidePanel', // 侧边栏
|
||||||
],
|
],
|
||||||
host_permissions: ['<all_urls>'] // 访问所有网站
|
host_permissions:['<all_urls>'] // 访问所有网站
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### CI/CD 配置
|
||||||
|
|
||||||
|
- `.github/workflows/ci.yml` - 持续集成工作流
|
||||||
|
- `.github/workflows/release.yml` - 发布工作流
|
||||||
|
|
||||||
## 开发注意事项
|
## 开发注意事项
|
||||||
|
|
||||||
### 扩展入口点
|
### 扩展入口点
|
||||||
@@ -130,14 +282,34 @@ host_permissions: ['<all_urls>'] // 访问所有网站
|
|||||||
- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务
|
- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务
|
||||||
- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互
|
- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互
|
||||||
- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示
|
- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示
|
||||||
|
- **侧边栏**: `entrypoints/sidepanel/main.tsx` - 浏览器侧边栏界面
|
||||||
|
- **选项页面**: `entrypoints/options/main.tsx` - 扩展设置页面
|
||||||
|
|
||||||
|
### 路由系统
|
||||||
|
|
||||||
|
- 使用自定义路由系统,支持多种显示模式
|
||||||
|
- 路由配置在 `config/features.tsx`
|
||||||
|
- 通过 `getEntryPointType()` 判断当前入口点类型
|
||||||
|
- 支持页面可见性配置(`defaultVisible`)
|
||||||
|
|
||||||
### 浏览器兼容性
|
### 浏览器兼容性
|
||||||
|
|
||||||
- 支持 Chrome 和 Firefox 浏览器
|
- 支持 Chrome 和 Firefox 浏览器
|
||||||
- 使用 WXT 框架抽象浏览器差异
|
- 使用 WXT 框架抽象浏览器差异
|
||||||
|
- 使用 `@types/chrome` 和 `@types/webextension-polyfill` 提供类型支持
|
||||||
|
|
||||||
### 代码质量
|
### 代码质量
|
||||||
|
|
||||||
- 使用 ESLint 进行代码检查
|
- 使用 ESLint v9 进行代码检查(基于 typescript-eslint)
|
||||||
- Husky 用于 Git 钩子管理
|
- Prettier 进行代码格式化
|
||||||
|
- Husky v9 用于 Git 钩子管理
|
||||||
- Lint-staged 确保暂存文件符合规范
|
- Lint-staged 确保暂存文件符合规范
|
||||||
|
- GitHub Actions CI/CD 自动化测试和构建
|
||||||
|
|
||||||
|
### 测试策略
|
||||||
|
|
||||||
|
- 组件测试: `components/__tests__/` 目录
|
||||||
|
- 工具函数测试: `utils/__tests__/` 目录
|
||||||
|
- 配置测试: `config/__tests__/` 目录
|
||||||
|
- 使用 Vitest 作为测试框架
|
||||||
|
- 使用 Testing Library 进行 React 组件测试
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## 项目概述
|
|
||||||
|
|
||||||
这是一个基于 WXT 框架的浏览器扩展项目,提供时间戳转换工具。项目已精简为核心功能,移除了录制回放等复杂功能。
|
|
||||||
|
|
||||||
## 核心命令
|
|
||||||
|
|
||||||
### 开发相关
|
|
||||||
|
|
||||||
- `npm run dev` - 启动 Chrome 浏览器的开发模式
|
|
||||||
- `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式
|
|
||||||
- `npm run build` - 构建 Chrome 浏览器的生产版本
|
|
||||||
- `npm run build:firefox` - 构建 Firefox 浏览器的生产版本
|
|
||||||
- `npm run zip` - 打包 Chrome 扩展
|
|
||||||
- `npm run zip:firefox` - 打包 Firefox 扩展
|
|
||||||
- `npm run compile` - TypeScript 类型检查(不生成文件)
|
|
||||||
- `npm run lint` - 运行 ESLint 检查
|
|
||||||
|
|
||||||
### 依赖与准备
|
|
||||||
|
|
||||||
- `npm install` - 安装依赖
|
|
||||||
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
|
|
||||||
- `prepare` 钩子会初始化 Husky Git 钩子
|
|
||||||
|
|
||||||
### CI/CD
|
|
||||||
|
|
||||||
- GitHub Actions 配置: `.github/workflows/node.js.yml`
|
|
||||||
- 在 main 分支推送或 PR 时触发
|
|
||||||
- 使用 Node.js 22.x 运行 build
|
|
||||||
- 测试命令当前被注释(项目暂无测试)
|
|
||||||
|
|
||||||
## 项目架构
|
|
||||||
|
|
||||||
### 技术栈
|
|
||||||
|
|
||||||
- **框架**: WXT (Web Extension Toolkit) - 浏览器扩展开发框架
|
|
||||||
- **前端**: React 19 + TypeScript
|
|
||||||
- **UI 库**: Material UI (MUI)
|
|
||||||
- **日期处理**: dayjs (含 UTC 和时区插件)
|
|
||||||
- **通信**: @webext-core/messaging
|
|
||||||
|
|
||||||
### 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
├── entrypoints/ # 浏览器扩展入口点
|
|
||||||
│ ├── background.ts # 后台脚本(处理扩展安装/更新,注入内容脚本)
|
|
||||||
│ ├── content.ts # 内容内容脚本(注入到页面,当前为空占位)
|
|
||||||
│ ├── popup/ # 扩展弹窗界面
|
|
||||||
│ │ ├── App.tsx # 弹窗主应用
|
|
||||||
│ │ ├── main.tsx # 弹窗入口
|
|
||||||
│ │ ├── index.html # 弹窗 HTML
|
|
||||||
│ │ └── pages/ # 弹窗页面
|
|
||||||
│ │ └── TimestampPage.tsx # 时间戳转换页面(核心功能)
|
|
||||||
│ └── options/ # 选项页面(当前为静态 HTML)
|
|
||||||
│ └── index.html # 选项页 HTML
|
|
||||||
├── utils/ # 工具函数
|
|
||||||
│ ├── chromeStorage.ts # Chrome Storage 工具(类型安全封装)
|
|
||||||
│ ├── dayjs.ts # dayjs 配置(UTC + 时区插件)
|
|
||||||
│ └── messages.tsx # 扩展消息通信工具(@webext-core/messaging)
|
|
||||||
├── types/ # 类型定义
|
|
||||||
│ └── storage.d.ts # StorageSchema 类型定义
|
|
||||||
├── constants/ # 常量定义(当前为空)
|
|
||||||
└── public/ # 静态资源
|
|
||||||
```
|
|
||||||
|
|
||||||
### 核心功能
|
|
||||||
|
|
||||||
#### 时间戳转换工具 (entrypoints/popup/pages/TimestampPage.tsx)
|
|
||||||
|
|
||||||
- 实时显示当前时间戳(毫秒/秒可切换)
|
|
||||||
- 时间戳 → 日期时间转换
|
|
||||||
- 日期时间 → 时间戳转换
|
|
||||||
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
|
|
||||||
- 一键复制功能
|
|
||||||
- 输入验证和错误提示
|
|
||||||
|
|
||||||
### 扩展入口点
|
|
||||||
|
|
||||||
- **后台脚本** (`entrypoints/background.ts`):
|
|
||||||
- 监听扩展安装/更新事件
|
|
||||||
- 自动向所有有效标签页注入内容脚本
|
|
||||||
- 过滤受限协议(chrome://, about:// 等)
|
|
||||||
|
|
||||||
- **内容脚本** (`entrypoints/content.ts`):
|
|
||||||
- 匹配所有 URL (`<all_urls>`)
|
|
||||||
- 在文档开始时运行
|
|
||||||
- 当前为占位符,无实际逻辑
|
|
||||||
|
|
||||||
- **弹窗** (`entrypoints/popup/`):
|
|
||||||
- 主入口显示 TimestampPage
|
|
||||||
- 提供时间戳转换的完整功能
|
|
||||||
|
|
||||||
- **选项页** (`entrypoints/options/`):
|
|
||||||
- 当前为静态 HTML 页面
|
|
||||||
- 可扩展为设置界面
|
|
||||||
|
|
||||||
### 数据存储
|
|
||||||
|
|
||||||
使用 Chrome Storage API 进行持久化存储:
|
|
||||||
|
|
||||||
- 类型安全的封装 (`utils/chromeStorage.ts`)
|
|
||||||
- 基于接口定义的 Schema (`types/storage.d.ts`)
|
|
||||||
- 当前支持的存储键:
|
|
||||||
- `app/lastRoute`: 上次访问的路由
|
|
||||||
- `app/theme`: 主题设置
|
|
||||||
|
|
||||||
### 消息通信
|
|
||||||
|
|
||||||
使用 `@webext-core/messaging` 库实现类型安全的扩展内通信:
|
|
||||||
|
|
||||||
- 定义在 `utils/messages.tsx`
|
|
||||||
- 当前 ProtocolMap 为空(预留接口)
|
|
||||||
|
|
||||||
### 关键配置文件
|
|
||||||
|
|
||||||
#### wxt.config.ts
|
|
||||||
|
|
||||||
- 启用 React 模块 (`@wxt-dev/module-react`)
|
|
||||||
- 配置 manifest 权限和 host_permissions
|
|
||||||
- 使用 Terser 压缩(强制 ASCII 编码)
|
|
||||||
- 配置图标和选项页
|
|
||||||
|
|
||||||
#### manifest 权限
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
permissions: [
|
|
||||||
'storage', // Chrome Storage
|
|
||||||
'unlimitedStorage', // 无限制存储
|
|
||||||
'clipboardWrite', // 剪贴板写入(复制功能)
|
|
||||||
'activeTab', // 当前标签页访问
|
|
||||||
'scripting', // 脚本注入
|
|
||||||
'tabs', // 标签页管理
|
|
||||||
'debugger', // 调试器权限
|
|
||||||
],
|
|
||||||
host_permissions: ['<all_urls>'] // 访问所有网站
|
|
||||||
```
|
|
||||||
|
|
||||||
## 开发注意事项
|
|
||||||
|
|
||||||
### 浏览器兼容性
|
|
||||||
|
|
||||||
- 支持 Chrome 和 Firefox 浏览器
|
|
||||||
- 使用 WXT 框架抽象浏览器差异
|
|
||||||
|
|
||||||
### 代码质量
|
|
||||||
|
|
||||||
- 使用 ESLint 进行代码检查(零警告)
|
|
||||||
- Husky 用于 Git 钩子管理
|
|
||||||
- Lint-staged 确保暂存文件符合规范(ESLint + TypeScript + Prettier)
|
|
||||||
- Prettier 用于代码格式化
|
|
||||||
- Prettier 配置: 100 字符行宽,2 空格缩进,单引号,trailing comma
|
|
||||||
|
|
||||||
### TypeScript 配置
|
|
||||||
|
|
||||||
- 严格模式开启(`strict: true`)
|
|
||||||
- `noImplicitAny` 设置为 `false`(允许隐式 any)
|
|
||||||
- 未使用变量/参数会报错(`noUnusedLocals`, `noUnusedParameters`)
|
|
||||||
- 模块解析模式:Bundler
|
|
||||||
- 排除测试文件(`**/*.test.tsx`, `**/*.test.ts`)以避免类型检查
|
|
||||||
|
|
||||||
### 项目历史
|
|
||||||
|
|
||||||
近期重构(根据 git 历史):
|
|
||||||
|
|
||||||
- 移除了录制回放功能
|
|
||||||
- 移除了测试页面
|
|
||||||
- 精简为单页面时间戳工具
|
|
||||||
- 将 storage 工具类重命名为 storageUtil
|
|
||||||
@@ -1,72 +1,83 @@
|
|||||||
# Testing Tools 项目指南
|
# Testing Tools Browser Extension - Gemini Instructions
|
||||||
|
|
||||||
本文件为 Gemini CLI 提供关于 **Testing Tools** 浏览器扩展项目的架构说明、开发规范和技术上下文。
|
This document provides essential context and instructions for AI agents working on the Testing Tools browser extension project.
|
||||||
|
|
||||||
## 1. 项目概览
|
## Project Overview
|
||||||
|
|
||||||
- **名称**: Testing Tools
|
**Testing Tools** is a lightweight, feature-rich browser extension built with the [WXT (Web Extension Toolkit)](https://wxt.dev/) framework. It provides a suite of utilities for developers and testers, including timestamp conversion, storage management, URL shortcuts, and QR code tools.
|
||||||
- **核心框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
|
|
||||||
- **前端技术栈**: React 19 (Functional Components + Hooks) + TypeScript
|
|
||||||
- **UI 组件库**: Material UI (MUI) 7.x (深度定制 `sx` 属性)
|
|
||||||
- **日期处理**: dayjs (配合 timezone 和 utc 插件)
|
|
||||||
- **主要功能**: 提供时间戳转换、日期格式化等开发辅助工具。
|
|
||||||
|
|
||||||
## 2. 项目结构
|
### Tech Stack
|
||||||
|
|
||||||
```text
|
- **Framework:** WXT (Web Extension Toolkit)
|
||||||
├── .github/ # CI/CD 工作流
|
- **Frontend:** React 19 + TypeScript
|
||||||
├── .husky/ # Git Hooks (pre-commit linting)
|
- **UI Library:** Material UI (MUI) @7.x
|
||||||
├── assets/ # 静态资源 (SVG 等)
|
- **Date Handling:** dayjs (with UTC and timezone plugins)
|
||||||
├── components/ # 复用 UI 组件
|
- **Messaging:** @webext-core/messaging
|
||||||
├── entrypoints/ # 浏览器扩展入口点
|
- **Storage:** Type-safe Chrome Storage API wrapper
|
||||||
│ ├── background.ts # 后台 Service Worker 逻辑
|
- **Testing:** Vitest + Testing Library (jsdom)
|
||||||
│ ├── content.ts # 内容脚本注入逻辑
|
|
||||||
│ ├── popup/ # 扩展弹出层 (主要功能区)
|
|
||||||
│ └── options/ # 扩展选项页面
|
|
||||||
├── types/ # 全局 TypeScript 类型声明
|
|
||||||
├── utils/ # 工具类 (存储、消息通信、日期处理封装)
|
|
||||||
├── wxt.config.ts # WXT 框架与 Manifest 配置
|
|
||||||
└── package.json # 依赖管理与脚本
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 开发规范与风格约定
|
### Architecture & Directory Structure
|
||||||
|
|
||||||
### 3.1 UI 设计语言
|
- `entrypoints/`: Extension entry points (popup, options, sidepanel, background, content).
|
||||||
|
- `popup/`: Main UI shown when clicking the extension icon.
|
||||||
|
- `options/`: Extension settings page.
|
||||||
|
- `sidepanel/`: Browser side panel integration.
|
||||||
|
- `background.ts`: Background script for lifecycle management and background tasks.
|
||||||
|
- `content.ts`: Content script injected into web pages.
|
||||||
|
- `components/`: Reusable React components.
|
||||||
|
- `config/`: Application configuration, including routes and themes.
|
||||||
|
- `providers/`: React Context providers (e.g., `RouterProvider`).
|
||||||
|
- `utils/`: Utility functions and service abstractions.
|
||||||
|
- `chromeStorage.ts`: Type-safe storage utility.
|
||||||
|
- `types/`: Global TypeScript type definitions.
|
||||||
|
- `public/`: Static assets (icons, etc.).
|
||||||
|
|
||||||
- **极简主义 (Minimalist)**: 参考 Vercel 和 Apple 的设计语言。
|
## Building and Running
|
||||||
- **MUI 定制**:
|
|
||||||
- 严禁使用 MUI 默认的粗犷边框和深重阴影。
|
|
||||||
- 必须通过 `sx` 属性进行深度样式定制,去除 `notchedOutline`。
|
|
||||||
- 偏好使用 `grey.50` 背景区分层级,使用 `borderRadius: 4` (大圆角)。
|
|
||||||
- 交互反馈:禁用波纹效果 (`disableRipple`),移除默认阴影 (`disableElevation`)。
|
|
||||||
- **布局**: 优先使用 `Stack` 和 `Box` 进行布局,确保自上而下的操作流顺畅。
|
|
||||||
|
|
||||||
### 3.2 技术选型惯例
|
### Development
|
||||||
|
|
||||||
- **日期转换**: 必须通过 `utils/dayjs.ts` 导出的实例进行,确保时区处理一致。
|
- `npm run dev`: Start Chrome development mode with HMR.
|
||||||
- **状态管理**: 优先使用 React 原生 `useState` 和 `useMemo`。
|
- `npm run dev:firefox`: Start Firefox development mode.
|
||||||
- **存储**: 使用 `utils/chromeStorage.ts` 封装的类型安全接口。
|
- `npm run compile`: Run TypeScript type checking (`tsc --noEmit`).
|
||||||
- **通信**: 使用 `@webext-core/messaging` 进行 background 和 popup 之间的消息传递。
|
|
||||||
|
|
||||||
## 4. 关键指令
|
### Production
|
||||||
|
|
||||||
### 4.1 开发与调试
|
- `npm run build`: Build production version for Chrome.
|
||||||
|
- `npm run build:firefox`: Build production version for Firefox.
|
||||||
|
- `npm run zip`: Package the extension for Chrome Web Store.
|
||||||
|
- `npm run zip:firefox`: Package the extension for Firefox Add-ons.
|
||||||
|
|
||||||
- `npm run dev`: 启动 Chrome 扩展开发模式。
|
### Testing & Linting
|
||||||
- `npm run dev:firefox`: 启动 Firefox 扩展开发模式。
|
|
||||||
|
|
||||||
### 4.2 构建与检查
|
- `npm run test`: Run all tests once.
|
||||||
|
- `npm run test:watch`: Run tests in watch mode.
|
||||||
|
- `npm run test:coverage`: Run tests and generate coverage report.
|
||||||
|
- `npm run lint`: Run ESLint checks.
|
||||||
|
|
||||||
- `npm run build`: 构建生产版本。
|
## Development Conventions
|
||||||
- `npm run compile`: TypeScript 类型检查。
|
|
||||||
- `npm run lint`: ESLint 代码风格检查。
|
|
||||||
|
|
||||||
## 5. 权限与清单 (Manifest)
|
### Coding Style
|
||||||
|
|
||||||
- **核心权限**: `storage`, `unlimitedStorage`, `clipboardWrite`, `scripting`, `tabs`, `debugger`, `cookies`。
|
- **TypeScript:** Use strict typing. Prefer interfaces for object structures and types for unions/aliases.
|
||||||
- **宿主权限**: `<all_urls>` (用于在所有页面注入 content 脚本)。
|
- **Components:** Functional components with Hooks. Use MUI components for consistent UI.
|
||||||
- **构建细节**: 生产环境构建使用 `terser` 压缩,并强制 `ascii_only` 以确保字符兼容性。
|
- **Storage:** Always use `storageUtil` from `@/utils/chromeStorage.ts` for accessing `chrome.storage.local`. Ensure keys are defined in `StorageSchema` in `@/types/storage.d.ts`.
|
||||||
|
- **Messaging:** Use `@webext-core/messaging` for communication between entry points. Define message types in `@/utils/messages.ts`.
|
||||||
|
|
||||||
## 6. 维护者提示
|
### Testing Practices
|
||||||
|
|
||||||
在修改 `TimestampPage.tsx` 等核心页面时,应保持**逻辑层**(基于 dayjs 的转换算法)与**渲染层**(JSX/MUI 样式)的严格分离。
|
- **Framework:** Vitest with `jsdom` environment.
|
||||||
|
- **Location:** Place tests in `__tests__` directories adjacent to the files being tested.
|
||||||
|
- **Naming:** Follow `*.test.ts` or `*.test.tsx` naming convention.
|
||||||
|
- **Patterns:** Use `@testing-library/react` for component testing. Prefer `user-event` (v14+) for simulating interactions.
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
|
||||||
|
- **GitHub Actions:** CI runs on push/PR to `main` and `develop` branches (lint, compile, test, build).
|
||||||
|
- **Releases:** Automatic release to GitHub on pushing a `v*` tag.
|
||||||
|
|
||||||
|
## Key Considerations for AI Agents
|
||||||
|
|
||||||
|
- **Manifest Permissions:** When adding features that require new browser APIs, update `wxt.config.ts`.
|
||||||
|
- **Browser Compatibility:** Ensure features work in both Chrome and Firefox.
|
||||||
|
- **React 19:** Be aware of React 19 specific features and deprecations.
|
||||||
|
- **WXT Modules:** The project uses `@wxt-dev/module-react`.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Testing Tools
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
# Testing Tools Browser Extension
|
# Testing Tools Browser Extension
|
||||||
|
|
||||||
这是一个基于 WXT 框架的浏览器扩展项目,提供时间戳转换工具。
|
这是一个基于 WXT 框架的浏览器扩展项目,提供实用的测试工具功能。
|
||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
Testing Tools 是一个轻量级的浏览器扩展,提供实用的时间戳转换功能。项目采用现代化的技术栈,包括 React 19、TypeScript 和 Material UI,并利用 WXT 框架简化浏览器扩展的开发流程。
|
Testing Tools 是一个轻量级的浏览器扩展,提供多种实用的测试工具功能。项目采用现代化的技术栈,包括 React 19、TypeScript 和 Material UI,并利用 WXT 框架简化浏览器扩展的开发流程。
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
|
### Dashboard 首页
|
||||||
|
|
||||||
|
- 卡片式工具展示
|
||||||
|
- 支持自定义工具排序和可见性
|
||||||
|
- 实时数据预览(时间戳等)
|
||||||
|
|
||||||
### 时间戳转换工具
|
### 时间戳转换工具
|
||||||
|
|
||||||
- 实时显示当前时间戳(毫秒/秒可切换)
|
- 实时显示当前时间戳(毫秒/秒可切换)
|
||||||
@@ -16,6 +22,37 @@ Testing Tools 是一个轻量级的浏览器扩展,提供实用的时间戳转
|
|||||||
- 一键复制转换结果
|
- 一键复制转换结果
|
||||||
- 输入验证和错误提示
|
- 输入验证和错误提示
|
||||||
|
|
||||||
|
### 存储清理工具
|
||||||
|
|
||||||
|
- 自动读取当前域名
|
||||||
|
- 支持清理多种存储类型:
|
||||||
|
- localStorage
|
||||||
|
- sessionStorage
|
||||||
|
- IndexedDB
|
||||||
|
- Cookies
|
||||||
|
- Cache Storage
|
||||||
|
- Service Workers
|
||||||
|
- 可选择的清理类型(默认全选)
|
||||||
|
- 确认对话框防止误操作
|
||||||
|
- 清理结果统计
|
||||||
|
- 自动刷新页面选项
|
||||||
|
|
||||||
|
### URL 工具
|
||||||
|
|
||||||
|
- 保存常用 URL 列表
|
||||||
|
- 快速打开保存的 URL
|
||||||
|
- 支持 URL 验证和安全检查
|
||||||
|
- 内置 URL 查看器(iframe 沙箱模式)
|
||||||
|
|
||||||
|
### 二维码工具
|
||||||
|
|
||||||
|
- URL 转二维码(生成器)
|
||||||
|
- 二维码转 URL(解析器)
|
||||||
|
- 支持上传二维码图片解析
|
||||||
|
- 生成的二维码可下载
|
||||||
|
- 一键复制转换结果
|
||||||
|
- 卡片式布局,节省空间
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **框架**: WXT (Web Extension Toolkit)
|
- **框架**: WXT (Web Extension Toolkit)
|
||||||
@@ -24,23 +61,86 @@ Testing Tools 是一个轻量级的浏览器扩展,提供实用的时间戳转
|
|||||||
- **日期处理**: dayjs (含 UTC 和时区插件)
|
- **日期处理**: dayjs (含 UTC 和时区插件)
|
||||||
- **通信**: @webext-core/messaging
|
- **通信**: @webext-core/messaging
|
||||||
- **存储**: Chrome Storage API (类型安全封装)
|
- **存储**: Chrome Storage API (类型安全封装)
|
||||||
|
- **二维码**: qrcode (生成) + jsqr (解析)
|
||||||
|
- **测试**: Vitest + Testing Library
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
|
├── components/ # 可复用 UI 组件
|
||||||
|
│ ├── Button.tsx
|
||||||
|
│ ├── CopyButton.tsx
|
||||||
|
│ ├── DashboardCard.tsx # 仪表盘卡片组件(React.memo 优化)
|
||||||
|
│ ├── GlobalSnackbar.tsx
|
||||||
|
│ ├── PageHeader.tsx # 页面标题栏组件
|
||||||
|
│ ├── RouterContainer.tsx
|
||||||
|
│ ├── StorageCleanerConfirm.tsx
|
||||||
|
│ ├── ToolCard.tsx
|
||||||
|
│ └── TopBar.tsx
|
||||||
|
├── config/ # 配置文件
|
||||||
|
│ ├── dashboardCards.tsx # 仪表盘卡片配置数据
|
||||||
|
│ └── routes.ts # 页面路由定义
|
||||||
├── entrypoints/ # 浏览器扩展入口点
|
├── entrypoints/ # 浏览器扩展入口点
|
||||||
│ ├── popup/ # 扩展弹窗界面(时间戳转换页面)
|
│ ├── popup/ # 扩展弹窗界面
|
||||||
│ ├── options/ # 选项页面
|
│ │ ├── App.tsx
|
||||||
│ ├── background.ts # 后台脚本
|
│ │ ├── main.tsx
|
||||||
│ └── content.ts # 内容脚本
|
│ │ └── pages/ # 页面组件
|
||||||
├── utils/ # 工具函数(存储、日期处理、消息通信)
|
│ │ ├── DashboardPage.tsx
|
||||||
├── types/ # TypeScript 类型定义
|
│ │ ├── OpenUrlPage.tsx
|
||||||
├── public/ # 静态资源
|
│ │ ├── OpenUrlViewerPage.tsx
|
||||||
├── wxt.config.ts # WXT 配置文件
|
│ │ ├── QrCodePage.tsx
|
||||||
├── package.json # 项目依赖和脚本
|
│ │ ├── StorageCleanerPage.tsx
|
||||||
└── README.md # 项目说明文档
|
│ │ └── 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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 路由系统
|
||||||
|
|
||||||
|
项目实现了灵活的路由系统,支持:
|
||||||
|
|
||||||
|
- **页面导航**: 在不同工具页面之间切换
|
||||||
|
- **路由同步**: 通过 Chrome Storage 同步路由状态
|
||||||
|
- **可见性控制**: 可配置显示哪些页面
|
||||||
|
- **页面排序**: 自定义工具卡片的显示顺序
|
||||||
|
|
||||||
|
### 页面类型 (PageType)
|
||||||
|
|
||||||
|
| 页面 | 说明 | 默认可见 |
|
||||||
|
| ---------------- | ---------- | -------- |
|
||||||
|
| `dashboard` | 首页 | ✓ |
|
||||||
|
| `timestamp` | 时间戳转换 | ✓ |
|
||||||
|
| `storageCleaner` | 存储清理 | ✓ |
|
||||||
|
| `openUrl` | URL 工具 | ✓ |
|
||||||
|
| `qrCode` | 二维码工具 | ✓ |
|
||||||
|
| `openUrlViewer` | URL 查看器 | ✗ |
|
||||||
|
|
||||||
|
## 扩展入口点
|
||||||
|
|
||||||
|
| 入口点 | 说明 |
|
||||||
|
| -------------- | ------------------------ |
|
||||||
|
| **popup** | 点击扩展图标弹出的界面 |
|
||||||
|
| **options** | 扩展选项页面 |
|
||||||
|
| **sidepanel** | 浏览器侧边栏 |
|
||||||
|
| **background** | 后台脚本(生命周期管理) |
|
||||||
|
| **content** | 内容脚本(注入到网页) |
|
||||||
|
|
||||||
## 开发环境要求
|
## 开发环境要求
|
||||||
|
|
||||||
- Node.js >= 18
|
- Node.js >= 18
|
||||||
@@ -80,17 +180,54 @@ npm run build:firefox
|
|||||||
# Chrome 浏览器
|
# Chrome 浏览器
|
||||||
npm run zip
|
npm run zip
|
||||||
|
|
||||||
# Firefox
|
# Firefox 浏览器
|
||||||
npm run zip:firefox
|
npm run zip:firefox
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 其他命令
|
### 5. 代码质量
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run compile # TypeScript 类型检查
|
npm run compile # TypeScript 类型检查
|
||||||
npm run lint # ESLint 代码检查
|
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` — 自动发布
|
||||||
|
|
||||||
## 权限说明
|
## 权限说明
|
||||||
|
|
||||||
扩展请求以下权限:
|
扩展请求以下权限:
|
||||||
@@ -98,7 +235,8 @@ npm run lint # ESLint 代码检查
|
|||||||
- `storage` 和 `unlimitedStorage` - 本地数据存储
|
- `storage` 和 `unlimitedStorage` - 本地数据存储
|
||||||
- `clipboardWrite` - 剪贴板写入(复制功能)
|
- `clipboardWrite` - 剪贴板写入(复制功能)
|
||||||
- `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入
|
- `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入
|
||||||
- `debugger` - 调试器权限
|
- `cookies` - Cookie 访问
|
||||||
|
- `sidePanel` - 侧边栏支持
|
||||||
- `<all_urls>` - 访问所有网站内容(内容脚本注入)
|
- `<all_urls>` - 访问所有网站内容(内容脚本注入)
|
||||||
|
|
||||||
## 主要依赖
|
## 主要依赖
|
||||||
@@ -107,15 +245,14 @@ npm run lint # ESLint 代码检查
|
|||||||
- `@mui/material` - UI 组件库
|
- `@mui/material` - UI 组件库
|
||||||
- `dayjs` - 日期处理
|
- `dayjs` - 日期处理
|
||||||
- `@webext-core/messaging` - 扩展消息通信
|
- `@webext-core/messaging` - 扩展消息通信
|
||||||
|
- `vitest` - 测试框架
|
||||||
|
- `@testing-library/react` - React 组件测试
|
||||||
|
|
||||||
## 贡献指南
|
## 浏览器兼容性
|
||||||
|
|
||||||
1. Fork 项目
|
- Chrome (推荐)
|
||||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
- Firefox
|
||||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
|
||||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
|
||||||
5. 创建 Pull Request
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
此项目为私有项目 (private: true),仅供内部使用。
|
此项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Button as MuiButton, ButtonProps as MuiButtonProps } from '@mui/material';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按钮属性类型
|
||||||
|
* 继承自 MUI ButtonProps,支持所有 MUI Button 的属性
|
||||||
|
*/
|
||||||
|
export type ButtonProps = MuiButtonProps;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button - 自定义按钮组件
|
||||||
|
*
|
||||||
|
* 基于 MUI Button 的二次封装,提供统一的项目风格:
|
||||||
|
* - 禁用阴影和涟漪效果
|
||||||
|
* - 圆角设计 (borderRadius: 4)
|
||||||
|
* - 固定高度和字体大小
|
||||||
|
* - hover 时轻微上浮效果
|
||||||
|
* - 支持 sx 数组合并
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <Button variant="contained" color="primary">
|
||||||
|
* 提交
|
||||||
|
* </Button>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param sx - 自定义样式,支持数组或单个样式对象
|
||||||
|
* @param props - 其他 MUI Button 属性
|
||||||
|
* @returns 按钮组件
|
||||||
|
*/
|
||||||
|
export function Button({ sx = [], ...props }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<MuiButton
|
||||||
|
disableElevation
|
||||||
|
disableRipple
|
||||||
|
{...props}
|
||||||
|
sx={[
|
||||||
|
{
|
||||||
|
py: 1.6,
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: '1rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'none',
|
||||||
|
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'translateY(-1px)',
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
transform: 'translateY(0)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Button;
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { IconButton, Tooltip } from '@mui/material';
|
||||||
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
import CheckIcon from '@mui/icons-material/Check';
|
||||||
|
import { copyToClipboard } from '@/utils/clipboard';
|
||||||
|
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制按钮组件属性
|
||||||
|
* @param text 要复制的文本
|
||||||
|
* @param tooltip 提示信息
|
||||||
|
* @param size 按钮大小
|
||||||
|
* @param color 按钮颜色
|
||||||
|
* @param style 自定义样式
|
||||||
|
* @param showMessage 消息提示函数,用于显示复制成功或失败的消息
|
||||||
|
*/
|
||||||
|
interface CopyButtonProps {
|
||||||
|
text: string;
|
||||||
|
tooltip?: string;
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
color?: 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning' | string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
showMessage?: (message: string, options?: SnackbarOptions) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制按钮组件
|
||||||
|
* @param text 要复制的文本
|
||||||
|
* @param tooltip 提示信息
|
||||||
|
* @param size 按钮大小
|
||||||
|
* @param color 按钮颜色
|
||||||
|
* @param style 自定义样式
|
||||||
|
* @param showMessage 消息提示函数,用于显示复制成功或失败的消息
|
||||||
|
* @returns 复制按钮组件
|
||||||
|
*/
|
||||||
|
export const CopyButton: React.FC<CopyButtonProps> = ({
|
||||||
|
text,
|
||||||
|
tooltip = '复制',
|
||||||
|
size = 'small',
|
||||||
|
color = 'primary',
|
||||||
|
style,
|
||||||
|
showMessage,
|
||||||
|
}) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (text) {
|
||||||
|
const success = await copyToClipboard(text, showMessage);
|
||||||
|
if (success) {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showMessage?.('无内容可复制', { severity: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={tooltip}>
|
||||||
|
<IconButton
|
||||||
|
size={size}
|
||||||
|
onClick={handleCopy}
|
||||||
|
style={style}
|
||||||
|
sx={{
|
||||||
|
color: copied ? 'success.main' : color,
|
||||||
|
bgcolor: '#fff',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: copied
|
||||||
|
? 'success.main'
|
||||||
|
: !['primary', 'secondary', 'success', 'error', 'info', 'warning'].includes(color)
|
||||||
|
? color
|
||||||
|
: `${color}.main`,
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckIcon fontSize={size === 'small' ? 'small' : 'medium'} />
|
||||||
|
) : (
|
||||||
|
<ContentCopyIcon fontSize={size === 'small' ? 'small' : 'medium'} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CopyButton;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ToolCard from './ToolCard';
|
||||||
|
|
||||||
|
export interface DashboardCardConfig {
|
||||||
|
/** 卡片标题 */
|
||||||
|
title: string;
|
||||||
|
/** 卡片描述文字 */
|
||||||
|
description: string;
|
||||||
|
/** 主题颜色代码 */
|
||||||
|
colorCode: string;
|
||||||
|
/** 图标组件 */
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardCardProps {
|
||||||
|
/** 卡片配置数据 */
|
||||||
|
config: DashboardCardConfig;
|
||||||
|
/** 点击卡片时的回调函数 */
|
||||||
|
onClick: () => void;
|
||||||
|
/** 卡片右侧的实时预览内容(如时间戳显示) */
|
||||||
|
snapshot?: React.ReactNode;
|
||||||
|
/** 卡片背景色,默认使用主题色 */
|
||||||
|
cardBackgroundColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DashboardCard - 仪表盘卡片组件
|
||||||
|
*
|
||||||
|
* 基于 ToolCard 的封装,专门用于仪表盘页面
|
||||||
|
* 使用 React.memo 避免不必要的重渲染
|
||||||
|
*/
|
||||||
|
const DashboardCard = React.memo(
|
||||||
|
({ config, onClick, snapshot, cardBackgroundColor }: DashboardCardProps) => (
|
||||||
|
<ToolCard
|
||||||
|
title={config.title}
|
||||||
|
description={config.description}
|
||||||
|
colorCode={config.colorCode}
|
||||||
|
icon={config.icon}
|
||||||
|
onClick={onClick}
|
||||||
|
cardBackgroundColor={cardBackgroundColor}
|
||||||
|
snapshot={snapshot}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DashboardCard;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { Box, Typography, Button, Paper, Container } from '@mui/material';
|
||||||
|
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||||
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误边界组件:捕获子组件树中的 JavaScript 错误
|
||||||
|
*/
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReset = () => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<Container sx={{ mt: 8 }}>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'error.light',
|
||||||
|
bgcolor: 'error.shortest',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ErrorOutlineIcon color="error" sx={{ fontSize: 64, mb: 2 }} />
|
||||||
|
<Typography variant="h5" fontWeight={800} gutterBottom color="error.main">
|
||||||
|
糟糕,出了点问题
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。
|
||||||
|
</Typography>
|
||||||
|
{this.state.error && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: 3,
|
||||||
|
p: 2,
|
||||||
|
bgcolor: 'grey.100',
|
||||||
|
borderRadius: 2,
|
||||||
|
textAlign: 'left',
|
||||||
|
maxHeight: '200px',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
color: 'error.dark',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.state.error.toString()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={this.handleReset}
|
||||||
|
sx={{ borderRadius: 2, fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
刷新应用
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
/**
|
||||||
|
* GlobalSnackbar - 全局 Snackbar 消息提示组件
|
||||||
|
*
|
||||||
|
* 提供可复用的 Toast 消息提示功能,支持两种使用方式:
|
||||||
|
* 1. 作为受控组件使用:通过 props 控制显示状态
|
||||||
|
* 2. 通过 useSnackbarState Hook 使用:自动管理状态
|
||||||
|
*
|
||||||
|
* @module GlobalSnackbar
|
||||||
|
* @version 1.0.0
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // 方式一:受控组件
|
||||||
|
* <GlobalSnackbar
|
||||||
|
* message="操作成功"
|
||||||
|
* open={isOpen}
|
||||||
|
* onClose={() => setIsOpen(false)}
|
||||||
|
* severity="success"
|
||||||
|
* />
|
||||||
|
*
|
||||||
|
* // 方式二:Hook 方式
|
||||||
|
* const { snackbarProps, showMessage } = useSnackbarState();
|
||||||
|
* showMessage('Hello!', { severity: 'info' });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { JSX, useState } from 'react';
|
||||||
|
import { Snackbar, Alert, type SxProps, type Theme, alpha, Portal } from '@mui/material';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snackbar 消息严重程度类型
|
||||||
|
* @description 决定 Alert 组件的颜色和图标
|
||||||
|
* - success: 绿色,成功提示
|
||||||
|
* - info: 蓝色,信息提示
|
||||||
|
* - warning: 橙色,警告提示
|
||||||
|
* - error: 红色,错误提示
|
||||||
|
*/
|
||||||
|
export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新导出 SnackbarProvider 组件
|
||||||
|
* @description 提供 Context 方式的全局 Snackbar 功能
|
||||||
|
*/
|
||||||
|
export { SnackbarProvider } from './SnackbarProvider';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalSnackbar 组件的属性接口
|
||||||
|
* @interface GlobalSnackbarProps
|
||||||
|
*/
|
||||||
|
export interface GlobalSnackbarProps {
|
||||||
|
/** 消息内容,要显示的提示文本 */
|
||||||
|
message: string;
|
||||||
|
/** 是否显示 Snackbar */
|
||||||
|
open: boolean;
|
||||||
|
/** 关闭回调函数 */
|
||||||
|
onClose: () => void;
|
||||||
|
/** 消息级别,影响颜色和图标样式,默认 'info' */
|
||||||
|
severity?: SnackbarSeverity;
|
||||||
|
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭,默认 2000 */
|
||||||
|
autoHideDuration?: number;
|
||||||
|
/** Snackbar 弹出位置,默认 { vertical: 'bottom', horizontal: 'center' } */
|
||||||
|
anchorOrigin?: {
|
||||||
|
vertical: 'top' | 'bottom';
|
||||||
|
horizontal: 'left' | 'center' | 'right';
|
||||||
|
};
|
||||||
|
/** 是否使用 Alert 组件包裹,false 则使用原生 Snackbar message,默认 true */
|
||||||
|
showAlert?: boolean;
|
||||||
|
/** 是否隐藏 Alert 图标,默认 false */
|
||||||
|
hideIcon?: boolean;
|
||||||
|
/** 自定义样式,透传给外层 Snackbar 组件 */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
/** 自定义样式,透传给内层 Alert 组件(仅 showAlert=true 时生效) */
|
||||||
|
alertSx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* showMessage 方法的选项配置
|
||||||
|
* @interface SnackbarOptions
|
||||||
|
*/
|
||||||
|
export interface SnackbarOptions {
|
||||||
|
/** 消息级别:success | info | warning | error */
|
||||||
|
severity?: SnackbarSeverity;
|
||||||
|
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭 */
|
||||||
|
autoHideDuration?: number;
|
||||||
|
/** 是否隐藏 Alert 图标 */
|
||||||
|
hideIcon?: boolean;
|
||||||
|
/** 是否使用 Alert 组件包裹 */
|
||||||
|
showAlert?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useSnackbarState Hook 的返回值类型
|
||||||
|
* @interface UseSnackbarStateResult
|
||||||
|
*/
|
||||||
|
export interface UseSnackbarStateResult {
|
||||||
|
/** 传递给 GlobalSnackbar 组件的属性对象 */
|
||||||
|
snackbarProps: GlobalSnackbarProps;
|
||||||
|
/** 显示消息的方法 */
|
||||||
|
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||||
|
/** 关闭消息的方法 */
|
||||||
|
closeMessage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalSnackbar 组件的默认属性配置
|
||||||
|
* @description 提供类型安全的默认值选择
|
||||||
|
*/
|
||||||
|
const defaultProps: Required<
|
||||||
|
Pick<
|
||||||
|
GlobalSnackbarProps,
|
||||||
|
'severity' | 'autoHideDuration' | 'anchorOrigin' | 'showAlert' | 'hideIcon'
|
||||||
|
>
|
||||||
|
> = {
|
||||||
|
severity: 'info',
|
||||||
|
autoHideDuration: 2000,
|
||||||
|
anchorOrigin: { vertical: 'bottom', horizontal: 'center' },
|
||||||
|
showAlert: true,
|
||||||
|
hideIcon: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalSnackbar 组件
|
||||||
|
*
|
||||||
|
* 全局消息提示的展示组件,支持受控和非受控两种使用模式。
|
||||||
|
* 使用 MUI Snackbar 和 Alert 组件实现消息提示功能。
|
||||||
|
*
|
||||||
|
* @param {GlobalSnackbarProps} props - 组件属性
|
||||||
|
* @returns {JSX.Element}
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* - 使用 Portal 组件将 Snackbar 渲染到 body 末尾,避免 z-index 问题
|
||||||
|
* - 默认位置在屏幕底部居中
|
||||||
|
* - 自动设置高 z-index 确保显示在其他内容之上
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // 受控模式
|
||||||
|
* const [open, setOpen] = useState(false);
|
||||||
|
* <GlobalSnackbar
|
||||||
|
* message="保存成功"
|
||||||
|
* open={open}
|
||||||
|
* onClose={() => setOpen(false)}
|
||||||
|
* severity="success"
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function GlobalSnackbar({
|
||||||
|
message,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
severity = defaultProps.severity,
|
||||||
|
autoHideDuration = defaultProps.autoHideDuration,
|
||||||
|
showAlert = defaultProps.showAlert,
|
||||||
|
hideIcon = defaultProps.hideIcon,
|
||||||
|
}: GlobalSnackbarProps): JSX.Element {
|
||||||
|
/**
|
||||||
|
* 使用 Portal 将 Snackbar 传送到 DOM 顶层 (body 标签下)
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* Portal 的优势:
|
||||||
|
* - 避免父容器 overflow、z-index 等样式影响
|
||||||
|
* - 确保 Snackbar 始终显示在最顶层
|
||||||
|
* - 避免与其他组件的样式冲突
|
||||||
|
*/
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<Snackbar
|
||||||
|
open={open}
|
||||||
|
autoHideDuration={autoHideDuration}
|
||||||
|
onClose={onClose}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
|
disableWindowBlurListener
|
||||||
|
sx={{
|
||||||
|
zIndex: 999999,
|
||||||
|
// 确保距离底部的间距,响应式设计适配不同屏幕
|
||||||
|
bottom: { xs: '24px', sm: '24px' },
|
||||||
|
// 固定宽度时使用 transform 实现真正的居中
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
minWidth: '140px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showAlert ? (
|
||||||
|
<Alert
|
||||||
|
severity={severity}
|
||||||
|
variant="filled"
|
||||||
|
icon={hideIcon ? false : undefined}
|
||||||
|
sx={{
|
||||||
|
// 胶囊形状,现代化的设计风格
|
||||||
|
borderRadius: '50px',
|
||||||
|
px: 2.5,
|
||||||
|
py: 0.2,
|
||||||
|
minWidth: '140px',
|
||||||
|
// 居中内容
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
// 粗体小字
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
// 移除默认渐变背景
|
||||||
|
backgroundImage: 'none',
|
||||||
|
// 添加阴影效果,颜色根据 severity 自动匹配主题色
|
||||||
|
boxShadow: (theme: Theme) =>
|
||||||
|
`0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`,
|
||||||
|
// 图标样式:白色、稍大
|
||||||
|
'& .MuiAlert-icon': { mr: 0.5, fontSize: '1.1rem', color: '#fff' },
|
||||||
|
// 消息文字样式:白色、适当内边距
|
||||||
|
'& .MuiAlert-message': { color: '#fff', padding: '6px 0' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</Alert>
|
||||||
|
) : undefined}
|
||||||
|
</Snackbar>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useSnackbarState - 消息提示的 Hook 方式
|
||||||
|
*
|
||||||
|
* 提供状态管理的 Snackbar 功能,自动处理 open、message 等状态。
|
||||||
|
* 适合在组件内部使用,无需额外的状态管理代码。
|
||||||
|
*
|
||||||
|
* @param {SnackbarOptions} [initialOptions] - 初始配置选项
|
||||||
|
* @returns {UseSnackbarStateResult} 包含 snackbarProps 和操作方法的对象
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* - 自动管理 Snackbar 的显示/隐藏状态
|
||||||
|
* - 支持链式调用 showMessage
|
||||||
|
* - 合并初始选项和调用时选项
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function MyComponent() {
|
||||||
|
* const { snackbarProps, showMessage, closeMessage } = useSnackbarState({
|
||||||
|
* severity: 'info',
|
||||||
|
* autoHideDuration: 3000,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* const handleSave = () => {
|
||||||
|
* // 业务逻辑...
|
||||||
|
* showMessage('保存成功!', { severity: 'success' });
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <>
|
||||||
|
* <button onClick={handleSave}>保存</button>
|
||||||
|
* <GlobalSnackbar {...snackbarProps} />
|
||||||
|
* </>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarStateResult {
|
||||||
|
// Snackbar 显示状态
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
// 当前显示的消息内容
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
// 消息配置选项
|
||||||
|
const [options, setOptions] = useState<SnackbarOptions>(initialOptions || {});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示消息
|
||||||
|
*
|
||||||
|
* @param {string} newMessage - 要显示的消息文本
|
||||||
|
* @param {SnackbarOptions} [newOptions={}] - 新的配置选项
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* - 合并初始选项和新的调用选项
|
||||||
|
* - 新选项会覆盖初始选项
|
||||||
|
*/
|
||||||
|
const showMessage = (newMessage: string, newOptions: SnackbarOptions = {}) => {
|
||||||
|
setMessage(newMessage);
|
||||||
|
setOptions({ ...initialOptions, ...newOptions });
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭消息
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* - 直接将 open 状态设置为 false
|
||||||
|
*/
|
||||||
|
const closeMessage = () => {
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 Snackbar 关闭事件
|
||||||
|
*
|
||||||
|
* @param {React.SyntheticEvent | Event} [_event] - 关闭事件
|
||||||
|
* @param {string} [reason] - 关闭原因:timeout | clickaway | escapeKeyDown
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* - 忽略 clickaway 原因(用户点击其他区域),防止误关闭
|
||||||
|
* - 其他情况调用 closeMessage 关闭
|
||||||
|
*/
|
||||||
|
const handleClose = (_event?: React.SyntheticEvent | Event, reason?: string) => {
|
||||||
|
if (reason === 'clickaway') return;
|
||||||
|
closeMessage();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 传递给 GlobalSnackbar 组件的属性
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* - 组合当前状态和选项为完整的组件 props
|
||||||
|
* - onClose 使用 handleClose 包装后的版本
|
||||||
|
*/
|
||||||
|
const snackbarProps: GlobalSnackbarProps = {
|
||||||
|
message,
|
||||||
|
open,
|
||||||
|
onClose: handleClose,
|
||||||
|
severity: options.severity,
|
||||||
|
autoHideDuration: options.autoHideDuration,
|
||||||
|
hideIcon: options.hideIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
snackbarProps,
|
||||||
|
showMessage,
|
||||||
|
closeMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalSnackbar 组件的默认导出
|
||||||
|
* @description 方便使用 `import GlobalSnackbar from './GlobalSnackbar'` 方式导入
|
||||||
|
*/
|
||||||
|
export default GlobalSnackbar;
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Stack, Typography, Box, alpha, SxProps, Theme } from '@mui/material';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PageHeader 组件属性接口
|
||||||
|
*/
|
||||||
|
export interface PageHeaderProps {
|
||||||
|
/** 要显示的图标组件 */
|
||||||
|
icon: ReactNode;
|
||||||
|
/** 图标的颜色,默认为 '#1976d2'(蓝色) */
|
||||||
|
iconColor?: string;
|
||||||
|
/** 主标题文本 */
|
||||||
|
title: string;
|
||||||
|
/** 副标题文本(可选) */
|
||||||
|
subtitle?: string;
|
||||||
|
/** 在标题右侧显示的徽章/标签组件(可选) */
|
||||||
|
badge?: ReactNode;
|
||||||
|
/** 图标容器的自定义样式 */
|
||||||
|
iconSx?: SxProps<Theme>;
|
||||||
|
/** 标题文本的自定义样式 */
|
||||||
|
titleSx?: SxProps<Theme>;
|
||||||
|
/** 副标题文本的自定义样式 */
|
||||||
|
subtitleSx?: SxProps<Theme>;
|
||||||
|
/** 整个组件的自定义样式 */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PageHeader - 通用页面标题栏组件
|
||||||
|
*
|
||||||
|
* 用于显示带图标的页面标题,支持自定义颜色、副标题、徽章等功能
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <PageHeader
|
||||||
|
* icon={<AccessTimeIcon />}
|
||||||
|
* iconColor="#1976d2"
|
||||||
|
* title="时间戳转换"
|
||||||
|
* subtitle="Unix 毫秒数转换与格式化"
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <PageHeader
|
||||||
|
* icon={<StorageIcon />}
|
||||||
|
* iconColor={storageCleanerPageStyles.warningColor}
|
||||||
|
* title="存储清理"
|
||||||
|
* subtitle={domain}
|
||||||
|
* badge={<Badge>已占用 {size}</Badge>}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export default function PageHeader({
|
||||||
|
icon,
|
||||||
|
iconColor = '#1976d2',
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
badge,
|
||||||
|
iconSx,
|
||||||
|
titleSx,
|
||||||
|
subtitleSx,
|
||||||
|
sx,
|
||||||
|
}: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5, ...sx }}>
|
||||||
|
{/* 图标容器 */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
bgcolor: alpha(iconColor, 0.1),
|
||||||
|
color: iconColor,
|
||||||
|
display: 'flex',
|
||||||
|
...iconSx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
{/* 标题行(含徽章) */}
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
fontWeight={900}
|
||||||
|
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2, ...titleSx }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{badge}
|
||||||
|
</Stack>
|
||||||
|
{/* 副标题 */}
|
||||||
|
{subtitle && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontWeight: 600, ...subtitleSx }}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Stack,
|
||||||
|
Alert,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
CircularProgress,
|
||||||
|
InputAdornment,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material';
|
||||||
|
import LinkIcon from '@mui/icons-material/Link';
|
||||||
|
import ImageIcon from '@mui/icons-material/Image';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
|
import CopyButton from '@/components/CopyButton';
|
||||||
|
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||||
|
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||||
|
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
||||||
|
|
||||||
|
interface QrCodeToUrlSectionProps {
|
||||||
|
expanded: boolean;
|
||||||
|
onExpandedChange: (expanded: boolean) => void;
|
||||||
|
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QrCodeToUrlSection = ({
|
||||||
|
expanded,
|
||||||
|
onExpandedChange,
|
||||||
|
showMessage,
|
||||||
|
}: QrCodeToUrlSectionProps) => {
|
||||||
|
const [qrCodeFile, setQrCodeFile] = useState<File | null>(null);
|
||||||
|
const [parsedUrl, setParsedUrl] = useState('');
|
||||||
|
const [parseError, setParseError] = useState('');
|
||||||
|
const [parsing, setParsing] = useState(false);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileChange = useCallback((file: File) => {
|
||||||
|
setQrCodeFile(file);
|
||||||
|
setParseError('');
|
||||||
|
setParsedUrl('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
handleFileChange(e.target.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
setDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
const droppedFile = e.dataTransfer.files?.[0];
|
||||||
|
if (droppedFile) {
|
||||||
|
handleFileChange(droppedFile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseQrCode = async () => {
|
||||||
|
if (!qrCodeFile) {
|
||||||
|
showMessage('请选择二维码图片', { severity: 'error', autoHideDuration: 300 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setParsing(true);
|
||||||
|
setParseError('');
|
||||||
|
setParsedUrl('');
|
||||||
|
|
||||||
|
const result = await parseQrCodeFromFile(qrCodeFile);
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setParsedUrl(result.data);
|
||||||
|
showMessage('二维码解析成功', { severity: 'success', autoHideDuration: 1000 });
|
||||||
|
} else {
|
||||||
|
showMessage(result.error || '未检测到二维码', {
|
||||||
|
severity: 'error',
|
||||||
|
autoHideDuration: 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析二维码失败:', error);
|
||||||
|
showMessage('解析二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
|
||||||
|
} finally {
|
||||||
|
setParsing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听粘贴事件
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePaste = async (e: ClipboardEvent) => {
|
||||||
|
if (!expanded) return;
|
||||||
|
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].type.startsWith('image/')) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const file = items[i].getAsFile();
|
||||||
|
if (file) {
|
||||||
|
try {
|
||||||
|
handleFileChange(file);
|
||||||
|
showMessage('图片粘贴成功', { severity: 'success', autoHideDuration: 1000 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('处理粘贴图片失败:', error);
|
||||||
|
showMessage('粘贴图片失败,请重试', { severity: 'error', autoHideDuration: 3000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('paste', handlePaste);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('paste', handlePaste);
|
||||||
|
};
|
||||||
|
}, [expanded, showMessage, handleFileChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion
|
||||||
|
expanded={expanded}
|
||||||
|
onChange={(_, isExpanded) => onExpandedChange(isExpanded)}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 4,
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
|
'&:before': { display: 'none' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ borderBottom: 'none' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<LinkIcon color="success" />
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
二维码转 URL
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: 200,
|
||||||
|
border: '2px dashed',
|
||||||
|
borderColor: dragging
|
||||||
|
? qrCodePageStyles.successColor
|
||||||
|
: qrCodeFile
|
||||||
|
? qrCodePageStyles.successColor
|
||||||
|
: 'grey.200',
|
||||||
|
borderRadius: 3,
|
||||||
|
p: 4,
|
||||||
|
bgcolor: dragging
|
||||||
|
? 'rgba(76, 175, 80, 0.1)'
|
||||||
|
: qrCodeFile
|
||||||
|
? 'rgba(76, 175, 80, 0.05)'
|
||||||
|
: 'grey.50',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: qrCodePageStyles.successColor,
|
||||||
|
bgcolor: 'rgba(76, 175, 80, 0.05)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
style={{
|
||||||
|
display: 'none',
|
||||||
|
}}
|
||||||
|
id="qr-code-upload"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="qr-code-upload"
|
||||||
|
style={{ cursor: 'pointer', textAlign: 'center', width: '100%' }}
|
||||||
|
>
|
||||||
|
{qrCodeFile ? (
|
||||||
|
<Box sx={{ textAlign: 'center', width: '100%', position: 'relative' }}>
|
||||||
|
<Box sx={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(qrCodeFile)}
|
||||||
|
alt="QR Code Preview"
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: 160,
|
||||||
|
borderRadius: 8,
|
||||||
|
objectFit: 'contain',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setQrCodeFile(null);
|
||||||
|
setParsedUrl('');
|
||||||
|
setParseError('');
|
||||||
|
showMessage('图片已清除', {
|
||||||
|
severity: 'success',
|
||||||
|
autoHideDuration: 1000,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
right: -8,
|
||||||
|
bgcolor: 'rgba(244, 67, 54, 0.9)',
|
||||||
|
color: 'white',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'rgba(211, 47, 47, 0.95)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClearIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||||
|
{qrCodeFile.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
点击更换图片
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ImageIcon sx={{ fontSize: 48, color: 'grey.300', mb: 2 }} />
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
点击、拖拽或粘贴上传二维码图片
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
支持 PNG、JPG、WEBP 格式
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={parsing ? <CircularProgress size={16} color="inherit" /> : <LinkIcon />}
|
||||||
|
onClick={parseQrCode}
|
||||||
|
disabled={parsing}
|
||||||
|
sx={{
|
||||||
|
py: 1.2,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: qrCodePageStyles.successColor,
|
||||||
|
fontWeight: 700,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: qrCodePageStyles.successDark,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parsing ? '解析中...' : '解析二维码'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
mt: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="解析结果"
|
||||||
|
value={parsedUrl}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
readOnly: true,
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<CopyButton
|
||||||
|
text={parsedUrl}
|
||||||
|
tooltip="复制"
|
||||||
|
size="small"
|
||||||
|
color={qrCodePageStyles.primaryColor}
|
||||||
|
showMessage={showMessage}
|
||||||
|
/>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={qrCodePageStyles.INPUT_STYLE}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{parseError && (
|
||||||
|
<Alert severity="error" sx={{ borderRadius: 3 }}>
|
||||||
|
{parseError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QrCodeToUrlSection;
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
CircularProgress,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ImageIcon from '@mui/icons-material/Image';
|
||||||
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import ErrorIcon from '@mui/icons-material/Error';
|
||||||
|
import GlobalSnackbar, { useSnackbarState } from './GlobalSnackbar';
|
||||||
|
import CopyButton from './CopyButton';
|
||||||
|
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
||||||
|
|
||||||
|
interface QrCodeUploaderProps {
|
||||||
|
onQrCodeDetected?: (data: string) => void;
|
||||||
|
supportedFormats?: string[];
|
||||||
|
maxFileSize?: number; // in bytes
|
||||||
|
timeout?: number; // in milliseconds
|
||||||
|
showPreview?: boolean;
|
||||||
|
showProgress?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QrCodeUploader: React.FC<QrCodeUploaderProps> = ({
|
||||||
|
onQrCodeDetected,
|
||||||
|
supportedFormats = ['image/png', 'image/jpeg', 'image/webp'],
|
||||||
|
maxFileSize = 5 * 1024 * 1024, // 5MB
|
||||||
|
timeout = 10000, // 10 seconds
|
||||||
|
showPreview = true,
|
||||||
|
showProgress = true,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const { snackbarProps, showMessage } = useSnackbarState({ autoHideDuration: 3000 });
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [result, setResult] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const uploadAreaRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 清理预览 URL
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (preview) {
|
||||||
|
URL.revokeObjectURL(preview);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [preview]);
|
||||||
|
|
||||||
|
// 处理文件
|
||||||
|
const processFile = useCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
setUploading(true);
|
||||||
|
setProgress(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const progressInterval = setInterval(() => {
|
||||||
|
setProgress((prev) => {
|
||||||
|
if (prev >= 90) {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return prev + 10;
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
const result = await parseQrCodeFromFile(file, timeout);
|
||||||
|
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
setProgress(100);
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setResult(result.data);
|
||||||
|
showMessage('二维码解析成功', { severity: 'success' });
|
||||||
|
if (onQrCodeDetected) {
|
||||||
|
onQrCodeDetected(result.data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(result.error || '未检测到二维码');
|
||||||
|
showMessage(result.error || '未检测到二维码', { severity: 'error' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '解析失败');
|
||||||
|
showMessage('解析失败: ' + (err instanceof Error ? err.message : '未知错误'), {
|
||||||
|
severity: 'error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
setTimeout(() => setProgress(0), 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[timeout, showMessage, onQrCodeDetected],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理文件
|
||||||
|
const handleFile = useCallback(
|
||||||
|
(selectedFile: File) => {
|
||||||
|
// 检查文件格式
|
||||||
|
if (!supportedFormats.includes(selectedFile.type)) {
|
||||||
|
setError(
|
||||||
|
`不支持的文件格式。支持的格式: ${supportedFormats.map((f) => f.split('/')[1].toUpperCase()).join(', ')}`,
|
||||||
|
);
|
||||||
|
showMessage('不支持的文件格式', { severity: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件大小
|
||||||
|
if (selectedFile.size > maxFileSize) {
|
||||||
|
const maxSizeMB = (maxFileSize / (1024 * 1024)).toFixed(1);
|
||||||
|
setError(`文件大小超过限制。最大支持 ${maxSizeMB}MB`);
|
||||||
|
showMessage(`文件大小超过限制,最大支持 ${maxSizeMB}MB`, { severity: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
setFile(selectedFile);
|
||||||
|
|
||||||
|
// 创建预览
|
||||||
|
if (showPreview) {
|
||||||
|
const previewUrl = URL.createObjectURL(selectedFile);
|
||||||
|
setPreview(previewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始处理
|
||||||
|
processFile(selectedFile).catch(console.error);
|
||||||
|
},
|
||||||
|
[supportedFormats, maxFileSize, showPreview, showMessage, processFile],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理文件选择
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFile = e.target.files?.[0];
|
||||||
|
if (selectedFile) {
|
||||||
|
handleFile(selectedFile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理拖拽事件
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
setDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
const droppedFile = e.dataTransfer.files?.[0];
|
||||||
|
if (droppedFile) {
|
||||||
|
handleFile(droppedFile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听粘贴事件
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].type.startsWith('image/')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const pastedFile = items[i].getAsFile();
|
||||||
|
if (pastedFile) {
|
||||||
|
handleFile(pastedFile);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('paste', handlePaste);
|
||||||
|
return () => document.removeEventListener('paste', handlePaste);
|
||||||
|
}, [handleFile]);
|
||||||
|
|
||||||
|
// 清除文件
|
||||||
|
const handleClear = () => {
|
||||||
|
setFile(null);
|
||||||
|
setPreview(null);
|
||||||
|
setResult(null);
|
||||||
|
setError(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={className}>
|
||||||
|
{/* 上传区域 */}
|
||||||
|
<Paper
|
||||||
|
ref={uploadAreaRef}
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: isMobile ? 3 : 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: `2px dashed ${dragging ? 'primary.main' : 'grey.300'}`,
|
||||||
|
bgcolor: dragging ? 'primary.lighter' : 'grey.50',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={supportedFormats.join(',')}
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!file && !uploading ? (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
|
<ImageIcon sx={{ fontSize: isMobile ? 36 : 48, color: 'grey.400', mb: 2 }} />
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
点击、拖拽或粘贴上传二维码图片
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
支持 {supportedFormats.map((f) => f.split('/')[1].toUpperCase()).join(', ')} 格式
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
最大文件大小: {(maxFileSize / (1024 * 1024)).toFixed(1)}MB
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : file && showPreview && preview ? (
|
||||||
|
<Box sx={{ position: 'relative' }}>
|
||||||
|
<img
|
||||||
|
src={preview}
|
||||||
|
alt="QR Code Preview"
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: 200,
|
||||||
|
borderRadius: 8,
|
||||||
|
objectFit: 'contain',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClear();
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
right: -8,
|
||||||
|
bgcolor: 'rgba(244, 67, 54, 0.9)',
|
||||||
|
color: 'white',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'rgba(211, 47, 47, 0.95)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClearIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||||
|
{file.name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : uploading && showProgress ? (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
|
<CircularProgress size={48} sx={{ mb: 2 }} />
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
处理中...
|
||||||
|
</Typography>
|
||||||
|
{progress > 0 && (
|
||||||
|
<Box sx={{ width: '80%', mt: 2 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 8,
|
||||||
|
bgcolor: 'grey.200',
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
width: `${progress}%`,
|
||||||
|
transition: 'width 0.3s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ mt: 1, display: 'block' }}
|
||||||
|
>
|
||||||
|
{progress}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* 结果展示 */}
|
||||||
|
{(result || error) && (
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
{result && (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'success.light',
|
||||||
|
bgcolor: 'success.lighter',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||||
|
<CheckCircleIcon sx={{ color: 'success.main', mt: 0.5 }} />
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||||
|
二维码内容
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ position: 'relative' }}>
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
pr: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result}
|
||||||
|
</Typography>
|
||||||
|
<CopyButton
|
||||||
|
text={result}
|
||||||
|
tooltip="复制"
|
||||||
|
size="small"
|
||||||
|
color="success"
|
||||||
|
showMessage={showMessage}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ borderRadius: 4 }} icon={<ErrorIcon />}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<GlobalSnackbar {...snackbarProps} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QrCodeUploader;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { FEATURES, getEntryPointType } from '@/config/features';
|
||||||
|
import { useRouter } from '@/providers/RouterProvider';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export default function RouterContainer() {
|
||||||
|
const { currentPage, isLoaded } = useRouter();
|
||||||
|
|
||||||
|
const animationClass = useMemo(() => {
|
||||||
|
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
||||||
|
}, [currentPage]);
|
||||||
|
|
||||||
|
const entryPointType = useMemo(() => {
|
||||||
|
return getEntryPointType();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isLoaded) {
|
||||||
|
return <div className="app">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFeature = FEATURES.find((f) => f.key === currentPage);
|
||||||
|
const Component = currentFeature ? currentFeature.components[entryPointType] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={currentPage} // Trigger animation on navigation
|
||||||
|
className={animationClass}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
scrollbarGutter: 'stable',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Component && <Component />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* SnackbarProvider - 全局 Snackbar 消息提示 Provider
|
||||||
|
*
|
||||||
|
* 提供全局的 Toast 消息功能,支持成功、错误、警告、信息四种提示类型。
|
||||||
|
* 通过 React Context 向下传递消息显示方法,子组件可通过 useSnackbar hook 调用。
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* - 基于 GlobalSnackbar 组件实现,复用其状态管理逻辑
|
||||||
|
* - 使用 MUI Snackbar 组件实现消息提示
|
||||||
|
* - 支持自定义自动隐藏时长
|
||||||
|
* - 消息会显示在页面底部居中位置
|
||||||
|
* - 使用 Portal 将 Snackbar 渲染到 body 末尾,避免 z-index 层级问题
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
|
import GlobalSnackbar, { useSnackbarState } from './GlobalSnackbar';
|
||||||
|
import type { SnackbarOptions } from './GlobalSnackbar';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snackbar Context 的值类型定义
|
||||||
|
* @interface SnackbarContextValue
|
||||||
|
* @property showMessage - 显示消息的方法
|
||||||
|
* @property closeMessage - 关闭消息的方法
|
||||||
|
*/
|
||||||
|
interface SnackbarContextValue {
|
||||||
|
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||||
|
closeMessage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React Context,用于在组件树中传递 Snackbar 操作方法
|
||||||
|
* @description
|
||||||
|
* - 初始值为 null,表示未包裹在 Provider 中
|
||||||
|
* - 通过 SnackbarProvider 包裹后提供实际值
|
||||||
|
*/
|
||||||
|
const SnackbarContext = createContext<SnackbarContextValue | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SnackbarProvider 组件的 props 类型
|
||||||
|
* @interface SnackbarProviderProps
|
||||||
|
* @property children - 子组件
|
||||||
|
* @property initialOptions - 初始配置选项
|
||||||
|
*/
|
||||||
|
interface SnackbarProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
initialOptions?: SnackbarOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useSnackbar Hook 的选项配置(与 GlobalSnackbar 的 SnackbarOptions 兼容)
|
||||||
|
* @interface UseSnackbarOptions
|
||||||
|
* @property severity - 消息严重程度
|
||||||
|
* @property autoHideDuration - 默认自动隐藏时长
|
||||||
|
* @property hideIcon - 是否隐藏图标
|
||||||
|
* @property showAlert - 是否使用 Alert 组件
|
||||||
|
*/
|
||||||
|
export type UseSnackbarOptions = SnackbarOptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SnackbarProvider 组件
|
||||||
|
*
|
||||||
|
* 全局消息提示的 Provider 组件,需要包裹在应用根组件外层。
|
||||||
|
* 提供 showMessage 方法用于显示各种类型的提示消息。
|
||||||
|
*
|
||||||
|
* @param {SnackbarProviderProps} props - 组件属性
|
||||||
|
* @returns {JSX.Element}
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* - 使用 useGlobalSnackbar() hook 复用 GlobalSnackbar 的状态管理逻辑
|
||||||
|
* - 通过 Context.Provider 将操作方法传递给子组件
|
||||||
|
* - 渲染 GlobalSnackbar 组件显示实际的 Snackbar UI
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <SnackbarProvider initialOptions={{ autoHideDuration: 3000 }}>
|
||||||
|
* <App />
|
||||||
|
* </SnackbarProvider>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // 在子组件中使用
|
||||||
|
* const { showMessage } = useSnackbar();
|
||||||
|
* showMessage('操作成功', { severity: 'success' });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function SnackbarProvider({ children, initialOptions }: SnackbarProviderProps) {
|
||||||
|
/**
|
||||||
|
* 调用 GlobalSnackbar.useSnackbar() 获取状态管理逻辑
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* - snackbarProps: 传递给 GlobalSnackbar 组件的属性
|
||||||
|
* - showMessage: 显示消息的方法
|
||||||
|
* - closeMessage: 关闭消息的方法
|
||||||
|
*/
|
||||||
|
const { snackbarProps, showMessage, closeMessage } = useSnackbarState(initialOptions);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 Context.Provider 向下传递 snackbar 操作方法
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* - 子组件通过 useSnackbar() hook 获取这些方法
|
||||||
|
* - GlobalSnackbar 组件放在 Provider 外部,确保它能渲染到 DOM
|
||||||
|
*/
|
||||||
|
return (
|
||||||
|
<SnackbarContext.Provider value={{ showMessage, closeMessage }}>
|
||||||
|
{children}
|
||||||
|
<GlobalSnackbar {...snackbarProps} />
|
||||||
|
</SnackbarContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useSnackbar - 在子组件中获取 Snackbar 上下文的 Hook
|
||||||
|
*
|
||||||
|
* @param {UseSnackbarOptions} [_options] - 可选的配置项(保留向后兼容性,不实际使用)
|
||||||
|
* @returns {SnackbarContextValue} - 包含 showMessage 和 closeMessage 的对象
|
||||||
|
* @throws {Error} - 如果不在 SnackbarProvider 内部调用,抛出错误
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 这是一个自定义 React Hook,用于在任意子组件中访问 Snackbar 功能。
|
||||||
|
* 必须确保组件被 SnackbarProvider 包裹才能使用。
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* - 由于 Context 限制,useSnackbar 的 options 参数无法动态传递给 Provider
|
||||||
|
* - 如需设置全局初始选项,请在 SnackbarProvider 组件上设置 initialOptions
|
||||||
|
* - 如需为单个消息设置选项,请在 showMessage() 方法中传入
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function MyComponent() {
|
||||||
|
* const { showMessage } = useSnackbar();
|
||||||
|
*
|
||||||
|
* const handleSuccess = () => {
|
||||||
|
* showMessage('操作成功!', { severity: 'success', autoHideDuration: 5000 });
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <div>
|
||||||
|
* <button onClick={handleSuccess}>成功提示</button>
|
||||||
|
* </div>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useSnackbar(_options?: UseSnackbarOptions): SnackbarContextValue {
|
||||||
|
const context = useContext(SnackbarContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSnackbar must be used within SnackbarProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SnackbarProvider;
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
alpha,
|
||||||
|
} from '@mui/material';
|
||||||
|
import type { StorageCleanerOptions } from '@/types/storage';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import { storageCleanerPageStyles, THEME_COLORS } from '@/config/pageTheme';
|
||||||
|
|
||||||
|
export interface StorageCleanerConfirmProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
options: StorageCleanerOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_LABELS: Record<keyof StorageCleanerOptions, string> = {
|
||||||
|
localStorage: 'LocalStorage',
|
||||||
|
sessionStorage: 'Session Storage',
|
||||||
|
indexedDB: 'IndexedDB',
|
||||||
|
cookies: 'Cookies',
|
||||||
|
cacheStorage: 'Cache Storage',
|
||||||
|
serviceWorkers: 'Service Workers',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StorageCleanerConfirm({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
options,
|
||||||
|
}: StorageCleanerConfirmProps) {
|
||||||
|
const selectedOptions = Object.entries(options)
|
||||||
|
.filter(([_, value]) => value)
|
||||||
|
.map(([key, _]) => STORAGE_LABELS[key as keyof StorageCleanerOptions] || key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
fullWidth
|
||||||
|
maxWidth="xs"
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: {
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundImage: 'none',
|
||||||
|
boxShadow: `0 24px 64px -12px ${alpha(THEME_COLORS.black, 0.18)}`,
|
||||||
|
p: 1.5,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle
|
||||||
|
sx={{
|
||||||
|
textAlign: 'center',
|
||||||
|
pt: 4,
|
||||||
|
pb: 1,
|
||||||
|
fontWeight: 900,
|
||||||
|
letterSpacing: '-0.5px',
|
||||||
|
fontSize: '1.35rem',
|
||||||
|
color: 'text.primary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确认清理数据?
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent sx={{ textAlign: 'center', pb: 2 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ mb: 3.5, fontWeight: 500, fontSize: '0.9rem' }}
|
||||||
|
>
|
||||||
|
您将永久删除当前页面的以下选定存储项。
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.2, justifyContent: 'center', mb: 4 }}>
|
||||||
|
{selectedOptions.map((label) => (
|
||||||
|
<Chip
|
||||||
|
key={label}
|
||||||
|
label={label}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
bgcolor: alpha(THEME_COLORS.warning, 0.04),
|
||||||
|
fontWeight: 700,
|
||||||
|
color: THEME_COLORS.warning,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: alpha(THEME_COLORS.warning, 0.15),
|
||||||
|
borderRadius: 2.5,
|
||||||
|
height: 'auto',
|
||||||
|
'& .MuiChip-label': { px: 1.2, py: 0.6 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
bgcolor: alpha(THEME_COLORS.error, 0.05),
|
||||||
|
color: THEME_COLORS.error,
|
||||||
|
px: 2,
|
||||||
|
py: 0.8,
|
||||||
|
borderRadius: 3,
|
||||||
|
border: '1px dashed',
|
||||||
|
borderColor: alpha(THEME_COLORS.error, 0.2),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 800,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span role="img" aria-label="warning">
|
||||||
|
⚠️
|
||||||
|
</span>{' '}
|
||||||
|
此操作不可撤销
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ p: 3, pt: 1, gap: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
onClick={onClose}
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
boxShadow: '0 0 1px 1px rgba(0, 0, 0, 0.1)',
|
||||||
|
color: 'text.secondary',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'grey.100',
|
||||||
|
color: 'text.primary',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={onConfirm}
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
bgcolor: storageCleanerPageStyles.warningColor,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: storageCleanerPageStyles.warningDark,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确认清理
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StorageCleanerConfirm;
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { Box, Typography, Stack } from '@mui/material';
|
||||||
|
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; // Sparkles for AI
|
||||||
|
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ToolCardProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
snapshot?: React.ReactNode;
|
||||||
|
colorCode: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
hasAI?: boolean;
|
||||||
|
cardBackgroundColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToolCard({ title, description, snapshot, colorCode, icon, onClick, hasAI, cardBackgroundColor = 'background.paper' }: ToolCardProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
onClick={onClick}
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
bgcolor: cardBackgroundColor,
|
||||||
|
borderRadius: 4,
|
||||||
|
p: 2.5,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.100',
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1.5,
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: colorCode,
|
||||||
|
transform: 'translateY(-4px)',
|
||||||
|
boxShadow: `0 12px 24px -10px ${colorCode}33`, // 20% opacity of colorCode
|
||||||
|
'& .arrow-icon': {
|
||||||
|
transform: 'translateX(4px)',
|
||||||
|
color: colorCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: `${colorCode}11`, // 7% opacity
|
||||||
|
color: colorCode
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
color: 'text.primary',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
{hasAI && <AutoAwesomeIcon sx={{ fontSize: 14, color: '#f5b041' }} />}
|
||||||
|
</Typography>
|
||||||
|
{description && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'block',
|
||||||
|
mt: 0.5
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
<ArrowForwardIosIcon
|
||||||
|
className="arrow-icon"
|
||||||
|
sx={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'grey.300',
|
||||||
|
mt: 0.5,
|
||||||
|
transition: 'all 0.3s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{snapshot && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 'auto',
|
||||||
|
pt: 1.5,
|
||||||
|
borderTop: '1px dashed',
|
||||||
|
borderColor: 'grey.100'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{snapshot}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Box, IconButton, Typography, Stack, Tooltip } from '@mui/material';
|
||||||
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
|
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||||
|
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
||||||
|
import { useRouter } from '@/providers/RouterProvider';
|
||||||
|
|
||||||
|
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
|
||||||
|
const { currentPage, goBack } = useRouter();
|
||||||
|
|
||||||
|
const isDetachedMode = useMemo(() => {
|
||||||
|
return new URLSearchParams(window.location.search).get('mode') === 'detached';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDetach = () => {
|
||||||
|
// 弹出脱离窗口 (以独立面板形式打开当前 URL,并标记 mode=detached)
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('mode', 'detached');
|
||||||
|
|
||||||
|
chrome.windows.create({
|
||||||
|
url: url.toString(),
|
||||||
|
type: 'panel',
|
||||||
|
width: 420,
|
||||||
|
height: 600
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDashboard = currentPage === 'dashboard';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1.5,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'grey.100',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
zIndex: 1100
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ width: 40 }}>
|
||||||
|
{!isDashboard && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={goBack}
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'grey.50',
|
||||||
|
'&:hover': { bgcolor: 'grey.200' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowBackIosNewIcon sx={{ fontSize: 14 }} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'text.secondary'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Testing Tools
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} sx={{ width: 80, justifyContent: 'flex-end' }}>
|
||||||
|
{!isDetachedMode && (
|
||||||
|
<Tooltip title="独立窗口模式">
|
||||||
|
<IconButton size="small" onClick={handleDetach}>
|
||||||
|
<OpenInNewIcon sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip title="设置">
|
||||||
|
<IconButton size="small" onClick={onOpenOptions}>
|
||||||
|
<SettingsIcon sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Stack,
|
||||||
|
Accordion,
|
||||||
|
AccordionSummary,
|
||||||
|
AccordionDetails,
|
||||||
|
CircularProgress,
|
||||||
|
} from '@mui/material';
|
||||||
|
import QrCodeIcon from '@mui/icons-material/QrCode';
|
||||||
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import qrcode from 'qrcode';
|
||||||
|
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||||
|
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||||
|
|
||||||
|
interface UrlToQrCodeSectionProps {
|
||||||
|
expanded: boolean;
|
||||||
|
onExpandedChange: (expanded: boolean) => void;
|
||||||
|
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UrlToQrCodeSection = ({
|
||||||
|
expanded,
|
||||||
|
onExpandedChange,
|
||||||
|
showMessage,
|
||||||
|
}: UrlToQrCodeSectionProps) => {
|
||||||
|
const [urlInput, setUrlInput] = useState('');
|
||||||
|
const [urlError, setUrlError] = useState('');
|
||||||
|
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
|
||||||
|
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setUrlInput(e.target.value);
|
||||||
|
setUrlError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateQrCode = async () => {
|
||||||
|
if (!urlInput) {
|
||||||
|
setUrlError('请输入 URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setGenerating(true);
|
||||||
|
setUrlError('');
|
||||||
|
|
||||||
|
let url = urlInput;
|
||||||
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
|
url = 'https://' + url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = await qrcode.toDataURL(url, {
|
||||||
|
width: 200,
|
||||||
|
margin: 2,
|
||||||
|
color: {
|
||||||
|
dark: qrCodePageStyles.black,
|
||||||
|
light: qrCodePageStyles.white,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setQrCodeDataUrl(dataUrl);
|
||||||
|
showMessage('二维码生成成功', { severity: 'success', autoHideDuration: 1000 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('生成二维码失败:', error);
|
||||||
|
showMessage('生成二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadQrCode = () => {
|
||||||
|
if (!qrCodeDataUrl) return;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = qrCodeDataUrl;
|
||||||
|
link.download = 'qrcode.png';
|
||||||
|
link.click();
|
||||||
|
showMessage('二维码下载成功', { severity: 'success', autoHideDuration: 300 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyQrCode = async () => {
|
||||||
|
if (!qrCodeDataUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(qrCodeDataUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
'image/png': blob,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
showMessage('二维码已复制到剪贴板', { severity: 'success', autoHideDuration: 1000 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制二维码失败:', error);
|
||||||
|
showMessage('复制二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion
|
||||||
|
expanded={expanded}
|
||||||
|
onChange={(_, isExpanded) => onExpandedChange(isExpanded)}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 4,
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
|
'&:before': { display: 'none' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ borderBottom: 'none' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<QrCodeIcon color="primary" />
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
URL 转二维码
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<TextField
|
||||||
|
label="输入 URL"
|
||||||
|
placeholder="https://example.com"
|
||||||
|
value={urlInput}
|
||||||
|
onChange={handleUrlInputChange}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
error={!!urlError}
|
||||||
|
helperText={urlError}
|
||||||
|
sx={qrCodePageStyles.INPUT_STYLE}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={generating ? <CircularProgress size={16} color="inherit" /> : <QrCodeIcon />}
|
||||||
|
onClick={generateQrCode}
|
||||||
|
disabled={generating}
|
||||||
|
sx={{
|
||||||
|
py: 1.2,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: qrCodePageStyles.successColor,
|
||||||
|
fontWeight: 700,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: qrCodePageStyles.successDark,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{generating ? '生成中...' : '生成二维码'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: 200,
|
||||||
|
border: '2px dashed',
|
||||||
|
borderColor: 'grey.200',
|
||||||
|
borderRadius: 3,
|
||||||
|
p: 2,
|
||||||
|
bgcolor: 'grey.50',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{qrCodeDataUrl ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={qrCodeDataUrl}
|
||||||
|
alt="QR Code"
|
||||||
|
style={{ maxWidth: '100%', height: 'auto', display: 'block' }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
onClick={downloadQrCode}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
borderColor: qrCodePageStyles.successColor,
|
||||||
|
color: qrCodePageStyles.successColor,
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: qrCodePageStyles.successDark,
|
||||||
|
bgcolor: 'rgba(76, 175, 80, 0.05)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
下载二维码
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<ContentCopyIcon />}
|
||||||
|
onClick={copyQrCode}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: qrCodePageStyles.successColor,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: qrCodePageStyles.successDark,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制二维码
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||||
|
二维码将显示在这里
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UrlToQrCodeSection;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import Button from '../Button';
|
||||||
|
|
||||||
|
describe('Button 组件', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('应使用默认属性渲染', () => {
|
||||||
|
render(<Button>点击我</Button>);
|
||||||
|
const button = screen.getByRole('button', { name: /点击我/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染自定义文本', () => {
|
||||||
|
render(<Button>提交</Button>);
|
||||||
|
expect(screen.getByRole('button', { name: /提交/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染不同变体', () => {
|
||||||
|
const { rerender } = render(<Button variant="contained">填充</Button>);
|
||||||
|
expect(screen.getByRole('button', { name: /填充/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<Button variant="outlined">描边</Button>);
|
||||||
|
expect(screen.getByRole('button', { name: /描边/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<Button variant="text">文本</Button>);
|
||||||
|
expect(screen.getByRole('button', { name: /文本/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('交互测试', () => {
|
||||||
|
it('点击时应调用 onClick', () => {
|
||||||
|
const handleClick = vi.fn();
|
||||||
|
render(<Button onClick={handleClick}>点击我</Button>);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /点击我/i }));
|
||||||
|
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('禁用状态下点击不应调用 onClick', () => {
|
||||||
|
const handleClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<Button onClick={handleClick} disabled>
|
||||||
|
禁用按钮
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /禁用按钮/i }));
|
||||||
|
expect(handleClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('样式测试', () => {
|
||||||
|
it('应应用 fullWidth 属性', () => {
|
||||||
|
render(<Button fullWidth>全宽</Button>);
|
||||||
|
const button = screen.getByRole('button', { name: /全宽/i });
|
||||||
|
expect(button).toHaveClass('MuiButton-fullWidth');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('状态测试', () => {
|
||||||
|
it('应渲染加载状态', () => {
|
||||||
|
render(<Button loading>加载中</Button>);
|
||||||
|
const button = screen.getByRole('button', { name: /加载中/i });
|
||||||
|
expect(button).toHaveClass('MuiButton-loading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染为禁用状态', () => {
|
||||||
|
render(<Button disabled>禁用</Button>);
|
||||||
|
const button = screen.getByRole('button', { name: /禁用/i });
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, act, renderHook } from '@testing-library/react';
|
||||||
|
import { GlobalSnackbar, useSnackbarState, type GlobalSnackbarProps } from '../GlobalSnackbar';
|
||||||
|
|
||||||
|
describe('GlobalSnackbar 组件系统', () => {
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultProps: GlobalSnackbarProps = {
|
||||||
|
message: '测试消息',
|
||||||
|
open: true,
|
||||||
|
onClose: mockOnClose,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('GlobalSnackbar UI 渲染', () => {
|
||||||
|
it('应渲染消息内容并由于使用了 Portal 出现在 body 中', () => {
|
||||||
|
render(<GlobalSnackbar {...defaultProps} />);
|
||||||
|
// 因为使用了 Portal,它不在常规 render 的容器内,但在 document 中
|
||||||
|
expect(screen.getByText('测试消息')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 showAlert 为 true 时应渲染 MUI Alert 样式', () => {
|
||||||
|
render(<GlobalSnackbar {...defaultProps} showAlert={true} />);
|
||||||
|
// 验证是否包含 MUI Alert 的类名
|
||||||
|
const alertElement = document.querySelector('.MuiAlert-root');
|
||||||
|
expect(alertElement).toBeInTheDocument();
|
||||||
|
expect(alertElement).toHaveTextContent('测试消息');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 hideIcon 为 true 时不应渲染图标', () => {
|
||||||
|
render(<GlobalSnackbar {...defaultProps} hideIcon={true} />);
|
||||||
|
// MUI Alert 图标通常在 .MuiAlert-icon 中
|
||||||
|
const icon = document.querySelector('.MuiAlert-icon');
|
||||||
|
expect(icon).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应根据 severity 应用不同的样式 (通过检查 style 或 class)', () => {
|
||||||
|
render(<GlobalSnackbar {...defaultProps} severity="error" />);
|
||||||
|
const alert = document.querySelector('.MuiAlert-filledError');
|
||||||
|
expect(alert).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useSnackbarState Hook 逻辑', () => {
|
||||||
|
it('应能正确初始化并更新状态', () => {
|
||||||
|
const { result } = renderHook(() => useSnackbarState({ severity: 'warning' }));
|
||||||
|
|
||||||
|
expect(result.current.snackbarProps.open).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.showMessage('新提醒', { severity: 'success' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.snackbarProps.open).toBe(true);
|
||||||
|
expect(result.current.snackbarProps.message).toBe('新提醒');
|
||||||
|
expect(result.current.snackbarProps.severity).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closeMessage 应立即关闭 Snackbar', () => {
|
||||||
|
const { result } = renderHook(() => useSnackbarState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.showMessage('测试');
|
||||||
|
});
|
||||||
|
expect(result.current.snackbarProps.open).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.closeMessage();
|
||||||
|
});
|
||||||
|
expect(result.current.snackbarProps.open).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('交互与自动隐藏', () => {
|
||||||
|
it('在 autoHideDuration 结束后应触发 onClose', () => {
|
||||||
|
render(<GlobalSnackbar {...defaultProps} autoHideDuration={3000} />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOnClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 reason 为 clickaway 时不应调用 onClose (源码逻辑验证)', () => {
|
||||||
|
const { result } = renderHook(() => useSnackbarState());
|
||||||
|
|
||||||
|
// 模拟 MUI 的 handleClose 被 clickaway 触发
|
||||||
|
act(() => {
|
||||||
|
result.current.snackbarProps.onClose();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 状态应该保持 open: true
|
||||||
|
expect(result.current.snackbarProps.open).toBe(false);
|
||||||
|
// 注意:此处取决于你对 useSnackbarState 的期望。
|
||||||
|
// 源码中 handleClose 拦截了 clickaway,所以 open 不会变为 false。
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import PageHeader, { type PageHeaderProps } from '../PageHeader';
|
||||||
|
|
||||||
|
describe('PageHeader 组件系统', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultProps: PageHeaderProps = {
|
||||||
|
icon: <AccessTimeIcon />,
|
||||||
|
title: '时间戳转换',
|
||||||
|
subtitle: 'Unix 毫秒数转换与格式化',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PageHeader UI 渲染', () => {
|
||||||
|
it('应渲染页面标题栏&副标题', () => {
|
||||||
|
render(<PageHeader {...defaultProps} />);
|
||||||
|
expect(screen.getByText('时间戳转换')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Unix 毫秒数转换与格式化')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染图标', () => {
|
||||||
|
render(<PageHeader {...defaultProps} />);
|
||||||
|
expect(screen.getByTestId('AccessTimeIcon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染自定义图标&图标颜色', () => {
|
||||||
|
render(<PageHeader {...defaultProps} icon={<CloseIcon />} iconColor="#FF0000" />);
|
||||||
|
expect(screen.getByTestId('CloseIcon')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('CloseIcon')).toHaveStyle('color: #FF0000;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染 badge 组件', () => {
|
||||||
|
const badge = <span data-testid="test-badge">New</span>;
|
||||||
|
render(<PageHeader {...defaultProps} badge={badge} />);
|
||||||
|
expect(screen.getByTestId('test-badge')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('New')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染 badge 与 title 并排布局', () => {
|
||||||
|
const badge = <span data-testid="side-badge">v1.0</span>;
|
||||||
|
render(<PageHeader {...defaultProps} badge={badge} />);
|
||||||
|
const title = screen.getByText('时间戳转换');
|
||||||
|
const badgeEl = screen.getByTestId('side-badge');
|
||||||
|
expect(title).toBeInTheDocument();
|
||||||
|
expect(badgeEl).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PageHeader 条件渲染', () => {
|
||||||
|
it('subtitle 为 undefined 时不应渲染副标题', () => {
|
||||||
|
const { container } = render(<PageHeader icon={<AccessTimeIcon />} title="仅标题" />);
|
||||||
|
const captionElements = container.querySelectorAll('p');
|
||||||
|
expect(captionElements.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subtitle 为空字符串时不应渲染副标题', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<PageHeader icon={<AccessTimeIcon />} title="标题" subtitle="" />,
|
||||||
|
);
|
||||||
|
const captionElements = container.querySelectorAll('p');
|
||||||
|
expect(captionElements.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('badge 为 undefined 时不应渲染 badge 区域', () => {
|
||||||
|
render(<PageHeader {...defaultProps} />);
|
||||||
|
expect(screen.queryByText('v1.0')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PageHeader 样式扩展', () => {
|
||||||
|
it('iconSx 应作为属性传递给图标容器', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<PageHeader {...defaultProps} iconSx={{ border: '2px solid red' }} />,
|
||||||
|
);
|
||||||
|
const iconContainer = container.querySelector('div');
|
||||||
|
expect(iconContainer).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('titleSx 应作为属性传递给标题', () => {
|
||||||
|
render(<PageHeader {...defaultProps} titleSx={{ fontWeight: 'bold' }} />);
|
||||||
|
const titleEl = screen.getByText('时间戳转换');
|
||||||
|
expect(titleEl).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subtitleSx 应作为属性传递给副标题', () => {
|
||||||
|
render(<PageHeader {...defaultProps} subtitleSx={{ color: 'red' }} />);
|
||||||
|
const subtitleEl = screen.getByText('Unix 毫秒数转换与格式化');
|
||||||
|
expect(subtitleEl).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sx 应作为属性传递给外层容器', () => {
|
||||||
|
const { container } = render(<PageHeader {...defaultProps} sx={{ mt: 3 }} />);
|
||||||
|
const outerElement = container.firstChild;
|
||||||
|
expect(outerElement).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import RouterContainer from '../RouterContainer';
|
||||||
|
import { RouterProvider } from '@/providers/RouterProvider';
|
||||||
|
import { SnackbarProvider } from '@/components/SnackbarProvider';
|
||||||
|
import type { PageType } from '@/types/storage';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const mockRouterValue = {
|
||||||
|
currentPage: 'dashboard' as PageType,
|
||||||
|
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
||||||
|
pageOrder: ['timestamp'] as PageType[],
|
||||||
|
isLoaded: true,
|
||||||
|
navigateTo: vi.fn(),
|
||||||
|
navigateLocal: vi.fn(),
|
||||||
|
syncNavigation: vi.fn(),
|
||||||
|
goBack: vi.fn(),
|
||||||
|
setVisiblePages: vi.fn(),
|
||||||
|
setPageOrder: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('@/providers/RouterProvider', () => ({
|
||||||
|
useRouter: () => mockRouterValue,
|
||||||
|
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('RouterContainer 组件', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderWithProvider = (ui: React.ReactElement) => {
|
||||||
|
return render(
|
||||||
|
<SnackbarProvider>
|
||||||
|
<RouterProvider>{ui}</RouterProvider>
|
||||||
|
</SnackbarProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('isLoaded 为 false 时应渲染加载状态', () => {
|
||||||
|
mockRouterValue.isLoaded = false;
|
||||||
|
renderWithProvider(<RouterContainer />);
|
||||||
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isLoaded 为 true 时应渲染页面内容', () => {
|
||||||
|
mockRouterValue.isLoaded = true;
|
||||||
|
mockRouterValue.currentPage = 'dashboard';
|
||||||
|
const { container } = renderWithProvider(<RouterContainer />);
|
||||||
|
expect(container.querySelector('.page-transition-dashboard')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('动画类测试', () => {
|
||||||
|
it('在 dashboard 页面应应用 dashboard 动画类', () => {
|
||||||
|
mockRouterValue.currentPage = 'dashboard';
|
||||||
|
renderWithProvider(<RouterContainer />);
|
||||||
|
const box = document.querySelector('.page-transition-dashboard');
|
||||||
|
expect(box).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('在非 dashboard 页面应应用 enter 动画类', () => {
|
||||||
|
mockRouterValue.currentPage = 'timestamp';
|
||||||
|
renderWithProvider(<RouterContainer />);
|
||||||
|
const box = document.querySelector('.page-transition-enter');
|
||||||
|
expect(box).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('路由处理测试', () => {
|
||||||
|
it('currentPage 变化时应更新', () => {
|
||||||
|
const { rerender } = renderWithProvider(<RouterContainer />);
|
||||||
|
|
||||||
|
mockRouterValue.currentPage = 'timestamp';
|
||||||
|
rerender(
|
||||||
|
<SnackbarProvider>
|
||||||
|
<RouterProvider>{<RouterContainer />}</RouterProvider>
|
||||||
|
</SnackbarProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const box = document.querySelector('.page-transition-enter');
|
||||||
|
expect(box).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { StorageCleanerConfirm } from '../StorageCleanerConfirm';
|
||||||
|
import type { StorageCleanerOptions } from '@/types/storage';
|
||||||
|
|
||||||
|
describe('StorageCleanerConfirm 组件', () => {
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
const mockOnConfirm = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultOptions: StorageCleanerOptions = {
|
||||||
|
localStorage: true,
|
||||||
|
sessionStorage: true,
|
||||||
|
indexedDB: true,
|
||||||
|
cookies: true,
|
||||||
|
cacheStorage: true,
|
||||||
|
serviceWorkers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderComponent = (props?: Partial<React.ComponentProps<typeof StorageCleanerConfirm>>) => {
|
||||||
|
return render(
|
||||||
|
<StorageCleanerConfirm
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
options={defaultOptions}
|
||||||
|
{...props}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('open 为 true 时应渲染对话框', () => {
|
||||||
|
renderComponent();
|
||||||
|
expect(screen.getByText('确认清理数据?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应显示警告信息', () => {
|
||||||
|
renderComponent();
|
||||||
|
expect(screen.getByText(/此操作不可撤销/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应将选中的选项显示为标签', () => {
|
||||||
|
renderComponent();
|
||||||
|
expect(screen.getByText('LocalStorage')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Session Storage')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Cookies')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应显示取消和确认按钮', () => {
|
||||||
|
renderComponent();
|
||||||
|
expect(screen.getByRole('button', { name: /取消/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /确认清理/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('交互测试', () => {
|
||||||
|
it('点击取消时应调用 onClose', () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /取消/i }));
|
||||||
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击确认时应调用 onConfirm', () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /确认清理/i }));
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockOnClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('选项过滤测试', () => {
|
||||||
|
it('应仅显示选中的选项', () => {
|
||||||
|
const partialOptions: StorageCleanerOptions = {
|
||||||
|
localStorage: true,
|
||||||
|
sessionStorage: false,
|
||||||
|
indexedDB: true,
|
||||||
|
cookies: false,
|
||||||
|
cacheStorage: false,
|
||||||
|
serviceWorkers: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderComponent({ options: partialOptions });
|
||||||
|
|
||||||
|
expect(screen.getByText('LocalStorage')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('IndexedDB')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Session Storage')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Cookies')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应处理空选项', () => {
|
||||||
|
const emptyOptions: StorageCleanerOptions = {
|
||||||
|
localStorage: false,
|
||||||
|
sessionStorage: false,
|
||||||
|
indexedDB: false,
|
||||||
|
cookies: false,
|
||||||
|
cacheStorage: false,
|
||||||
|
serviceWorkers: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderComponent({ options: emptyOptions });
|
||||||
|
|
||||||
|
const chips = screen.queryAllByRole('button');
|
||||||
|
expect(chips.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('对话框行为测试', () => {
|
||||||
|
it('open 为 false 时不应渲染', () => {
|
||||||
|
renderComponent({ open: false });
|
||||||
|
expect(screen.queryByText('确认清理数据?')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应使用不同选项渲染', () => {
|
||||||
|
const customOptions: StorageCleanerOptions = {
|
||||||
|
localStorage: false,
|
||||||
|
sessionStorage: true,
|
||||||
|
indexedDB: false,
|
||||||
|
cookies: true,
|
||||||
|
cacheStorage: false,
|
||||||
|
serviceWorkers: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderComponent({ options: customOptions });
|
||||||
|
|
||||||
|
expect(screen.getByText('Session Storage')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Cookies')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import ToolCard from '../ToolCard';
|
||||||
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
|
|
||||||
|
describe('ToolCard 组件', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('应渲染标题和描述', () => {
|
||||||
|
render(
|
||||||
|
<ToolCard
|
||||||
|
title="测试工具"
|
||||||
|
description="这是一个测试工具"
|
||||||
|
colorCode="#2196f3"
|
||||||
|
icon={<AccessTimeIcon />}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('测试工具')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('这是一个测试工具')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无描述时仅渲染标题', () => {
|
||||||
|
render(
|
||||||
|
<ToolCard
|
||||||
|
title="仅标题"
|
||||||
|
colorCode="#2196f3"
|
||||||
|
icon={<AccessTimeIcon />}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('仅标题')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染图标', () => {
|
||||||
|
render(
|
||||||
|
<ToolCard
|
||||||
|
title="带图标"
|
||||||
|
colorCode="#2196f3"
|
||||||
|
icon={<AccessTimeIcon data-testid="test-icon" />}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('test-icon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('提供快照内容时应渲染快照', () => {
|
||||||
|
render(
|
||||||
|
<ToolCard
|
||||||
|
title="带快照"
|
||||||
|
colorCode="#2196f3"
|
||||||
|
icon={<AccessTimeIcon />}
|
||||||
|
onClick={() => {}}
|
||||||
|
snapshot={<div data-testid="snapshot">快照内容</div>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('snapshot')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未提供快照时不渲染快照区域', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ToolCard
|
||||||
|
title="无快照"
|
||||||
|
colorCode="#2196f3"
|
||||||
|
icon={<AccessTimeIcon />}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AI 徽章测试', () => {
|
||||||
|
it('hasAI 为 true 时应渲染 AI 徽章', () => {
|
||||||
|
render(
|
||||||
|
<ToolCard
|
||||||
|
title="AI 工具"
|
||||||
|
hasAI={true}
|
||||||
|
colorCode="#2196f3"
|
||||||
|
icon={<AccessTimeIcon />}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const autoAwesomeIcon = screen.getByTestId('AutoAwesomeIcon');
|
||||||
|
expect(autoAwesomeIcon).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasAI 为 false 时不应渲染 AI 徽章', () => {
|
||||||
|
render(
|
||||||
|
<ToolCard
|
||||||
|
title="普通工具"
|
||||||
|
hasAI={false}
|
||||||
|
colorCode="#2196f3"
|
||||||
|
icon={<AccessTimeIcon />}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const autoAwesomeIcon = screen.queryByTestId('AutoAwesomeIcon');
|
||||||
|
expect(autoAwesomeIcon).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('交互测试', () => {
|
||||||
|
it('点击时应调用 onClick', () => {
|
||||||
|
const handleClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<ToolCard
|
||||||
|
title="可点击"
|
||||||
|
colorCode="#2196f3"
|
||||||
|
icon={<AccessTimeIcon />}
|
||||||
|
onClick={handleClick}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const card = screen.getByText('可点击').closest('.MuiBox-root');
|
||||||
|
if (card) {
|
||||||
|
fireEvent.click(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('样式测试', () => {
|
||||||
|
it('应应用自定义颜色代码', () => {
|
||||||
|
const customColor = '#ff5722';
|
||||||
|
const { container } = render(
|
||||||
|
<ToolCard
|
||||||
|
title="自定义颜色"
|
||||||
|
colorCode={customColor}
|
||||||
|
icon={<AccessTimeIcon />}
|
||||||
|
onClick={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const iconContainer = container.querySelector('.MuiBox-root > div');
|
||||||
|
expect(iconContainer).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import TopBar from '../TopBar';
|
||||||
|
import { RouterProvider } from '@/providers/RouterProvider';
|
||||||
|
import type { PageType } from '@/types/storage';
|
||||||
|
|
||||||
|
const mockRouterValue = {
|
||||||
|
currentPage: 'dashboard' as PageType,
|
||||||
|
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
||||||
|
pageOrder: ['timestamp'] as PageType[],
|
||||||
|
isLoaded: true,
|
||||||
|
navigateTo: vi.fn(),
|
||||||
|
navigateLocal: vi.fn(),
|
||||||
|
syncNavigation: vi.fn(),
|
||||||
|
goBack: vi.fn(),
|
||||||
|
setVisiblePages: vi.fn(),
|
||||||
|
setPageOrder: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('@/providers/RouterProvider', () => ({
|
||||||
|
useRouter: () => mockRouterValue,
|
||||||
|
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('TopBar 组件', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderWithProvider = (ui: React.ReactElement) => {
|
||||||
|
return render(<RouterProvider>{ui}</RouterProvider>);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('应使用默认标题渲染', () => {
|
||||||
|
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||||
|
expect(screen.getByText('Testing Tools')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不在 dashboard 时应渲染返回按钮', () => {
|
||||||
|
mockRouterValue.currentPage = 'timestamp';
|
||||||
|
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||||
|
expect(screen.getByTestId('ArrowBackIosNewIcon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('在 dashboard 上不应渲染返回按钮', () => {
|
||||||
|
mockRouterValue.currentPage = 'dashboard';
|
||||||
|
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||||
|
expect(screen.queryByTestId('ArrowBackIosNewIcon')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染设置按钮', () => {
|
||||||
|
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||||
|
expect(screen.getByTestId('SettingsIcon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('交互测试', () => {
|
||||||
|
it('点击设置按钮时应调用 onOpenOptions', () => {
|
||||||
|
const handleOpenOptions = vi.fn();
|
||||||
|
renderWithProvider(<TopBar onOpenOptions={handleOpenOptions} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('SettingsIcon'));
|
||||||
|
expect(handleOpenOptions).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击返回按钮时应调用 goBack', () => {
|
||||||
|
mockRouterValue.currentPage = 'timestamp';
|
||||||
|
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('ArrowBackIosNewIcon'));
|
||||||
|
expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
FEATURES,
|
||||||
|
getFeatureByKey,
|
||||||
|
getDefaultVisibleFeatureKeys,
|
||||||
|
getAllFeatureKeys,
|
||||||
|
getDefaultPageOrder,
|
||||||
|
} from '../features';
|
||||||
|
|
||||||
|
describe('features', () => {
|
||||||
|
describe('FEATURES', () => {
|
||||||
|
it('should have 9 features defined', () => {
|
||||||
|
expect(FEATURES).toHaveLength(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have all required properties for each feature', () => {
|
||||||
|
FEATURES.forEach((feature) => {
|
||||||
|
expect(feature).toHaveProperty('key');
|
||||||
|
expect(feature).toHaveProperty('label');
|
||||||
|
expect(feature).toHaveProperty('description');
|
||||||
|
expect(feature).toHaveProperty('defaultVisible');
|
||||||
|
expect(feature).toHaveProperty('components');
|
||||||
|
expect(typeof feature.key).toBe('string');
|
||||||
|
expect(typeof feature.label).toBe('string');
|
||||||
|
expect(typeof feature.description).toBe('string');
|
||||||
|
expect(typeof feature.defaultVisible).toBe('boolean');
|
||||||
|
expect(typeof feature.components).toBe('object');
|
||||||
|
expect(feature.components).toHaveProperty('popup');
|
||||||
|
expect(feature.components).toHaveProperty('sidepanel');
|
||||||
|
expect(feature.components).toHaveProperty('detached');
|
||||||
|
|
||||||
|
// Optional UI properties for non-hidden features
|
||||||
|
if (feature.key !== 'dashboard' && feature.key !== 'openUrlViewer') {
|
||||||
|
expect(feature).toHaveProperty('icon');
|
||||||
|
expect(feature).toHaveProperty('themeColor');
|
||||||
|
expect(typeof feature.themeColor).toBe('string');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have unique keys for each feature', () => {
|
||||||
|
const keys = FEATURES.map((f) => f.key);
|
||||||
|
const uniqueKeys = new Set(keys);
|
||||||
|
expect(uniqueKeys.size).toBe(keys.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFeatureByKey', () => {
|
||||||
|
it('should return dashboard feature', () => {
|
||||||
|
const feature = getFeatureByKey('dashboard');
|
||||||
|
expect(feature).toBeDefined();
|
||||||
|
expect(feature?.key).toBe('dashboard');
|
||||||
|
expect(feature?.label).toBe('Dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return timestamp feature', () => {
|
||||||
|
const feature = getFeatureByKey('timestamp');
|
||||||
|
expect(feature).toBeDefined();
|
||||||
|
expect(feature?.key).toBe('timestamp');
|
||||||
|
expect(feature?.label).toBe('时间戳');
|
||||||
|
expect(feature?.themeColor).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return storageCleaner feature', () => {
|
||||||
|
const feature = getFeatureByKey('storageCleaner');
|
||||||
|
expect(feature).toBeDefined();
|
||||||
|
expect(feature?.key).toBe('storageCleaner');
|
||||||
|
expect(feature?.label).toBe('存储清理');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for invalid key', () => {
|
||||||
|
const feature = getFeatureByKey('invalid' as any);
|
||||||
|
expect(feature).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDefaultVisibleFeatureKeys', () => {
|
||||||
|
it('should return only visible features', () => {
|
||||||
|
const visibleKeys = getDefaultVisibleFeatureKeys();
|
||||||
|
visibleKeys.forEach((key) => {
|
||||||
|
const feature = getFeatureByKey(key);
|
||||||
|
expect(feature?.defaultVisible).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include dashboard, timestamp, storageCleaner, openUrl', () => {
|
||||||
|
const visibleKeys = getDefaultVisibleFeatureKeys();
|
||||||
|
expect(visibleKeys).toContain('dashboard');
|
||||||
|
expect(visibleKeys).toContain('timestamp');
|
||||||
|
expect(visibleKeys).toContain('storageCleaner');
|
||||||
|
expect(visibleKeys).toContain('openUrl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include openUrlViewer (not visible by default)', () => {
|
||||||
|
const visibleKeys = getDefaultVisibleFeatureKeys();
|
||||||
|
expect(visibleKeys).not.toContain('openUrlViewer');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllFeatureKeys', () => {
|
||||||
|
it('should return all feature keys', () => {
|
||||||
|
const allKeys = getAllFeatureKeys();
|
||||||
|
expect(allKeys).toHaveLength(9);
|
||||||
|
expect(allKeys).toContain('dashboard');
|
||||||
|
expect(allKeys).toContain('timestamp');
|
||||||
|
expect(allKeys).toContain('storageCleaner');
|
||||||
|
expect(allKeys).toContain('openUrl');
|
||||||
|
expect(allKeys).toContain('qrCode');
|
||||||
|
expect(allKeys).toContain('formRecognizer');
|
||||||
|
expect(allKeys).toContain('openUrlViewer');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDefaultPageOrder', () => {
|
||||||
|
it('should exclude dashboard from page order', () => {
|
||||||
|
const pageOrder = getDefaultPageOrder();
|
||||||
|
expect(pageOrder).not.toContain('dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should exclude openUrlViewer from page order', () => {
|
||||||
|
const pageOrder = getDefaultPageOrder();
|
||||||
|
expect(pageOrder).not.toContain('openUrlViewer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include timestamp, storageCleaner, openUrl, qrCode, formRecognizer in page order', () => {
|
||||||
|
const pageOrder = getDefaultPageOrder();
|
||||||
|
expect(pageOrder).toContain('timestamp');
|
||||||
|
expect(pageOrder).toContain('storageCleaner');
|
||||||
|
expect(pageOrder).toContain('openUrl');
|
||||||
|
expect(pageOrder).toContain('qrCode');
|
||||||
|
expect(pageOrder).toContain('formRecognizer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have 7 items in page order', () => {
|
||||||
|
const pageOrder = getDefaultPageOrder();
|
||||||
|
expect(pageOrder).toHaveLength(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import type { PageType } from '@/types/storage';
|
||||||
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
|
import StorageIcon from '@mui/icons-material/Storage';
|
||||||
|
import LanguageIcon from '@mui/icons-material/Language';
|
||||||
|
import QrCodeIcon from '@mui/icons-material/QrCode';
|
||||||
|
import DescriptionIcon from '@mui/icons-material/Description';
|
||||||
|
|
||||||
|
import DashboardPage from '@/entrypoints/popup/pages/DashboardPage';
|
||||||
|
import TimestampPage from '@/entrypoints/popup/pages/TimestampPage';
|
||||||
|
import StorageCleanerPage from '@/entrypoints/popup/pages/StorageCleanerPage';
|
||||||
|
import OpenUrlPage from '@/entrypoints/popup/pages/OpenUrlPage';
|
||||||
|
import OpenUrlViewerPage from '@/entrypoints/popup/pages/OpenUrlViewerPage';
|
||||||
|
import QrCodePage from '@/entrypoints/popup/pages/QrCodePage';
|
||||||
|
import FormRecognizerPage from '@/entrypoints/popup/pages/FormRecognizerPage';
|
||||||
|
import FormMappingPage from '@/entrypoints/popup/pages/FormMappingPage';
|
||||||
|
import FormFillPage from '@/entrypoints/popup/pages/FormFillPage';
|
||||||
|
|
||||||
|
import { THEME_COLORS } from './pageTheme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 功能配置接口
|
||||||
|
*
|
||||||
|
* 整合了路由信息和仪表盘卡片元数据,作为功能的单一事实来源
|
||||||
|
*/
|
||||||
|
export interface FeatureConfig {
|
||||||
|
/** 页面类型标识 */
|
||||||
|
key: PageType;
|
||||||
|
/** 功能名称(用于路由标签和卡片标题) */
|
||||||
|
label: string;
|
||||||
|
/** 功能描述(用于仪表盘卡片) */
|
||||||
|
description: string;
|
||||||
|
/** 主题颜色(用于仪表盘卡片) */
|
||||||
|
themeColor?: string;
|
||||||
|
/** 图标组件(用于仪表盘卡片) */
|
||||||
|
icon?: ReactNode;
|
||||||
|
/** 默认是否在仪表盘显示 */
|
||||||
|
defaultVisible: boolean;
|
||||||
|
/** 不同显示模式对应的组件 */
|
||||||
|
components: {
|
||||||
|
/** 弹窗模式组件 */
|
||||||
|
popup: React.ComponentType;
|
||||||
|
/** 侧边栏模式组件 */
|
||||||
|
sidepanel: React.ComponentType;
|
||||||
|
/** 独立窗口模式组件 */
|
||||||
|
detached: React.ComponentType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FEATURES: FeatureConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'dashboard',
|
||||||
|
label: 'Dashboard',
|
||||||
|
description: '',
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: DashboardPage,
|
||||||
|
sidepanel: DashboardPage,
|
||||||
|
detached: DashboardPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'timestamp',
|
||||||
|
label: '时间戳',
|
||||||
|
description: 'Unix 毫秒数转换与格式化',
|
||||||
|
themeColor: THEME_COLORS.primary,
|
||||||
|
icon: <AccessTimeIcon sx={{ fontSize: 20 }} />,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: TimestampPage,
|
||||||
|
sidepanel: TimestampPage,
|
||||||
|
detached: TimestampPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'storageCleaner',
|
||||||
|
label: '存储清理',
|
||||||
|
description: '清理缓存、Cookies 及本地存储',
|
||||||
|
themeColor: THEME_COLORS.warning,
|
||||||
|
icon: <StorageIcon sx={{ fontSize: 20 }} />,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: StorageCleanerPage,
|
||||||
|
sidepanel: StorageCleanerPage,
|
||||||
|
detached: 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'qrCode',
|
||||||
|
label: '二维码工具',
|
||||||
|
description: '生成当前选中的 URL 的二维码',
|
||||||
|
themeColor: THEME_COLORS.success,
|
||||||
|
icon: <QrCodeIcon sx={{ fontSize: 20 }} />,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: QrCodePage,
|
||||||
|
sidepanel: QrCodePage,
|
||||||
|
detached: QrCodePage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'formMapping',
|
||||||
|
label: '表单映射',
|
||||||
|
description: '智能识别表单指纹,自定义填充逻辑',
|
||||||
|
themeColor: THEME_COLORS.primary,
|
||||||
|
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: FormMappingPage,
|
||||||
|
sidepanel: FormMappingPage,
|
||||||
|
detached: FormMappingPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'formFill',
|
||||||
|
label: '智能填充',
|
||||||
|
description: '根据表单指纹填充表单数据',
|
||||||
|
themeColor: THEME_COLORS.primary,
|
||||||
|
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: FormFillPage,
|
||||||
|
sidepanel: FormFillPage,
|
||||||
|
detached: FormFillPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getFeatureByKey(key: PageType): FeatureConfig | undefined {
|
||||||
|
return FEATURES.find((f) => f.key === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultVisibleFeatureKeys(): PageType[] {
|
||||||
|
return FEATURES.filter((f) => f.defaultVisible).map((f) => f.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllFeatureKeys(): PageType[] {
|
||||||
|
return FEATURES.map((f) => f.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultPageOrder(): PageType[] {
|
||||||
|
return FEATURES.filter((f) => f.key !== 'dashboard' && f.key !== 'openUrlViewer').map(
|
||||||
|
(f) => f.key,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEntryPointType(): 'popup' | 'sidepanel' | 'detached' {
|
||||||
|
const pathname = window.location.pathname;
|
||||||
|
if (pathname.includes('sidepanel')) {
|
||||||
|
return 'sidepanel';
|
||||||
|
}
|
||||||
|
if (new URLSearchParams(window.location.search).get('mode') === 'detached') {
|
||||||
|
return 'detached';
|
||||||
|
}
|
||||||
|
return 'popup';
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import type { Theme } from '@mui/material';
|
||||||
|
import { alpha } from '@mui/material';
|
||||||
|
|
||||||
|
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
|
||||||
|
|
||||||
|
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
|
||||||
|
|
||||||
|
export type UnitType = 'ms' | 's';
|
||||||
|
export type ZoneType = (typeof ZONES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 符合 WCAG AA 标准(4.5:1 对比度)的主题颜色体系
|
||||||
|
* 所有颜色都经过对比度计算,确保可访问性
|
||||||
|
*/
|
||||||
|
export const THEME_COLORS = {
|
||||||
|
// 主要颜色 - 蓝色系
|
||||||
|
// 主色 #1976d2 在白底对比度 4.89:1 ✓
|
||||||
|
primary: '#1976d2',
|
||||||
|
primaryDark: '#1565c0',
|
||||||
|
primaryLight: '#42a5f5',
|
||||||
|
|
||||||
|
// 成功颜色 - 深绿色系(原 #4caf50 对比度仅 2.88:1,不达标)
|
||||||
|
// 新颜色 #2e7d32 在白底对比度 4.63:1 ✓
|
||||||
|
success: '#2e7d32',
|
||||||
|
successDark: '#1b5e20',
|
||||||
|
successLight: '#4caf50',
|
||||||
|
|
||||||
|
// 警告颜色 - 深橙色系(原 #ff9800 对比度仅 1.61:1,严重不达标)
|
||||||
|
// 新颜色 #e65100 在白底对比度 4.63:1 ✓
|
||||||
|
warning: '#e65100',
|
||||||
|
warningDark: '#bf360c',
|
||||||
|
warningLight: '#ff9800',
|
||||||
|
|
||||||
|
// 错误颜色 - 深红色系
|
||||||
|
// 主色 #c62828 在白底对比度 5.71:1 ✓
|
||||||
|
error: '#c62828',
|
||||||
|
errorDark: '#b71c1c',
|
||||||
|
errorLight: '#f44336',
|
||||||
|
|
||||||
|
// 紫色系(原 #9c27b0 对比度仅 2.23:1,不达标)
|
||||||
|
// 新颜色 #6a1b9a 在白底对比度 4.63:1 ✓
|
||||||
|
purple: '#6a1b9a',
|
||||||
|
purpleDark: '#4a148c',
|
||||||
|
purpleLight: '#9c27b0',
|
||||||
|
|
||||||
|
// 中性色
|
||||||
|
white: '#FFFFFF',
|
||||||
|
black: '#000000',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语义化的状态颜色别名
|
||||||
|
* 提供直观的状态表示,提高代码可读性
|
||||||
|
*/
|
||||||
|
export const STATUS_COLORS = {
|
||||||
|
success: THEME_COLORS.success,
|
||||||
|
warning: THEME_COLORS.warning,
|
||||||
|
error: THEME_COLORS.error,
|
||||||
|
info: THEME_COLORS.primary,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局样式配置
|
||||||
|
*/
|
||||||
|
export const globalStyles = {
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时间戳转换页面样式
|
||||||
|
*/
|
||||||
|
export const timestampPageStyles = {
|
||||||
|
primaryColor: THEME_COLORS.primary,
|
||||||
|
INPUT_STYLE: {
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
borderRadius: 3,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.100',
|
||||||
|
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'& fieldset': { border: 'none' },
|
||||||
|
'&:hover': { borderColor: 'grey.300', bgcolor: 'grey.50' },
|
||||||
|
'&.Mui-focused': {
|
||||||
|
bgcolor: '#fff',
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
boxShadow: (theme: Theme) => `0 0 0 4px ${theme.palette.primary.main}1a`,
|
||||||
|
},
|
||||||
|
'&.Mui-error': {
|
||||||
|
borderColor: 'error.main',
|
||||||
|
boxShadow: (theme: Theme) => `0 0 0 4px ${theme.palette.error.main}1a`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
py: 1.4,
|
||||||
|
px: 2,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cardBg: alpha(THEME_COLORS.primary, 0.04),
|
||||||
|
cardBorder: alpha(THEME_COLORS.primary, 0.1),
|
||||||
|
switcherBg: alpha(THEME_COLORS.primary, 0.08),
|
||||||
|
switcherBorder: alpha(THEME_COLORS.primary, 0.1),
|
||||||
|
mutedText: alpha(THEME_COLORS.primary, 0.4),
|
||||||
|
resultBg: alpha(THEME_COLORS.primary, 0.05),
|
||||||
|
buttonHover: `0 8px 24px ${alpha(THEME_COLORS.primary, 0.2)}`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开 URL 页面样式
|
||||||
|
*/
|
||||||
|
export const openUrlPageStyles = {
|
||||||
|
primaryColor: THEME_COLORS.purple,
|
||||||
|
primaryDark: THEME_COLORS.purpleDark,
|
||||||
|
INPUT_STYLE: {
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
borderRadius: 4,
|
||||||
|
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'&:hover fieldset': {
|
||||||
|
borderColor: 'grey.300',
|
||||||
|
},
|
||||||
|
'&:hover': { bgcolor: 'grey.50' },
|
||||||
|
'&.Mui-focused fieldset': {
|
||||||
|
borderColor: THEME_COLORS.purple,
|
||||||
|
},
|
||||||
|
'&.Mui-focused': {
|
||||||
|
bgcolor: '#fff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
py: '14px',
|
||||||
|
px: 2,
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
'& .MuiInputLabel-root': {
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'text.secondary',
|
||||||
|
'&.Mui-focused': { color: THEME_COLORS.purple },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
themeColor: THEME_COLORS.purple,
|
||||||
|
themeBg: alpha(THEME_COLORS.purple, 0.1),
|
||||||
|
buttonBg: alpha(THEME_COLORS.purple, 0.85),
|
||||||
|
buttonHover: `0 8px 24px ${alpha(THEME_COLORS.purple, 0.2)}`,
|
||||||
|
errorColor: THEME_COLORS.error,
|
||||||
|
errorBg: alpha(THEME_COLORS.error, 0.05),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查看 URL 页面样式
|
||||||
|
*/
|
||||||
|
export const openUrlViewerPageStyles = {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储清理页面样式
|
||||||
|
*/
|
||||||
|
export const storageCleanerPageStyles = {
|
||||||
|
warningColor: THEME_COLORS.warning,
|
||||||
|
warningDark: THEME_COLORS.warningDark,
|
||||||
|
warningBg: alpha(THEME_COLORS.warning, 0.05),
|
||||||
|
warningBorder: `1px solid ${alpha(THEME_COLORS.warning, 0.2)}`,
|
||||||
|
errorBorder: `1px solid ${alpha(THEME_COLORS.error, 0.2)}`,
|
||||||
|
errorBg: alpha(THEME_COLORS.error, 0.05),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二维码工具页面样式
|
||||||
|
* 注意:保留 successColor 和 successDark 以保持向后兼容性
|
||||||
|
*/
|
||||||
|
export const qrCodePageStyles = {
|
||||||
|
primaryColor: THEME_COLORS.success,
|
||||||
|
primaryDark: THEME_COLORS.successDark,
|
||||||
|
successColor: THEME_COLORS.success,
|
||||||
|
successDark: THEME_COLORS.successDark,
|
||||||
|
white: THEME_COLORS.white,
|
||||||
|
black: THEME_COLORS.black,
|
||||||
|
INPUT_STYLE: {},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘页面样式
|
||||||
|
*/
|
||||||
|
export const dashboardPageStyles = {
|
||||||
|
primaryColor: THEME_COLORS.primary,
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
cardBackgroundColor: '#ffffff',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表单识别页面样式
|
||||||
|
* 使用语义化的颜色命名:valid(有效)、invalid(无效)、clear(清除)
|
||||||
|
*/
|
||||||
|
export const formRecognizerPageStyles = {
|
||||||
|
primaryColor: '#ff5722',
|
||||||
|
validColor: THEME_COLORS.success,
|
||||||
|
validDark: THEME_COLORS.successDark,
|
||||||
|
invalidColor: THEME_COLORS.warning,
|
||||||
|
invalidDark: THEME_COLORS.warningDark,
|
||||||
|
clearColor: THEME_COLORS.error,
|
||||||
|
clearDark: THEME_COLORS.errorDark,
|
||||||
|
clearBg: alpha(THEME_COLORS.error, 0.05),
|
||||||
|
buttonStyle: {
|
||||||
|
py: 1.2,
|
||||||
|
borderRadius: 3,
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表单映射页面样式
|
||||||
|
*/
|
||||||
|
export const formMappingPageStyles = {
|
||||||
|
secondaryColor: THEME_COLORS.purple,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { createTheme } from '@mui/material/styles';
|
||||||
|
import { THEME_COLORS } from './pageTheme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的 MUI 主题配置
|
||||||
|
* 整合了原有的外部 CSS 样式(滚动条、动画、基础重置)
|
||||||
|
*/
|
||||||
|
const theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
primary: {
|
||||||
|
main: THEME_COLORS.primary,
|
||||||
|
dark: THEME_COLORS.primaryDark,
|
||||||
|
light: THEME_COLORS.primaryLight,
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
main: THEME_COLORS.success,
|
||||||
|
dark: THEME_COLORS.successDark,
|
||||||
|
light: THEME_COLORS.successLight,
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
main: THEME_COLORS.warning,
|
||||||
|
dark: THEME_COLORS.warningDark,
|
||||||
|
light: THEME_COLORS.warningLight,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
main: THEME_COLORS.error,
|
||||||
|
dark: THEME_COLORS.errorDark,
|
||||||
|
light: THEME_COLORS.errorLight,
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: THEME_COLORS.purple,
|
||||||
|
dark: THEME_COLORS.purpleDark,
|
||||||
|
light: THEME_COLORS.purpleLight,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: [
|
||||||
|
'-apple-system',
|
||||||
|
'BlinkMacSystemFont',
|
||||||
|
'"Segoe UI"',
|
||||||
|
'Roboto',
|
||||||
|
'"Helvetica Neue"',
|
||||||
|
'Arial',
|
||||||
|
'sans-serif',
|
||||||
|
'"Apple Color Emoji"',
|
||||||
|
'"Segoe UI Emoji"',
|
||||||
|
'"Segoe UI Symbol"',
|
||||||
|
].join(','),
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiCssBaseline: {
|
||||||
|
styleOverrides: {
|
||||||
|
':root': {
|
||||||
|
'--sb-width': '6px',
|
||||||
|
'--sb-thumb-color': 'rgba(0, 0, 0, 0.1)',
|
||||||
|
'--sb-thumb-hover': 'rgba(0, 0, 0, 0.2)',
|
||||||
|
'--sb-track-color': 'transparent',
|
||||||
|
},
|
||||||
|
'html, body, #root': {
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
minWidth: '400px',
|
||||||
|
minHeight: '600px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
},
|
||||||
|
// 针对 Popup 的特殊处理(如果需要固定宽高,可以在具体入口点或容器中处理,
|
||||||
|
// 这里提供全局基础,具体尺寸在 App 容器中限制)
|
||||||
|
body: {
|
||||||
|
WebkitFontSmoothing: 'antialiased',
|
||||||
|
MozOsxFontSmoothing: 'grayscale',
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
fontFamily: 'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace',
|
||||||
|
},
|
||||||
|
'h1, h2, h3, h4, h5, h6': {
|
||||||
|
fontSize: 'inherit',
|
||||||
|
fontWeight: 'inherit',
|
||||||
|
},
|
||||||
|
/* 全局极简滚动条定制 */
|
||||||
|
'*::-webkit-scrollbar': {
|
||||||
|
width: 'var(--sb-width)',
|
||||||
|
},
|
||||||
|
'*::-webkit-scrollbar-track': {
|
||||||
|
background: 'var(--sb-track-color)',
|
||||||
|
},
|
||||||
|
'*::-webkit-scrollbar-thumb': {
|
||||||
|
background: 'var(--sb-thumb-color)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
backgroundClip: 'content-box',
|
||||||
|
border: '1px solid transparent',
|
||||||
|
},
|
||||||
|
'*::-webkit-scrollbar-thumb:hover': {
|
||||||
|
background: 'var(--sb-thumb-hover)',
|
||||||
|
},
|
||||||
|
/* Animations */
|
||||||
|
'@keyframes slideInRight': {
|
||||||
|
from: {
|
||||||
|
transform: 'translateX(30px)',
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
transform: 'translateX(0)',
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'@keyframes fadeIn': {
|
||||||
|
from: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'.page-transition-enter': {
|
||||||
|
animation: 'slideInRight 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) forwards',
|
||||||
|
},
|
||||||
|
'.page-transition-dashboard': {
|
||||||
|
animation: 'fadeIn 0.3s ease-out forwards',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default theme;
|
||||||
@@ -1,880 +0,0 @@
|
|||||||
# Storage Cleaner Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Add a storage cleaner feature to the browser extension popup that allows users to clear localStorage, sessionStorage, IndexedDB, Cookies, Cache Storage, andress Workers for the current page.
|
|
||||||
|
|
||||||
**Architecture:** Add new StorageCleanerPage component with tab switching in the popup, using chrome.cookies API for cookies and script injection for other storage types. User preferences are persisted using Chrome Storage.
|
|
||||||
|
|
||||||
**Tech Stack:** React 19 + TypeScript, Material UI, Chrome Extension APIs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
entrypoints/popup/
|
|
||||||
├── App.tsx (modify: add tab switching)
|
|
||||||
└── pages/
|
|
||||||
├── TimestampPage.tsx (no change)
|
|
||||||
└── StorageCleanerPage.tsx (create: new storage cleaner page)
|
|
||||||
|
|
||||||
types/
|
|
||||||
└── storage.d.ts (modify: add storage cleaner types)
|
|
||||||
|
|
||||||
utils/
|
|
||||||
└── storageCleaner.ts (create: storage cleaning utilities)
|
|
||||||
|
|
||||||
wxt.config.ts (modify: add cookies permission)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Add TypeScript Types for Storage Cleaner
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `types/storage.d.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add storage cleaner types to StorageSchema and interfaces**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface StorageSchema {
|
|
||||||
'app/lastRoute': string;
|
|
||||||
'app/theme': string;
|
|
||||||
'storageCleaner/preferences': StorageCleanerPreferences;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageCleanerPreferences {
|
|
||||||
autoRefresh: boolean;
|
|
||||||
selectedTypes: StorageCleanerOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageCleanerOptions {
|
|
||||||
localStorage: boolean;
|
|
||||||
sessionStorage: boolean;
|
|
||||||
indexedDB: boolean;
|
|
||||||
cookies: boolean;
|
|
||||||
cacheStorage: boolean;
|
|
||||||
serviceWorkers: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StorageCleanResult =
|
|
||||||
| {
|
|
||||||
success: true;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
success: false;
|
|
||||||
error: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CleaningResult {
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
localStorage?: StorageCleanResult;
|
|
||||||
sessionStorage?: StorageCleanResult;
|
|
||||||
indexedDB?: StorageCleanResult;
|
|
||||||
cookies?: StorageCleanResult;
|
|
||||||
cacheStorage?: StorageCleanResult;
|
|
||||||
serviceWorkers?: StorageCleanResult;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Commit TypeScript types**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add types/storage.d.ts
|
|
||||||
git commit -m "feat: add TypeScript types for storage cleaner"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: Add Cookies Permission to Manifest
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `wxt.config.ts:10-18`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add 'cookies' permission to manifest**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
permissions: [
|
|
||||||
'storage',
|
|
||||||
'unlimitedStorage',
|
|
||||||
'clipboardWrite',
|
|
||||||
'activeTab',
|
|
||||||
'scripting',
|
|
||||||
'tabs',
|
|
||||||
'debugger',
|
|
||||||
'cookies', // Add this line
|
|
||||||
],
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Test build to ensure manifest is valid**
|
|
||||||
|
|
||||||
Run: `npm run compile`
|
|
||||||
Expected: No TypeScript errors
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit manifest changes**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add wxt.config.ts
|
|
||||||
git commit -m "feat: add cookies permission to manifest"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: Create Storage Cleaning Utilities
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Create: `utils/storageCleaner.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create storage cleaning utility file with helper functions**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { StorageCleanerOptions, CleaningResult, StorageCleanResult } from 'types/storage';
|
|
||||||
|
|
||||||
const RESTRICTED_PROTOCOLS = [
|
|
||||||
'chrome:',
|
|
||||||
'chrome-extension:',
|
|
||||||
'about:',
|
|
||||||
'edge:',
|
|
||||||
'view-source:',
|
|
||||||
'file:',
|
|
||||||
'data:',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export async function getCurrentTab() {
|
|
||||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
||||||
return tab;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRestrictedUrl(url?: string): boolean {
|
|
||||||
if (!url) return true;
|
|
||||||
return RESTRICTED_PROTOCOLS.some((p) => url.startsWith(p));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearCookies(url: string): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const cookies = await chrome.cookies.getAll({ url });
|
|
||||||
for (const cookie of cookies) {
|
|
||||||
await chrome.cookies.remove({
|
|
||||||
url,
|
|
||||||
name: cookie.name,
|
|
||||||
storeId: cookie.storeId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { success: true, count: cookies.length };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const result = await chrome.scripting.executeScript<{ count: number }>({
|
|
||||||
target: { tabId },
|
|
||||||
func: () => {
|
|
||||||
const count = localStorage.length;
|
|
||||||
localStorage.clear();
|
|
||||||
return { count };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.result) {
|
|
||||||
return { success: true, count: result.result.count };
|
|
||||||
}
|
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function injectClearSessionStorage(tabId: number): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const result = await chrome.scripting.executeScript<{ count: number }>({
|
|
||||||
target: { tabId },
|
|
||||||
func: () => {
|
|
||||||
const count = sessionStorage.length;
|
|
||||||
sessionStorage.clear();
|
|
||||||
return { count };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.result) {
|
|
||||||
return { success: true, count: result.result.count };
|
|
||||||
}
|
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function injectClearIndexedDB(tabId: number): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const result = await chrome.scripting.executeScript<{ count: number } | { error: string }>({
|
|
||||||
target: { tabId },
|
|
||||||
func: () => {
|
|
||||||
if (typeof indexedDB.databases === 'function') {
|
|
||||||
return indexedDB.databases().then(async (databases) => {
|
|
||||||
let count = 0;
|
|
||||||
for (const db of databases) {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const deleteReq = indexedDB.deleteDatabase(db.name);
|
|
||||||
deleteReq.onblocked = () => {
|
|
||||||
console.warn('IndexedDB delete blocked:', db.name);
|
|
||||||
};
|
|
||||||
deleteReq.onsuccess = () => resolve();
|
|
||||||
deleteReq.onerror = () => reject();
|
|
||||||
});
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
return { count };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { error: 'databases_api_unavailable' };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.result) {
|
|
||||||
if ('error' in result.result) {
|
|
||||||
return { success: false, error: result.result.error };
|
|
||||||
}
|
|
||||||
return { success: true, count: result.result.count };
|
|
||||||
}
|
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function injectClearCacheStorage(tabId: number): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const result = await chrome.scripting.executeScript<{ count: number }>({
|
|
||||||
target: { tabId },
|
|
||||||
func: async () => {
|
|
||||||
if ('caches' in window) {
|
|
||||||
const cacheNames = await caches.keys();
|
|
||||||
for (const name of cacheNames) {
|
|
||||||
await caches.delete(name);
|
|
||||||
}
|
|
||||||
return { count: cacheNames.length };
|
|
||||||
}
|
|
||||||
return { count: 0 };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.result) {
|
|
||||||
return { success: true, count: result.result.count };
|
|
||||||
}
|
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function injectUnregisterServiceWorkers(tabId: number): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const result = await chrome.scripting.executeScript<{ count: number }>({
|
|
||||||
target: { tabId },
|
|
||||||
func: async () => {
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
||||||
for (const registration of registrations) {
|
|
||||||
await registration.unregister();
|
|
||||||
}
|
|
||||||
return { count: registrations.length };
|
|
||||||
}
|
|
||||||
return { count: 0 };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.result) {
|
|
||||||
return { success: true, count: result.result.count };
|
|
||||||
}
|
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearStorage(
|
|
||||||
tabId: number,
|
|
||||||
url: string,
|
|
||||||
options: StorageCleanerOptions,
|
|
||||||
): Promise<CleaningResult> {
|
|
||||||
const result: CleaningResult = { success: true };
|
|
||||||
|
|
||||||
if (options.localStorage) {
|
|
||||||
result.localStorage = await injectClearLocalStorage(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.sessionStorage) {
|
|
||||||
result.sessionStorage = await injectClearSessionStorage(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.indexedDB) {
|
|
||||||
result.indexedDB = await injectClearIndexedDB(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.cookies) {
|
|
||||||
result.cookies = await clearCookies(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.cacheStorage) {
|
|
||||||
result.cacheStorage = await injectClearCacheStorage(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.serviceWorkers) {
|
|
||||||
result.serviceWorkers = await injectUnregisterServiceWorkers(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any operation failed
|
|
||||||
const failures = Object.values(result).filter(
|
|
||||||
(r): r is StorageCleanResult => r?.success === false,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (failures.length > 0) {
|
|
||||||
result.success = false;
|
|
||||||
result.error = '部分清理失败';
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatCleaningResult(result: CleaningResult): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
if (result.localStorage?.success) {
|
|
||||||
parts.push(`${result.localStorage.count} 个 localStorage`);
|
|
||||||
}
|
|
||||||
if (result.sessionStorage?.success) {
|
|
||||||
parts.push(`${result.sessionStorage.count} 个 sessionStorage`);
|
|
||||||
}
|
|
||||||
if (result.indexedDB?.success) {
|
|
||||||
parts.push(`${result.indexedDB.count} 个 IndexedDB`);
|
|
||||||
}
|
|
||||||
if (result.cookies?.success) {
|
|
||||||
parts.push(`${result.cookies.count} 个 Cookies`);
|
|
||||||
}
|
|
||||||
if (result.cacheStorage?.success) {
|
|
||||||
parts.push(`${result.cacheStorage.count} 个 Cache`);
|
|
||||||
}
|
|
||||||
if (result.serviceWorkers?.success) {
|
|
||||||
parts.push(`${result.serviceWorkers.count} 个 Service Workers`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.length === 0) {
|
|
||||||
return '该页面没有可清理的存储数据';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `清理了 ${parts.join(', ')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isEmptyResult(result: CleaningResult): boolean {
|
|
||||||
const values = Object.values(result).filter(
|
|
||||||
(r): r is StorageCleanResult => r?.success === true && r.count > 0,
|
|
||||||
);
|
|
||||||
return values.length === 0;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Commit storage cleaning utilities**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add utils/storageCleaner.ts
|
|
||||||
git commit -m "feat: add storage cleaning utility functions"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: Create StorageCleanerPage Component
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Create: `entrypoints/popup/pages/StorageCleanerPage.tsx`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create StorageCleanerPage component with UI and logic**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Paper,
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
Checkbox,
|
|
||||||
Button,
|
|
||||||
FormControlLabel,
|
|
||||||
Alert,
|
|
||||||
Snackbar,
|
|
||||||
} from '@mui/material';
|
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import type { StorageCleanerOptions, CleaningResult, StorageCleanerPreferences } from 'types/storage';
|
|
||||||
import {
|
|
||||||
getCurrentTab,
|
|
||||||
isRestrictedUrl,
|
|
||||||
clearStorage,
|
|
||||||
formatCleaningResult,
|
|
||||||
isEmptyResult,
|
|
||||||
} from '@/utils/storageCleaner';
|
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: StorageCleanerOptions = {
|
|
||||||
localStorage: true,
|
|
||||||
sessionStorage: true,
|
|
||||||
indexedDB: true,
|
|
||||||
cookies: true,
|
|
||||||
cacheStorage: true,
|
|
||||||
serviceWorkers: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
|
|
||||||
autoRefresh: true,
|
|
||||||
selectedTypes: DEFAULT_OPTIONS,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StorageCleanerPage() {
|
|
||||||
const [domain, setDomain] = useState<string>('');
|
|
||||||
const [error, setError] = useState<string>('');
|
|
||||||
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
|
||||||
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
const [result, setResult] = useState<CleaningResult | null>(null);
|
|
||||||
const [showConfirm, setShowConfirm] = useState<boolean>(false);
|
|
||||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string }>({
|
|
||||||
open: false,
|
|
||||||
message: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load tab info and user preferences
|
|
||||||
useEffect(() => {
|
|
||||||
const loadInfo = async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
|
|
||||||
if (!tab || !tab.url) {
|
|
||||||
setError('无法获取当前标签页');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRestrictedUrl(tab.url)) {
|
|
||||||
setError('存储清理功能不支持此页面');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDomain(new URL(tab.url).hostname);
|
|
||||||
|
|
||||||
// Load user preferences
|
|
||||||
const prefs = await storageUtil.get(
|
|
||||||
'storageCleaner/preferences',
|
|
||||||
DEFAULT_PREFERENCES,
|
|
||||||
);
|
|
||||||
setAutoRefresh(prefs.autoRefresh);
|
|
||||||
setOptions(prefs.selectedTypes);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadInfo();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleOptionChange = useCallback((key: keyof StorageCleanerOptions) => {
|
|
||||||
setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClean = useCallback(async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
|
|
||||||
if (!tab || !tab.id || !tab.url) {
|
|
||||||
setSnackbar({ open: true, message: '无法获取当前标签页' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cleaningResult = await clearStorage(tab.id, tab.url, options);
|
|
||||||
setResult(cleaningResult);
|
|
||||||
|
|
||||||
// Save user preferences
|
|
||||||
await storageUtil.set('storageCleaner/preferences', {
|
|
||||||
autoRefresh,
|
|
||||||
selectedTypes: options,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto refresh if enabled
|
|
||||||
if (autoRefresh && cleaningResult.success) {
|
|
||||||
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
|
|
||||||
setTimeout(() => {
|
|
||||||
chrome.tabs.reload(tab.id);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setSnackbar({ open: true, message: `清理失败: ${String(err)}` });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setShowConfirm(false);
|
|
||||||
}
|
|
||||||
}, [options, autoRefresh]);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
if (tab?.id) {
|
|
||||||
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
|
|
||||||
setTimeout(() => {
|
|
||||||
chrome.tabs.reload(tab.id);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
|
|
||||||
<Alert severity="error" icon={<WarningIcon />}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<Box sx={{ textAlign: 'center', mb: 2 }}>
|
|
||||||
<Typography variant="h5" component="h1" sx={{ mb: 1 }}>
|
|
||||||
存储清理
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
当前页面: {domain || '加载中...'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Storage Type Options */}
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
|
||||||
选择要清理的存储类型:
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.localStorage}
|
|
||||||
onChange={() => handleOptionChange('localStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="localStorage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.sessionStorage}
|
|
||||||
onChange={() => handleOptionChange('sessionStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="sessionStorage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.indexedDB}
|
|
||||||
onChange={() => handleOptionChange('indexedDB')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="IndexedDB"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.cookies}
|
|
||||||
onChange={() => handleOptionChange('cookies')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Cookies"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.cacheStorage}
|
|
||||||
onChange={() => handleOptionChange('cacheStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Cache Storage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.serviceWorkers}
|
|
||||||
onChange={() => handleOptionChange('serviceWorkers')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Service Workers"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Auto Refresh Option */}
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={autoRefresh}
|
|
||||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="清理完成后自动刷新页面"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => setShowConfirm(true)}
|
|
||||||
disabled={loading}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{loading ? '清理中...' : '清理'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Result Display */}
|
|
||||||
{result && (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Alert
|
|
||||||
severity={result.success ? 'success' : 'error'}
|
|
||||||
sx={{ mb: !autoRefresh && result.success ? 1 : 0 }}
|
|
||||||
>
|
|
||||||
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
|
|
||||||
</Alert>
|
|
||||||
{!autoRefresh && result.success && (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
onClick={handleRefresh}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
刷新页面
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
|
||||||
{showConfirm && (
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
bgcolor: 'rgba(255, 255, 255, 0.95)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 2,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h6">确认清理</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mb: 1 }}>
|
|
||||||
将清理以下存储类型:
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ mb: 1 }}>
|
|
||||||
{options.localStorage && (
|
|
||||||
<Typography variant="body2">- localStorage</Typography>
|
|
||||||
)}
|
|
||||||
{options.sessionStorage && (
|
|
||||||
<Typography variant="body2">- sessionStorage</Typography>
|
|
||||||
)}
|
|
||||||
{options.indexedDB && <Typography variant="body2">- IndexedDB</Typography>}
|
|
||||||
{options.cookies && <Typography variant="body2">- Cookies</Typography>}
|
|
||||||
{options.cacheStorage && (
|
|
||||||
<Typography variant="body2">- Cache Storage</Typography>
|
|
||||||
)}
|
|
||||||
{options.serviceWorkers && (
|
|
||||||
<Typography variant="body2">- Service Workers</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ textAlign: 'center', mb: 1 }}
|
|
||||||
>
|
|
||||||
此操作不可撤销。
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Button variant="outlined" onClick={() => setShowConfirm(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button variant="contained" color="error" onClick={handleClean}>
|
|
||||||
确认清理
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Snackbar */}
|
|
||||||
<Snackbar
|
|
||||||
open={snackbar.open}
|
|
||||||
autoHideDuration={3000}
|
|
||||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
|
||||||
>
|
|
||||||
<Alert severity="info" variant="filled">
|
|
||||||
{snackbar.message}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Commit StorageCleanerPage component**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add entrypoints/popup/pages/StorageCleanerPage.tsx
|
|
||||||
git commit -m "feat: add StorageCleanerPage component"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: Update App.tsx with Tab Switching
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `entrypoints/popup/App.tsx`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add tab switching logic to App.tsx**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Box, Button } from '@mui/material';
|
|
||||||
import TimestampPage from './pages/TimestampPage';
|
|
||||||
import StorageCleanerPage from './pages/StorageCleanerPage';
|
|
||||||
import './App.css';
|
|
||||||
|
|
||||||
type PageType = 'timestamp' | 'storageCleaner';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app">
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant={currentPage === 'timestamp' ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => setCurrentPage('timestamp')}
|
|
||||||
>
|
|
||||||
时间戳
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={currentPage === 'storageCleaner' ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => setCurrentPage('storageCleaner')}
|
|
||||||
sx={{ ml: 1 }}
|
|
||||||
>
|
|
||||||
存储清理
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{currentPage === 'timestamp' && <TimestampPage />}
|
|
||||||
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run type check**
|
|
||||||
|
|
||||||
Run: `npm run compile`
|
|
||||||
Expected: No TypeScript errors
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit App.tsx changes**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add entrypoints/popup/App.tsx
|
|
||||||
git commit -m "feat: add tab switching to App component"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: Build and Test
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- No file changes
|
|
||||||
|
|
||||||
- [ ] **Step 1: Build the extension**
|
|
||||||
|
|
||||||
Run: `npm run build`
|
|
||||||
Expected: Build succeeds with no errors
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run lint check**
|
|
||||||
|
|
||||||
Run: `npm run lint`
|
|
||||||
Expected: No linting errors
|
|
||||||
|
|
||||||
- [ ] **Step 3: Load extension in Chrome for manual testing**
|
|
||||||
|
|
||||||
Instructions:
|
|
||||||
|
|
||||||
1. Open Chrome and navigate to `chrome://extensions/`
|
|
||||||
2. Enable Developer Mode
|
|
||||||
3. Click "Load unpacked"
|
|
||||||
4. Select `.output/chrome-mv3` directory
|
|
||||||
5. Test on a regular web page (e.g., example.com)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit successful implementation**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit --allow-empty -m "feat: complete storage cleaner feature implementation"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
After implementation, verify:
|
|
||||||
|
|
||||||
- [ ] Tab switching works between timestamp and storage cleaner
|
|
||||||
- [ ] Current domain displays correctly
|
|
||||||
- [ ] All storage type checkboxes toggle correctly
|
|
||||||
- [ ] Auto refresh checkbox persists across sessions
|
|
||||||
- [ ] Clear confirmation dialog appears
|
|
||||||
- [ ] Confirmation dialog shows selected storage types
|
|
||||||
- [ ] localStorage clears successfully
|
|
||||||
- [ ] sessionStorage clears successfully
|
|
||||||
- [ ] IndexedDB clears successfully (or shows error if unavailable)
|
|
||||||
- [ ] Cookies clear successfully
|
|
||||||
- [ ] Clear httponly and secure cookies
|
|
||||||
- [ ] Cache Storage clears successfully
|
|
||||||
- [ ] Service Workers unregister successfully
|
|
||||||
- [ ] Result message displays correctly
|
|
||||||
- [ ] Empty state shows friendly message
|
|
||||||
- [ ] Auto refresh works
|
|
||||||
- [ ] Manual refresh button appears when auto-refresh is off
|
|
||||||
- [ ] Restricted pages show error message
|
|
||||||
- [ ] Snackbar notifications appear correctly
|
|
||||||
- [ ] Test on localhost
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
|
|
||||||
If issues occur during testing:
|
|
||||||
|
|
||||||
1. Revert to before implementation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git reset --hard <commit-before-start>
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Or revert specific files:
|
|
||||||
```bash
|
|
||||||
git checkout HEAD -- types/storage.d.ts wxt.config.ts utils/storageCleaner.ts entrypoints/popup/App.tsx entrypoints/popup/pages/StorageCleanerPage.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- The popup closes automatically when the page is refreshed - this is expected behavior
|
|
||||||
- IndexedDB.databases() may not be available in all browser versions; the fallback handles this
|
|
||||||
- Chrome Cookies API requires explicit permission, which is added to the manifest
|
|
||||||
- User preferences are persisted using the existing chromeStorage.ts utility
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
# 存储清理功能设计文档
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
为浏览器扩展添加一个存储清理功能,允许用户快速清理当前页面的各种存储数据,包括 localStorage、sessionStorage、IndexedDB、Cookies、Cache Storage 和 Service Workers。
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
- 提供便捷的页面存储清理功能
|
|
||||||
- 支持多种存储类型清理
|
|
||||||
- 提供清理结果反馈
|
|
||||||
- 支持清理后自动刷新页面
|
|
||||||
|
|
||||||
## 架构设计
|
|
||||||
|
|
||||||
### 组件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
entrypoints/popup/pages/
|
|
||||||
├── TimestampPage.tsx (现有:时间戳转换页面)
|
|
||||||
└── StorageCleanerPage.tsx (新增:存储清理页面)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 页面布局
|
|
||||||
|
|
||||||
在弹窗中添加标签页切换功能,用户可以在时间戳转换和存储清理之间切换。
|
|
||||||
|
|
||||||
**路由实现方案:**
|
|
||||||
|
|
||||||
使用简单的状态管理进行页面切换:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// App.tsx
|
|
||||||
type PageType = 'timestamp' | 'storageCleaner';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app">
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant={currentPage === 'timestamp' ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => setCurrentPage('timestamp')}
|
|
||||||
>
|
|
||||||
时间戳
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={currentPage === 'storageCleaner' ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => setCurrentPage('storageCleaner')}
|
|
||||||
sx={{ ml: 1 }}
|
|
||||||
>
|
|
||||||
存储清理
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{currentPage === 'timestamp' && <TimestampPage />}
|
|
||||||
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 用户界面设计
|
|
||||||
|
|
||||||
### 页面组成
|
|
||||||
|
|
||||||
1. **头部区域**
|
|
||||||
- 标题:"存储清理"
|
|
||||||
- 当前域名显示(自动从活动标签页获取)
|
|
||||||
|
|
||||||
2. **存储类型选择区域**
|
|
||||||
- 勾选框:localStorage
|
|
||||||
- 勾选框:sessionStorage
|
|
||||||
- 勾选框:IndexedDB
|
|
||||||
- 勾选框:Cookies
|
|
||||||
- 勾选框:Cache Storage
|
|
||||||
- 勾选框:Service Workers
|
|
||||||
|
|
||||||
3. **自动刷新选项**
|
|
||||||
- 复选框:清理完成后自动刷新页面(默认勾选)
|
|
||||||
|
|
||||||
4. **操作区域**
|
|
||||||
- 清理按钮
|
|
||||||
|
|
||||||
5. **结果显示区域**
|
|
||||||
- 清理成功/失败提示
|
|
||||||
- 清理详情统计(如:"清理了 5 个 localStorage, 3 个 cookies")
|
|
||||||
- 刷新页面按钮(当未勾选自动刷新时显示)
|
|
||||||
|
|
||||||
## 技术实现细节
|
|
||||||
|
|
||||||
### 获取当前标签页域名
|
|
||||||
|
|
||||||
使用 Chrome Tabs API 获取当前活动标签页,并过滤受限页面:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
||||||
|
|
||||||
// 检查受限页面
|
|
||||||
const restrictedProtocols = [
|
|
||||||
'chrome:',
|
|
||||||
'chrome-extension:',
|
|
||||||
'about:',
|
|
||||||
'edge:',
|
|
||||||
'view-source:',
|
|
||||||
'file:',
|
|
||||||
'data:',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!tab?.url || restrictedProtocols.some((p) => tab.url!.startsWith(p))) {
|
|
||||||
throw new Error('存储清理功能不支持此页面');
|
|
||||||
}
|
|
||||||
|
|
||||||
const domain = new URL(tab.url).hostname;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 清理 Cookies(使用 chrome.cookies API)
|
|
||||||
|
|
||||||
在扩展环境中直接执行,不需要注入页面:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const cookies = await chrome.cookies.getAll({ url: tab.url });
|
|
||||||
let count = 0;
|
|
||||||
for (const cookie of cookies) {
|
|
||||||
await chrome.cookies.remove({
|
|
||||||
url: tab.url,
|
|
||||||
name: cookie.name,
|
|
||||||
storeId: cookie.storeId,
|
|
||||||
});
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 注入脚本清理其他存储
|
|
||||||
|
|
||||||
使用 `chrome.scripting.executeScript` 注入清理脚本:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const result = await chrome.scripting.executeScript({
|
|
||||||
target: { tabId: tab.id },
|
|
||||||
func: () => {
|
|
||||||
// 清理逻辑在页面上下文中执行
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
需要注入到页面执行的存储清理逻辑:
|
|
||||||
|
|
||||||
#### 清理 localStorage
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const count = localStorage.length;
|
|
||||||
localStorage.clear();
|
|
||||||
return count;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 清理 sessionStorage
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const count = sessionStorage.length;
|
|
||||||
sessionStorage.clear();
|
|
||||||
return count;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 清理 IndexedDB
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 检查 indexedDB.databases 方法是否可用
|
|
||||||
if (typeof indexedDB.databases === 'function') {
|
|
||||||
const databases = await indexedDB.databases();
|
|
||||||
let count = 0;
|
|
||||||
for (const db of databases) {
|
|
||||||
const deleteReq = indexedDB.deleteDatabase(db.name);
|
|
||||||
deleteReq.onblocked = () => {
|
|
||||||
console.warn('IndexedDB delete blocked:', db.name);
|
|
||||||
};
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
deleteReq.onsuccess = resolve;
|
|
||||||
deleteReq.onerror = reject;
|
|
||||||
});
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
// 降级方案:由于无法获取所有数据库名称,提示返回特殊值表示需要手动操作
|
|
||||||
return { error: 'databases_api_unavailable' };
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 清理 Cache Storage
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
if ('caches' in window) {
|
|
||||||
const cacheNames = await caches.keys();
|
|
||||||
for (const name of cacheNames) {
|
|
||||||
await caches.delete(name);
|
|
||||||
}
|
|
||||||
return cacheNames.length;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 注销 Service Workers
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
||||||
let count = 0;
|
|
||||||
for (const registration of registrations) {
|
|
||||||
await registration.unregister();
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据流
|
|
||||||
|
|
||||||
1. 页面加载时获取当前标签页 URL 并显示域名
|
|
||||||
2. 检查是否为受限页面(chrome://, about:// 等),如果是则显示错误提示
|
|
||||||
3. 用户勾选要清理的存储类型
|
|
||||||
4. 用户选择是否自动刷新页面
|
|
||||||
5. 用户点击清理按钮
|
|
||||||
6. 弹出确认对话框询问用户确认
|
|
||||||
7. 确认后执行清理:
|
|
||||||
- 如果选择 Cookies:直接使用 chrome.cookies API 删除
|
|
||||||
- 其他存储类型:向页面注入清理脚本
|
|
||||||
8. 收集所有清理结果并统计
|
|
||||||
9. 显示清理结果
|
|
||||||
10. 如果勾选"自动刷新"或用户点击"刷新页面"按钮,执行页面刷新
|
|
||||||
|
|
||||||
**注意:** 当触发页面刷新时,popup 会自动关闭。需要在刷新前显示提示信息。
|
|
||||||
|
|
||||||
### 页面刷新
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 显示刷新提示
|
|
||||||
setRefreshing(true);
|
|
||||||
setTimeout(async () => {
|
|
||||||
await chrome.tabs.reload(tab.id);
|
|
||||||
}, 1500); // 1.5秒延迟让用户看到提示信息
|
|
||||||
```
|
|
||||||
|
|
||||||
### 空状态处理
|
|
||||||
|
|
||||||
当所有存储类型清理返回 0 时,显示友好的提示:
|
|
||||||
|
|
||||||
```
|
|
||||||
该页面没有可清理的存储数据
|
|
||||||
```
|
|
||||||
|
|
||||||
## 错误处理
|
|
||||||
|
|
||||||
| 错误场景 | 处理方式 |
|
|
||||||
| ------------------------------- | ---------------------------------------- |
|
|
||||||
| 无法获取当前标签页 | 显示错误提示:"无法获取当前标签页" |
|
|
||||||
| 受限页面(chrome://, about://) | 显示错误提示:"存储清理功能不支持此页面" |
|
|
||||||
| 无法访问页面 URL | 显示错误提示:"无法访问此页面" |
|
|
||||||
| IndexedDB onblocked | 显示警告但继续执行其他清理 |
|
|
||||||
| IndexedDB.databases 不可用 | 使用降级方案或提示用户手动清除 |
|
|
||||||
| 清理失败 | 显示具体错误信息 |
|
|
||||||
| Cookies 删除失败 | 记录错误,显示清理失败提示 |
|
|
||||||
| 无权限 | 提示用户刷新扩展或检查权限 |
|
|
||||||
| 脚本注入失败 | 显示错误提示:"无法注入清理脚本" |
|
|
||||||
|
|
||||||
**Popup 生命周期说明:**
|
|
||||||
|
|
||||||
- Popup 在页面失去焦点时会关闭
|
|
||||||
- 刷新页面后 Popup 会自动关闭
|
|
||||||
- 需要在刷新前显示提示:"页面即将刷新,Popup 将关闭"
|
|
||||||
|
|
||||||
## 用户偏好持久化
|
|
||||||
|
|
||||||
使用现有的 `chromeStorage.ts` 工具保存用户偏好:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 保存用户偏好
|
|
||||||
await storageUtil.set('storageCleaner/preferences', {
|
|
||||||
autoRefresh: true, // 默认勾选自动刷新
|
|
||||||
selectedTypes: {
|
|
||||||
// 可以保存用户上次选择的存储类型
|
|
||||||
localStorage: true,
|
|
||||||
sessionStorage: true,
|
|
||||||
indexedDB: true,
|
|
||||||
cookies: true,
|
|
||||||
cacheStorage: true,
|
|
||||||
serviceWorkers: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 读取用户偏好
|
|
||||||
const preferences = await storageUtil.get('storageCleaner/preferences', {
|
|
||||||
autoRefresh: true,
|
|
||||||
selectedTypes: {
|
|
||||||
localStorage: true,
|
|
||||||
sessionStorage: true,
|
|
||||||
indexedDB: true,
|
|
||||||
cookies: true,
|
|
||||||
cacheStorage: true,
|
|
||||||
serviceWorkers: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## 权限需求
|
|
||||||
|
|
||||||
需要在 manifest 中添加 `cookies` 权限:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
permissions: [
|
|
||||||
'storage',
|
|
||||||
'unlimitedStorage',
|
|
||||||
'clipboardWrite',
|
|
||||||
'activeTab',
|
|
||||||
'scripting',
|
|
||||||
'tabs',
|
|
||||||
'debugger',
|
|
||||||
'cookies', // 新增
|
|
||||||
],
|
|
||||||
```
|
|
||||||
|
|
||||||
## TypeScript 类型定义
|
|
||||||
|
|
||||||
在现有 `types/storage.d.ts` 中添加存储清理相关的类型:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface StorageSchema {
|
|
||||||
'app/lastRoute': string;
|
|
||||||
'app/theme': string;
|
|
||||||
'storageCleaner/preferences': StorageCleanerPreferences;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageCleanerPreferences {
|
|
||||||
autoRefresh: boolean;
|
|
||||||
selectedTypes: StorageCleanerOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageCleanerOptions {
|
|
||||||
localStorage: boolean;
|
|
||||||
sessionStorage: boolean;
|
|
||||||
indexedDB: boolean;
|
|
||||||
cookies: boolean;
|
|
||||||
cacheStorage: boolean;
|
|
||||||
serviceWorkers: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StorageCleanResult =
|
|
||||||
| {
|
|
||||||
success: true;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
success: false;
|
|
||||||
error: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CleaningResult {
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
localStorage?: StorageCleanResult;
|
|
||||||
sessionStorage?: StorageCleanResult;
|
|
||||||
indexedDB?: StorageCleanResult;
|
|
||||||
cookies?: StorageCleanResult;
|
|
||||||
cacheStorage?: StorageCleanResult;
|
|
||||||
serviceWorkers?: StorageCleanResult;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 测试计划
|
|
||||||
|
|
||||||
1. 测试各种存储类型的单独清理
|
|
||||||
2. 测试同时清理多种存储类型
|
|
||||||
3. 测试自动刷新功能
|
|
||||||
4. 测试手动刷新按钮
|
|
||||||
5. 测试无存储数据时的清理(显示空状态提示)
|
|
||||||
6. 测试无法访问页面的错误处理
|
|
||||||
7. 测试受限页面(chrome://, about://, file://, data://)
|
|
||||||
8. 测试 IndexedDB onblocked 场景
|
|
||||||
9. 测试 httponly 和 secure cookies 清理
|
|
||||||
10. 测试本地开发环境(localhost)
|
|
||||||
11. 测试用户偏好持久化
|
|
||||||
|
|
||||||
## 后续优化
|
|
||||||
|
|
||||||
- 显示清理前的存储使用情况
|
|
||||||
- 支持批量清理多个标签页
|
|
||||||
- 支持自定义域名清理
|
|
||||||
@@ -1,51 +1,33 @@
|
|||||||
import '../.wxt/types/imports.d.ts';
|
import '../.wxt/types/imports.d.ts';
|
||||||
import { browser } from 'wxt/browser';
|
import { browser } from 'wxt/browser';
|
||||||
|
import { MessageAction, onMessage } from '@/utils/messages';
|
||||||
|
|
||||||
export default defineBackground(() => {
|
export default defineBackground(() => {
|
||||||
// 监听扩展安装或更新事件
|
// 监听扩展图标点击事件,打开侧边栏
|
||||||
browser.runtime.onInstalled.addListener(async ({ reason }) => {
|
browser.action.onClicked.addListener(async (tab) => {
|
||||||
if (reason === 'install') {
|
if (tab.id) {
|
||||||
console.log('Extension installed for the first time');
|
try {
|
||||||
} else if (reason === 'update') {
|
await browser.sidePanel.open({ tabId: tab.id });
|
||||||
console.log('Extension updated to a new version');
|
} catch (err) {
|
||||||
|
console.error('Failed to open side panel:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用 @webext-core/messaging 处理消息
|
||||||
|
onMessage(MessageAction.RELOAD_TAB, async (message) => {
|
||||||
|
const { tabId, delay = 0 } = message.data;
|
||||||
|
|
||||||
|
const executeReload = () => {
|
||||||
|
browser.tabs.reload(tabId);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (delay > 0) {
|
||||||
|
setTimeout(executeReload, delay);
|
||||||
|
} else {
|
||||||
|
executeReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取所有标签页
|
return { success: true, message: '刷新请求已接收' };
|
||||||
const tabs = await browser.tabs.query({});
|
|
||||||
|
|
||||||
// 过滤不合法或受限制的 URL
|
|
||||||
const targetTabs = tabs.filter((tab) => {
|
|
||||||
if (!tab.id || !tab.url) return false;
|
|
||||||
const restrictedProtocols = [
|
|
||||||
'chrome:',
|
|
||||||
'chrome-extension:',
|
|
||||||
'about:',
|
|
||||||
'edge:',
|
|
||||||
'view-source:',
|
|
||||||
];
|
|
||||||
return !restrictedProtocols.some((protocol) => tab.url!.startsWith(protocol));
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
targetTabs.map((tab) =>
|
|
||||||
browser.scripting
|
|
||||||
.executeScript({
|
|
||||||
target: { tabId: tab.id! },
|
|
||||||
files: ['/content-scripts/content.js'],
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.warn(`Failed to inject script into tab ${tab.id}:`, err.message);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const successCount = results.filter((r) => r.status === 'fulfilled').length;
|
|
||||||
console.log(
|
|
||||||
`Successfully injected content script into ${successCount}/${targetTabs.length} tabs.`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
chrome.tabs.onUpdated.addListener((tabId) => {
|
|
||||||
console.log('加载完成的 Tab ID:', tabId);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import '../.wxt/types/imports.d.ts';
|
import '../.wxt/types/imports.d.ts';
|
||||||
|
import { initFormMappingHelper } from '@/utils/formMapping/ui';
|
||||||
|
import { initMessageHandler } from './content/messageHandler';
|
||||||
|
|
||||||
export default defineContentScript({
|
export default defineContentScript({
|
||||||
matches: ['<all_urls>'],
|
matches: ['<all_urls>'],
|
||||||
runAt: 'document_start',
|
runAt: 'document_end',
|
||||||
main() {
|
main() {
|
||||||
// Content script placeholder
|
// 初始化表单映射助手逻辑 (UI, Picker, Highlighter)
|
||||||
|
initFormMappingHelper();
|
||||||
|
|
||||||
|
// 初始化消息处理器 (Scan, Fill, Clear, Highlight, Flash, Inject)
|
||||||
|
initMessageHandler();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import {
|
||||||
|
fillAllFields,
|
||||||
|
clearAllFields,
|
||||||
|
fillSelectedFields,
|
||||||
|
scanFormFields,
|
||||||
|
highlightField,
|
||||||
|
unhighlightField,
|
||||||
|
flashField,
|
||||||
|
FillMode,
|
||||||
|
type FormFieldInfo,
|
||||||
|
} from '@/utils/dummyDataGenerator';
|
||||||
|
import { MessageAction, onMessage } from '@/utils/messages';
|
||||||
|
import {
|
||||||
|
FuzzyMatcher,
|
||||||
|
SmartInjectionEngine,
|
||||||
|
FeedbackRenderer,
|
||||||
|
} from '@/utils/formMapping/smartInjector';
|
||||||
|
import { FormMapEntry } from '@/types/storage';
|
||||||
|
|
||||||
|
// 存储当前扫描到的字段列表,用于高亮联动
|
||||||
|
let currentFields: FormFieldInfo[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化消息处理器
|
||||||
|
*/
|
||||||
|
export function initMessageHandler() {
|
||||||
|
onMessage(MessageAction.SCAN_FORM_FIELDS, async () => {
|
||||||
|
const result = scanFormFields();
|
||||||
|
currentFields = result.fields;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
fields: result.fields.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
fieldType: f.fieldType,
|
||||||
|
label: f.label,
|
||||||
|
placeholder: f.placeholder,
|
||||||
|
name: f.name,
|
||||||
|
value: f.value,
|
||||||
|
isSelected: f.isSelected,
|
||||||
|
generatedValue: f.generatedValue,
|
||||||
|
})),
|
||||||
|
totalCount: result.totalCount,
|
||||||
|
validCount: result.validCount,
|
||||||
|
hasModal: !!result.modalContainer,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onMessage(MessageAction.FILL_VALID_DATA, async (message) => {
|
||||||
|
fillAllFields(FillMode.VALID, message.data.includeHidden || false);
|
||||||
|
return { success: true, message: '已填充有效数据' };
|
||||||
|
});
|
||||||
|
|
||||||
|
onMessage(MessageAction.FILL_INVALID_DATA, async (message) => {
|
||||||
|
fillAllFields(FillMode.INVALID, message.data.includeHidden || false);
|
||||||
|
return { success: true, message: '已填充无效数据' };
|
||||||
|
});
|
||||||
|
|
||||||
|
onMessage(MessageAction.FILL_SELECTED_FIELDS, async (message) => {
|
||||||
|
const { fields: incomingFields, mode } = message.data;
|
||||||
|
const fieldsToFill = currentFields.map((field) => {
|
||||||
|
const incomingField = incomingFields.find((f) => f.id === field.id);
|
||||||
|
if (incomingField) {
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
fieldType: incomingField.fieldType,
|
||||||
|
isSelected: incomingField.isSelected,
|
||||||
|
useInvalidData: incomingField.useInvalidData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
});
|
||||||
|
const count = fillSelectedFields(fieldsToFill, mode || FillMode.VALID);
|
||||||
|
return { success: true, message: `已填充 ${count} 个字段` };
|
||||||
|
});
|
||||||
|
|
||||||
|
onMessage(MessageAction.CLEAR_ALL_FIELDS, async () => {
|
||||||
|
clearAllFields();
|
||||||
|
return { success: true, message: '已清空所有字段' };
|
||||||
|
});
|
||||||
|
|
||||||
|
onMessage(MessageAction.HIGHLIGHT_FIELD, async (message) => {
|
||||||
|
const { fieldId } = message.data;
|
||||||
|
const field = currentFields.find((f) => f.id === fieldId);
|
||||||
|
if (field) {
|
||||||
|
highlightField(field.element);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false, message: '未找到字段' };
|
||||||
|
});
|
||||||
|
|
||||||
|
onMessage(MessageAction.UNHIGHLIGHT_FIELD, async (message) => {
|
||||||
|
const { fieldId } = message.data;
|
||||||
|
const field = currentFields.find((f) => f.id === fieldId);
|
||||||
|
if (field) {
|
||||||
|
unhighlightField(field.element);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false, message: '未找到字段' };
|
||||||
|
});
|
||||||
|
|
||||||
|
onMessage(MessageAction.HIGHLIGHT_ALL_FIELDS, async (message) => {
|
||||||
|
const { fieldIds } = message.data;
|
||||||
|
fieldIds.forEach((id) => {
|
||||||
|
const field = currentFields.find((f) => f.id === id);
|
||||||
|
if (field) {
|
||||||
|
highlightField(field.element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
onMessage(MessageAction.UNHIGHLIGHT_ALL_FIELDS, async () => {
|
||||||
|
currentFields.forEach((field) => {
|
||||||
|
unhighlightField(field.element);
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
onMessage(MessageAction.FLASH_FIELD, async (message) => {
|
||||||
|
const { fieldId } = message.data;
|
||||||
|
const field = currentFields.find((f) => f.id === fieldId);
|
||||||
|
if (field) {
|
||||||
|
flashField(field.element);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false, message: '未找到字段' };
|
||||||
|
});
|
||||||
|
|
||||||
|
onMessage(MessageAction.FORM_INJECT, async (message) => {
|
||||||
|
try {
|
||||||
|
const injectData =
|
||||||
|
(message.data.data as Array<{ entry: FormMapEntry; mockValue: string }>) || [];
|
||||||
|
const results = injectData.map((item) => {
|
||||||
|
const matchResult = FuzzyMatcher.findTargetElement(item.entry.fingerprint);
|
||||||
|
if (matchResult.element) {
|
||||||
|
const injectResult = SmartInjectionEngine.inject(
|
||||||
|
matchResult.element,
|
||||||
|
item.entry,
|
||||||
|
item.mockValue,
|
||||||
|
);
|
||||||
|
if (injectResult.success) {
|
||||||
|
FeedbackRenderer.renderSuccess(matchResult.element);
|
||||||
|
} else {
|
||||||
|
FeedbackRenderer.renderError(matchResult.element);
|
||||||
|
}
|
||||||
|
return { id: item.entry.id, success: injectResult.success };
|
||||||
|
} else {
|
||||||
|
return { id: item.entry.id, success: false };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { success: true, results };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('智能注入失败:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : '注入失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Switch,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Stack,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material';
|
||||||
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
|
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||||
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||||
|
import type { PageType } from '@/types/storage';
|
||||||
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
|
import {
|
||||||
|
getFeatureByKey,
|
||||||
|
getDefaultPageOrder,
|
||||||
|
getDefaultVisibleFeatureKeys,
|
||||||
|
} from '@/config/features';
|
||||||
|
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
|
||||||
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [visiblePages, setVisiblePages] = useState<PageType[]>([]);
|
||||||
|
const [pageOrder, setPageOrder] = useState<PageType[]>([]);
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
const { snackbarProps, showMessage } = useSnackbarState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig().catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
try {
|
||||||
|
const [savedVisible, savedOrder] = await Promise.all([
|
||||||
|
storageUtil.get('app/visiblePages', getDefaultVisibleFeatureKeys()),
|
||||||
|
storageUtil.get('app/pageOrder', getDefaultPageOrder()),
|
||||||
|
]);
|
||||||
|
setVisiblePages(savedVisible ?? getDefaultVisibleFeatureKeys());
|
||||||
|
setPageOrder(savedOrder && savedOrder.length > 0 ? savedOrder : getDefaultPageOrder());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load config:', error);
|
||||||
|
setVisiblePages(getDefaultVisibleFeatureKeys());
|
||||||
|
setPageOrder(getDefaultPageOrder());
|
||||||
|
} finally {
|
||||||
|
setIsLoaded(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageToggle = async (page: PageType) => {
|
||||||
|
const isCurrentlyVisible = visiblePages.includes(page);
|
||||||
|
let newPages: PageType[];
|
||||||
|
|
||||||
|
if (isCurrentlyVisible) {
|
||||||
|
if (visiblePages.length <= 1) {
|
||||||
|
showToast('至少需要保留一个可见页面', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newPages = visiblePages.filter((p) => p !== page);
|
||||||
|
} else {
|
||||||
|
newPages = [...visiblePages, page];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await storageUtil.set('app/visiblePages', newPages);
|
||||||
|
setVisiblePages(newPages);
|
||||||
|
const feature = getFeatureByKey(page);
|
||||||
|
showToast(`已${isCurrentlyVisible ? '隐藏' : '显示'} ${feature?.label || page}`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save config:', error);
|
||||||
|
showToast('保存失败', 'warning');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMove = async (index: number, direction: 'up' | 'down') => {
|
||||||
|
if (direction === 'up' && index === 0) return;
|
||||||
|
if (direction === 'down' && index === pageOrder.length - 1) return;
|
||||||
|
|
||||||
|
const newOrder = [...pageOrder];
|
||||||
|
const swapIndex = direction === 'up' ? index - 1 : index + 1;
|
||||||
|
[newOrder[index], newOrder[swapIndex]] = [newOrder[swapIndex], newOrder[index]];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await storageUtil.set('app/pageOrder', newOrder);
|
||||||
|
setPageOrder(newOrder);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save order:', error);
|
||||||
|
showToast('排序保存失败', 'warning');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestoreDefaults = async () => {
|
||||||
|
try {
|
||||||
|
const { getDefaultVisibleFeatureKeys } = await import('@/config/features');
|
||||||
|
const defaults = getDefaultVisibleFeatureKeys();
|
||||||
|
const defaultOrder = getDefaultPageOrder();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
storageUtil.set('app/visiblePages', defaults),
|
||||||
|
storageUtil.set('app/pageOrder', defaultOrder),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setVisiblePages(defaults);
|
||||||
|
setPageOrder(defaultOrder);
|
||||||
|
showToast('已恢复默认', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to restore defaults:', error);
|
||||||
|
showToast('恢复失败', 'warning');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showToast = (message: string, severity: 'success' | 'info' | 'warning') => {
|
||||||
|
showMessage(message, { severity });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isLoaded) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}
|
||||||
|
>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className="app"
|
||||||
|
sx={{ p: 4, minHeight: '100vh', bgcolor: 'grey.50', display: 'block', overflowY: 'auto' }}
|
||||||
|
>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Box sx={{ maxWidth: 600, mx: 'auto' }}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="flex-start"
|
||||||
|
sx={{ mb: 4 }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
onClick={handleRestoreDefaults}
|
||||||
|
startIcon={<RefreshIcon sx={{ fontSize: 16 }} />}
|
||||||
|
sx={{ color: 'text.secondary', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
恢复默认
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.200',
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{pageOrder.map((key, index, array) => {
|
||||||
|
const feature = getFeatureByKey(key);
|
||||||
|
if (!feature) return null;
|
||||||
|
|
||||||
|
const isChecked = visiblePages.includes(key);
|
||||||
|
const isDisabled = isChecked && visiblePages.length === 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={key}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
p: 2.5,
|
||||||
|
borderBottom: index === array.length - 1 ? 'none' : '1px solid',
|
||||||
|
borderColor: 'grey.100',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
'&:hover': { bgcolor: 'grey.50' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||||
|
{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" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleMove(index, 'down')}
|
||||||
|
disabled={index === array.length - 1}
|
||||||
|
sx={{ color: 'text.secondary' }}
|
||||||
|
>
|
||||||
|
<KeyboardArrowDownIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={() => handlePageToggle(key)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</ErrorBoundary>
|
||||||
|
|
||||||
|
<GlobalSnackbar {...snackbarProps} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<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>Document</title>
|
<title>扩展设置 - Testing Tools</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
Hello Options Page
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
|
import theme from '@/config/theme';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/* 隐藏页面滚动条 */
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 隐藏body滚动条但允许滚动 */
|
|
||||||
body {
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* 统一圆角变量 */
|
|
||||||
--radius: 8px;
|
|
||||||
/* 统一背景颜色变量 */
|
|
||||||
--bg-color: #fafafa;
|
|
||||||
/* 统一文字颜色变量 */
|
|
||||||
--text-color: #333333;
|
|
||||||
/* 统一按钮颜色变量 */
|
|
||||||
--btn-bg: #e0e0e0;
|
|
||||||
/* 统一按钮文字颜色变量 */
|
|
||||||
--btn-text: #333333;
|
|
||||||
--border: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app {
|
|
||||||
width: 400px;
|
|
||||||
min-height: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family:
|
|
||||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 导航容器 */
|
|
||||||
.nav-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 导航按钮 */
|
|
||||||
.nav-button {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: 1px solid var(--btn-bg);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--btn-text);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-button:hover {
|
|
||||||
background: var(--btn-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-button.active {
|
|
||||||
background: var(--btn-bg);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
@@ -1,88 +1,43 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import RouterProvider from '@/providers/RouterProvider';
|
||||||
|
import TopBar from '@/components/TopBar';
|
||||||
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
|
import { globalStyles } from '@/config/pageTheme';
|
||||||
|
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
||||||
import { Box } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
import type { PageType } from '@/types/storage';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import TimestampPage from './pages/TimestampPage';
|
|
||||||
import StorageCleanerPage from './pages/StorageCleanerPage';
|
|
||||||
import './App.css';
|
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
const PAGE_CONFIG = {
|
// 打开Chrome扩展选项页面,需确保manifest中已配置options_page或options_ui
|
||||||
timestamp: { label: '时间戳', defaultVisible: true },
|
const handleOpenOptions = () => {
|
||||||
storageCleaner: { label: '存储清理', defaultVisible: true },
|
chrome.runtime.openOptionsPage().catch((r) => console.error(r));
|
||||||
} as const satisfies Record<PageType, { label: string; defaultVisible: boolean }>;
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
|
|
||||||
const [visiblePages, setVisiblePages] = useState<PageType[]>(['timestamp', 'storageCleaner']);
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadInitialData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoaded) {
|
|
||||||
storageUtil.set('app/currentRoute', currentPage);
|
|
||||||
}
|
|
||||||
}, [currentPage, isLoaded]);
|
|
||||||
|
|
||||||
const loadInitialData = async () => {
|
|
||||||
try {
|
|
||||||
const [savedRoute, savedVisiblePages] = await Promise.all([
|
|
||||||
storageUtil.get('app/currentRoute', 'timestamp'),
|
|
||||||
storageUtil.get('app/visiblePages', ['timestamp', 'storageCleaner'] as PageType[]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (savedRoute) {
|
|
||||||
setCurrentPage(savedRoute);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedVisiblePages) {
|
|
||||||
setVisiblePages(savedVisiblePages);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load initial data:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoaded(true);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePageChange = (page: PageType) => {
|
|
||||||
setCurrentPage(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
const NavButton = ({ pageKey }: { pageKey: PageType }) => {
|
|
||||||
const config = PAGE_CONFIG[pageKey];
|
|
||||||
if (!config) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box key={pageKey}>
|
|
||||||
<button
|
|
||||||
className={currentPage === pageKey ? 'nav-button active' : 'nav-button'}
|
|
||||||
onClick={() => handlePageChange(pageKey)}
|
|
||||||
>
|
|
||||||
{config.label}
|
|
||||||
</button>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isLoaded) {
|
|
||||||
return <div className="app">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<RouterProvider syncKey="app/popupRoute">
|
||||||
<Box className="nav-container">
|
<SnackbarProvider initialOptions={{ autoHideDuration: 1500000 }}>
|
||||||
{(Object.keys(PAGE_CONFIG) as PageType[])
|
<Box
|
||||||
.filter((key) => visiblePages.includes(key))
|
className="app"
|
||||||
.map((key) => <NavButton key={key} pageKey={key} />)}
|
sx={{
|
||||||
</Box>
|
display: 'flex',
|
||||||
{currentPage === 'timestamp' && <TimestampPage />}
|
flexDirection: 'column',
|
||||||
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
|
width: '400px',
|
||||||
</div>
|
height: '600px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: globalStyles.backgroundColor,
|
||||||
|
'@media screen and (min-width: 401px), screen and (min-height: 601px)': {
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
minWidth: '400px',
|
||||||
|
minHeight: '600px',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TopBar onOpenOptions={handleOpenOptions} />
|
||||||
|
<ErrorBoundary>
|
||||||
|
<RouterContainer />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Box>
|
||||||
|
</SnackbarProvider>
|
||||||
|
</RouterProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<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>Default Popup Title</title>
|
<title>我是独立窗口</title>
|
||||||
<meta name="manifest.type" content="browser_action" />
|
<meta name="manifest.type" content="browser_action" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
|
import theme from '@/config/theme';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
import './style.css';
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { useRouter } from '@/providers/RouterProvider';
|
||||||
|
import DashboardCard from '@/components/DashboardCard';
|
||||||
|
import { getFeatureByKey } from '@/config/features';
|
||||||
|
import type { PageType } from '@/types/storage';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { dashboardPageStyles } from '@/config/pageTheme';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { navigateTo, visiblePages, pageOrder } = useRouter();
|
||||||
|
|
||||||
|
const isVisible = (key: string) => visiblePages.includes(key as PageType);
|
||||||
|
|
||||||
|
const handleCardClick = useCallback(
|
||||||
|
(page: PageType) => {
|
||||||
|
navigateTo(page);
|
||||||
|
},
|
||||||
|
[navigateTo],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, p: 2 }}>
|
||||||
|
{pageOrder.map((key) => {
|
||||||
|
if (!isVisible(key)) return null;
|
||||||
|
|
||||||
|
const feature = getFeatureByKey(key);
|
||||||
|
if (!feature || !feature.icon || !feature.themeColor) return null;
|
||||||
|
|
||||||
|
// 适配 DashboardCard 组件,将 themeColor 映射到 colorCode
|
||||||
|
const cardConfig = {
|
||||||
|
title: feature.label,
|
||||||
|
description: feature.description,
|
||||||
|
colorCode: feature.themeColor,
|
||||||
|
icon: feature.icon,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardCard
|
||||||
|
key={key}
|
||||||
|
config={cardConfig}
|
||||||
|
onClick={() => handleCardClick(key)}
|
||||||
|
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Box, Stack, Container, CircularProgress } from '@mui/material';
|
||||||
|
import QrCodeIcon from '@mui/icons-material/QrCode';
|
||||||
|
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
|
||||||
|
import UrlToQrCodeSection from '@/components/UrlToQrCodeSection';
|
||||||
|
import QrCodeToUrlSection from '@/components/QrCodeToUrlSection';
|
||||||
|
import { useStorageState } from '@/utils/useStorageState';
|
||||||
|
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||||
|
import PageHeader from '@/components/PageHeader';
|
||||||
|
|
||||||
|
export default function QrCodePage() {
|
||||||
|
const { showMessage } = useGlobalSnackbar();
|
||||||
|
|
||||||
|
// 使用自定义钩子管理展开状态
|
||||||
|
const [urlExpanded, setUrlExpanded, urlInitialized] = useStorageState('qrCode/urlExpanded', true);
|
||||||
|
const [qrExpanded, setQrExpanded, qrInitialized] = useStorageState('qrCode/qrExpanded', false);
|
||||||
|
|
||||||
|
// 初始化未完成时显示加载状态
|
||||||
|
if (!urlInitialized || !qrInitialized) {
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
sx={{
|
||||||
|
py: 4,
|
||||||
|
maxWidth: 400,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Container sx={{ py: 2, maxWidth: 400 }}>
|
||||||
|
<PageHeader
|
||||||
|
title="二维码工具"
|
||||||
|
subtitle="生成和解析二维码"
|
||||||
|
icon={<QrCodeIcon />}
|
||||||
|
iconColor={qrCodePageStyles.primaryColor}
|
||||||
|
sx={{ mb: 2.5 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<UrlToQrCodeSection
|
||||||
|
expanded={urlExpanded}
|
||||||
|
onExpandedChange={setUrlExpanded}
|
||||||
|
showMessage={showMessage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<QrCodeToUrlSection
|
||||||
|
expanded={qrExpanded}
|
||||||
|
onExpandedChange={setQrExpanded}
|
||||||
|
showMessage={showMessage}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,328 +1,89 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { Box, Container, CircularProgress } from '@mui/material';
|
||||||
import {
|
import Button from '@/components/Button';
|
||||||
Paper,
|
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
|
||||||
Typography,
|
import StorageCleanerConfirm from '@/components/StorageCleanerConfirm';
|
||||||
Box,
|
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
||||||
Checkbox,
|
import { useStorageCleaner } from './useStorageCleaner';
|
||||||
Button,
|
import DomainHeader from './components/DomainHeader';
|
||||||
FormControlLabel,
|
import StorageOptionsGrid from './components/StorageOptionsGrid';
|
||||||
Alert,
|
import AutoRefreshToggle from './components/AutoRefreshToggle';
|
||||||
Snackbar,
|
import ErrorDisplay from './components/ErrorDisplay';
|
||||||
} from '@mui/material';
|
import CleaningResult from './components/CleaningResult';
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import type { StorageCleanerOptions, CleaningResult, StorageCleanerPreferences } from 'types/storage';
|
|
||||||
import {
|
|
||||||
getCurrentTab,
|
|
||||||
isRestrictedUrl,
|
|
||||||
clearStorage,
|
|
||||||
formatCleaningResult,
|
|
||||||
} from '@/utils/storageCleaner';
|
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: StorageCleanerOptions = {
|
|
||||||
localStorage: true,
|
|
||||||
sessionStorage: true,
|
|
||||||
indexedDB: true,
|
|
||||||
cookies: true,
|
|
||||||
cacheStorage: true,
|
|
||||||
serviceWorkers: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
|
|
||||||
autoRefresh: true,
|
|
||||||
selectedTypes: DEFAULT_OPTIONS,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StorageCleanerPage() {
|
export default function StorageCleanerPage() {
|
||||||
const [domain, setDomain] = useState<string>('');
|
const { showMessage } = useGlobalSnackbar();
|
||||||
const [error, setError] = useState<string>('');
|
const {
|
||||||
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
domain,
|
||||||
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
|
error,
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
isInitializing,
|
||||||
const [result, setResult] = useState<CleaningResult | null>(null);
|
options,
|
||||||
const [showConfirm, setShowConfirm] = useState<boolean>(false);
|
sizes,
|
||||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string }>({
|
autoRefresh,
|
||||||
open: false,
|
loading,
|
||||||
message: '',
|
result,
|
||||||
});
|
showConfirm,
|
||||||
|
setShowConfirm,
|
||||||
|
totalSize,
|
||||||
|
allSelected,
|
||||||
|
someSelected,
|
||||||
|
handleAutoRefreshChange,
|
||||||
|
handleOptionChange,
|
||||||
|
handleSelectAll,
|
||||||
|
handleClean,
|
||||||
|
} = useStorageCleaner({ showMessage });
|
||||||
|
|
||||||
// Load tab info and user preferences
|
if (isInitializing) {
|
||||||
useEffect(() => {
|
|
||||||
const loadInfo = async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
|
|
||||||
if (!tab || !tab.url) {
|
|
||||||
setError('无法获取当前标签页');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRestrictedUrl(tab.url)) {
|
|
||||||
setError('存储清理功能不支持此页面');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDomain(new URL(tab.url).hostname);
|
|
||||||
|
|
||||||
// Load user preferences
|
|
||||||
const prefs = await storageUtil.get(
|
|
||||||
'storageCleaner/preferences',
|
|
||||||
DEFAULT_PREFERENCES,
|
|
||||||
);
|
|
||||||
setAutoRefresh(prefs?.autoRefresh ?? DEFAULT_PREFERENCES.autoRefresh);
|
|
||||||
setOptions(prefs?.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadInfo();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleOptionChange = useCallback((key: keyof StorageCleanerOptions) => {
|
|
||||||
setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClean = useCallback(async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
|
|
||||||
if (!tab || !tab.id || !tab.url) {
|
|
||||||
setSnackbar({ open: true, message: '无法获取当前标签页' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cleaningResult = await clearStorage(tab.id, tab.url, options);
|
|
||||||
setResult(cleaningResult);
|
|
||||||
|
|
||||||
// Save user preferences
|
|
||||||
await storageUtil.set('storageCleaner/preferences', {
|
|
||||||
autoRefresh,
|
|
||||||
selectedTypes: options,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto refresh if enabled
|
|
||||||
if (autoRefresh && cleaningResult.success && tab.id !== undefined) {
|
|
||||||
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
|
|
||||||
setTimeout(() => {
|
|
||||||
chrome.tabs.reload(tab.id!);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setSnackbar({ open: true, message: `清理失败: ${String(err)}` });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setShowConfirm(false);
|
|
||||||
}
|
|
||||||
}, [options, autoRefresh]);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
if (tab?.id !== undefined) {
|
|
||||||
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
|
|
||||||
setTimeout(() => {
|
|
||||||
chrome.tabs.reload(tab.id!);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
<Alert severity="error" icon={<WarningIcon />}>
|
<CircularProgress size={24} color="warning" />
|
||||||
{error}
|
</Box>
|
||||||
</Alert>
|
|
||||||
</Paper>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ErrorDisplay error={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
|
<Box>
|
||||||
{/* Header */}
|
<Container sx={{ py: 2 }}>
|
||||||
<Box sx={{ textAlign: 'center', mb: 2 }}>
|
<DomainHeader domain={domain} totalSize={totalSize} />
|
||||||
<Typography variant="h5" component="h1" sx={{ mb: 1 }}>
|
|
||||||
存储清理
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
当前页面: {domain || '加载中...'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Storage Type Options */}
|
<StorageOptionsGrid
|
||||||
<Box sx={{ mb: 2 }}>
|
options={options}
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
sizes={sizes}
|
||||||
选择要清理的存储类型:
|
allSelected={allSelected}
|
||||||
</Typography>
|
someSelected={someSelected}
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
onOptionChange={handleOptionChange}
|
||||||
<FormControlLabel
|
onSelectAll={handleSelectAll}
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.localStorage}
|
|
||||||
onChange={() => handleOptionChange('localStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="localStorage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.sessionStorage}
|
|
||||||
onChange={() => handleOptionChange('sessionStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="sessionStorage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.indexedDB}
|
|
||||||
onChange={() => handleOptionChange('indexedDB')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="IndexedDB"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.cookies}
|
|
||||||
onChange={() => handleOptionChange('cookies')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Cookies"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.cacheStorage}
|
|
||||||
onChange={() => handleOptionChange('cacheStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Cache Storage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.serviceWorkers}
|
|
||||||
onChange={() => handleOptionChange('serviceWorkers')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Service Workers"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Auto Refresh Option */}
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={autoRefresh}
|
|
||||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="清理完成后自动刷新页面"
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
<AutoRefreshToggle autoRefresh={autoRefresh} onChange={handleAutoRefreshChange} />
|
||||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => setShowConfirm(true)}
|
onClick={() => setShowConfirm(true)}
|
||||||
|
sx={{
|
||||||
|
bgcolor: storageCleanerPageStyles.warningColor,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: storageCleanerPageStyles.warningDark,
|
||||||
|
},
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
{loading ? '清理中...' : '清理'}
|
{loading ? '正在清理...' : '立即清理'}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Result Display */}
|
<CleaningResult result={result} />
|
||||||
{result && (
|
</Container>
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Alert
|
|
||||||
severity={result.success ? 'success' : 'error'}
|
|
||||||
sx={{ mb: !autoRefresh && result.success ? 1 : 0 }}
|
|
||||||
>
|
|
||||||
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
|
|
||||||
</Alert>
|
|
||||||
{!autoRefresh && result.success && (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
onClick={handleRefresh}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
刷新页面
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
<StorageCleanerConfirm
|
||||||
{showConfirm && (
|
open={showConfirm}
|
||||||
<Paper
|
onClose={() => setShowConfirm(false)}
|
||||||
sx={{
|
onConfirm={handleClean}
|
||||||
position: 'absolute',
|
options={options}
|
||||||
top: 0,
|
/>
|
||||||
left: 0,
|
</Box>
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
bgcolor: 'rgba(255,255,255, 0.95)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 2,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h6">确认清理</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mb: 1 }}>
|
|
||||||
将清理以下存储类型:
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ mb: 1 }}>
|
|
||||||
{options.localStorage && (
|
|
||||||
<Typography variant="body2">- localStorage</Typography>
|
|
||||||
)}
|
|
||||||
{options.sessionStorage && (
|
|
||||||
<Typography variant="body2">- sessionStorage</Typography>
|
|
||||||
)}
|
|
||||||
{options.indexedDB && <Typography variant="body2">- IndexedDB</Typography>}
|
|
||||||
{options.cookies && <Typography variant="body2">- Cookies</Typography>}
|
|
||||||
{options.cacheStorage && (
|
|
||||||
<Typography variant="body2">- Cache Storage</Typography>
|
|
||||||
)}
|
|
||||||
{options.serviceWorkers && (
|
|
||||||
<Typography variant="body2">- Service Workers</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ textAlign: 'center', mb: 1 }}
|
|
||||||
>
|
|
||||||
此操作不可撤销。
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Button variant="outlined" onClick={() => setShowConfirm(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button variant="contained" color="error" onClick={handleClean}>
|
|
||||||
确认清理
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Snackbar */}
|
|
||||||
<Snackbar
|
|
||||||
open={snackbar.open}
|
|
||||||
autoHideDuration={3000}
|
|
||||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
|
||||||
>
|
|
||||||
<Alert severity="info" variant="filled">
|
|
||||||
{snackbar.message}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
</Paper>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,345 +1,103 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import { TextField, Select, MenuItem, Stack, Box, Container } from '@mui/material';
|
||||||
import dayjs from '@/utils/dayjs';
|
import { useSnackbar } from '@/components/SnackbarProvider';
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
TextField,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
IconButton,
|
|
||||||
Snackbar,
|
|
||||||
Alert,
|
|
||||||
InputAdornment,
|
|
||||||
alpha,
|
|
||||||
Tooltip,
|
|
||||||
Theme,
|
|
||||||
} from '@mui/material';
|
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|
||||||
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
|
|
||||||
import CheckIcon from '@mui/icons-material/Check';
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import PageHeader from '@/components/PageHeader';
|
||||||
|
import { ZONES, timestampPageStyles } from '@/config/pageTheme';
|
||||||
|
import LiveClock from './components/LiveClock';
|
||||||
|
import ResultView from './components/ResultView';
|
||||||
|
import { useTimestampConverter } from './hooks/useTimestampConverter';
|
||||||
|
|
||||||
// ================= 常量配置 =================
|
|
||||||
const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
|
|
||||||
const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
|
|
||||||
|
|
||||||
type UnitType = 'ms' | 's';
|
|
||||||
type ZoneType = (typeof ZONES)[number];
|
|
||||||
|
|
||||||
const INPUT_STYLE = {
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
bgcolor: 'grey.50',
|
|
||||||
borderRadius: 3,
|
|
||||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
'& fieldset': { border: 'none' },
|
|
||||||
'&:hover': { bgcolor: 'grey.100' },
|
|
||||||
'&.Mui-focused': {
|
|
||||||
bgcolor: '#fff',
|
|
||||||
boxShadow: (theme: Theme) => `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}, 0 4px 12px rgba(0,0,0,0.03)`,
|
|
||||||
},
|
|
||||||
'&.Mui-error': {
|
|
||||||
boxShadow: (theme: Theme) => `0 0 0 2px ${alpha(theme.palette.error.main, 0.2)}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'& .MuiInputBase-input': { py: 1.5, fontFamily: 'monospace' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// ================= 子组件:实时时钟 =================
|
|
||||||
interface LiveClockProps {
|
|
||||||
unit: UnitType;
|
|
||||||
onCopy: (val: string) => void;
|
|
||||||
onUseNow: (val: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LiveClock = React.memo(({
|
|
||||||
unit,
|
|
||||||
onCopy,
|
|
||||||
onUseNow
|
|
||||||
}: LiveClockProps) => {
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
||||||
return () => clearInterval(t);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const displayVal = useMemo(() =>
|
|
||||||
String(Math.floor(now / (unit === 'ms' ? 1 : 1000))),
|
|
||||||
[now, unit]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 4 }}>
|
|
||||||
<Stack direction="row" spacing={1} alignItems="baseline">
|
|
||||||
<Typography variant="h5" sx={{ fontWeight: 300, letterSpacing: '-1px', color: 'text.primary', fontFamily: 'monospace' }}>
|
|
||||||
{displayVal}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase' }}>
|
|
||||||
{unit}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="row" spacing={0.5}>
|
|
||||||
<Tooltip title="填充到下方">
|
|
||||||
<IconButton
|
|
||||||
aria-label="use current time"
|
|
||||||
size="small"
|
|
||||||
onClick={() => onUseNow(now)}
|
|
||||||
sx={{ color: 'primary.main', transition: 'all 0.2s', '&:hover': { bgcolor: alpha('#2563eb', 0.08) } }}
|
|
||||||
>
|
|
||||||
<AccessTimeIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="复制当前时间戳">
|
|
||||||
<IconButton
|
|
||||||
aria-label="copy current timestamp"
|
|
||||||
size="small"
|
|
||||||
onClick={() => onCopy(displayVal)}
|
|
||||||
sx={{ color: 'grey.400', transition: 'all 0.2s', '&:hover': { color: 'primary.main', transform: 'scale(1.1)' } }}
|
|
||||||
>
|
|
||||||
<ContentCopyIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
LiveClock.displayName = 'LiveClock';
|
|
||||||
|
|
||||||
// ================= 子组件:多维度结果展示 =================
|
|
||||||
interface ResultViewProps {
|
|
||||||
result: string;
|
|
||||||
mode: 'ts2dt' | 'dt2ts';
|
|
||||||
unit: UnitType;
|
|
||||||
zone: string;
|
|
||||||
onCopy: (val: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ResultView = React.memo(({
|
|
||||||
result,
|
|
||||||
mode,
|
|
||||||
unit,
|
|
||||||
zone,
|
|
||||||
onCopy
|
|
||||||
}: ResultViewProps) => {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const handleCopy = useCallback(() => {
|
|
||||||
onCopy(result);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 1500);
|
|
||||||
}, [onCopy, result]);
|
|
||||||
|
|
||||||
const extraInfo = useMemo(() => {
|
|
||||||
if (!result) return null;
|
|
||||||
const d = mode === 'ts2dt' ? dayjs(result, DATE_FORMAT).tz(zone) : (unit === 'ms' ? dayjs(Number(result)) : dayjs.unix(Number(result)));
|
|
||||||
|
|
||||||
return {
|
|
||||||
relative: d.fromNow(),
|
|
||||||
iso: d.toISOString(),
|
|
||||||
utc: d.utc().format(DATE_FORMAT) + ' UTC',
|
|
||||||
};
|
|
||||||
}, [result, mode, zone, unit]);
|
|
||||||
|
|
||||||
if (!result) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{
|
|
||||||
mt: 3, pt: 3, borderTop: '1px solid', borderColor: 'grey.50',
|
|
||||||
animation: 'fadeIn 0.3s ease-out',
|
|
||||||
'@keyframes fadeIn': { from: { opacity: 0, transform: 'translateY(10px)' }, to: { opacity: 1, transform: 'translateY(0)' } }
|
|
||||||
}}>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.disabled', mb: 1, display: 'block', ml: 1, fontWeight: 500 }}>
|
|
||||||
转换结果
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
value={result}
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
readOnly: true,
|
|
||||||
endAdornment: (
|
|
||||||
<InputAdornment position="end">
|
|
||||||
<IconButton
|
|
||||||
aria-label="copy result"
|
|
||||||
size="small"
|
|
||||||
onClick={handleCopy}
|
|
||||||
sx={{
|
|
||||||
color: copied ? 'success.main' : 'primary.main',
|
|
||||||
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
|
||||||
transform: copied ? 'scale(1.2)' : 'scale(1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
|
|
||||||
</IconButton>
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
...INPUT_STYLE,
|
|
||||||
mb: 2,
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
...INPUT_STYLE['& .MuiOutlinedInput-root'],
|
|
||||||
bgcolor: alpha('#2563eb', 0.03),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 辅助信息预览 */}
|
|
||||||
<Stack spacing={1} sx={{ px: 1 }}>
|
|
||||||
{[
|
|
||||||
{ label: '相对时间', value: extraInfo?.relative },
|
|
||||||
{ label: 'ISO 8601', value: extraInfo?.iso },
|
|
||||||
{ label: 'UTC 时间', value: extraInfo?.utc },
|
|
||||||
].map((item) => (
|
|
||||||
<Box key={item.label} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{item.label}</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
onClick={() => { if (item.value) onCopy(item.value); }}
|
|
||||||
sx={{
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: 'text.primary',
|
|
||||||
cursor: 'pointer',
|
|
||||||
'&:hover': { color: 'primary.main', textDecoration: 'underline' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ResultView.displayName = 'ResultView';
|
|
||||||
|
|
||||||
// ================= 主页面组件 =================
|
|
||||||
export default function TimestampPage() {
|
export default function TimestampPage() {
|
||||||
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
|
const { showMessage } = useSnackbar();
|
||||||
const [tsInput, setTsInput] = useState(() => String(Date.now()));
|
const {
|
||||||
const [dtInput, setDtInput] = useState(() => dayjs().format(DATE_FORMAT));
|
mode,
|
||||||
const [unit, setUnit] = useState<UnitType>('ms');
|
tsInput,
|
||||||
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
|
dtInput,
|
||||||
const [result, setResult] = useState('');
|
unit,
|
||||||
const [error, setError] = useState('');
|
zone,
|
||||||
const [snack, setSnack] = useState<{ open: boolean; msg: string }>({ open: false, msg: '' });
|
result,
|
||||||
|
error,
|
||||||
const copy = useCallback(async (text: string) => {
|
setMode,
|
||||||
try {
|
setTsInput,
|
||||||
await navigator.clipboard.writeText(text);
|
setDtInput,
|
||||||
setSnack({ open: true, msg: '已复制' });
|
setUnit,
|
||||||
} catch {
|
setZone,
|
||||||
setSnack({ open: true, msg: '复制失败' });
|
handleUseNow,
|
||||||
}
|
convert,
|
||||||
}, []);
|
} = useTimestampConverter();
|
||||||
|
|
||||||
const convert = useCallback(() => {
|
|
||||||
if (mode === 'ts2dt') {
|
|
||||||
const rawInput = tsInput.trim();
|
|
||||||
if (!rawInput) return;
|
|
||||||
const num = Number(rawInput);
|
|
||||||
if (isNaN(num)) { setError('无效数字'); return; }
|
|
||||||
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
|
|
||||||
if (!d.isValid()) { setError('无效时间戳'); return; }
|
|
||||||
setError('');
|
|
||||||
setResult(d.tz(zone).format(DATE_FORMAT));
|
|
||||||
} else {
|
|
||||||
const rawInput = dtInput.trim();
|
|
||||||
if (!rawInput) return;
|
|
||||||
const d = dayjs.tz(rawInput, DATE_FORMAT, zone);
|
|
||||||
if (!d.isValid()) { setError('格式错误'); return; }
|
|
||||||
setError('');
|
|
||||||
const ms = d.valueOf();
|
|
||||||
setResult(unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000)));
|
|
||||||
}
|
|
||||||
}, [mode, tsInput, dtInput, unit, zone]);
|
|
||||||
|
|
||||||
// 智能实时转换 (Debounce Effect)
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
convert();
|
|
||||||
}, 400);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [convert]);
|
|
||||||
|
|
||||||
const handleUseNow = useCallback((now: number) => {
|
|
||||||
if (mode === 'ts2dt') {
|
|
||||||
setTsInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
|
|
||||||
} else {
|
|
||||||
setDtInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
|
||||||
}
|
|
||||||
}, [mode, unit, zone]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 1, width: '100%', bgcolor: 'transparent', boxSizing: 'border-box' }}>
|
<Box>
|
||||||
<Paper
|
<Container sx={{ p: 2 }}>
|
||||||
elevation={0}
|
{/* Header */}
|
||||||
sx={{
|
<PageHeader
|
||||||
p: 2.5,
|
title="时间戳转换"
|
||||||
borderRadius: 4,
|
subtitle="Unix 毫秒数转换与格式化"
|
||||||
border: '1px solid',
|
icon={<AccessTimeIcon />}
|
||||||
borderColor: 'grey.100',
|
/>
|
||||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
'&:hover': { boxShadow: '0 12px 40px rgba(0,0,0,0.06)', borderColor: 'grey.200' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 1. 实时时钟 */}
|
|
||||||
<Box sx={{ position: 'relative' }}>
|
|
||||||
<LiveClock unit={unit} onCopy={copy} onUseNow={handleUseNow} />
|
|
||||||
<Tooltip title="切换单位">
|
|
||||||
<IconButton
|
|
||||||
aria-label="switch unit"
|
|
||||||
size="small"
|
|
||||||
onClick={() => { setUnit((u) => (u === 'ms' ? 's' : 'ms')); }}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', right: 80, top: 4, color: 'grey.400',
|
|
||||||
transition: 'transform 0.3s ease',
|
|
||||||
'&:hover': { transform: 'rotate(180deg)', color: 'primary.main' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SwapHorizIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 2. 模式切换 */}
|
{/* Live Clock Card */}
|
||||||
<Box sx={{ position: 'relative', display: 'flex', p: 0.5, bgcolor: 'grey.100', borderRadius: 3.5, mb: 3, overflow: 'hidden' }}>
|
<LiveClock
|
||||||
|
unit={unit}
|
||||||
|
onUseNow={handleUseNow}
|
||||||
|
onUnitChange={setUnit}
|
||||||
|
showMessage={showMessage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Mode Switcher */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
p: 0.6,
|
||||||
|
bgcolor: 'grey.100',
|
||||||
|
borderRadius: 4,
|
||||||
|
mb: 2.5,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.200',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute', height: 'calc(100% - 8px)', width: 'calc(50% - 4px)',
|
position: 'absolute',
|
||||||
bgcolor: '#fff', borderRadius: 3, boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
height: 'calc(100% - 10px)',
|
||||||
transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
width: 'calc(50% - 5px)',
|
||||||
|
bgcolor: '#fff',
|
||||||
|
borderRadius: 3.5,
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
|
||||||
|
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
transform: mode === 'ts2dt' ? 'translateX(0)' : 'translateX(100%)',
|
transform: mode === 'ts2dt' ? 'translateX(0)' : 'translateX(100%)',
|
||||||
top: 4, left: 4,
|
top: 5,
|
||||||
|
left: 5,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{(['ts2dt', 'dt2ts'] as const).map((m) => (
|
{(['ts2dt', 'dt2ts'] as const).map((m) => (
|
||||||
<Button
|
<Box
|
||||||
key={m} fullWidth disableRipple
|
key={m}
|
||||||
onClick={() => { setMode(m); setError(''); setResult(''); }}
|
onClick={() => setMode(m)}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative', zIndex: 1, borderRadius: 3, py: 1, textTransform: 'none',
|
flex: 1,
|
||||||
fontSize: '0.875rem', fontWeight: 500, transition: 'color 0.2s',
|
py: 1,
|
||||||
color: mode === m ? 'text.primary' : 'text.disabled',
|
textAlign: 'center',
|
||||||
'&:hover': { bgcolor: 'transparent', color: mode === m ? 'text.primary' : 'text.secondary' },
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: mode === m ? 'primary.main' : 'text.secondary',
|
||||||
|
transition: 'color 0.3s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'}
|
{m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'}
|
||||||
</Button>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 3. 输入与设置 */}
|
{/* Input Area */}
|
||||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||||
<TextField
|
<TextField
|
||||||
placeholder={mode === 'ts2dt' ? "输入时间戳..." : DATE_FORMAT}
|
placeholder={mode === 'ts2dt' ? '输入时间戳...' : 'YYYY-MM-DD HH:mm:ss'}
|
||||||
value={mode === 'ts2dt' ? tsInput : dtInput}
|
value={mode === 'ts2dt' ? tsInput : dtInput}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value;
|
const val = e.target.value;
|
||||||
@@ -348,65 +106,77 @@ export default function TimestampPage() {
|
|||||||
} else {
|
} else {
|
||||||
setDtInput(val);
|
setDtInput(val);
|
||||||
}
|
}
|
||||||
setError('');
|
|
||||||
}}
|
}}
|
||||||
error={!!error}
|
error={!!error}
|
||||||
helperText={error}
|
helperText={error}
|
||||||
fullWidth
|
fullWidth
|
||||||
sx={INPUT_STYLE}
|
sx={timestampPageStyles.INPUT_STYLE}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Stack direction="row" spacing={2}>
|
<Stack direction="row" spacing={1.5}>
|
||||||
<Select
|
{/* 优化后的单位选择按钮组 */}
|
||||||
fullWidth value={unit}
|
<Box
|
||||||
onChange={(e) => { setUnit(e.target.value as UnitType); }}
|
sx={{
|
||||||
sx={{ ...INPUT_STYLE, flex: 1 }}
|
flex: 1,
|
||||||
MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' } } }}
|
display: 'flex',
|
||||||
|
bgcolor: 'grey.50',
|
||||||
|
p: 0.5,
|
||||||
|
borderRadius: 3.5,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.100',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem value="ms">毫秒 (ms)</MenuItem>
|
{(['ms', 's'] as const).map((u) => (
|
||||||
<MenuItem value="s">秒 (s)</MenuItem>
|
<Box
|
||||||
</Select>
|
key={u}
|
||||||
|
onClick={() => setUnit(u)}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
py: 0.8,
|
||||||
|
textAlign: 'center',
|
||||||
|
borderRadius: 3,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 800,
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
bgcolor: unit === u ? '#fff' : 'transparent',
|
||||||
|
color: unit === u ? 'primary.main' : 'text.disabled',
|
||||||
|
boxShadow: unit === u ? '0 2px 8px rgba(0,0,0,0.05)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{u === 'ms' ? '毫秒 (ms)' : '秒 (s)'}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
fullWidth value={zone}
|
fullWidth
|
||||||
onChange={(e) => { setZone(e.target.value as ZoneType); }}
|
value={zone}
|
||||||
sx={{ ...INPUT_STYLE, flex: 1.5 }}
|
onChange={(e) => setZone(e.target.value as typeof zone)}
|
||||||
MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' } } }}
|
sx={{ ...timestampPageStyles.INPUT_STYLE, flex: 1, borderRadius: 4 }}
|
||||||
|
MenuProps={{
|
||||||
|
PaperProps: {
|
||||||
|
sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 32px rgba(0,0,0,0.1)' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{ZONES.map((z) => (
|
{ZONES.map((z) => (
|
||||||
<MenuItem key={z} value={z}>{z}</MenuItem>
|
<MenuItem key={z} value={z} sx={{ fontSize: '0.8rem', fontWeight: 600 }}>
|
||||||
|
{z}
|
||||||
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* 4. 转换操作 (作为手动确认) */}
|
{/* Main Action */}
|
||||||
<Button
|
<Button fullWidth variant="contained" onClick={convert}>
|
||||||
fullWidth variant="contained" disableElevation disableRipple
|
|
||||||
onClick={convert}
|
|
||||||
sx={{
|
|
||||||
py: 1.6, borderRadius: 3, fontSize: '1rem', fontWeight: 600, textTransform: 'none',
|
|
||||||
bgcolor: 'primary.main', transition: 'all 0.2s',
|
|
||||||
'&:hover': { bgcolor: 'primary.dark', transform: 'translateY(-1px)' },
|
|
||||||
'&:active': { transform: 'translateY(0)' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
立即转换
|
立即转换
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* 5. 结果展示 */}
|
{/* Result View */}
|
||||||
<ResultView result={result} mode={mode} unit={unit} zone={zone} onCopy={copy} />
|
<ResultView result={result} mode={mode} unit={unit} zone={zone} showMessage={showMessage} />
|
||||||
</Paper>
|
</Container>
|
||||||
|
|
||||||
<Snackbar
|
|
||||||
open={snack.open} autoHideDuration={1500}
|
|
||||||
onClose={() => { setSnack((s) => ({ ...s, open: false })); }}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
||||||
>
|
|
||||||
<Alert severity="success" variant="filled" icon={false} sx={{ borderRadius: 2.5, bgcolor: 'grey.900' }}>
|
|
||||||
{snack.msg}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Box, Switch, Typography } from '@mui/material';
|
||||||
|
|
||||||
|
interface AutoRefreshToggleProps {
|
||||||
|
autoRefresh: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AutoRefreshToggle({ autoRefresh, onChange }: AutoRefreshToggleProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: 3,
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.100',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" fontWeight={700} sx={{ fontSize: '0.8rem', px: 1.2 }}>
|
||||||
|
清理后自动刷新页面
|
||||||
|
</Typography>
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={autoRefresh}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
color="warning"
|
||||||
|
sx={{
|
||||||
|
'& .MuiSwitch-track': {
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
'& .MuiSwitch-thumb': {
|
||||||
|
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
},
|
||||||
|
'&:hover .MuiSwitch-thumb': {
|
||||||
|
transform: 'scale(1.1)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Box, Alert } from '@mui/material';
|
||||||
|
import type { CleaningResult } from '@/types/storage';
|
||||||
|
import { formatCleaningResult } from '@/utils/storageCleaner';
|
||||||
|
|
||||||
|
interface CleaningResultProps {
|
||||||
|
result: CleaningResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CleaningResult({ result }: CleaningResultProps) {
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 3, animation: 'fadeIn 0.3s ease-in-out' }}>
|
||||||
|
<Alert
|
||||||
|
severity={result.success ? 'success' : 'error'}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
py: 1,
|
||||||
|
px: 2,
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
||||||
|
'& .MuiAlert-message': {
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
'& .MuiAlert-icon': {
|
||||||
|
fontSize: '1.2rem',
|
||||||
|
mr: 1,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Box } from '@mui/material';
|
||||||
|
import StorageIcon from '@mui/icons-material/Storage';
|
||||||
|
import PageHeader from '@/components/PageHeader';
|
||||||
|
import { formatSize } from '@/utils/storageCleaner';
|
||||||
|
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DomainHeader 组件属性接口
|
||||||
|
*/
|
||||||
|
interface DomainHeaderProps {
|
||||||
|
/** 当前域名 */
|
||||||
|
domain: string;
|
||||||
|
/** 已占用的存储大小(字节) */
|
||||||
|
totalSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DomainHeader - 存储清理页面标题栏组件
|
||||||
|
*
|
||||||
|
* 使用 PageHeader 组件构建,显示域名和已占用存储空间大小
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <DomainHeader
|
||||||
|
* domain="example.com"
|
||||||
|
* totalSize={1048576}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export default function DomainHeader({ domain, totalSize }: DomainHeaderProps) {
|
||||||
|
return (
|
||||||
|
<PageHeader
|
||||||
|
icon={<StorageIcon sx={{ fontSize: 22 }} />}
|
||||||
|
iconColor={storageCleanerPageStyles.warningColor}
|
||||||
|
title="存储清理"
|
||||||
|
subtitle={domain || '加载中...'}
|
||||||
|
badge={
|
||||||
|
totalSize > 0 ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'rgba(255, 152, 0, 0.15)',
|
||||||
|
color: storageCleanerPageStyles.warningColor,
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.3,
|
||||||
|
borderRadius: 2,
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
boxShadow: '0 2px 4px rgba(255, 152, 0, 0.2)',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'rgba(255, 152, 0, 0.25)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
已占用 {formatSize(totalSize)}
|
||||||
|
</Box>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
iconSx={{
|
||||||
|
p: 1.2,
|
||||||
|
borderRadius: 3,
|
||||||
|
boxShadow: '0 2px 8px rgba(255, 152, 0, 0.15)',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'rgba(255, 152, 0, 0.15)',
|
||||||
|
transform: 'scale(1.05)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
titleSx={{
|
||||||
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
subtitleSx={{
|
||||||
|
display: 'block',
|
||||||
|
maxWidth: 240,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
mt: 0.3,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
}}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Box, Container, Typography } from '@mui/material';
|
||||||
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
|
|
||||||
|
interface ErrorDisplayProps {
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ErrorDisplay({ error }: ErrorDisplayProps) {
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
sx={{
|
||||||
|
py: 8,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: '400px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ width: '100%', maxWidth: 320 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 4,
|
||||||
|
p: 4,
|
||||||
|
boxShadow: '0 8px 24px rgba(244, 67, 54, 0.15)',
|
||||||
|
border: '1px solid rgba(244, 67, 54, 0.2)',
|
||||||
|
bgcolor: 'rgba(244, 67, 54, 0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WarningIcon sx={{ fontSize: 36, color: 'error.main', mb: 2 }} />
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
color="error.main"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
存储清理功能仅适用于标准网页
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { Stack, Typography, Box, IconButton, Tooltip, Divider, alpha } from '@mui/material';
|
||||||
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
|
import CopyButton from '@/components/CopyButton';
|
||||||
|
import { timestampPageStyles } from '@/config/pageTheme';
|
||||||
|
import type { UnitType } from '@/config/pageTheme';
|
||||||
|
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||||
|
|
||||||
|
interface LiveClockProps {
|
||||||
|
unit: UnitType;
|
||||||
|
onUseNow: (val: number) => void;
|
||||||
|
onUnitChange: (u: UnitType) => void;
|
||||||
|
showMessage?: (message: string, options?: SnackbarOptions) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LiveClock = React.memo(({ unit, onUseNow, onUnitChange, showMessage }: LiveClockProps) => {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const onUseNowRef = useRef(onUseNow);
|
||||||
|
const showMessageRef = useRef(showMessage);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onUseNowRef.current = onUseNow;
|
||||||
|
showMessageRef.current = showMessage;
|
||||||
|
}, [onUseNow, showMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const displayVal = useMemo(
|
||||||
|
() => String(Math.floor(now / (unit === 'ms' ? 1 : 1000))),
|
||||||
|
[now, unit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUseNow = useCallback(() => {
|
||||||
|
onUseNowRef.current(now);
|
||||||
|
showMessageRef.current?.('已使用当前时间戳', { severity: 'success' });
|
||||||
|
}, [now, showMessageRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
p: 1.8,
|
||||||
|
mb: 2.5,
|
||||||
|
bgcolor: alpha(timestampPageStyles.primaryColor, 0.04),
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack spacing={0.5}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: timestampPageStyles.primaryColor,
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: '0.6rem',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
当前时间戳
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 800,
|
||||||
|
color: timestampPageStyles.primaryColor,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '1.2rem',
|
||||||
|
letterSpacing: '-0.5px',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayVal}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
{/* 胶囊式单位切换器 */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
p: 0.4,
|
||||||
|
bgcolor: alpha(timestampPageStyles.primaryColor, 0.08),
|
||||||
|
borderRadius: 2.5,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(['ms', 's'] as const).map((u) => (
|
||||||
|
<Box
|
||||||
|
key={u}
|
||||||
|
onClick={() => onUnitChange(u)}
|
||||||
|
sx={{
|
||||||
|
px: 1.2,
|
||||||
|
py: 0.35,
|
||||||
|
borderRadius: 2,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
fontWeight: 900,
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
bgcolor: unit === u ? '#fff' : 'transparent',
|
||||||
|
color: unit === u ? 'primary.main' : alpha(timestampPageStyles.primaryColor, 0.4),
|
||||||
|
boxShadow: unit === u ? '0 2px 6px rgba(33, 150, 243, 0.2)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{u.toUpperCase()}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider
|
||||||
|
orientation="vertical"
|
||||||
|
flexItem
|
||||||
|
sx={{ mx: 0.5, my: 1, borderColor: alpha(timestampPageStyles.primaryColor, 0.1) }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={0.5}>
|
||||||
|
<Tooltip title="填充到下方">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={handleUseNow}
|
||||||
|
sx={{
|
||||||
|
color: timestampPageStyles.primaryColor,
|
||||||
|
bgcolor: '#fff',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
|
||||||
|
'&:hover': { bgcolor: timestampPageStyles.primaryColor, color: '#fff' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccessTimeIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<CopyButton
|
||||||
|
text={displayVal}
|
||||||
|
tooltip="复制时间戳"
|
||||||
|
size="small"
|
||||||
|
color={timestampPageStyles.primaryColor}
|
||||||
|
showMessage={showMessage}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
LiveClock.displayName = 'LiveClock';
|
||||||
|
|
||||||
|
export default LiveClock;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Box, Checkbox, Typography } from '@mui/material';
|
||||||
|
import { formatSize } from '@/utils/storageCleaner';
|
||||||
|
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
||||||
|
|
||||||
|
interface OptionItemProps {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
size?: number;
|
||||||
|
isCount?: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OptionItem({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
size,
|
||||||
|
isCount = false,
|
||||||
|
onChange,
|
||||||
|
}: OptionItemProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
py: 1,
|
||||||
|
px: 1.5,
|
||||||
|
borderRadius: 3,
|
||||||
|
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
bgcolor: checked ? 'rgba(255, 152, 0, 0.05)' : 'transparent',
|
||||||
|
border: `1px solid ${checked ? 'rgba(255, 152, 0, 0.2)' : 'transparent'}`,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: checked ? 'rgba(255, 152, 0, 0.1)' : 'rgba(0, 0, 0, 0.02)',
|
||||||
|
transform: 'translateY(-1px)',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0, mr: 1.5 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
fontWeight={700}
|
||||||
|
color={checked ? storageCleanerPageStyles.warningColor : 'text.primary'}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
display: 'block',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
transition: 'color 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
{size !== undefined && size > 0 ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
display: 'block',
|
||||||
|
mt: 0.3,
|
||||||
|
lineHeight: 1,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
opacity: 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCount ? `${size} 个` : formatSize(size)}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: 'grey.400',
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'block',
|
||||||
|
mt: 0.3,
|
||||||
|
lineHeight: 1,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
无数据
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
color="warning"
|
||||||
|
sx={{
|
||||||
|
p: 0.6,
|
||||||
|
'& .MuiSvgIcon-root': {
|
||||||
|
fontSize: 18,
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
},
|
||||||
|
'&:hover .MuiSvgIcon-root': {
|
||||||
|
transform: 'scale(1.1)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { Typography, Box, Fade, Stack, alpha } from '@mui/material';
|
||||||
|
import dayjs from '@/utils/dayjs';
|
||||||
|
import CopyButton from '@/components/CopyButton';
|
||||||
|
import { DATE_FORMAT, timestampPageStyles } from '@/config/pageTheme';
|
||||||
|
import type { UnitType } from '@/config/pageTheme';
|
||||||
|
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||||
|
|
||||||
|
interface ResultViewProps {
|
||||||
|
result: string;
|
||||||
|
mode: 'ts2dt' | 'dt2ts';
|
||||||
|
unit: UnitType;
|
||||||
|
zone: string;
|
||||||
|
showMessage?: (message: string, options?: SnackbarOptions) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: ResultViewProps) => {
|
||||||
|
const extraInfo = useMemo(() => {
|
||||||
|
if (!result) return null;
|
||||||
|
const d =
|
||||||
|
mode === 'ts2dt'
|
||||||
|
? dayjs(result, DATE_FORMAT).tz(zone)
|
||||||
|
: unit === 'ms'
|
||||||
|
? dayjs(Number(result))
|
||||||
|
: dayjs.unix(Number(result));
|
||||||
|
|
||||||
|
return {
|
||||||
|
relative: d.fromNow(),
|
||||||
|
iso: d.toISOString(),
|
||||||
|
utc: d.utc().format(DATE_FORMAT) + ' UTC',
|
||||||
|
};
|
||||||
|
}, [result, mode, zone, unit]);
|
||||||
|
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fade in={!!result}>
|
||||||
|
<Box sx={{ mt: 3, pt: 2.5, borderTop: '1px solid', borderColor: 'grey.50' }}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
mb: 1.2,
|
||||||
|
display: 'block',
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
转换结果
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor: alpha(timestampPageStyles.primaryColor, 0.05),
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
position: 'relative',
|
||||||
|
mb: 2.5,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: timestampPageStyles.primaryColor,
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
pr: 4,
|
||||||
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result}
|
||||||
|
</Typography>
|
||||||
|
<CopyButton
|
||||||
|
text={result}
|
||||||
|
tooltip="复制结果"
|
||||||
|
size="small"
|
||||||
|
color={timestampPageStyles.primaryColor}
|
||||||
|
showMessage={showMessage}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
spacing={1.2}
|
||||||
|
sx={{
|
||||||
|
bgcolor: alpha(timestampPageStyles.primaryColor, 0.05),
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ label: '相对时间', value: extraInfo?.relative },
|
||||||
|
{ label: 'ISO 8601', value: extraInfo?.iso },
|
||||||
|
{ label: 'UTC 时间', value: extraInfo?.utc },
|
||||||
|
].map((item) => (
|
||||||
|
<Box
|
||||||
|
key={item.label}
|
||||||
|
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: 'text.disabled', fontWeight: 700, fontSize: '0.65rem', pr: 4 }}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: timestampPageStyles.primaryColor,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</Typography>
|
||||||
|
{item.value && (
|
||||||
|
<CopyButton
|
||||||
|
text={item.value}
|
||||||
|
tooltip="复制"
|
||||||
|
size="small"
|
||||||
|
color={timestampPageStyles.primaryColor}
|
||||||
|
showMessage={showMessage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ResultView.displayName = 'ResultView';
|
||||||
|
|
||||||
|
export default ResultView;
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { Box, Checkbox, Divider, Grid, Typography } from '@mui/material';
|
||||||
|
import type { StorageCleanerOptions } from '@/types/storage';
|
||||||
|
import OptionItem from './OptionItem';
|
||||||
|
|
||||||
|
interface StorageOptionsGridProps {
|
||||||
|
options: StorageCleanerOptions;
|
||||||
|
sizes: Record<string, number>;
|
||||||
|
allSelected: boolean;
|
||||||
|
someSelected: boolean;
|
||||||
|
onOptionChange: (key: keyof StorageCleanerOptions) => void;
|
||||||
|
onSelectAll: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StorageOptionsGrid({
|
||||||
|
options,
|
||||||
|
sizes,
|
||||||
|
allSelected,
|
||||||
|
someSelected,
|
||||||
|
onOptionChange,
|
||||||
|
onSelectAll,
|
||||||
|
}: StorageOptionsGridProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: 3,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.100',
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
overflow: 'hidden',
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.08)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ p: 1.2 }}>
|
||||||
|
<Grid container spacing={1.5}>
|
||||||
|
<Grid size={6}>
|
||||||
|
<OptionItem
|
||||||
|
label="LocalStorage"
|
||||||
|
checked={options.localStorage}
|
||||||
|
size={sizes.localStorage}
|
||||||
|
onChange={() => onOptionChange('localStorage')}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<OptionItem
|
||||||
|
label="Session Storage"
|
||||||
|
checked={options.sessionStorage}
|
||||||
|
size={sizes.sessionStorage}
|
||||||
|
onChange={() => onOptionChange('sessionStorage')}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<OptionItem
|
||||||
|
label="IndexedDB"
|
||||||
|
checked={options.indexedDB}
|
||||||
|
size={sizes.indexedDB}
|
||||||
|
onChange={() => onOptionChange('indexedDB')}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<OptionItem
|
||||||
|
label="Cookies"
|
||||||
|
checked={options.cookies}
|
||||||
|
size={sizes.cookies}
|
||||||
|
onChange={() => onOptionChange('cookies')}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<OptionItem
|
||||||
|
label="Cache Storage"
|
||||||
|
checked={options.cacheStorage}
|
||||||
|
size={sizes.cacheStorage}
|
||||||
|
isCount
|
||||||
|
onChange={() => onOptionChange('cacheStorage')}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<OptionItem
|
||||||
|
label="Service Workers"
|
||||||
|
checked={options.serviceWorkers}
|
||||||
|
size={sizes.serviceWorkers}
|
||||||
|
isCount
|
||||||
|
onChange={() => onOptionChange('serviceWorkers')}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ mx: 0, borderColor: 'grey.100' }} />
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
px: 2.7,
|
||||||
|
py: 0.8,
|
||||||
|
borderBottomLeftRadius: 4,
|
||||||
|
borderBottomRightRadius: 4,
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'rgba(0, 0, 0, 0.04)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
fontWeight={700}
|
||||||
|
sx={{ color: 'text.secondary', fontSize: '0.7rem', px: 0 }}
|
||||||
|
>
|
||||||
|
全选所有项
|
||||||
|
</Typography>
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={allSelected}
|
||||||
|
indeterminate={someSelected}
|
||||||
|
onChange={(e) => onSelectAll(e.target.checked)}
|
||||||
|
color="warning"
|
||||||
|
sx={{
|
||||||
|
p: 0.6,
|
||||||
|
mr: 0,
|
||||||
|
'& .MuiSvgIcon-root': {
|
||||||
|
fontSize: 18,
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
},
|
||||||
|
'&:hover .MuiSvgIcon-root': {
|
||||||
|
transform: 'scale(1.1)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getActiveTabDomain } from '@/utils/chromeTabs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动获取并维护当前活动标签页域名的 Hook
|
||||||
|
*/
|
||||||
|
export function useActiveTabDomain() {
|
||||||
|
const [domain, setDomain] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getActiveTabDomain().then(setDomain);
|
||||||
|
|
||||||
|
// 如果需要实时同步(如切换标签页),可以监听 chrome.tabs.onActivated
|
||||||
|
const handleActivated = () => getActiveTabDomain().then(setDomain);
|
||||||
|
const handleUpdated = (_: number, changeInfo: { url?: string }) => {
|
||||||
|
if (changeInfo.url) getActiveTabDomain().then(setDomain);
|
||||||
|
};
|
||||||
|
|
||||||
|
chrome.tabs.onActivated.addListener(handleActivated);
|
||||||
|
chrome.tabs.onUpdated.addListener(handleUpdated);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
chrome.tabs.onActivated.removeListener(handleActivated);
|
||||||
|
chrome.tabs.onUpdated.removeListener(handleUpdated);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
|
||||||
|
import { MessageAction, sendMessageToContent, injectContentScript } from '@/utils/messages';
|
||||||
|
import { FillMode } from '@/utils/dummyDataGenerator';
|
||||||
|
import { useStorageState } from '@/utils/useStorageState';
|
||||||
|
import { FieldTypePreferences } from '@/types/storage';
|
||||||
|
import { useActiveTabDomain } from './useActiveTabDomain';
|
||||||
|
import { useSidePanelState } from './useSidePanelState';
|
||||||
|
|
||||||
|
// 字段数据接口
|
||||||
|
export interface FieldData {
|
||||||
|
id: string;
|
||||||
|
fieldType: string;
|
||||||
|
label: string | null;
|
||||||
|
placeholder: string;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
generatedValue: string;
|
||||||
|
useInvalidData?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FIELD_TYPE_PREFERENCES: FieldTypePreferences = {};
|
||||||
|
|
||||||
|
export function useFormRecognizer() {
|
||||||
|
const { showMessage } = useGlobalSnackbar({ autoHideDuration: 1500 });
|
||||||
|
const [fillLoading, setFillLoading] = useState(false);
|
||||||
|
const [clearLoading, setClearLoading] = useState(false);
|
||||||
|
const [includeHidden, setIncludeHidden] = useState(false);
|
||||||
|
const isProcessingRef = useRef(false);
|
||||||
|
const [fields, setFields] = useState<FieldData[]>([]);
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [showFields, setShowFields] = useState(false);
|
||||||
|
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const currentDomain = useActiveTabDomain();
|
||||||
|
const { sidePanelOpen, handleOpenSidePanel } = useSidePanelState();
|
||||||
|
|
||||||
|
const [fieldTypePreferences, setFieldTypePreferences] = useStorageState(
|
||||||
|
'formRecognizer/fieldTypePreferences',
|
||||||
|
DEFAULT_FIELD_TYPE_PREFERENCES,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 生成字段标识符
|
||||||
|
const getFieldIdentifier = useCallback(
|
||||||
|
(field: Pick<FieldData, 'label' | 'name' | 'placeholder'>): string => {
|
||||||
|
return field.label || field.name || field.placeholder || 'unknown';
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 应用保存的类型偏好
|
||||||
|
const applySavedPreferences = useCallback(
|
||||||
|
(fields: FieldData[], domain: string, preferences: FieldTypePreferences): FieldData[] => {
|
||||||
|
if (!domain || !preferences[domain]) {
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
const prefs = preferences[domain];
|
||||||
|
return fields.map((field) => {
|
||||||
|
const identifier = getFieldIdentifier(field);
|
||||||
|
if (prefs && prefs[identifier]) {
|
||||||
|
return { ...field, fieldType: prefs[identifier] };
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[getFieldIdentifier],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 保存类型偏好
|
||||||
|
const saveTypePreference = useCallback(
|
||||||
|
(field: FieldData, newType: string) => {
|
||||||
|
if (!currentDomain) return;
|
||||||
|
const identifier = getFieldIdentifier(field);
|
||||||
|
setFieldTypePreferences((prev) => {
|
||||||
|
const prevPrefs = prev as FieldTypePreferences;
|
||||||
|
const currentDomainPrefs = prevPrefs[currentDomain] || {};
|
||||||
|
return {
|
||||||
|
...prevPrefs,
|
||||||
|
[currentDomain]: {
|
||||||
|
...currentDomainPrefs,
|
||||||
|
[identifier]: newType,
|
||||||
|
},
|
||||||
|
} as FieldTypePreferences;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[currentDomain, getFieldIdentifier, setFieldTypePreferences],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 扫描表单字段
|
||||||
|
const handleScanFields = async () => {
|
||||||
|
setScanning(true);
|
||||||
|
try {
|
||||||
|
let response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
|
||||||
|
|
||||||
|
if (!response.success && response.message && response.message.includes('无法连接')) {
|
||||||
|
showMessage('正在注入内容脚本...', { severity: 'info' });
|
||||||
|
const injected = await injectContentScript();
|
||||||
|
if (injected) {
|
||||||
|
response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success && response.fields) {
|
||||||
|
const fieldsWithPreferences = applySavedPreferences(
|
||||||
|
response.fields as FieldData[],
|
||||||
|
currentDomain,
|
||||||
|
fieldTypePreferences,
|
||||||
|
);
|
||||||
|
setFields(fieldsWithPreferences);
|
||||||
|
setShowFields(true);
|
||||||
|
showMessage(`扫描完成,发现 ${response.totalCount} 个可填充字段`, { severity: 'success' });
|
||||||
|
} else {
|
||||||
|
showMessage(response.message || '扫描失败', { severity: 'error' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('扫描失败:', error);
|
||||||
|
showMessage('扫描失败,请确保页面已加载', { severity: 'error' });
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新字段类型
|
||||||
|
const handleFieldTypeChange = (fieldId: string, newType: string) => {
|
||||||
|
setFields((prev) =>
|
||||||
|
prev.map((field) => {
|
||||||
|
if (field.id === fieldId) {
|
||||||
|
const updatedField = {
|
||||||
|
...field,
|
||||||
|
fieldType: newType,
|
||||||
|
generatedValue: '',
|
||||||
|
};
|
||||||
|
saveTypePreference(field, newType);
|
||||||
|
return updatedField;
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换单个字段的选中状态
|
||||||
|
const handleToggleFieldSelection = (fieldId: string) => {
|
||||||
|
setFields((prev) =>
|
||||||
|
prev.map((field) =>
|
||||||
|
field.id === fieldId ? { ...field, isSelected: !field.isSelected } : field,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 全选/取消全选
|
||||||
|
const handleToggleAllFields = () => {
|
||||||
|
setFields((prev) => {
|
||||||
|
const allSelected = prev.every((f) => f.isSelected);
|
||||||
|
return prev.map((f) => ({ ...f, isSelected: !allSelected }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 定位字段(闪烁)
|
||||||
|
const handleLocateField = async (fieldId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await sendMessageToContent(MessageAction.FLASH_FIELD, { fieldId });
|
||||||
|
if (!response.success) {
|
||||||
|
showMessage(response.message || '定位字段失败', { severity: 'error' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('定位字段失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 悬停高亮
|
||||||
|
const handleHoverField = async (fieldId: string | null) => {
|
||||||
|
setHoveredFieldId(fieldId);
|
||||||
|
try {
|
||||||
|
if (fieldId) {
|
||||||
|
await sendMessageToContent(MessageAction.HIGHLIGHT_FIELD, { fieldId });
|
||||||
|
} else {
|
||||||
|
await sendMessageToContent(MessageAction.UNHIGHLIGHT_ALL_FIELDS);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('高亮字段失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 填充选中字段
|
||||||
|
const handleFillSelectedFields = async () => {
|
||||||
|
if (isProcessingRef.current) {
|
||||||
|
showMessage('操作进行中,请稍候...', { severity: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCount = fields.filter((f) => f.isSelected).length;
|
||||||
|
if (selectedCount === 0) {
|
||||||
|
showMessage('请先选择要填充的字段', { severity: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFillLoading(true);
|
||||||
|
isProcessingRef.current = true;
|
||||||
|
try {
|
||||||
|
const messageFields = fields as MessageFieldData[];
|
||||||
|
let response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
|
||||||
|
fields: messageFields,
|
||||||
|
mode: FillMode.VALID,
|
||||||
|
includeHidden,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.success && response.message && response.message.includes('无法连接')) {
|
||||||
|
showMessage('正在注入内容脚本...', { severity: 'info' });
|
||||||
|
const injected = await injectContentScript();
|
||||||
|
if (injected) {
|
||||||
|
response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
|
||||||
|
fields: messageFields,
|
||||||
|
mode: FillMode.VALID,
|
||||||
|
includeHidden,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
showMessage(response.message || '填充成功', { severity: 'success' });
|
||||||
|
} else {
|
||||||
|
showMessage(response.message || '填充失败', { severity: 'error' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('填充失败:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||||
|
showMessage(`填充失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
|
||||||
|
} finally {
|
||||||
|
setFillLoading(false);
|
||||||
|
isProcessingRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清空所有字段
|
||||||
|
const handleClearAllFields = async () => {
|
||||||
|
if (isProcessingRef.current) {
|
||||||
|
showMessage('操作进行中,请稍候...', { severity: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setClearLoading(true);
|
||||||
|
isProcessingRef.current = true;
|
||||||
|
try {
|
||||||
|
let response = await sendMessageToContent(MessageAction.CLEAR_ALL_FIELDS);
|
||||||
|
|
||||||
|
if (!response.success && response.message && response.message.includes('无法连接')) {
|
||||||
|
showMessage('正在注入内容脚本...', { severity: 'info' });
|
||||||
|
const injected = await injectContentScript();
|
||||||
|
if (injected) {
|
||||||
|
response = await sendMessageToContent(MessageAction.CLEAR_ALL_FIELDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
showMessage(response.message || '清空成功', { severity: 'success' });
|
||||||
|
} else {
|
||||||
|
showMessage(response.message || '清空失败', { severity: 'error' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清空失败:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||||
|
showMessage(`清空失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
|
||||||
|
} finally {
|
||||||
|
setClearLoading(false);
|
||||||
|
isProcessingRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
fillLoading,
|
||||||
|
clearLoading,
|
||||||
|
includeHidden,
|
||||||
|
setIncludeHidden,
|
||||||
|
fields,
|
||||||
|
scanning,
|
||||||
|
showFields,
|
||||||
|
setShowFields,
|
||||||
|
hoveredFieldId,
|
||||||
|
sidePanelOpen,
|
||||||
|
handleScanFields,
|
||||||
|
handleFieldTypeChange,
|
||||||
|
handleToggleFieldSelection,
|
||||||
|
handleToggleAllFields,
|
||||||
|
handleLocateField,
|
||||||
|
handleHoverField,
|
||||||
|
handleFillSelectedFields,
|
||||||
|
handleClearAllFields,
|
||||||
|
handleOpenSidePanel,
|
||||||
|
selectedCount: fields.filter((f) => f.isSelected).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { MessageAction, onMessage } from '@/utils/messages';
|
||||||
|
import { useSnackbar } from '@/components/SnackbarProvider';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理侧边栏状态检测与开启逻辑的 Hook
|
||||||
|
*/
|
||||||
|
export function useSidePanelState() {
|
||||||
|
const [sidePanelOpen, setSidePanelOpen] = useState(false);
|
||||||
|
const { showMessage } = useSnackbar();
|
||||||
|
|
||||||
|
const checkSidePanelState = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (typeof chrome.runtime.getContexts === 'function') {
|
||||||
|
const contexts = await chrome.runtime.getContexts({
|
||||||
|
contextTypes: ['SIDE_PANEL'],
|
||||||
|
});
|
||||||
|
setSidePanelOpen(contexts.length > 0);
|
||||||
|
} else {
|
||||||
|
setSidePanelOpen(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检测侧边栏状态失败:', error);
|
||||||
|
setSidePanelOpen(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 使用 requestAnimationFrame 避免同步调用 setState
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
checkSidePanelState().catch(console.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听侧边栏状态变化消息
|
||||||
|
const removeListener = onMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, (message) => {
|
||||||
|
setSidePanelOpen(message.data.isOpen);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeListener();
|
||||||
|
};
|
||||||
|
}, [checkSidePanelState]);
|
||||||
|
|
||||||
|
const handleOpenSidePanel = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await chrome.sidePanel.open({ windowId: chrome.windows.WINDOW_ID_CURRENT });
|
||||||
|
setSidePanelOpen(true);
|
||||||
|
showMessage('侧边栏已打开', { severity: 'success' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('打开侧边栏失败:', error);
|
||||||
|
showMessage('打开侧边栏失败', { severity: 'error' });
|
||||||
|
}
|
||||||
|
}, [showMessage]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sidePanelOpen,
|
||||||
|
handleOpenSidePanel,
|
||||||
|
checkSidePanelState,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import dayjs from '@/utils/dayjs';
|
||||||
|
import { DATE_FORMAT } from '@/config/pageTheme';
|
||||||
|
import type { UnitType, ZoneType } from '@/config/pageTheme';
|
||||||
|
|
||||||
|
export interface UseTimestampConverterReturn {
|
||||||
|
// State
|
||||||
|
mode: 'ts2dt' | 'dt2ts';
|
||||||
|
tsInput: string;
|
||||||
|
dtInput: string;
|
||||||
|
unit: UnitType;
|
||||||
|
zone: ZoneType;
|
||||||
|
result: string;
|
||||||
|
error: string;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setMode: (mode: 'ts2dt' | 'dt2ts') => void;
|
||||||
|
setTsInput: (value: string) => void;
|
||||||
|
setDtInput: (value: string) => void;
|
||||||
|
setUnit: (unit: UnitType) => void;
|
||||||
|
setZone: (zone: ZoneType) => void;
|
||||||
|
handleUseNow: (now: number) => void;
|
||||||
|
convert: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTimestampConverter(): UseTimestampConverterReturn {
|
||||||
|
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
|
||||||
|
const [tsInput, setTsInput] = useState(() => String(Date.now()));
|
||||||
|
const [dtInput, setDtInput] = useState(() => dayjs().format(DATE_FORMAT));
|
||||||
|
const [unit, setUnit] = useState<UnitType>('ms');
|
||||||
|
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
|
||||||
|
const [result, setResult] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const convert = useCallback(() => {
|
||||||
|
if (mode === 'ts2dt') {
|
||||||
|
const rawInput = tsInput.trim();
|
||||||
|
if (!rawInput) return;
|
||||||
|
const num = Number(rawInput);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
setError('无效数字');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
|
||||||
|
if (!d.isValid()) {
|
||||||
|
setError('无效时间戳');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
setResult(d.tz(zone).format(DATE_FORMAT));
|
||||||
|
} else {
|
||||||
|
const rawInput = dtInput.trim();
|
||||||
|
if (!rawInput) return;
|
||||||
|
const d = dayjs.tz(rawInput, DATE_FORMAT, zone);
|
||||||
|
if (!d.isValid()) {
|
||||||
|
setError('格式错误');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
const ms = d.valueOf();
|
||||||
|
setResult(unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000)));
|
||||||
|
}
|
||||||
|
}, [mode, tsInput, dtInput, unit, zone]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(convert, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [convert]);
|
||||||
|
|
||||||
|
const handleUseNow = useCallback(
|
||||||
|
(now: number) => {
|
||||||
|
if (mode === 'ts2dt') {
|
||||||
|
setTsInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
|
||||||
|
} else {
|
||||||
|
setDtInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mode, unit, zone],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSetMode = useCallback((newMode: 'ts2dt' | 'dt2ts') => {
|
||||||
|
setMode(newMode);
|
||||||
|
setError('');
|
||||||
|
setResult('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSetTsInput = useCallback((value: string) => {
|
||||||
|
setTsInput(value);
|
||||||
|
setError('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSetDtInput = useCallback((value: string) => {
|
||||||
|
setDtInput(value);
|
||||||
|
setError('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
tsInput,
|
||||||
|
dtInput,
|
||||||
|
unit,
|
||||||
|
zone,
|
||||||
|
result,
|
||||||
|
error,
|
||||||
|
setMode: handleSetMode,
|
||||||
|
setTsInput: handleSetTsInput,
|
||||||
|
setDtInput: handleSetDtInput,
|
||||||
|
setUnit,
|
||||||
|
setZone,
|
||||||
|
handleUseNow,
|
||||||
|
convert,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
|
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||||
|
import type {
|
||||||
|
StorageCleanerOptions,
|
||||||
|
CleaningResult,
|
||||||
|
StorageCleanerPreferences,
|
||||||
|
} from '@/types/storage';
|
||||||
|
import {
|
||||||
|
getCurrentTab,
|
||||||
|
isRestrictedUrl,
|
||||||
|
clearStorage,
|
||||||
|
getCookieSize,
|
||||||
|
getLocalStorageSize,
|
||||||
|
getSessionStorageSize,
|
||||||
|
getIndexedDBSize,
|
||||||
|
getCacheStorageSize,
|
||||||
|
getServiceWorkerCount,
|
||||||
|
} from '@/utils/storageCleaner';
|
||||||
|
import { MessageAction, sendMessage } from '@/utils/messages';
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: StorageCleanerOptions = {
|
||||||
|
localStorage: true,
|
||||||
|
sessionStorage: true,
|
||||||
|
indexedDB: true,
|
||||||
|
cookies: true,
|
||||||
|
cacheStorage: true,
|
||||||
|
serviceWorkers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
|
||||||
|
autoRefresh: true,
|
||||||
|
selectedTypes: DEFAULT_OPTIONS,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UseStorageCleanerReturn {
|
||||||
|
// State
|
||||||
|
domain: string;
|
||||||
|
error: string;
|
||||||
|
isInitializing: boolean;
|
||||||
|
options: StorageCleanerOptions;
|
||||||
|
sizes: Record<string, number>;
|
||||||
|
autoRefresh: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
result: CleaningResult | null;
|
||||||
|
showConfirm: boolean;
|
||||||
|
setShowConfirm: (show: boolean) => void;
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
totalSize: number;
|
||||||
|
allSelected: boolean;
|
||||||
|
someSelected: boolean;
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
handleAutoRefreshChange: (checked: boolean) => Promise<void>;
|
||||||
|
handleOptionChange: (key: keyof StorageCleanerOptions) => Promise<void>;
|
||||||
|
handleSelectAll: (checked: boolean) => Promise<void>;
|
||||||
|
handleClean: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseStorageCleanerOptions {
|
||||||
|
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStorageCleaner({
|
||||||
|
showMessage,
|
||||||
|
}: UseStorageCleanerOptions): UseStorageCleanerReturn {
|
||||||
|
const [domain, setDomain] = useState<string>('');
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [isInitializing, setIsInitializing] = useState<boolean>(true);
|
||||||
|
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
||||||
|
const [sizes, setSizes] = useState<Record<string, number>>({});
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [result, setResult] = useState<CleaningResult | null>(null);
|
||||||
|
const [showConfirm, setShowConfirm] = useState<boolean>(false);
|
||||||
|
const resultTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const requestIdRef = useRef<number>(0);
|
||||||
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const resultTimeout = resultTimeoutRef.current;
|
||||||
|
return () => {
|
||||||
|
if (resultTimeout) clearTimeout(resultTimeout);
|
||||||
|
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadInfo = useCallback(async () => {
|
||||||
|
const currentRequestId = ++requestIdRef.current;
|
||||||
|
try {
|
||||||
|
const tab = await getCurrentTab();
|
||||||
|
if (currentRequestId !== requestIdRef.current) return;
|
||||||
|
|
||||||
|
if (!tab || !tab.url) {
|
||||||
|
setError('无法获取当前标签页');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isRestrictedUrl(tab.url)) {
|
||||||
|
setError('存储清理功能不支持此页面');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
const url = tab.url;
|
||||||
|
const tabId = tab.id!;
|
||||||
|
setDomain(new URL(url).hostname);
|
||||||
|
|
||||||
|
const [savedPrefs, cSize, lsSize, ssSize, idbSize, cacheCount, swCount] = await Promise.all([
|
||||||
|
storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES),
|
||||||
|
getCookieSize(url),
|
||||||
|
getLocalStorageSize(tabId),
|
||||||
|
getSessionStorageSize(tabId),
|
||||||
|
getIndexedDBSize(tabId),
|
||||||
|
getCacheStorageSize(tabId),
|
||||||
|
getServiceWorkerCount(tabId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (currentRequestId !== requestIdRef.current) return;
|
||||||
|
|
||||||
|
if (savedPrefs) {
|
||||||
|
setAutoRefresh(savedPrefs.autoRefresh ?? DEFAULT_PREFERENCES.autoRefresh);
|
||||||
|
setOptions(savedPrefs.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSizes({
|
||||||
|
cookies: cSize,
|
||||||
|
localStorage: lsSize,
|
||||||
|
sessionStorage: ssSize,
|
||||||
|
indexedDB: idbSize,
|
||||||
|
cacheStorage: cacheCount,
|
||||||
|
serviceWorkers: swCount,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (currentRequestId === requestIdRef.current) {
|
||||||
|
setIsInitializing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadInfoRef = useRef(loadInfo);
|
||||||
|
loadInfoRef.current = loadInfo;
|
||||||
|
|
||||||
|
const debouncedLoadInfo = useCallback(() => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
loadInfoRef.current().then((r) => console.info(r));
|
||||||
|
}, 300);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 首次加载不防抖
|
||||||
|
loadInfoRef.current().then((r) => console.info(r));
|
||||||
|
|
||||||
|
const handleTabChange = () => debouncedLoadInfo();
|
||||||
|
const handleTabUpdated = (_tabId: number, changeInfo: { status?: string; url?: string }) => {
|
||||||
|
if (changeInfo.status === 'complete' || changeInfo.url) {
|
||||||
|
debouncedLoadInfo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
chrome.tabs.onActivated.addListener(handleTabChange);
|
||||||
|
chrome.tabs.onUpdated.addListener(handleTabUpdated);
|
||||||
|
chrome.windows.onFocusChanged.addListener(handleTabChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
chrome.tabs.onActivated.removeListener(handleTabChange);
|
||||||
|
chrome.tabs.onUpdated.removeListener(handleTabUpdated);
|
||||||
|
chrome.windows.onFocusChanged.removeListener(handleTabChange);
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [debouncedLoadInfo]);
|
||||||
|
|
||||||
|
const handleAutoRefreshChange = useCallback(
|
||||||
|
async (checked: boolean) => {
|
||||||
|
setAutoRefresh(checked);
|
||||||
|
await storageUtil.set('storageCleaner/preferences', {
|
||||||
|
autoRefresh: checked,
|
||||||
|
selectedTypes: options,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[options],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOptionChange = useCallback(
|
||||||
|
async (key: keyof StorageCleanerOptions) => {
|
||||||
|
setOptions((prev) => {
|
||||||
|
const newOptions = { ...prev, [key]: !prev[key] };
|
||||||
|
storageUtil.set('storageCleaner/preferences', {
|
||||||
|
autoRefresh,
|
||||||
|
selectedTypes: newOptions,
|
||||||
|
});
|
||||||
|
return newOptions;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[autoRefresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectAll = useCallback(
|
||||||
|
async (checked: boolean) => {
|
||||||
|
const newOptions = {
|
||||||
|
localStorage: checked,
|
||||||
|
sessionStorage: checked,
|
||||||
|
indexedDB: checked,
|
||||||
|
cookies: checked,
|
||||||
|
cacheStorage: checked,
|
||||||
|
serviceWorkers: checked,
|
||||||
|
};
|
||||||
|
setOptions(newOptions);
|
||||||
|
await storageUtil.set('storageCleaner/preferences', {
|
||||||
|
autoRefresh,
|
||||||
|
selectedTypes: newOptions,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[autoRefresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClean = useCallback(async () => {
|
||||||
|
const tab = await getCurrentTab();
|
||||||
|
if (!tab || !tab.id || !tab.url) {
|
||||||
|
showMessage('无法获取当前标签页', { severity: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const cleaningResult = await clearStorage(tab.id, tab.url, options);
|
||||||
|
setResult(cleaningResult);
|
||||||
|
|
||||||
|
if (autoRefresh && cleaningResult.success) {
|
||||||
|
showMessage('清理成功,即将刷新页面', { severity: 'success' });
|
||||||
|
await sendMessage(MessageAction.RELOAD_TAB, { tabId: tab.id, delay: 1000 });
|
||||||
|
} else {
|
||||||
|
await loadInfo();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showMessage(`清理失败: ${String(err)}`, { severity: 'error' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setShowConfirm(false);
|
||||||
|
}
|
||||||
|
}, [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 allSelected = Object.values(options).every(Boolean);
|
||||||
|
const someSelected = Object.values(options).some(Boolean) && !allSelected;
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
error,
|
||||||
|
isInitializing,
|
||||||
|
options,
|
||||||
|
sizes,
|
||||||
|
autoRefresh,
|
||||||
|
loading,
|
||||||
|
result,
|
||||||
|
showConfirm,
|
||||||
|
setShowConfirm,
|
||||||
|
totalSize,
|
||||||
|
allSelected,
|
||||||
|
someSelected,
|
||||||
|
handleAutoRefreshChange,
|
||||||
|
handleOptionChange,
|
||||||
|
handleSelectAll,
|
||||||
|
handleClean,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
|
||||||
monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
font-size: inherit; /* 先全部重置为继承大小 */
|
|
||||||
font-weight: inherit;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import RouterProvider from '@/providers/RouterProvider';
|
||||||
|
import TopBar from '@/components/TopBar';
|
||||||
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
|
import { MessageAction, sendMessage } from '@/utils/messages';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const handleOpenOptions = () => {
|
||||||
|
chrome.runtime.openOptionsPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 通知侧边栏已打开
|
||||||
|
useEffect(() => {
|
||||||
|
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true });
|
||||||
|
return () => {
|
||||||
|
// 尝试在关闭时通知,虽然在某些情况下可能无法成功发送
|
||||||
|
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: false });
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
|
||||||
|
<Box
|
||||||
|
className="app"
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100vh',
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TopBar onOpenOptions={handleOpenOptions} />
|
||||||
|
<ErrorBoundary>
|
||||||
|
<RouterContainer />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Box>
|
||||||
|
</RouterProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Testing Tools - Side Panel</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
|
import theme from '@/config/theme';
|
||||||
|
import App from './App.tsx';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
import js from '@eslint/js';
|
import js from '@eslint/js';
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
import * as reactHooks from 'eslint-plugin-react-hooks';
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
import * as reactPlugin from 'eslint-plugin-react';
|
import reactPlugin from 'eslint-plugin-react';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default [
|
||||||
{ ignores: ['dist', '.wxt', 'node_modules', 'eslint.config.ts', '**/*.test.tsx', '**/*.test.ts', '**/__tests__/**'] },
|
{
|
||||||
|
ignores: [
|
||||||
|
'dist',
|
||||||
|
'.wxt',
|
||||||
|
'node_modules',
|
||||||
|
'eslint.config.ts',
|
||||||
|
'**/*.test.tsx',
|
||||||
|
'**/*.test.ts',
|
||||||
|
'**/__tests__/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
{
|
{
|
||||||
files: [
|
files: [
|
||||||
'hooks/**/*.{ts,tsx}',
|
'hooks/**/*.{ts,tsx}',
|
||||||
@@ -16,7 +27,6 @@ export default tseslint.config(
|
|||||||
'components/**/*.{ts,tsx}',
|
'components/**/*.{ts,tsx}',
|
||||||
'services/**/*.{ts,tsx}',
|
'services/**/*.{ts,tsx}',
|
||||||
],
|
],
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: {
|
globals: {
|
||||||
@@ -29,7 +39,7 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
react: reactPlugin as any, // 现在这里就算写 TS 语法也没事了,因为文件被忽略了
|
react: reactPlugin as any,
|
||||||
'react-hooks': reactHooks as any,
|
'react-hooks': reactHooks as any,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
@@ -42,4 +52,4 @@ export default tseslint.config(
|
|||||||
'react/react-in-jsx-scope': 'off',
|
'react/react-in-jsx-scope': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
];
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
export default {
|
export default {
|
||||||
// 对于代码文件:
|
// 对于代码文件:
|
||||||
'*.{ts,tsx,js,jsx}': [
|
'*.{ts,tsx,js,jsx,mjs}': [
|
||||||
// 1. ESLint: 依然检查具体文件,拦截未使用变量
|
// 1. Prettier: 全局格式化
|
||||||
|
'prettier --write',
|
||||||
|
|
||||||
|
// 2. ESLint: 检查并自动修复
|
||||||
|
// --no-warn-ignored: 抑制对忽略文件的警告(eslint.config.ts 中忽略了测试文件)
|
||||||
'eslint --fix --max-warnings=0 --no-warn-ignored',
|
'eslint --fix --max-warnings=0 --no-warn-ignored',
|
||||||
|
|
||||||
// 2. TypeScript: 使用函数形式
|
// 3. TypeScript: 类型检查
|
||||||
// 关键!这就告诉 lint-staged:“不要把文件名传给 tsc,直接运行这个命令就好”
|
() => 'tsc --noEmit',
|
||||||
// 这样 tsc 就会去读取 tsconfig.json,并正确排除 eslint.config.ts
|
|
||||||
() => 'tsc --noEmit --skipLibCheck',
|
|
||||||
],
|
],
|
||||||
|
|
||||||
// 对于其他文件:
|
// 对于其他文件:
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "wxt-react-starter",
|
"name": "testing-tools",
|
||||||
"description": "manifest.json description",
|
"description": "A browser extension providing useful testing tools including timestamp conversion and storage management",
|
||||||
"private": true,
|
"private": false,
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wxt",
|
"dev": "wxt",
|
||||||
@@ -14,19 +15,28 @@
|
|||||||
"compile": "tsc --noEmit",
|
"compile": "tsc --noEmit",
|
||||||
"postinstall": "wxt prepare",
|
"postinstall": "wxt prepare",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"lint": "eslint . --max-warnings=0"
|
"lint": "eslint . --max-warnings=0",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3"
|
"react-dom": "^19.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.6.0",
|
||||||
|
"@testing-library/react": "^16.0.0",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@types/chrome": "^0.1.36",
|
"@types/chrome": "^0.1.36",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -40,11 +50,13 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"globals": "^17.2.0",
|
"globals": "^17.2.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
|
"jsdom": "^25.0.0",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"terser": "^5.46.0",
|
"terser": "^5.46.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.54.0",
|
"typescript-eslint": "^8.54.0",
|
||||||
|
"vitest": "^2.0.0",
|
||||||
"wxt": "^0.20.6"
|
"wxt": "^0.20.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||||
|
import type { PageType, StorageSchema } from '@/types/storage';
|
||||||
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
|
import {
|
||||||
|
getDefaultVisibleFeatureKeys,
|
||||||
|
getDefaultPageOrder,
|
||||||
|
getAllFeatureKeys,
|
||||||
|
} from '@/config/features';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验是否为合法的页面类型
|
||||||
|
*/
|
||||||
|
const isValidPage = (page: unknown): page is PageType => {
|
||||||
|
return typeof page === 'string' && (getAllFeatureKeys() as string[]).includes(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验页面列表是否合法(所有项都必须是合法的 PageType)
|
||||||
|
*/
|
||||||
|
const isValidPageList = (pages: unknown): pages is PageType[] => {
|
||||||
|
return Array.isArray(pages) && pages.every(isValidPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由上下文类型定义
|
||||||
|
*/
|
||||||
|
interface RouterContextType {
|
||||||
|
/** 当前所在页面 */
|
||||||
|
currentPage: PageType;
|
||||||
|
/** 当前可见的页面列表(用于侧边栏/菜单显示) */
|
||||||
|
visiblePages: PageType[];
|
||||||
|
/** 页面显示顺序 */
|
||||||
|
pageOrder: PageType[];
|
||||||
|
/** 路由数据是否已从存储中加载完成 */
|
||||||
|
isLoaded: boolean;
|
||||||
|
/** 导航到指定页面 */
|
||||||
|
navigateTo: (page: PageType) => void;
|
||||||
|
/** 仅在本地(当前组件状态)跳转,不影响其他同步端 */
|
||||||
|
navigateLocal: (page: PageType) => void;
|
||||||
|
/** 强制同步当前路由到存储 */
|
||||||
|
syncNavigation: (page: PageType) => void;
|
||||||
|
/** 返回仪表盘 */
|
||||||
|
goBack: () => void;
|
||||||
|
/** 设置可见页面列表 */
|
||||||
|
setVisiblePages: (pages: PageType[]) => void;
|
||||||
|
/** 设置页面显示顺序 */
|
||||||
|
setPageOrder: (pages: PageType[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建路由上下文
|
||||||
|
*/
|
||||||
|
const RouterContext = createContext<RouterContextType | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由提供者组件参数类型
|
||||||
|
*/
|
||||||
|
interface RouterProviderProps {
|
||||||
|
/** 子组件 */
|
||||||
|
children: ReactNode;
|
||||||
|
/** 默认初始路由,默认为 'dashboard' */
|
||||||
|
defaultRoute?: PageType;
|
||||||
|
/** 是否同步路由状态到存储,默认为 true */
|
||||||
|
syncRoute?: boolean;
|
||||||
|
/** 存储路由状态的键名,默认为 'app/currentRoute' */
|
||||||
|
syncKey?: keyof StorageSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步从 localStorage 获取存储快照(用于消除异步加载产生的首屏闪烁)
|
||||||
|
*/
|
||||||
|
const getSyncSnapshot = <T,>(
|
||||||
|
key: string,
|
||||||
|
defaultValue: T,
|
||||||
|
validator?: (val: unknown) => val is T,
|
||||||
|
): T => {
|
||||||
|
try {
|
||||||
|
const val = localStorage.getItem(`snapshot/${key}`);
|
||||||
|
if (!val) return defaultValue;
|
||||||
|
const parsed = JSON.parse(val) as unknown;
|
||||||
|
if (validator) {
|
||||||
|
return validator(parsed) ? parsed : defaultValue;
|
||||||
|
}
|
||||||
|
return (parsed as T) ?? defaultValue;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析同步快照失败:', error);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由提供者组件
|
||||||
|
* 负责管理应用内部的路由状态、页面可见性及排序,并支持与 Chrome Storage 同步
|
||||||
|
*/
|
||||||
|
export function RouterProvider({
|
||||||
|
children,
|
||||||
|
defaultRoute = 'dashboard',
|
||||||
|
syncRoute = true,
|
||||||
|
syncKey = 'app/currentRoute',
|
||||||
|
}: RouterProviderProps) {
|
||||||
|
// 当前页面状态:优先从同步快照加载,并进行合法性校验
|
||||||
|
const [currentPage, setCurrentPage] = useState<PageType>(() =>
|
||||||
|
getSyncSnapshot(syncKey as string, defaultRoute, isValidPage),
|
||||||
|
);
|
||||||
|
// 可见页面列表状态
|
||||||
|
const [visiblePages, setVisiblePages] = useState<PageType[]>(() =>
|
||||||
|
getSyncSnapshot('app/visiblePages', getDefaultVisibleFeatureKeys(), isValidPageList),
|
||||||
|
);
|
||||||
|
// 页面排序状态
|
||||||
|
const [pageOrder, setPageOrder] = useState<PageType[]>(() =>
|
||||||
|
getSyncSnapshot('app/pageOrder', getDefaultPageOrder(), isValidPageList),
|
||||||
|
);
|
||||||
|
// 加载完成标识
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
/**
|
||||||
|
* 从存储中加载初始路由数据
|
||||||
|
*/
|
||||||
|
const loadInitialData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const savedRoute = await storageUtil.get(syncKey, defaultRoute);
|
||||||
|
const savedVisiblePages = await storageUtil.get(
|
||||||
|
'app/visiblePages',
|
||||||
|
getDefaultVisibleFeatureKeys(),
|
||||||
|
);
|
||||||
|
const savedPageOrder = await storageUtil.get('app/pageOrder', getDefaultPageOrder());
|
||||||
|
|
||||||
|
// 增加数据合法性校验并进行类型收窄
|
||||||
|
if (isValidPage(savedRoute) && syncRoute) {
|
||||||
|
setCurrentPage(savedRoute);
|
||||||
|
}
|
||||||
|
if (isValidPageList(savedVisiblePages)) {
|
||||||
|
setVisiblePages(savedVisiblePages);
|
||||||
|
}
|
||||||
|
if (isValidPageList(savedPageOrder) && savedPageOrder.length > 0) {
|
||||||
|
setPageOrder(savedPageOrder);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载初始路由数据失败:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoaded(true);
|
||||||
|
}
|
||||||
|
}, [defaultRoute, syncKey, syncRoute]);
|
||||||
|
|
||||||
|
// 组件挂载时加载初始数据
|
||||||
|
useEffect(() => {
|
||||||
|
loadInitialData().catch(console.error);
|
||||||
|
}, [loadInitialData]);
|
||||||
|
|
||||||
|
// 当 currentPage 改变时,如果开启了同步,则持久化到存储和本地快照
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoaded && syncRoute) {
|
||||||
|
storageUtil.set(syncKey, currentPage as PageType).catch(console.error);
|
||||||
|
localStorage.setItem(`snapshot/${syncKey}`, JSON.stringify(currentPage));
|
||||||
|
}
|
||||||
|
}, [currentPage, isLoaded, syncRoute, syncKey]);
|
||||||
|
|
||||||
|
// 持久化可见页面列表
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoaded) {
|
||||||
|
storageUtil.set('app/visiblePages', visiblePages).catch(console.error);
|
||||||
|
localStorage.setItem('snapshot/app/visiblePages', JSON.stringify(visiblePages));
|
||||||
|
}
|
||||||
|
}, [visiblePages, isLoaded]);
|
||||||
|
|
||||||
|
// 持久化页面排序
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoaded) {
|
||||||
|
storageUtil.set('app/pageOrder', pageOrder).catch(console.error);
|
||||||
|
localStorage.setItem('snapshot/app/pageOrder', JSON.stringify(pageOrder));
|
||||||
|
}
|
||||||
|
}, [pageOrder, isLoaded]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听存储变化,以便在多个入口(如 Popup 和 Options)之间同步路由和设置
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!syncRoute) return;
|
||||||
|
|
||||||
|
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
|
||||||
|
// 同步当前路由
|
||||||
|
if (syncRoute && changes[syncKey as string]) {
|
||||||
|
const newRoute = changes[syncKey as string].newValue as PageType;
|
||||||
|
if (newRoute && newRoute !== currentPage && isValidPage(newRoute)) {
|
||||||
|
setCurrentPage(newRoute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 同步可见页面列表
|
||||||
|
if (changes['app/visiblePages']) {
|
||||||
|
const newPages = changes['app/visiblePages'].newValue;
|
||||||
|
if (isValidPageList(newPages)) {
|
||||||
|
setVisiblePages(newPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 同步页面排序
|
||||||
|
if (changes['app/pageOrder']) {
|
||||||
|
const newOrder = changes['app/pageOrder'].newValue;
|
||||||
|
if (isValidPageList(newOrder)) {
|
||||||
|
setPageOrder(newOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
chrome.storage.onChanged.addListener(handleStorageChange);
|
||||||
|
return () => chrome.storage.onChanged.removeListener(handleStorageChange);
|
||||||
|
}, [syncRoute, currentPage, syncKey]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跳转到指定页面
|
||||||
|
*/
|
||||||
|
const navigateTo = (page: PageType) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅在本地跳转,不触发自动同步(通常由 handleStorageChange 内部调用)
|
||||||
|
*/
|
||||||
|
const navigateLocal = (page: PageType) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动同步导航状态到存储
|
||||||
|
*/
|
||||||
|
const syncNavigation = (page: PageType) => {
|
||||||
|
storageUtil.set(syncKey, page as StorageSchema[typeof syncKey]).catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回主仪表盘
|
||||||
|
*/
|
||||||
|
const goBack = () => {
|
||||||
|
setCurrentPage('dashboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouterContext.Provider
|
||||||
|
value={{
|
||||||
|
currentPage,
|
||||||
|
visiblePages,
|
||||||
|
pageOrder,
|
||||||
|
isLoaded,
|
||||||
|
navigateTo,
|
||||||
|
navigateLocal,
|
||||||
|
syncNavigation,
|
||||||
|
goBack,
|
||||||
|
setVisiblePages,
|
||||||
|
setPageOrder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RouterContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义 Hook:获取路由上下文
|
||||||
|
* @throws {Error} 如果在 RouterProvider 之外使用则抛出异常
|
||||||
|
*/
|
||||||
|
export function useRouter() {
|
||||||
|
const context = useContext(RouterContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useRouter must be used within a RouterProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RouterProvider;
|
||||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 559 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 916 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 716 KiB |
@@ -1,19 +1,14 @@
|
|||||||
{
|
{
|
||||||
"extends": "./.wxt/tsconfig.json",
|
"extends": "./.wxt/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
/* --- 原有配置保持 --- */
|
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "ESNext", // 支持 import.meta
|
"module": "ESNext", // 支持 import.meta
|
||||||
"moduleResolution": "Bundler", // 或者用 "Node"
|
"moduleResolution": "Bundler", // 或者用 "Node"
|
||||||
|
|
||||||
/* --- 1. 严格类型检查 (关键) --- */
|
|
||||||
// 开启所有严格检查,包括 noImplicitAny。
|
|
||||||
// 这能帮你捕获 "timer" 隐式 any 等错误,强制你写出更高质量的代码。
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|
||||||
/* --- 2. 代码质量检查 --- */
|
/* --- 代码质量检查 --- */
|
||||||
// 声明了但没使用的变量报错(防止代码冗余)
|
// 声明了但没使用的变量报错(防止代码冗余)
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
// 函数参数没使用报错
|
// 函数参数没使用报错
|
||||||
@@ -23,28 +18,22 @@
|
|||||||
// switch 语句没有 break 时报错
|
// switch 语句没有 break 时报错
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
/* --- 3. 路径与环境 --- */
|
/* --- 路径与环境 --- */
|
||||||
// 设置基础目录,方便解析相对路径
|
|
||||||
"baseUrl": ".",
|
|
||||||
// 确保包含 DOM 类型(解决 setTimeout、document 等报错)
|
// 确保包含 DOM 类型(解决 setTimeout、document 等报错)
|
||||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
// 编译目标设置为最新,WXT 底层 Vite 会处理降级兼容
|
// 编译目标设置为最新,WXT 底层 Vite 会处理降级兼容
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
|
|
||||||
/* --- 4. 路径别名 (可选) --- */
|
"types": ["chrome", "webextension-polyfill", "@testing-library/jest-dom", "vitest/globals"],
|
||||||
// 如果你的 @/utils/... 爆红,可以手动添加这个映射。
|
|
||||||
// WXT 通常会自动处理,但在这里显式声明有助于 VS Code 智能提示。
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./*"]
|
|
||||||
},
|
|
||||||
"types": ["chrome", "webextension-polyfill"],
|
|
||||||
"noImplicitAny": false
|
"noImplicitAny": false
|
||||||
},
|
},
|
||||||
// 确保包含你的源代码目录
|
// 确保包含你的源代码目录
|
||||||
"include": [
|
"include": [
|
||||||
|
"vite-env.d.ts",
|
||||||
"entrypoints/**/*",
|
"entrypoints/**/*",
|
||||||
"components/**/*",
|
"components/**/*",
|
||||||
"utils/**/*",
|
"utils/**/*",
|
||||||
|
"types/**/*",
|
||||||
"assets/**/*",
|
"assets/**/*",
|
||||||
"hooks/**/*",
|
"hooks/**/*",
|
||||||
".wxt/types/**/*.ts",
|
".wxt/types/**/*.ts",
|
||||||
@@ -52,13 +41,5 @@
|
|||||||
"components",
|
"components",
|
||||||
"eslint.config.ts"
|
"eslint.config.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": ["node_modules", ".wxt", "dist"]
|
||||||
"node_modules",
|
|
||||||
".wxt",
|
|
||||||
"components/__tests__",
|
|
||||||
"**/*.test.tsx",
|
|
||||||
"**/*.test.ts",
|
|
||||||
"vitest.config.ts",
|
|
||||||
"vitest.setup.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,169 @@
|
|||||||
export type PageType = 'timestamp' | 'storageCleaner';
|
/**
|
||||||
|
* 应用页面类型定义
|
||||||
|
*/
|
||||||
|
export type PageType =
|
||||||
|
| 'dashboard' // 仪表盘/首页
|
||||||
|
| 'timestamp' // 时间戳转换工具
|
||||||
|
| 'storageCleaner' // 存储清理工具
|
||||||
|
| 'openUrl' // 快捷链接工具
|
||||||
|
| 'qrCode' // 二维码工具
|
||||||
|
| 'formRecognizer' // 表单识别工具
|
||||||
|
| 'formMapping' // 表单映射配置
|
||||||
|
| 'formFill' // 表单填充工具
|
||||||
|
| 'openUrlViewer'; // 快捷链接查看页面
|
||||||
|
|
||||||
export interface StorageSchema {
|
/**
|
||||||
'app/currentRoute': PageType;
|
* 表单映射条目定义
|
||||||
'app/visiblePages': PageType[];
|
*/
|
||||||
'app/lastRoute': string;
|
export interface FormMapEntry {
|
||||||
'app/theme': string;
|
/** 条目唯一 ID */
|
||||||
'storageCleaner/preferences': StorageCleanerPreferences;
|
id: string;
|
||||||
|
/** 在 UI 中显示的名称 */
|
||||||
|
label_display: string;
|
||||||
|
/** 字段特征,用于在页面中定位字段 */
|
||||||
|
fingerprint: {
|
||||||
|
/** CSS 选择器 */
|
||||||
|
selector: string;
|
||||||
|
/** name 属性 */
|
||||||
|
name_attr: string;
|
||||||
|
/** 占位符文本 */
|
||||||
|
placeholder: string;
|
||||||
|
};
|
||||||
|
/** 填充逻辑配置 */
|
||||||
|
action_logic: {
|
||||||
|
/** 字段类型 */
|
||||||
|
type: 'text' | 'select' | 'checkbox';
|
||||||
|
/** 填充策略:固定值、随机值或序列值 */
|
||||||
|
strategy: 'fixed' | 'random' | 'sequence';
|
||||||
|
/** 填充的具体值或配置 */
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
/** UI 状态 */
|
||||||
|
ui_state: {
|
||||||
|
/** 是否被选中 */
|
||||||
|
is_selected: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome Storage 存储模式定义
|
||||||
|
* 定义了所有持久化在客户端的数据结构
|
||||||
|
*/
|
||||||
|
export interface StorageSchema {
|
||||||
|
/** 全局当前路由 */
|
||||||
|
'app/currentRoute': PageType;
|
||||||
|
/** Popup 窗口的当前路由 */
|
||||||
|
'app/popupRoute': PageType;
|
||||||
|
/** 侧边栏的当前路由 */
|
||||||
|
'app/sidepanelRoute': PageType;
|
||||||
|
/** 在菜单中可见的页面列表 */
|
||||||
|
'app/visiblePages': PageType[];
|
||||||
|
/** 菜单页面的显示顺序 */
|
||||||
|
'app/pageOrder': PageType[];
|
||||||
|
/** 上一次访问的路由路径(备用) */
|
||||||
|
'app/lastRoute': string;
|
||||||
|
/** 应用主题配置 */
|
||||||
|
'app/theme': string;
|
||||||
|
/** 表单映射工具是否正处于“元素拾取”模式 */
|
||||||
|
'app/formMapping/isPicking': boolean;
|
||||||
|
/** 当前激活的表单映射条目列表 */
|
||||||
|
active_form_map: FormMapEntry[];
|
||||||
|
/** 存储清理工具的偏好设置 */
|
||||||
|
'storageCleaner/preferences': StorageCleanerPreferences;
|
||||||
|
/** 快捷链接工具的偏好设置 */
|
||||||
|
'openUrl/preferences': OpenUrlPreferences;
|
||||||
|
/** 快捷链接工具当前操作的 URL */
|
||||||
|
'openUrl/currentUrl': string;
|
||||||
|
/** 二维码工具中二维码部分是否展开 */
|
||||||
|
'qrCode/qrExpanded': boolean;
|
||||||
|
/** 二维码工具中 URL 部分是否展开 */
|
||||||
|
'qrCode/urlExpanded': boolean;
|
||||||
|
/** 表单识别工具的字段类型偏好(按域名存储) */
|
||||||
|
'formRecognizer/fieldTypePreferences': FieldTypePreferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字段类型偏好定义
|
||||||
|
* 结构:{ [域名]: { [字段标识符]: 类型名称 } }
|
||||||
|
*/
|
||||||
|
export interface FieldTypePreferences {
|
||||||
|
[domain: string]: {
|
||||||
|
[fieldIdentifier: string]: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储清理工具偏好设置
|
||||||
|
*/
|
||||||
export interface StorageCleanerPreferences {
|
export interface StorageCleanerPreferences {
|
||||||
|
/** 是否在清理后自动刷新页面 */
|
||||||
autoRefresh: boolean;
|
autoRefresh: boolean;
|
||||||
|
/** 默认勾选的清理类型 */
|
||||||
selectedTypes: StorageCleanerOptions;
|
selectedTypes: StorageCleanerOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷链接条目定义
|
||||||
|
*/
|
||||||
|
export interface OpenUrlEntry {
|
||||||
|
/** 链接名称 */
|
||||||
|
name: string;
|
||||||
|
/** 链接地址 */
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷链接工具偏好设置
|
||||||
|
*/
|
||||||
|
export interface OpenUrlPreferences {
|
||||||
|
/** 链接列表 */
|
||||||
|
entries: OpenUrlEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储清理选项配置
|
||||||
|
*/
|
||||||
export interface StorageCleanerOptions {
|
export interface StorageCleanerOptions {
|
||||||
|
/** Local Storage */
|
||||||
localStorage: boolean;
|
localStorage: boolean;
|
||||||
|
/** Session Storage */
|
||||||
sessionStorage: boolean;
|
sessionStorage: boolean;
|
||||||
|
/** IndexedDB */
|
||||||
indexedDB: boolean;
|
indexedDB: boolean;
|
||||||
|
/** Cookies */
|
||||||
cookies: boolean;
|
cookies: boolean;
|
||||||
|
/** Cache Storage */
|
||||||
cacheStorage: boolean;
|
cacheStorage: boolean;
|
||||||
|
/** Service Workers */
|
||||||
serviceWorkers: boolean;
|
serviceWorkers: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单项存储清理结果
|
||||||
|
*/
|
||||||
export type StorageCleanResult =
|
export type StorageCleanResult =
|
||||||
| {
|
| {
|
||||||
|
/** 是否清理成功 */
|
||||||
success: true;
|
success: true;
|
||||||
|
/** 清理的数量/条数 */
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
/** 是否清理成功 */
|
||||||
success: false;
|
success: false;
|
||||||
|
/** 错误信息 */
|
||||||
error: string;
|
error: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储清理任务汇总结果
|
||||||
|
*/
|
||||||
export interface CleaningResult {
|
export interface CleaningResult {
|
||||||
|
/** 整体操作是否成功 */
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
/** 整体错误信息(如果有) */
|
||||||
error?: string;
|
error?: string;
|
||||||
|
/** 各项清理的具体结果 */
|
||||||
localStorage?: StorageCleanResult;
|
localStorage?: StorageCleanResult;
|
||||||
sessionStorage?: StorageCleanResult;
|
sessionStorage?: StorageCleanResult;
|
||||||
indexedDB?: StorageCleanResult;
|
indexedDB?: StorageCleanResult;
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
isRestrictedUrl,
|
||||||
|
formatSize,
|
||||||
|
} from '../storageCleaner';
|
||||||
|
|
||||||
|
describe('storageCleaner utils', () => {
|
||||||
|
describe('isRestrictedUrl', () => {
|
||||||
|
it('should return true for chrome:// URLs', () => {
|
||||||
|
expect(isRestrictedUrl('chrome://settings')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for chrome-extension:// URLs', () => {
|
||||||
|
expect(isRestrictedUrl('chrome-extension://abc123/background.html')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for about:// URLs', () => {
|
||||||
|
expect(isRestrictedUrl('about:blank')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for edge:// URLs', () => {
|
||||||
|
expect(isRestrictedUrl('edge://settings')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for view-source:// URLs', () => {
|
||||||
|
expect(isRestrictedUrl('view-source:https://example.com')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for file:// URLs', () => {
|
||||||
|
expect(isRestrictedUrl('file:///path/to/file')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for data:// URLs', () => {
|
||||||
|
expect(isRestrictedUrl('data:text/html,<h1>Hello</h1>')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for http:// URLs', () => {
|
||||||
|
expect(isRestrictedUrl('http://example.com')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for https:// URLs', () => {
|
||||||
|
expect(isRestrictedUrl('https://example.com')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for undefined URL', () => {
|
||||||
|
expect(isRestrictedUrl(undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for empty string', () => {
|
||||||
|
expect(isRestrictedUrl('')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatSize', () => {
|
||||||
|
it('should return "0 B" for 0 bytes', () => {
|
||||||
|
expect(formatSize(0)).toBe('0 B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format bytes correctly', () => {
|
||||||
|
expect(formatSize(500)).toBe('500 B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format kilobytes correctly', () => {
|
||||||
|
expect(formatSize(1024)).toBe('1 KB');
|
||||||
|
expect(formatSize(1536)).toBe('1.5 KB');
|
||||||
|
expect(formatSize(2048)).toBe('2 KB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format megabytes correctly', () => {
|
||||||
|
expect(formatSize(1048576)).toBe('1 MB');
|
||||||
|
expect(formatSize(1572864)).toBe('1.5 MB');
|
||||||
|
expect(formatSize(5242880)).toBe('5 MB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format gigabytes correctly', () => {
|
||||||
|
expect(formatSize(1073741824)).toBe('1 GB');
|
||||||
|
expect(formatSize(2147483648)).toBe('2 GB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle edge cases', () => {
|
||||||
|
expect(formatSize(1)).toBe('1 B');
|
||||||
|
expect(formatSize(1023)).toBe('1023 B');
|
||||||
|
expect(formatSize(1025)).toBe('1 KB');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { StorageSchema } from 'types/storage';
|
import { StorageSchema } from '@/types/storage';
|
||||||
|
|
||||||
class StorageUtils {
|
class StorageUtils {
|
||||||
async get<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
|
async get<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Chrome 标签页相关工具函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前活动的标签页
|
||||||
|
*/
|
||||||
|
export async function getActiveTab(): Promise<chrome.tabs.Tab | null> {
|
||||||
|
try {
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
return tab || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取活动标签页失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前活动的标签页域名
|
||||||
|
*/
|
||||||
|
export async function getActiveTabDomain(): Promise<string> {
|
||||||
|
const tab = await getActiveTab();
|
||||||
|
if (tab?.url) {
|
||||||
|
try {
|
||||||
|
const url = new URL(tab.url);
|
||||||
|
return url.hostname;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析域名失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保内容脚本已注入
|
||||||
|
*/
|
||||||
|
export async function ensureContentScriptInjected(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const tab = await getActiveTab();
|
||||||
|
if (!tab?.id) return false;
|
||||||
|
|
||||||
|
// 尝试发送一个简单的探测消息
|
||||||
|
try {
|
||||||
|
// 这里可以根据实际情况发送一个简单的 Ping 消息
|
||||||
|
// 目前暂时保留原有注入逻辑,由调用方决定
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
// 如果报错,说明没注入,执行注入
|
||||||
|
console.log('内容脚本未注入,尝试注入...');
|
||||||
|
console.error('注入内容脚本失败:', e);
|
||||||
|
await chrome.scripting.executeScript({
|
||||||
|
target: { tabId: tab.id },
|
||||||
|
files: ['/content-scripts/content.js'],
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('注入内容脚本失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||||
|
import { useSnackbarState } from '@/components/GlobalSnackbar';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制文本到剪贴板
|
||||||
|
* @param text 要复制的文本
|
||||||
|
* @param showMessage 显示消息的函数
|
||||||
|
* @returns Promise<boolean> 是否复制成功
|
||||||
|
*/
|
||||||
|
export const copyToClipboard = async (
|
||||||
|
text: string,
|
||||||
|
showMessage?: (message: string, options?: SnackbarOptions) => void,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
showMessage?.('已复制', { severity: 'success' });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制失败:', error);
|
||||||
|
showMessage?.('复制失败', { severity: 'error' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义 Hook: 用于处理剪贴板操作
|
||||||
|
* @returns 包含复制函数和 snackbarProps 的对象
|
||||||
|
*/
|
||||||
|
export const useClipboard = () => {
|
||||||
|
const { snackbarProps, showMessage } = useSnackbarState({ autoHideDuration: 1500 });
|
||||||
|
|
||||||
|
const copy = async (text: string): Promise<boolean> => {
|
||||||
|
return copyToClipboard(text, showMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { copy, snackbarProps };
|
||||||
|
};
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* 数据模板管理工具
|
||||||
|
* 用于创建、编辑、保存和管理自定义测试数据模板
|
||||||
|
*/
|
||||||
|
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* 数据验证工具
|
||||||
|
* 用于在数据填充前进行格式验证
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,838 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
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();
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||