Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d36f21f1b | |||
| 60537f6e2e | |||
| c039475119 | |||
| fb5f98dd3b | |||
| af0089c980 | |||
| 4850c92365 | |||
| 0379f96e80 | |||
| be5e2f02ee | |||
| bbd0507bcd | |||
| 9a947d7431 | |||
| 84ffd2a132 | |||
| 2cd21973f3 | |||
| 979b45a898 | |||
| 8bc11eb696 | |||
| 71b9dcc35c | |||
| f4c2f6d374 |
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"webextensions": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [
|
||||
"react"
|
||||
],
|
||||
"rules": {
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"no-undef": "error"
|
||||
},
|
||||
"globals": {
|
||||
"chrome": "readonly"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -1,25 +1,30 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
.idea
|
||||
node_modules
|
||||
.output
|
||||
stats.html
|
||||
stats-*.json
|
||||
.wxt
|
||||
.vitest
|
||||
.claude
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.trae/*
|
||||
.workbuddy/*
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
@@ -3,17 +3,18 @@
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"quoteProps": "as-needed",
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "none",
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false,
|
||||
"arrowParens": "always",
|
||||
"requirePragma": false,
|
||||
"insertPragma": false,
|
||||
"proseWrap": "preserve",
|
||||
"htmlWhitespaceSensitivity": "ignore",
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
|
||||
"endOfLine": "lf",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.json",
|
||||
"options": {
|
||||
"trailingComma": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个基于 WXT 框架的浏览器扩展项目,提供多种测试工具功能,包括时间戳转换、存储管理、URL 管理、二维码生成、表单识别与填充等。
|
||||
|
||||
## 核心命令
|
||||
|
||||
### 开发相关
|
||||
|
||||
- `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 run test` - 运行所有测试(单次执行)
|
||||
- `npm run test:watch` - 运行测试并监听文件变化
|
||||
- `npm run test:coverage` - 运行测试并生成覆盖率报告
|
||||
|
||||
**运行单个测试文件:**
|
||||
|
||||
```bash
|
||||
npx vitest run components/__tests__/CopyButton.test.tsx
|
||||
```
|
||||
|
||||
**测试技术栈:**
|
||||
|
||||
- Vitest v2 - 测试框架
|
||||
- @testing-library/react v16 - React 组件测试
|
||||
- @testing-library/user-event v14 - 用户交互模拟
|
||||
- jsdom v25 - 浏览器环境模拟
|
||||
|
||||
### 依赖与准备
|
||||
|
||||
- `npm install` - 安装依赖
|
||||
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
|
||||
- `prepare` 钩子会初始化 Husky Git 钩子
|
||||
|
||||
## 项目架构
|
||||
|
||||
### 技术栈
|
||||
|
||||
- **框架**: WXT v0.20.6 (Web Extension Toolkit) - 浏览器扩展开发框架
|
||||
- **前端**: React 19 + TypeScript 5
|
||||
- **UI 库**: Material UI (MUI) v7 + Emotion
|
||||
- **状态管理**: React Hooks + 自定义 Hooks
|
||||
- **路由**: 自定义路由系统(支持 popup/sidepanel/detached 三种模式)
|
||||
- **测试**: Vitest + Testing Library
|
||||
- **代码质量**: ESLint v9 + Prettier + Husky + lint-staged
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
├── components/ # 可复用 UI 组件
|
||||
│ ├── __tests__/ # 组件测试文件
|
||||
│ ├── Button.tsx # 按钮组件
|
||||
│ ├── CopyButton.tsx # 复制按钮组件
|
||||
│ ├── DashboardCard.tsx # 仪表盘卡片组件
|
||||
│ ├── FieldList.tsx # 字段列表组件
|
||||
│ ├── GlobalSnackbar.tsx # 全局提示消息组件
|
||||
│ ├── PageHeader.tsx # 页面头部组件
|
||||
│ ├── QrCodeToUrlSection.tsx # 二维码解析为 URL 组件
|
||||
│ ├── QrCodeUploader.tsx # 二维码上传组件
|
||||
│ ├── RouterContainer.tsx # 路由容器组件
|
||||
│ ├── StorageCleanerConfirm.tsx # 存储清理确认组件
|
||||
│ ├── ToolCard.tsx # 工具卡片组件
|
||||
│ ├── TopBar.tsx # 顶部导航栏组件
|
||||
│ ├── UrlEntryForm.tsx # URL 录入表单组件
|
||||
│ ├── UrlEntryItem.tsx # URL 条目组件
|
||||
│ ├── UrlEntryList.tsx # URL 列表组件
|
||||
│ └── UrlToQrCodeSection.tsx # URL 转二维码组件
|
||||
├── config/ # 配置文件
|
||||
│ ├── __tests__/ # 配置测试文件
|
||||
│ ├── dashboardCards.tsx # 仪表盘卡片配置
|
||||
│ ├── pageTheme.ts # 页面主题配置
|
||||
│ ├── routes.ts # 路由配置
|
||||
│ └── theme.ts # 全局主题配置
|
||||
├── entrypoints/ # 浏览器扩展入口点
|
||||
│ ├── background.ts # 后台脚本(主进程)
|
||||
│ ├── content.ts # 内容脚本(注入到页面)
|
||||
│ ├── content/
|
||||
│ │ └── messageHandler.ts # 消息处理器
|
||||
│ ├── options/ # 选项页面
|
||||
│ │ ├── App.tsx # 选项应用
|
||||
│ │ ├── index.html # 选项页面 HTML
|
||||
│ │ └── main.tsx # 选项页面入口
|
||||
│ ├── popup/ # 扩展弹窗界面
|
||||
│ │ ├── App.tsx # 弹窗主应用
|
||||
│ │ ├── main.tsx # 弹窗入口
|
||||
│ │ ├── index.html # 弹窗 HTML
|
||||
│ │ ├── pages/ # 弹窗页面
|
||||
│ │ │ ├── components/ # 页面级组件
|
||||
│ │ │ │ ├── AutoRefreshToggle.tsx # 自动刷新开关
|
||||
│ │ │ │ ├── CleaningResult.tsx # 清理结果展示
|
||||
│ │ │ │ ├── DomainHeader.tsx # 域名头部
|
||||
│ │ │ │ ├── ErrorDisplay.tsx # 错误显示
|
||||
│ │ │ │ ├── LiveClock.tsx # 实时时钟
|
||||
│ │ │ │ ├── OptionItem.tsx # 选项条目
|
||||
│ │ │ │ ├── ResultView.tsx # 结果视图
|
||||
│ │ │ │ └── StorageOptionsGrid.tsx # 存储选项网格
|
||||
│ │ │ ├── hooks/ # 自定义 Hooks
|
||||
│ │ │ │ ├── useActiveTabDomain.ts # 当前标签页域名
|
||||
│ │ │ │ ├── useFormRecognizer.ts # 表单识别
|
||||
│ │ │ │ ├── useSidePanelState.ts # 侧边栏状态
|
||||
│ │ │ │ └── useTimestampConverter.ts # 时间戳转换
|
||||
│ │ │ ├── DashboardPage.tsx # 仪表盘页面
|
||||
│ │ │ ├── FormFillPage.tsx # 表单填充页面
|
||||
│ │ │ ├── FormMappingPage.tsx # 表单映射页面
|
||||
│ │ │ ├── FormRecognizerPage.tsx # 表单识别页面
|
||||
│ │ │ ├── OpenUrlPage.tsx # 打开 URL 页面
|
||||
│ │ │ ├── OpenUrlViewerPage.tsx # URL 查看页面
|
||||
│ │ │ ├── QrCodePage.tsx # 二维码页面
|
||||
│ │ │ ├── StorageCleanerPage.tsx # 存储清理页面
|
||||
│ │ │ ├── TimestampPage.tsx # 时间戳页面
|
||||
│ │ │ └── useStorageCleaner.ts # 存储清理 Hook
|
||||
│ └── sidepanel/ # 侧边栏界面
|
||||
│ ├── App.tsx # 侧边栏应用
|
||||
│ ├── index.html # 侧边栏 HTML
|
||||
│ └── main.tsx # 侧边栏入口
|
||||
├── providers/ # React Providers
|
||||
│ └── RouterProvider.tsx # 路由 Provider
|
||||
├── utils/ # 工具函数
|
||||
│ ├── __tests__/ # 工具测试文件
|
||||
│ ├── formMapping/ # 表单映射工具
|
||||
│ │ ├── highlighter.ts # 表单高亮器
|
||||
│ │ ├── scanner.ts # 表单扫描器
|
||||
│ │ ├── smartInjector.ts # 智能注入器
|
||||
│ │ └── ui.ts # UI 工具
|
||||
│ ├── chromeStorage.ts # Chrome 存储工具
|
||||
│ ├── chromeTabs.ts # Chrome 标签页工具
|
||||
│ ├── clipboard.ts # 剪贴板工具
|
||||
│ ├── dataTemplate.ts # 数据模板
|
||||
│ ├── dataValidator.ts # 数据验证器
|
||||
│ ├── dayjs.ts # 日期处理工具
|
||||
│ ├── dummyDataGenerator.ts # 虚拟数据生成器(基于 Faker)
|
||||
│ ├── messages.ts # 消息通信工具
|
||||
│ ├── qrCodeParser.ts # 二维码解析器
|
||||
│ ├── storageCleaner.ts # 存储清理工具
|
||||
│ ├── useStorageState.ts # 存储状态 Hook
|
||||
│ └── useUrlPreferences.ts # URL 偏好设置 Hook
|
||||
├── types/ # 类型定义
|
||||
│ └── storage.d.ts # 存储相关类型
|
||||
├── docs/ # 文档
|
||||
│ └── plans/ # 计划文档
|
||||
├── public/ # 静态资源
|
||||
│ └── icon/ # 扩展图标
|
||||
└── .github/ # GitHub 配置
|
||||
└── workflows/ # CI/CD 工作流
|
||||
├── ci.yml # 持续集成
|
||||
└── release.yml # 发布流程
|
||||
```
|
||||
|
||||
### 核心功能模块
|
||||
|
||||
#### 1. 时间戳转换工具
|
||||
|
||||
- 位置: `entrypoints/popup/pages/TimestampPage.tsx`
|
||||
- Hook: `entrypoints/popup/pages/hooks/useTimestampConverter.ts`
|
||||
- 依赖: dayjs 库进行日期处理
|
||||
- 功能: 支持日期与时间戳的双向转换,支持多种格式,实时时钟显示
|
||||
|
||||
#### 2. 存储清理工具
|
||||
|
||||
- 位置: `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` 库实现
|
||||
- 内容脚本消息处理: `entrypoints/content/messageHandler.ts`
|
||||
- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗/侧边栏
|
||||
|
||||
#### 9. 数据存储
|
||||
|
||||
- Chrome Storage API: `utils/chromeStorage.ts`
|
||||
- 存储状态 Hook: `utils/useStorageState.ts`
|
||||
- URL 偏好设置: `utils/useUrlPreferences.ts`
|
||||
- 类型定义: `types/storage.d.ts`
|
||||
|
||||
### 关键配置文件
|
||||
|
||||
#### wxt.config.ts
|
||||
|
||||
- 配置 WXT 框架参数
|
||||
- 启用 React 模块
|
||||
- 配置浏览器扩展权限(storage, unlimitedStorage, clipboardWrite, activeTab, scripting, tabs, cookies, sidePanel)
|
||||
- Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码)
|
||||
- 配置侧边栏和选项页面
|
||||
|
||||
#### manifest 权限
|
||||
|
||||
```typescript
|
||||
permissions: [
|
||||
'storage', // 存储权限
|
||||
'unlimitedStorage', // 无限制存储
|
||||
'clipboardWrite', // 剪贴板写入
|
||||
'activeTab', // 当前标签页
|
||||
'scripting', // 脚本注入
|
||||
'tabs', // 标签页管理
|
||||
'cookies', // Cookies 管理
|
||||
'sidePanel', // 侧边栏
|
||||
],
|
||||
host_permissions:['<all_urls>'] // 访问所有网站
|
||||
```
|
||||
|
||||
#### CI/CD 配置
|
||||
|
||||
- `.github/workflows/ci.yml` - 持续集成工作流
|
||||
- `.github/workflows/release.yml` - 发布工作流
|
||||
|
||||
## 开发注意事项
|
||||
|
||||
### 扩展入口点
|
||||
|
||||
- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务
|
||||
- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互
|
||||
- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示
|
||||
- **侧边栏**: `entrypoints/sidepanel/main.tsx` - 浏览器侧边栏界面
|
||||
- **选项页面**: `entrypoints/options/main.tsx` - 扩展设置页面
|
||||
|
||||
### 路由系统
|
||||
|
||||
- 使用自定义路由系统,支持多种显示模式
|
||||
- 路由配置在 `config/features.tsx`
|
||||
- 通过 `getEntryPointType()` 判断当前入口点类型
|
||||
- 支持页面可见性配置(`defaultVisible`)
|
||||
|
||||
### 浏览器兼容性
|
||||
|
||||
- 支持 Chrome 和 Firefox 浏览器
|
||||
- 使用 WXT 框架抽象浏览器差异
|
||||
- 使用 `@types/chrome` 和 `@types/webextension-polyfill` 提供类型支持
|
||||
|
||||
### 代码质量
|
||||
|
||||
- 使用 ESLint v9 进行代码检查(基于 typescript-eslint)
|
||||
- Prettier 进行代码格式化
|
||||
- Husky v9 用于 Git 钩子管理
|
||||
- Lint-staged 确保暂存文件符合规范
|
||||
- GitHub Actions CI/CD 自动化测试和构建
|
||||
|
||||
### 测试策略
|
||||
|
||||
- 组件测试: `components/__tests__/` 目录
|
||||
- 工具函数测试: `utils/__tests__/` 目录
|
||||
- 配置测试: `config/__tests__/` 目录
|
||||
- 使用 Vitest 作为测试框架
|
||||
- 使用 Testing Library 进行 React 组件测试
|
||||
@@ -0,0 +1,83 @@
|
||||
# Testing Tools Browser Extension - Gemini Instructions
|
||||
|
||||
This document provides essential context and instructions for AI agents working on the Testing Tools browser extension project.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**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.
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Framework:** WXT (Web Extension Toolkit)
|
||||
- **Frontend:** React 19 + TypeScript
|
||||
- **UI Library:** Material UI (MUI) @7.x
|
||||
- **Date Handling:** dayjs (with UTC and timezone plugins)
|
||||
- **Messaging:** @webext-core/messaging
|
||||
- **Storage:** Type-safe Chrome Storage API wrapper
|
||||
- **Testing:** Vitest + Testing Library (jsdom)
|
||||
|
||||
### Architecture & Directory Structure
|
||||
|
||||
- `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.).
|
||||
|
||||
## Building and Running
|
||||
|
||||
### Development
|
||||
|
||||
- `npm run dev`: Start Chrome development mode with HMR.
|
||||
- `npm run dev:firefox`: Start Firefox development mode.
|
||||
- `npm run compile`: Run TypeScript type checking (`tsc --noEmit`).
|
||||
|
||||
### Production
|
||||
|
||||
- `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.
|
||||
|
||||
### Testing & Linting
|
||||
|
||||
- `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.
|
||||
|
||||
## Development Conventions
|
||||
|
||||
### Coding Style
|
||||
|
||||
- **TypeScript:** Use strict typing. Prefer interfaces for object structures and types for unions/aliases.
|
||||
- **Components:** Functional components with Hooks. Use MUI components for consistent UI.
|
||||
- **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`.
|
||||
|
||||
### Testing Practices
|
||||
|
||||
- **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,70 +1,258 @@
|
||||
# Getting Started with Create React App
|
||||
# Testing Tools Browser Extension
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
这是一个基于 WXT 框架的浏览器扩展项目,提供实用的测试工具功能。
|
||||
|
||||
## Available Scripts
|
||||
## 项目概述
|
||||
|
||||
In the project directory, you can run:
|
||||
Testing Tools 是一个轻量级的浏览器扩展,提供多种实用的测试工具功能。项目采用现代化的技术栈,包括 React 19、TypeScript 和 Material UI,并利用 WXT 框架简化浏览器扩展的开发流程。
|
||||
|
||||
### `npm start`
|
||||
## 功能特性
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
### Dashboard 首页
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
- 卡片式工具展示
|
||||
- 支持自定义工具排序和可见性
|
||||
- 实时数据预览(时间戳等)
|
||||
|
||||
### `npm test`
|
||||
### 时间戳转换工具
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
- 实时显示当前时间戳(毫秒/秒可切换)
|
||||
- 日期与时间戳之间的双向转换
|
||||
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
|
||||
- 一键复制转换结果
|
||||
- 输入验证和错误提示
|
||||
|
||||
### `npm run build`
|
||||
### 存储清理工具
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
- 自动读取当前域名
|
||||
- 支持清理多种存储类型:
|
||||
- localStorage
|
||||
- sessionStorage
|
||||
- IndexedDB
|
||||
- Cookies
|
||||
- Cache Storage
|
||||
- Service Workers
|
||||
- 可选择的清理类型(默认全选)
|
||||
- 确认对话框防止误操作
|
||||
- 清理结果统计
|
||||
- 自动刷新页面选项
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
### URL 工具
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
- 保存常用 URL 列表
|
||||
- 快速打开保存的 URL
|
||||
- 支持 URL 验证和安全检查
|
||||
- 内置 URL 查看器(iframe 沙箱模式)
|
||||
|
||||
### `npm run eject`
|
||||
### 二维码工具
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
- URL 转二维码(生成器)
|
||||
- 二维码转 URL(解析器)
|
||||
- 支持上传二维码图片解析
|
||||
- 生成的二维码可下载
|
||||
- 一键复制转换结果
|
||||
- 卡片式布局,节省空间
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
## 技术栈
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
- **框架**: WXT (Web Extension Toolkit)
|
||||
- **前端**: React 19 + TypeScript
|
||||
- **UI 库**: Material UI
|
||||
- **日期处理**: dayjs (含 UTC 和时区插件)
|
||||
- **通信**: @webext-core/messaging
|
||||
- **存储**: Chrome Storage API (类型安全封装)
|
||||
- **二维码**: qrcode (生成) + jsqr (解析)
|
||||
- **测试**: Vitest + Testing Library
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
## 项目结构
|
||||
|
||||
## Learn More
|
||||
```
|
||||
├── components/ # 可复用 UI 组件
|
||||
│ ├── Button.tsx
|
||||
│ ├── CopyButton.tsx
|
||||
│ ├── DashboardCard.tsx # 仪表盘卡片组件(React.memo 优化)
|
||||
│ ├── GlobalSnackbar.tsx
|
||||
│ ├── PageHeader.tsx # 页面标题栏组件
|
||||
│ ├── RouterContainer.tsx
|
||||
│ ├── StorageCleanerConfirm.tsx
|
||||
│ ├── ToolCard.tsx
|
||||
│ └── TopBar.tsx
|
||||
├── config/ # 配置文件
|
||||
│ ├── dashboardCards.tsx # 仪表盘卡片配置数据
|
||||
│ └── routes.ts # 页面路由定义
|
||||
├── entrypoints/ # 浏览器扩展入口点
|
||||
│ ├── popup/ # 扩展弹窗界面
|
||||
│ │ ├── App.tsx
|
||||
│ │ ├── main.tsx
|
||||
│ │ └── pages/ # 页面组件
|
||||
│ │ ├── DashboardPage.tsx
|
||||
│ │ ├── OpenUrlPage.tsx
|
||||
│ │ ├── OpenUrlViewerPage.tsx
|
||||
│ │ ├── QrCodePage.tsx
|
||||
│ │ ├── StorageCleanerPage.tsx
|
||||
│ │ └── TimestampPage.tsx
|
||||
│ ├── options/ # 选项页面
|
||||
│ ├── sidepanel/ # 侧边栏
|
||||
│ ├── background.ts # 后台脚本
|
||||
│ └── content.ts # 内容脚本
|
||||
├── providers/ # React Context providers
|
||||
│ └── RouterProvider.tsx # 路由状态管理
|
||||
├── types/ # TypeScript 类型定义
|
||||
│ └── storage.d.ts
|
||||
├── utils/ # 工具函数
|
||||
│ ├── chromeStorage.ts
|
||||
│ ├── clipboard.ts
|
||||
│ ├── dayjs.ts
|
||||
│ ├── messages.tsx
|
||||
│ └── storageCleaner.ts
|
||||
├── public/ # 静态资源
|
||||
├── wxt.config.ts # WXT 配置文件
|
||||
├── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
## 路由系统
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
项目实现了灵活的路由系统,支持:
|
||||
|
||||
### Code Splitting
|
||||
- **页面导航**: 在不同工具页面之间切换
|
||||
- **路由同步**: 通过 Chrome Storage 同步路由状态
|
||||
- **可见性控制**: 可配置显示哪些页面
|
||||
- **页面排序**: 自定义工具卡片的显示顺序
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
### 页面类型 (PageType)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
| 页面 | 说明 | 默认可见 |
|
||||
| ---------------- | ---------- | -------- |
|
||||
| `dashboard` | 首页 | ✓ |
|
||||
| `timestamp` | 时间戳转换 | ✓ |
|
||||
| `storageCleaner` | 存储清理 | ✓ |
|
||||
| `openUrl` | URL 工具 | ✓ |
|
||||
| `qrCode` | 二维码工具 | ✓ |
|
||||
| `openUrlViewer` | URL 查看器 | ✗ |
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
## 扩展入口点
|
||||
|
||||
### Making a Progressive Web App
|
||||
| 入口点 | 说明 |
|
||||
| -------------- | ------------------------ |
|
||||
| **popup** | 点击扩展图标弹出的界面 |
|
||||
| **options** | 扩展选项页面 |
|
||||
| **sidepanel** | 浏览器侧边栏 |
|
||||
| **background** | 后台脚本(生命周期管理) |
|
||||
| **content** | 内容脚本(注入到网页) |
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
## 开发环境要求
|
||||
|
||||
### Advanced Configuration
|
||||
- Node.js >= 18
|
||||
- npm 或 yarn
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
## 安装与运行
|
||||
|
||||
### Deployment
|
||||
### 1. 安装依赖
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### `npm run build` fails to minify
|
||||
### 2. 开发模式
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
```bash
|
||||
# Chrome 浏览器
|
||||
npm run dev
|
||||
|
||||
# Firefox 浏览器
|
||||
npm run dev:firefox
|
||||
```
|
||||
|
||||
### 3. 构建生产版本
|
||||
|
||||
```bash
|
||||
# Chrome 浏览器
|
||||
npm run build
|
||||
|
||||
# Firefox 浏览器
|
||||
npm run build:firefox
|
||||
```
|
||||
|
||||
### 4. 打包分发
|
||||
|
||||
```bash
|
||||
# Chrome 浏览器
|
||||
npm run zip
|
||||
|
||||
# Firefox 浏览器
|
||||
npm run zip:firefox
|
||||
```
|
||||
|
||||
### 5. 代码质量
|
||||
|
||||
```bash
|
||||
npm run compile # TypeScript 类型检查
|
||||
npm run lint # ESLint 代码检查
|
||||
```
|
||||
|
||||
### 6. 测试
|
||||
|
||||
```bash
|
||||
npm run test # 运行所有测试
|
||||
npm run test:watch # 运行测试并监听文件变化
|
||||
npm run test:coverage # 运行测试并生成覆盖率报告
|
||||
```
|
||||
|
||||
## 持续集成与发布
|
||||
|
||||
项目使用 GitHub Actions 实现自动化 CI/CD,无需手动操作。
|
||||
|
||||
### CI — 持续集成
|
||||
|
||||
在以下场景自动触发:
|
||||
|
||||
- push 到 `main` / `develop` / `develop-*` 分支
|
||||
- 所有 PR(合并到 `main` 或 `develop`)
|
||||
|
||||
自动执行:ESLint 检查 → TypeScript 类型检查 → 单元测试 → Chrome & Firefox 构建验证。
|
||||
|
||||
### 发布版本
|
||||
|
||||
只需推送符合 `v*` 格式的 Git tag,即可自动完成全量 CI 检查、打包并发布到 GitHub Release:
|
||||
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
> 含 `-` 的 tag(如 `v1.0.0-beta.1`)会自动标记为预发布版本(prerelease)。
|
||||
|
||||
工作流文件位于 `.github/workflows/`:
|
||||
|
||||
- `ci.yml` — 持续集成
|
||||
- `release.yml` — 自动发布
|
||||
|
||||
## 权限说明
|
||||
|
||||
扩展请求以下权限:
|
||||
|
||||
- `storage` 和 `unlimitedStorage` - 本地数据存储
|
||||
- `clipboardWrite` - 剪贴板写入(复制功能)
|
||||
- `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入
|
||||
- `cookies` - Cookie 访问
|
||||
- `sidePanel` - 侧边栏支持
|
||||
- `<all_urls>` - 访问所有网站内容(内容脚本注入)
|
||||
|
||||
## 主要依赖
|
||||
|
||||
- `react`, `react-dom` - 前端框架
|
||||
- `@mui/material` - UI 组件库
|
||||
- `dayjs` - 日期处理
|
||||
- `@webext-core/messaging` - 扩展消息通信
|
||||
- `vitest` - 测试框架
|
||||
- `@testing-library/react` - React 组件测试
|
||||
|
||||
## 浏览器兼容性
|
||||
|
||||
- Chrome (推荐)
|
||||
- Firefox
|
||||
|
||||
## 许可证
|
||||
|
||||
此项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -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;
|
||||
@@ -0,0 +1,33 @@
|
||||
import '../.wxt/types/imports.d.ts';
|
||||
import { browser } from 'wxt/browser';
|
||||
import { MessageAction, onMessage } from '@/utils/messages';
|
||||
|
||||
export default defineBackground(() => {
|
||||
// 监听扩展图标点击事件,打开侧边栏
|
||||
browser.action.onClicked.addListener(async (tab) => {
|
||||
if (tab.id) {
|
||||
try {
|
||||
await browser.sidePanel.open({ tabId: tab.id });
|
||||
} 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: '刷新请求已接收' };
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import '../.wxt/types/imports.d.ts';
|
||||
import { initFormMappingHelper } from '@/utils/formMapping/ui';
|
||||
import { initMessageHandler } from './content/messageHandler';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
runAt: 'document_end',
|
||||
main() {
|
||||
// 初始化表单映射助手逻辑 (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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>扩展设置 - Testing Tools</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';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
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';
|
||||
|
||||
export default function App() {
|
||||
// 打开Chrome扩展选项页面,需确保manifest中已配置options_page或options_ui
|
||||
const handleOpenOptions = () => {
|
||||
chrome.runtime.openOptionsPage().catch((r) => console.error(r));
|
||||
};
|
||||
|
||||
return (
|
||||
<RouterProvider syncKey="app/popupRoute">
|
||||
<SnackbarProvider initialOptions={{ autoHideDuration: 1500000 }}>
|
||||
<Box
|
||||
className="app"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '400px',
|
||||
height: '600px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: globalStyles.backgroundColor,
|
||||
'@media screen and (min-width: 401px), screen and (min-height: 601px)': {
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
minWidth: '400px',
|
||||
minHeight: '600px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TopBar onOpenOptions={handleOpenOptions} />
|
||||
<ErrorBoundary>
|
||||
<RouterContainer />
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
</SnackbarProvider>
|
||||
</RouterProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>我是独立窗口</title>
|
||||
<meta name="manifest.type" content="browser_action" />
|
||||
</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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Box, Container, CircularProgress } from '@mui/material';
|
||||
import Button from '@/components/Button';
|
||||
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
|
||||
import StorageCleanerConfirm from '@/components/StorageCleanerConfirm';
|
||||
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
||||
import { useStorageCleaner } from './useStorageCleaner';
|
||||
import DomainHeader from './components/DomainHeader';
|
||||
import StorageOptionsGrid from './components/StorageOptionsGrid';
|
||||
import AutoRefreshToggle from './components/AutoRefreshToggle';
|
||||
import ErrorDisplay from './components/ErrorDisplay';
|
||||
import CleaningResult from './components/CleaningResult';
|
||||
|
||||
export default function StorageCleanerPage() {
|
||||
const { showMessage } = useGlobalSnackbar();
|
||||
const {
|
||||
domain,
|
||||
error,
|
||||
isInitializing,
|
||||
options,
|
||||
sizes,
|
||||
autoRefresh,
|
||||
loading,
|
||||
result,
|
||||
showConfirm,
|
||||
setShowConfirm,
|
||||
totalSize,
|
||||
allSelected,
|
||||
someSelected,
|
||||
handleAutoRefreshChange,
|
||||
handleOptionChange,
|
||||
handleSelectAll,
|
||||
handleClean,
|
||||
} = useStorageCleaner({ showMessage });
|
||||
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress size={24} color="warning" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorDisplay error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Container sx={{ py: 2 }}>
|
||||
<DomainHeader domain={domain} totalSize={totalSize} />
|
||||
|
||||
<StorageOptionsGrid
|
||||
options={options}
|
||||
sizes={sizes}
|
||||
allSelected={allSelected}
|
||||
someSelected={someSelected}
|
||||
onOptionChange={handleOptionChange}
|
||||
onSelectAll={handleSelectAll}
|
||||
/>
|
||||
|
||||
<AutoRefreshToggle autoRefresh={autoRefresh} onChange={handleAutoRefreshChange} />
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => setShowConfirm(true)}
|
||||
sx={{
|
||||
bgcolor: storageCleanerPageStyles.warningColor,
|
||||
'&:hover': {
|
||||
bgcolor: storageCleanerPageStyles.warningDark,
|
||||
},
|
||||
}}
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
>
|
||||
{loading ? '正在清理...' : '立即清理'}
|
||||
</Button>
|
||||
|
||||
<CleaningResult result={result} />
|
||||
</Container>
|
||||
|
||||
<StorageCleanerConfirm
|
||||
open={showConfirm}
|
||||
onClose={() => setShowConfirm(false)}
|
||||
onConfirm={handleClean}
|
||||
options={options}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { TextField, Select, MenuItem, Stack, Box, Container } from '@mui/material';
|
||||
import { useSnackbar } from '@/components/SnackbarProvider';
|
||||
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';
|
||||
|
||||
export default function TimestampPage() {
|
||||
const { showMessage } = useSnackbar();
|
||||
const {
|
||||
mode,
|
||||
tsInput,
|
||||
dtInput,
|
||||
unit,
|
||||
zone,
|
||||
result,
|
||||
error,
|
||||
setMode,
|
||||
setTsInput,
|
||||
setDtInput,
|
||||
setUnit,
|
||||
setZone,
|
||||
handleUseNow,
|
||||
convert,
|
||||
} = useTimestampConverter();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Container sx={{ p: 2 }}>
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="时间戳转换"
|
||||
subtitle="Unix 毫秒数转换与格式化"
|
||||
icon={<AccessTimeIcon />}
|
||||
/>
|
||||
|
||||
{/* Live Clock Card */}
|
||||
<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
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
height: 'calc(100% - 10px)',
|
||||
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%)',
|
||||
top: 5,
|
||||
left: 5,
|
||||
}}
|
||||
/>
|
||||
{(['ts2dt', 'dt2ts'] as const).map((m) => (
|
||||
<Box
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 1,
|
||||
textAlign: 'center',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
cursor: 'pointer',
|
||||
fontWeight: 800,
|
||||
fontSize: '0.75rem',
|
||||
color: mode === m ? 'primary.main' : 'text.secondary',
|
||||
transition: 'color 0.3s',
|
||||
}}
|
||||
>
|
||||
{m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Input Area */}
|
||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||
<TextField
|
||||
placeholder={mode === 'ts2dt' ? '输入时间戳...' : 'YYYY-MM-DD HH:mm:ss'}
|
||||
value={mode === 'ts2dt' ? tsInput : dtInput}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (mode === 'ts2dt') {
|
||||
setTsInput(val);
|
||||
} else {
|
||||
setDtInput(val);
|
||||
}
|
||||
}}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
fullWidth
|
||||
sx={timestampPageStyles.INPUT_STYLE}
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
{/* 优化后的单位选择按钮组 */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
bgcolor: 'grey.50',
|
||||
p: 0.5,
|
||||
borderRadius: 3.5,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
}}
|
||||
>
|
||||
{(['ms', 's'] as const).map((u) => (
|
||||
<Box
|
||||
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
|
||||
fullWidth
|
||||
value={zone}
|
||||
onChange={(e) => setZone(e.target.value as typeof zone)}
|
||||
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) => (
|
||||
<MenuItem key={z} value={z} sx={{ fontSize: '0.8rem', fontWeight: 600 }}>
|
||||
{z}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Main Action */}
|
||||
<Button fullWidth variant="contained" onClick={convert}>
|
||||
立即转换
|
||||
</Button>
|
||||
|
||||
{/* Result View */}
|
||||
<ResultView result={result} mode={mode} unit={unit} zone={zone} showMessage={showMessage} />
|
||||
</Container>
|
||||
</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,
|
||||
};
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'dist',
|
||||
'.wxt',
|
||||
'node_modules',
|
||||
'eslint.config.ts',
|
||||
'**/*.test.tsx',
|
||||
'**/*.test.ts',
|
||||
'**/__tests__/**',
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: [
|
||||
'hooks/**/*.{ts,tsx}',
|
||||
'entrypoints/**/*.{ts,tsx}',
|
||||
'pages/**/*.{ts,tsx}',
|
||||
'utils/**/*.{ts,tsx}',
|
||||
'components/**/*.{ts,tsx}',
|
||||
'services/**/*.{ts,tsx}',
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
react: reactPlugin as any,
|
||||
'react-hooks': reactHooks as any,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
export default {
|
||||
// 对于代码文件:
|
||||
'*.{ts,tsx,js,jsx,mjs}': [
|
||||
// 1. Prettier: 全局格式化
|
||||
'prettier --write',
|
||||
|
||||
// 2. ESLint: 检查并自动修复
|
||||
// --no-warn-ignored: 抑制对忽略文件的警告(eslint.config.ts 中忽略了测试文件)
|
||||
'eslint --fix --max-warnings=0 --no-warn-ignored',
|
||||
|
||||
// 3. TypeScript: 类型检查
|
||||
() => 'tsc --noEmit',
|
||||
],
|
||||
|
||||
// 对于其他文件:
|
||||
'*.{json,css,scss,md}': ['prettier --write'],
|
||||
};
|
||||
@@ -1,45 +1,62 @@
|
||||
{
|
||||
"name": "chrome-extension-router-demo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dexie": "^4.2.1",
|
||||
"dexie-react-hooks": "^4.2.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^6.0.3",
|
||||
"react-router-dom": "^6.30.2",
|
||||
"react-scripts": "5.0.1",
|
||||
"remark-gfm": "^1.0.0",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"name": "testing-tools",
|
||||
"description": "A browser extension providing useful testing tools including timestamp conversion and storage management",
|
||||
"private": false,
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
"dev": "wxt",
|
||||
"dev:firefox": "wxt -b firefox",
|
||||
"build": "wxt build",
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
"zip": "wxt zip",
|
||||
"zip:firefox": "wxt zip -b firefox",
|
||||
"compile": "tsc --noEmit",
|
||||
"postinstall": "wxt prepare",
|
||||
"prepare": "husky",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@faker-js/faker": "^10.4.0",
|
||||
"@mui/icons-material": "^7.3.8",
|
||||
"@mui/material": "^7.3.8",
|
||||
"@webext-core/messaging": "^2.3.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"jsqr": "^1.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
"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/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/webextension-polyfill": "^0.12.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||
"@typescript-eslint/parser": "^8.54.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@wxt-dev/module-react": "^1.1.5",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"globals": "^17.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^25.0.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.8.1",
|
||||
"terser": "^5.46.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vitest": "^2.0.0",
|
||||
"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;
|
||||
@@ -1,25 +0,0 @@
|
||||
console.log('Background script loaded');
|
||||
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
console.log('收到消息:', request);
|
||||
|
||||
if (request.action === 'copy') {
|
||||
console.log('开始复制文本:', request.text);
|
||||
|
||||
navigator.clipboard.writeText(request.text)
|
||||
.then(() => {
|
||||
console.log('复制成功');
|
||||
sendResponse({success: true});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('复制失败:', err);
|
||||
sendResponse({success: false, error: err.message});
|
||||
});
|
||||
|
||||
return true; // 保持消息端口开放以支持异步响应
|
||||
}
|
||||
|
||||
// 对于未知的操作,也返回响应
|
||||
sendResponse({success: false, error: '未知操作'});
|
||||
return false;
|
||||
});
|
||||
|
Before Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "React Router Chrome Extension",
|
||||
"version": "1.0",
|
||||
"description": "A Chrome Extension with React Router: User List + Actions Pages",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"clipboardWrite"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"icons": {
|
||||
"16": "favicon.ico",
|
||||
"48": "favicon.ico",
|
||||
"128": "favicon.ico"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "index.html"
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "index.html",
|
||||
"open_in_tab": true
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
|
After Width: | Height: | Size: 716 KiB |
@@ -1,104 +0,0 @@
|
||||
import {HashRouter as Router, Routes, Route, NavLink} from 'react-router-dom';
|
||||
import {useState, useEffect} from 'react';
|
||||
import TimestampPage from './pages/TimestampPage';
|
||||
// import ElectronicWoodenFishPage from "./pages/ElectronicWoodenFishPage";
|
||||
import './App.css';
|
||||
|
||||
// 导航项配置数组,便于后续添加
|
||||
// 为了测试折叠功能,我们添加更多导航项
|
||||
const navItems = [
|
||||
{path: '/', label: '时间戳', element: <TimestampPage/>},
|
||||
// {path: '/dzmy', label: '电子木鱼', element: <ElectronicWoodenFishPage/>},
|
||||
];
|
||||
|
||||
function App() {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [visibleItems, setVisibleItems] = useState(navItems.length);
|
||||
|
||||
// 检测屏幕尺寸变化
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const width = window.innerWidth;
|
||||
setIsMobile(width < 768);
|
||||
|
||||
// 根据屏幕宽度决定显示多少个导航项
|
||||
if (width >= 768) {
|
||||
setVisibleItems(navItems.length); // 大屏幕显示所有
|
||||
} else if (width >= 480) {
|
||||
// 中等屏幕:如果导航项超过3个,显示3个,否则显示全部
|
||||
setVisibleItems(Math.min(3, navItems.length));
|
||||
} else {
|
||||
// 小屏幕:如果导航项超过2个,显示2个,否则显示全部
|
||||
setVisibleItems(Math.min(2, navItems.length));
|
||||
}
|
||||
};
|
||||
|
||||
handleResize(); // 初始调用
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// 计算哪些导航项应该显示,哪些应该折叠
|
||||
const visibleNavItems = navItems.slice(0, visibleItems);
|
||||
const collapsedNavItems = navItems.slice(visibleItems);
|
||||
|
||||
return (<Router>
|
||||
|
||||
<div className="app">
|
||||
<nav className="nav">
|
||||
<div className="nav-content">
|
||||
<ul className={`nav-list ${isMenuOpen ? 'open' : ''}`}>
|
||||
{visibleNavItems.map((item) => (
|
||||
<li key={item.path}>
|
||||
<NavLink
|
||||
to={item.path}
|
||||
className={({isActive}) => isActive ? "nav-link active" : "nav-link"}
|
||||
onClick={() => isMobile && setIsMenuOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{/* 折叠的导航项 */}
|
||||
{collapsedNavItems.length > 0 && (
|
||||
<li className="nav-collapse-item">
|
||||
<div className={`nav-collapse-content ${isMenuOpen ? 'show' : ''}`}>
|
||||
{isMenuOpen && collapsedNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({isActive}) => isActive ? "nav-link active" : "nav-link"}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="nav-toggle"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
aria-label={isMenuOpen ? "收起菜单" : "展开菜单"}
|
||||
>
|
||||
<span className="nav-toggle-icon">{isMenuOpen ? '×' : '☰'}</span>
|
||||
{collapsedNavItems.length > 0 && !isMenuOpen && (
|
||||
<span className="nav-collapse-count">+{collapsedNavItems.length}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<Routes>
|
||||
{navItems.map((item) => (
|
||||
<Route key={item.path} path={item.path} element={item.element}/>
|
||||
))}
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
|
Before Width: | Height: | Size: 38 KiB |
@@ -1,110 +0,0 @@
|
||||
import {useState, useCallback} from "react";
|
||||
|
||||
const CopyButton = ({
|
||||
text = '要复制的文本',
|
||||
buttonText = '复制文本',
|
||||
className = 'action-btn',
|
||||
}) => {
|
||||
const [btnText, setBtnText] = useState(buttonText);
|
||||
const [copyStatus, setCopyStatus] = useState('');
|
||||
|
||||
// 重置按钮状态的函数
|
||||
const resetButton = useCallback(() => {
|
||||
setBtnText(buttonText);
|
||||
setCopyStatus('');
|
||||
}, [buttonText]);
|
||||
|
||||
// 复制成功的处理
|
||||
const handleCopySuccess = useCallback(() => {
|
||||
setCopyStatus('success');
|
||||
setBtnText('复制成功!');
|
||||
|
||||
// 2秒后恢复
|
||||
setTimeout(() => {
|
||||
resetButton();
|
||||
}, 2000);
|
||||
}, [text, resetButton]);
|
||||
|
||||
// 复制失败的处理
|
||||
const handleCopyError = useCallback(() => {
|
||||
setCopyStatus('error');
|
||||
|
||||
// 2秒后恢复
|
||||
setTimeout(() => {
|
||||
resetButton();
|
||||
}, 2000);
|
||||
}, [resetButton]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
console.log('开始复制:', text);
|
||||
|
||||
// 首先尝试直接使用navigator.clipboard(在popup页面中可能可用)
|
||||
try {
|
||||
console.log('尝试直接使用navigator.clipboard复制');
|
||||
await navigator.clipboard.writeText(text.toString());
|
||||
console.log('直接复制成功');
|
||||
handleCopySuccess();
|
||||
return;
|
||||
} catch (directError) {
|
||||
console.log('直接复制失败,尝试使用Chrome扩展API:', directError);
|
||||
}
|
||||
|
||||
// 如果直接复制失败,尝试使用Chrome扩展API
|
||||
if (chrome && chrome.runtime && chrome.runtime.sendMessage) {
|
||||
console.log('使用Chrome扩展API复制');
|
||||
|
||||
// 使用Chrome扩展API复制
|
||||
chrome.runtime.sendMessage({
|
||||
action: 'copy',
|
||||
text: text
|
||||
}, (response) => {
|
||||
console.log('收到background响应:', response, 'lastError:', chrome.runtime.lastError);
|
||||
|
||||
// 检查是否有运行时错误
|
||||
if (chrome.runtime.lastError) {
|
||||
console.error('Chrome运行时错误:', chrome.runtime.lastError.message);
|
||||
handleCopyError();
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查响应
|
||||
if (response && response.success) {
|
||||
console.log('通过background复制成功');
|
||||
handleCopySuccess();
|
||||
} else {
|
||||
console.error('通过background复制失败:', response?.error);
|
||||
handleCopyError();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('没有可用的复制方法');
|
||||
handleCopyError();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('复制过程中发生错误:', err);
|
||||
handleCopyError();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={className}
|
||||
style={{
|
||||
backgroundColor: copyStatus === 'success' ? '#4CAF50' :
|
||||
copyStatus === 'error' ? '#f44336' : '',
|
||||
color: copyStatus ? 'white' : '',
|
||||
transition: 'all 0.3s ease',
|
||||
padding: '8px 16px',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{btnText}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyButton;
|
||||
@@ -1,198 +0,0 @@
|
||||
import {formatWithDate, formatWithZone, TimezoneOptions} from "../utils/timeUtils";
|
||||
import {useState, useCallback} from "react";
|
||||
|
||||
/**
|
||||
* 日期时间转时间戳组件
|
||||
*
|
||||
* 功能特性:
|
||||
* 1. 将日期时间字符串转换为时间戳
|
||||
* 2. 支持多种时区选择
|
||||
* 3. 支持毫秒和秒单位切换
|
||||
* 4. 提供输入验证和错误提示
|
||||
* 5. 实时单位转换
|
||||
*
|
||||
* @component
|
||||
* @example
|
||||
* ```jsx
|
||||
* <DatetimeToTimestamp />
|
||||
* ```
|
||||
*
|
||||
* @returns {JSX.Element} 日期时间转时间戳组件
|
||||
*/
|
||||
|
||||
// 常用时区列表
|
||||
const TIME_ZONE_LIST = [
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Los_Angeles',
|
||||
'America/Anchorage',
|
||||
'America/Honolulu',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'Europe/Berlin',
|
||||
'Europe/Moscow',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Hong_Kong',
|
||||
'Asia/Singapore',
|
||||
'Asia/Dubai',
|
||||
'Asia/Kolkata',
|
||||
'Australia/Sydney',
|
||||
'Pacific/Auckland',
|
||||
];
|
||||
|
||||
// 时间戳单位选项
|
||||
const TIMESTAMP_UNITS = [
|
||||
{value: 'milliseconds', label: '毫秒(ms)'},
|
||||
{value: 'seconds', label: '秒(s)'},
|
||||
];
|
||||
|
||||
export function DatetimeToTimestamp() {
|
||||
/** @type {[string, function]} 输入的日期时间字符串 */
|
||||
const [dateValue, setDateValue] = useState(() => formatWithZone(Date.now()));
|
||||
|
||||
/** @type {[string, function]} 选择的时区 */
|
||||
const [selectedZone, setSelectedZone] = useState('Asia/Shanghai');
|
||||
|
||||
/** @type {[string, function]} 转换结果 */
|
||||
const [result, setResult] = useState('');
|
||||
|
||||
/** @type {[string, function]} 时间戳单位 ('milliseconds' | 'seconds') */
|
||||
const [unit, setUnit] = useState('milliseconds');
|
||||
|
||||
/** @type {[string, function]} 错误信息 */
|
||||
const [error, setError] = useState('');
|
||||
|
||||
/**
|
||||
* 转换日期时间为时间戳
|
||||
* @type {function(): void}
|
||||
*/
|
||||
const handleConvertDatetimeToTimestamp = useCallback(() => {
|
||||
try {
|
||||
setError('');
|
||||
const timestamp = formatWithDate(dateValue, selectedZone);
|
||||
|
||||
if (isNaN(timestamp)) {
|
||||
setError('无效的日期时间格式');
|
||||
setResult('');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalResult = unit === 'milliseconds'
|
||||
? timestamp
|
||||
: Math.floor(timestamp / 1000);
|
||||
|
||||
setResult(finalResult.toString());
|
||||
} catch (err) {
|
||||
setError('转换失败,请检查输入格式');
|
||||
setResult('');
|
||||
}
|
||||
}, [dateValue, selectedZone, unit]);
|
||||
|
||||
/**
|
||||
* 处理日期时间输入变化
|
||||
* @type {function(React.ChangeEvent<HTMLInputElement>): void}
|
||||
*/
|
||||
const handleDateChange = useCallback((e) => {
|
||||
setDateValue(e.target.value);
|
||||
setError(''); // 清除错误信息
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 处理时区选择变化
|
||||
* @type {function(React.ChangeEvent<HTMLSelectElement>): void}
|
||||
*/
|
||||
const handleZoneChange = useCallback((e) => {
|
||||
setSelectedZone(e.target.value);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 处理时间戳单位变化
|
||||
* @type {function(React.ChangeEvent<HTMLSelectElement>): void}
|
||||
*/
|
||||
const handleUnitChange = useCallback((e) => {
|
||||
const newUnit = e.target.value;
|
||||
setUnit(newUnit);
|
||||
|
||||
// 如果已有结果,重新计算
|
||||
if (result) {
|
||||
const currentResult = parseInt(result, 10);
|
||||
if (!isNaN(currentResult)) {
|
||||
const newResult = newUnit === 'milliseconds'
|
||||
? currentResult * 1000
|
||||
: Math.floor(currentResult / 1000);
|
||||
setResult(newResult.toString());
|
||||
}
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
return (
|
||||
<div className="datetime-converter">
|
||||
<h2 className="converter-title">日期时间转时间戳</h2>
|
||||
|
||||
<div className="converter-form">
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入日期时间 (如: 2024-01-01 12:00:00)"
|
||||
value={dateValue}
|
||||
className="datetime-input"
|
||||
onChange={handleDateChange}
|
||||
aria-label="输入要转换的日期时间"
|
||||
title="支持格式: YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
<select
|
||||
value={selectedZone}
|
||||
className="timezone-select"
|
||||
onChange={handleZoneChange}
|
||||
aria-label="选择时区"
|
||||
>
|
||||
<TimezoneOptions zones={TIME_ZONE_LIST}/>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message" role="alert">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="action-group">
|
||||
<button
|
||||
className="converter-btn action-btn"
|
||||
onClick={handleConvertDatetimeToTimestamp}
|
||||
aria-label="转换日期时间为时间戳"
|
||||
>
|
||||
转换
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="result-group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="转换结果"
|
||||
value={result}
|
||||
className="result-input"
|
||||
readOnly
|
||||
aria-label="转换结果"
|
||||
/>
|
||||
<select
|
||||
value={unit}
|
||||
className="unit-select"
|
||||
onChange={handleUnitChange}
|
||||
aria-label="选择时间戳单位"
|
||||
>
|
||||
{TIMESTAMP_UNITS.map(({value, label}) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import {useEffect, useState, useRef, useCallback} from "react";
|
||||
import CopyButton from "./CopyButton";
|
||||
|
||||
/**
|
||||
* 时间戳显示和执行组件
|
||||
*
|
||||
* 功能特性:
|
||||
* 1. 实时显示当前时间戳(毫秒/秒)
|
||||
* 2. 支持毫秒和秒单位切换
|
||||
* 3. 支持启动/停止时间戳自动更新
|
||||
* 4. 提供复制时间戳功能
|
||||
* 5. 响应式设计和良好的可访问性
|
||||
*
|
||||
* @component
|
||||
* @example
|
||||
* ```jsx
|
||||
* <TimestampExecution />
|
||||
* ```
|
||||
*
|
||||
* @returns {JSX.Element} 时间戳组件
|
||||
*/
|
||||
export function TimestampExecution() {
|
||||
/** @type {[number, function]} 当前时间戳(毫秒)和更新函数 */
|
||||
const [currentTimestamp, setCurrentTimestamp] = useState(() => Math.floor(Date.now()));
|
||||
|
||||
/** @type {[boolean, function]} 是否显示毫秒(true=毫秒,false=秒) */
|
||||
const [showMilliseconds, setShowMilliseconds] = useState(true);
|
||||
|
||||
/** @type {[boolean, function]} 时间戳是否正在自动更新 */
|
||||
const [isRunningTimestamp, setIsRunningTimestamp] = useState(true);
|
||||
|
||||
/** @type {React.RefObject<NodeJS.Timeout | null>} 定时器引用,用于清理 */
|
||||
const timerRef = useRef(null);
|
||||
|
||||
/**
|
||||
* 计算显示的时间戳值
|
||||
* @type {number}
|
||||
*/
|
||||
const displayTimestamp = showMilliseconds
|
||||
? currentTimestamp
|
||||
: Math.floor(currentTimestamp / 1000);
|
||||
|
||||
/**
|
||||
* 计算单位文本
|
||||
* @type {string}
|
||||
*/
|
||||
const unitText = showMilliseconds ? '毫秒' : '秒';
|
||||
|
||||
/**
|
||||
* 定时更新时间戳的副作用
|
||||
* 根据 isRunningTimestamp 和 showMilliseconds 控制定时器的启停和间隔
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 清除之前的定时器
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
// 如果需要运行,创建新的定时器
|
||||
if (isRunningTimestamp) {
|
||||
const interval = showMilliseconds ? 100 : 1000;
|
||||
timerRef.current = setInterval(() => {
|
||||
setCurrentTimestamp(Math.floor(Date.now()));
|
||||
}, interval);
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isRunningTimestamp, showMilliseconds]);
|
||||
|
||||
/**
|
||||
* 切换时间戳显示单位(毫秒/秒)
|
||||
* @type {function(): void}
|
||||
*/
|
||||
const toggleUnit = useCallback(() => {
|
||||
setShowMilliseconds(prev => !prev);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 切换时间戳自动更新状态(启动/停止)
|
||||
* @type {function(): void}
|
||||
*/
|
||||
const toggleTimestamp = useCallback(() => {
|
||||
setIsRunningTimestamp(prev => !prev);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 切换单位按钮的辅助文本
|
||||
* @type {string}
|
||||
*/
|
||||
const unitButtonLabel = showMilliseconds ? '切换为秒显示' : '切换为毫秒显示';
|
||||
|
||||
/**
|
||||
* 启动/停止按钮的辅助文本
|
||||
* @type {string}
|
||||
*/
|
||||
const toggleButtonLabel = isRunningTimestamp ? '停止时间戳自动更新' : '开始时间戳自动更新';
|
||||
|
||||
/**
|
||||
* 启动/停止按钮的显示文本
|
||||
* @type {string}
|
||||
*/
|
||||
const toggleButtonText = isRunningTimestamp ? '停止' : '开始';
|
||||
|
||||
return (
|
||||
<div className="timestamp-container">
|
||||
<div className="timestamp-display">
|
||||
<span className="timestamp-value">{displayTimestamp}</span>
|
||||
<span className="timestamp-unit">{unitText}</span>
|
||||
</div>
|
||||
|
||||
<div className="timestamp-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="timestamp-btn action-btn"
|
||||
onClick={toggleUnit}
|
||||
aria-label={unitButtonLabel}
|
||||
title={unitButtonLabel}
|
||||
>
|
||||
切换单位
|
||||
</button>
|
||||
|
||||
<CopyButton
|
||||
text={currentTimestamp.toString()}
|
||||
buttonText="复制时间戳"
|
||||
aria-label="复制当前时间戳到剪贴板"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`timestamp-btn ${isRunningTimestamp ? 'stop-btn' : 'action-btn'}`}
|
||||
onClick={toggleTimestamp}
|
||||
aria-label={toggleButtonLabel}
|
||||
title={toggleButtonLabel}
|
||||
>
|
||||
{toggleButtonText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import {useState, useCallback} from "react";
|
||||
import {formatWithZone, TimezoneOptions} from "../utils/timeUtils";
|
||||
|
||||
/**
|
||||
* 时间戳转日期时间组件
|
||||
*
|
||||
* 功能特性:
|
||||
* 1. 将时间戳转换为日期时间字符串
|
||||
* 2. 支持多种时区选择
|
||||
* 3. 支持毫秒和秒单位切换
|
||||
* 4. 提供输入验证和错误提示
|
||||
*
|
||||
* @component
|
||||
* @example
|
||||
* ```jsx
|
||||
* <TimestampToDatetime />
|
||||
* ```
|
||||
*
|
||||
* @returns {JSX.Element} 时间戳转日期时间组件
|
||||
*/
|
||||
|
||||
// 常用时区列表
|
||||
const TIME_ZONE_LIST = [
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Los_Angeles',
|
||||
'America/Anchorage',
|
||||
'America/Honolulu',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'Europe/Berlin',
|
||||
'Europe/Moscow',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Hong_Kong',
|
||||
'Asia/Singapore',
|
||||
'Asia/Dubai',
|
||||
'Asia/Kolkata',
|
||||
'Australia/Sydney',
|
||||
'Pacific/Auckland',
|
||||
];
|
||||
|
||||
// 时间戳单位选项
|
||||
const TIMESTAMP_UNITS = [
|
||||
{value: 'milliseconds', label: '毫秒(ms)'},
|
||||
{value: 'seconds', label: '秒(s)'},
|
||||
];
|
||||
|
||||
export function TimestampToDatetime() {
|
||||
/** @type {[string, function]} 输入的时间戳值 */
|
||||
const [timestampValue, setTimestampValue] = useState(Date.now());
|
||||
|
||||
/** @type {[string, function]} 转换结果 */
|
||||
const [timestampResult, setTimestampResult] = useState('');
|
||||
|
||||
/** @type {[string, function]} 时间戳单位 ('milliseconds' | 'seconds') */
|
||||
const [unit, setUnit] = useState('milliseconds');
|
||||
|
||||
/** @type {[string, function]} 选择的时区 */
|
||||
const [selectedZone, setSelectedZone] = useState('Asia/Shanghai');
|
||||
|
||||
/** @type {[string, function]} 错误信息 */
|
||||
const [error, setError] = useState('');
|
||||
|
||||
/**
|
||||
* 转换时间戳为日期时间
|
||||
* @type {function(): void}
|
||||
*/
|
||||
const handleConvertTimestampToDate = useCallback(() => {
|
||||
try {
|
||||
setError('');
|
||||
|
||||
if (!timestampValue) {
|
||||
setError('请输入时间戳');
|
||||
setTimestampResult('');
|
||||
return;
|
||||
}
|
||||
|
||||
const numericValue = Number(timestampValue);
|
||||
if (isNaN(numericValue)) {
|
||||
setError('无效的时间戳格式');
|
||||
setTimestampResult('');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = formatWithZone(numericValue, selectedZone, unit);
|
||||
setTimestampResult(result);
|
||||
} catch (err) {
|
||||
setError('转换失败,请检查输入格式');
|
||||
setTimestampResult('');
|
||||
}
|
||||
}, [timestampValue, selectedZone, unit]);
|
||||
|
||||
/**
|
||||
* 处理时间戳输入变化
|
||||
* @type {function(React.ChangeEvent<HTMLInputElement>): void}
|
||||
*/
|
||||
const handleInputChange = useCallback((e) => {
|
||||
setTimestampValue(e.target.value);
|
||||
setError(''); // 清除错误信息
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 处理时区选择变化
|
||||
* @type {function(React.ChangeEvent<HTMLSelectElement>): void}
|
||||
*/
|
||||
const handleZoneChange = useCallback((e) => {
|
||||
setSelectedZone(e.target.value);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 处理时间戳单位变化
|
||||
* @type {function(React.ChangeEvent<HTMLSelectElement>): void}
|
||||
*/
|
||||
const handleUnitChange = useCallback((e) => {
|
||||
setUnit(e.target.value);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="datetime-converter">
|
||||
<h2 className="converter-title">时间戳转日期时间</h2>
|
||||
|
||||
<div className="converter-form">
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="输入时间戳 (如: 1704067200000)"
|
||||
value={timestampValue}
|
||||
className="datetime-input"
|
||||
onChange={handleInputChange}
|
||||
aria-label="输入要转换的时间戳"
|
||||
title="支持毫秒或秒为单位的时间戳"
|
||||
/>
|
||||
<select
|
||||
value={unit}
|
||||
className="unit-select"
|
||||
onChange={handleUnitChange}
|
||||
aria-label="选择时间戳单位"
|
||||
>
|
||||
{TIMESTAMP_UNITS.map(({value, label}) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message" role="alert">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="action-group">
|
||||
<button
|
||||
className="converter-btn action-btn"
|
||||
onClick={handleConvertTimestampToDate}
|
||||
aria-label="转换时间戳为日期时间"
|
||||
>
|
||||
转换
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="result-group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="转换结果"
|
||||
value={timestampResult}
|
||||
className="result-input"
|
||||
readOnly
|
||||
aria-label="转换结果"
|
||||
/>
|
||||
<select
|
||||
value={selectedZone}
|
||||
className="timezone-select"
|
||||
onChange={handleZoneChange}
|
||||
aria-label="选择时区"
|
||||
>
|
||||
<TimezoneOptions zones={TIME_ZONE_LIST}/>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||