From 4850c923653f3aa0cca7eb9632c40324fd132116 Mon Sep 17 00:00:00 2001
From: LingandRX <56020800+LingandRX@users.noreply.github.com>
Date: Thu, 23 Apr 2026 13:57:55 +0800
Subject: [PATCH] Develop fill form (#11)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(formRecognizer): 添加表单识别功能及相关组件
添加表单识别功能,包括以下内容:
1. 在路由配置中添加表单识别页面
2. 实现表单识别页面和侧边栏面板
3. 添加表单数据生成工具类
4. 实现与内容脚本的通信机制
5. 添加faker-js依赖用于生成测试数据
6. 支持不同入口点(popup/sidepanel)的组件渲染
* refactor(消息通信): 重构消息通信机制并集中管理消息协议
将分散的消息协议和通信逻辑集中到 utils/messages.ts 中
移除旧的 messages.tsx 文件并更新相关引用
添加消息动作枚举和类型定义,提高类型安全性
优化内容脚本注入失败时的处理逻辑
* feat(QR码): 添加粘贴图片功能并优化上传组件
添加全局粘贴事件监听,支持从剪贴板直接粘贴二维码图片进行解析。重构上传组件为独立组件QrCodeUploader,包含拖拽上传、预览、进度显示和错误处理功能。优化页面样式和用户体验。
- 在QrCodePage添加粘贴事件监听
- 创建QrCodeUploader组件整合上传功能
- 更新测试用例格式
- 调整多个页面的背景色样式
* feat(表单识别): 新增表单识别页面功能与模板管理
- 添加表单识别页面样式配置
- 实现表单字段扫描与展示功能
- 新增数据模板管理工具类
- 添加数据验证工具类
- 扩展表单识别页面功能,包括操作历史记录
- 支持模板的导入导出功能
- 优化表单填充操作的用户体验
* feat(消息系统): 添加标签页刷新功能
在消息系统中新增 RELOAD_TAB 动作类型和 tabId 字段,用于处理标签页刷新请求
修改 StorageCleanerPage 使用后台脚本发送刷新请求,确保弹窗关闭后仍能执行
在 background.ts 中添加标签页刷新处理逻辑,包括错误处理和响应返回
* refactor(theme): 重构主题颜色和样式配置
- 更新主题颜色以满足 WCAG AA 可访问性标准
- 提取全局样式配置到统一变量
- 使用语义化颜色变量替换硬编码值
- 为输入框样式创建统一配置
* feat: add URL entry management components and QR code generation feature
- Introduced `UrlEntryItem` and `UrlEntryList` components for displaying and managing URL entries.
- Added `UrlToQrCodeSection` component for generating QR codes from URLs with download and copy functionality.
- Implemented `AutoRefreshToggle`, `CleaningResult`, `DomainHeader`, `ErrorDisplay`, `OptionItem`, and `StorageOptionsGrid` components for enhanced user interface in storage cleaning.
- Created custom hooks `useStorageCleaner` and `useStorageState` for managing storage-related states and preferences.
- Added utility hook `useUrlPreferences` for handling URL entry preferences.
* feat: 新增时间戳转换器和相关组件,优化时间戳页面功能
* Refactor message handling and storage cleaning logic
* feat: 添加 GitHub Actions CI/CD 工作流,支持自动化构建与发布
---
.github/workflows/ci.yml | 103 +++
.github/workflows/node.js.yml | 31 -
.github/workflows/release.yml | 118 ++++
.workbuddy/memory/2026-04-23.md | 11 +
.workbuddy/memory/MEMORY.md | 0
README.md | 29 +
components/FeatureDescription.tsx | 31 +
components/FieldList.tsx | 123 ++++
components/MainActions.tsx | 79 +++
components/OperationHistory.tsx | 80 +++
components/OptionsPanel.tsx | 34 +
components/QrCodeToUrlSection.tsx | 324 +++++++++
components/QrCodeUploader.tsx | 415 +++++++++++
components/TemplateManager.tsx | 117 +++
components/UrlEntryForm.tsx | 126 ++++
components/UrlEntryItem.tsx | 142 ++++
components/UrlEntryList.tsx | 63 ++
components/UrlToQrCodeSection.tsx | 229 ++++++
components/__tests__/Button.test.tsx | 8 +-
config/__tests__/routes.test.ts | 23 +-
config/pageTheme.ts | 123 +++-
entrypoints/background.ts | 27 +
.../popup/pages/FormRecognizerPage.tsx | 385 ++++++----
entrypoints/popup/pages/OpenUrlPage.tsx | 338 +--------
entrypoints/popup/pages/QrCodePage.tsx | 510 +-------------
.../popup/pages/StorageCleanerPage.tsx | 666 +-----------------
entrypoints/popup/pages/TimestampPage.tsx | 378 +---------
.../pages/components/AutoRefreshToggle.tsx | 51 ++
.../popup/pages/components/CleaningResult.tsx | 36 +
.../popup/pages/components/DomainHeader.tsx | 85 +++
.../popup/pages/components/ErrorDisplay.tsx | 62 ++
.../popup/pages/components/LiveClock.tsx | 152 ++++
.../popup/pages/components/OptionItem.tsx | 107 +++
.../popup/pages/components/ResultView.tsx | 148 ++++
.../pages/components/StorageOptionsGrid.tsx | 133 ++++
.../pages/hooks/useTimestampConverter.ts | 113 +++
entrypoints/popup/pages/useStorageCleaner.ts | 264 +++++++
utils/dataTemplate.ts | 164 +++++
utils/dataValidator.ts | 166 +++++
utils/dummyDataGenerator.ts | 18 +-
utils/messages.ts | 7 +-
utils/storageCleaner.ts | 84 ++-
utils/useStorageState.ts | 41 ++
utils/useUrlPreferences.ts | 45 ++
44 files changed, 4168 insertions(+), 2021 deletions(-)
create mode 100644 .github/workflows/ci.yml
delete mode 100644 .github/workflows/node.js.yml
create mode 100644 .github/workflows/release.yml
create mode 100644 .workbuddy/memory/2026-04-23.md
create mode 100644 .workbuddy/memory/MEMORY.md
create mode 100644 components/FeatureDescription.tsx
create mode 100644 components/FieldList.tsx
create mode 100644 components/MainActions.tsx
create mode 100644 components/OperationHistory.tsx
create mode 100644 components/OptionsPanel.tsx
create mode 100644 components/QrCodeToUrlSection.tsx
create mode 100644 components/QrCodeUploader.tsx
create mode 100644 components/TemplateManager.tsx
create mode 100644 components/UrlEntryForm.tsx
create mode 100644 components/UrlEntryItem.tsx
create mode 100644 components/UrlEntryList.tsx
create mode 100644 components/UrlToQrCodeSection.tsx
create mode 100644 entrypoints/popup/pages/components/AutoRefreshToggle.tsx
create mode 100644 entrypoints/popup/pages/components/CleaningResult.tsx
create mode 100644 entrypoints/popup/pages/components/DomainHeader.tsx
create mode 100644 entrypoints/popup/pages/components/ErrorDisplay.tsx
create mode 100644 entrypoints/popup/pages/components/LiveClock.tsx
create mode 100644 entrypoints/popup/pages/components/OptionItem.tsx
create mode 100644 entrypoints/popup/pages/components/ResultView.tsx
create mode 100644 entrypoints/popup/pages/components/StorageOptionsGrid.tsx
create mode 100644 entrypoints/popup/pages/hooks/useTimestampConverter.ts
create mode 100644 entrypoints/popup/pages/useStorageCleaner.ts
create mode 100644 utils/dataTemplate.ts
create mode 100644 utils/dataValidator.ts
create mode 100644 utils/useStorageState.ts
create mode 100644 utils/useUrlPreferences.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..1c31c00
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,103 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ - develop
+ - develop-*
+ pull_request:
+ branches:
+ - main
+ - develop
+
+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, firefox]
+ 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
+
+ - name: Build (Firefox)
+ if: matrix.browser == 'firefox'
+ run: npm run build:firefox
diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml
deleted file mode 100644
index 0f7b8c0..0000000
--- a/.github/workflows/node.js.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
-# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
-
-name: Node.js CI
-
-on:
- push:
- branches: [ "main" ]
- pull_request:
- branches: [ "main" ]
-
-jobs:
- build:
-
- runs-on: ubuntu-latest
-
- strategy:
- matrix:
- node-version: [22.x]
- # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
-
- steps:
- - uses: actions/checkout@v4
- - name: Use Node.js ${{ matrix.node-version }}
- uses: actions/setup-node@v4
- with:
- node-version: ${{ matrix.node-version }}
- cache: 'npm'
- - run: npm install
- - run: npm run build --if-present
- # - run: npm test
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..373a1f3
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -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 }}
diff --git a/.workbuddy/memory/2026-04-23.md b/.workbuddy/memory/2026-04-23.md
new file mode 100644
index 0000000..3c83c47
--- /dev/null
+++ b/.workbuddy/memory/2026-04-23.md
@@ -0,0 +1,11 @@
+# 2026-04-23
+
+## GitHub Actions 工作流搭建
+
+为 testing-tool 浏览器扩展项目编写了 GitHub Actions CI/CD 工作流:
+
+- 新建 `.github/workflows/ci.yml`:PR / push 到 main & develop 时触发,依次执行 Lint → TSC 类型检查 → 单元测试 → Chrome & Firefox 构建验证(build job 依赖前三个 job 全部通过)。
+- 新建 `.github/workflows/release.yml`:推送 `v*` tag 时触发,全量 CI 检查通过后自动打包 Chrome & Firefox zip,通过 `softprops/action-gh-release@v2` 发布到 GitHub Release,并使用 `generate_release_notes: true` 自动生成 changelog。
+- 删除了旧的 `.github/workflows/node.js.yml`(测试步骤全部注释,已废弃)。
+- 预发布判断:tag 名包含 `-`(如 v1.0.0-beta.1)时自动标记为 prerelease。
+- 将 CI/CD 说明写入了 README.md 的「持续集成与发布」章节。
diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md
new file mode 100644
index 0000000..e69de29
diff --git a/README.md b/README.md
index 307395b..62d40f0 100644
--- a/README.md
+++ b/README.md
@@ -196,6 +196,35 @@ 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` — 自动发布
+
## 权限说明
扩展请求以下权限:
diff --git a/components/FeatureDescription.tsx b/components/FeatureDescription.tsx
new file mode 100644
index 0000000..a60a84b
--- /dev/null
+++ b/components/FeatureDescription.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { Box, Typography, Paper } from '@mui/material';
+
+const FeatureDescription: React.FC = () => {
+ return (
+
+
+
+ 功能说明
+
+
+
+
+ 有效数据模式:生成符合格式要求的测试数据,适用于正常功能测试。
+
+
+ 异常数据模式:生成边界值或格式错误的数据,适用于异常场景测试。
+
+
+ 一键清空:快速清空当前页面所有表单字段的值。
+
+
+ 支持的字段类型:
+ 文本、邮箱、手机号、数字、日期、文本域、密码、身份证号等。
+
+
+
+ );
+};
+
+export default FeatureDescription;
diff --git a/components/FieldList.tsx b/components/FieldList.tsx
new file mode 100644
index 0000000..07974b6
--- /dev/null
+++ b/components/FieldList.tsx
@@ -0,0 +1,123 @@
+import React from 'react';
+import {
+ Box,
+ Typography,
+ Paper,
+ List,
+ ListItem,
+ ListItemText,
+ ListItemIcon,
+ Collapse,
+ Chip,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import ExpandLessIcon from '@mui/icons-material/ExpandLess';
+import InputIcon from '@mui/icons-material/Input';
+
+// 字段数据接口
+interface FieldData {
+ id: string;
+ fieldType: string;
+ label: string | null;
+ placeholder: string;
+ name: string;
+ value: string;
+ isSelected: boolean;
+ generatedValue: string;
+}
+
+// 字段类型显示名称映射
+const FIELD_TYPE_NAMES: Record = {
+ text: '文本',
+ email: '邮箱',
+ phone: '手机号',
+ number: '数字',
+ date: '日期',
+ textarea: '文本域',
+ radio: '单选框',
+ checkbox: '复选框',
+ select: '下拉框',
+ password: '密码',
+ name: '姓名',
+ id_card: '身份证号',
+ unknown: '未知',
+};
+
+// 字段类型颜色映射
+const FIELD_TYPE_COLORS: Record<
+ string,
+ 'default' | 'primary' | 'secondary' | 'error' | 'success' | 'warning'
+> = {
+ email: 'primary',
+ phone: 'success',
+ number: 'secondary',
+ date: 'warning',
+ password: 'error',
+ name: 'primary',
+ id_card: 'secondary',
+ text: 'default',
+ textarea: 'default',
+ unknown: 'default',
+};
+
+interface FieldListProps {
+ fields: FieldData[];
+ showFields: boolean;
+ onToggleShowFields: () => void;
+}
+
+const FieldList: React.FC = ({ fields, showFields, onToggleShowFields }) => {
+ if (fields.length === 0) return null;
+
+ return (
+
+
+
+ 已识别字段 ({fields.length})
+
+ {showFields ? : }
+
+
+
+ {fields.map((field, index) => (
+
+
+
+
+
+
+ {field.label || field.name || field.placeholder || `字段 ${index + 1}`}
+
+
+
+ }
+ secondary={field.placeholder || field.name}
+ />
+
+ ))}
+
+
+
+ );
+};
+
+export default FieldList;
diff --git a/components/MainActions.tsx b/components/MainActions.tsx
new file mode 100644
index 0000000..b915062
--- /dev/null
+++ b/components/MainActions.tsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import { Button, Stack, CircularProgress } from '@mui/material';
+import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
+import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
+import ClearAllIcon from '@mui/icons-material/ClearAll';
+import { formRecognizerPageStyles } from '@/config/pageTheme';
+
+interface MainActionsProps {
+ loading: boolean;
+ onFillValidData: () => void;
+ onFillInvalidData: () => void;
+ onClearAllFields: () => void;
+}
+
+const MainActions: React.FC = ({
+ loading,
+ onFillValidData,
+ onFillInvalidData,
+ onClearAllFields,
+}) => {
+ return (
+
+ : }
+ onClick={onFillValidData}
+ disabled={loading}
+ fullWidth
+ sx={{
+ ...formRecognizerPageStyles.buttonStyle,
+ bgcolor: formRecognizerPageStyles.validColor,
+ '&:hover': {
+ bgcolor: formRecognizerPageStyles.validDark,
+ },
+ }}
+ >
+ {loading ? '填充中...' : '一键填充(有效数据)'}
+
+
+ : }
+ onClick={onFillInvalidData}
+ disabled={loading}
+ fullWidth
+ sx={{
+ ...formRecognizerPageStyles.buttonStyle,
+ bgcolor: formRecognizerPageStyles.invalidColor,
+ '&:hover': {
+ bgcolor: formRecognizerPageStyles.invalidDark,
+ },
+ }}
+ >
+ {loading ? '填充中...' : '一键填充(异常数据)'}
+
+
+ : }
+ onClick={onClearAllFields}
+ disabled={loading}
+ fullWidth
+ sx={{
+ ...formRecognizerPageStyles.buttonStyle,
+ borderColor: formRecognizerPageStyles.clearColor,
+ color: formRecognizerPageStyles.clearColor,
+ '&:hover': {
+ borderColor: formRecognizerPageStyles.clearDark,
+ bgcolor: formRecognizerPageStyles.clearBg,
+ },
+ }}
+ >
+ {loading ? '清空中...' : '一键清空所有表单'}
+
+
+ );
+};
+
+export default MainActions;
diff --git a/components/OperationHistory.tsx b/components/OperationHistory.tsx
new file mode 100644
index 0000000..afc36d8
--- /dev/null
+++ b/components/OperationHistory.tsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import {
+ Box,
+ Typography,
+ Paper,
+ List,
+ ListItem,
+ ListItemText,
+ Collapse,
+ Chip,
+ Divider,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import ExpandLessIcon from '@mui/icons-material/ExpandLess';
+
+interface OperationHistoryItem {
+ time: string;
+ type: string;
+ content: string;
+ result: string;
+}
+
+interface OperationHistoryProps {
+ history: OperationHistoryItem[];
+ showHistory: boolean;
+ onToggleShowHistory: () => void;
+}
+
+const OperationHistory: React.FC = ({
+ history,
+ showHistory,
+ onToggleShowHistory,
+}) => {
+ if (history.length === 0) return null;
+
+ return (
+
+
+
+ 操作历史 ({history.length})
+
+ {showHistory ? : }
+
+
+
+ {history.map((item, index) => (
+
+ {index > 0 && }
+
+
+
+ {item.content}
+
+ }
+ secondary={`${item.time} · ${item.result}`}
+ />
+
+
+ ))}
+
+
+
+ );
+};
+
+export default OperationHistory;
diff --git a/components/OptionsPanel.tsx b/components/OptionsPanel.tsx
new file mode 100644
index 0000000..ccdc767
--- /dev/null
+++ b/components/OptionsPanel.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import { Box, Typography, Paper, FormControlLabel, Switch } from '@mui/material';
+
+interface OptionsPanelProps {
+ includeHidden: boolean;
+ onIncludeHiddenChange: (checked: boolean) => void;
+}
+
+const OptionsPanel: React.FC = ({ includeHidden, onIncludeHiddenChange }) => {
+ return (
+
+
+
+ 填充选项
+
+
+
+ onIncludeHiddenChange(e.target.checked)}
+ color="primary"
+ />
+ }
+ label="包含隐藏字段"
+ sx={{ width: '100%' }}
+ />
+
+
+ );
+};
+
+export default OptionsPanel;
diff --git a/components/QrCodeToUrlSection.tsx b/components/QrCodeToUrlSection.tsx
new file mode 100644
index 0000000..f84ab2c
--- /dev/null
+++ b/components/QrCodeToUrlSection.tsx
@@ -0,0 +1,324 @@
+import { useState, useEffect } from 'react';
+import {
+ Box,
+ Typography,
+ TextField,
+ Button,
+ Stack,
+ Alert,
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ CircularProgress,
+ InputAdornment,
+} 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 jsQR from 'jsqr';
+import CopyButton from '@/components/CopyButton';
+import { qrCodePageStyles } from '@/config/pageTheme';
+import type { SnackbarOptions } from '@/components/GlobalSnackbar';
+
+interface QrCodeToUrlSectionProps {
+ expanded: boolean;
+ onExpandedChange: (expanded: boolean) => void;
+ showMessage: (message: string, options?: SnackbarOptions) => void;
+}
+
+const QrCodeToUrlSection = ({
+ expanded,
+ onExpandedChange,
+ showMessage,
+}: QrCodeToUrlSectionProps) => {
+ const [qrCodeFile, setQrCodeFile] = useState(null);
+ const [parsedUrl, setParsedUrl] = useState('');
+ const [parseError, setParseError] = useState('');
+ const [parsing, setParsing] = useState(false);
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ if (e.target.files && e.target.files.length > 0) {
+ const file = e.target.files[0];
+ setQrCodeFile(file);
+ setParseError('');
+ setParsedUrl('');
+ }
+ };
+
+ const parseQrCode = async () => {
+ if (!qrCodeFile) {
+ showMessage('请选择二维码图片', { severity: 'error', autoHideDuration: 300 });
+ return;
+ }
+
+ try {
+ setParsing(true);
+ setParseError('');
+ setParsedUrl('');
+
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+
+ if (!ctx) {
+ throw new Error('无法创建 canvas 上下文');
+ }
+
+ const image = new Image();
+ image.src = URL.createObjectURL(qrCodeFile);
+
+ await new Promise((resolve, reject) => {
+ image.onload = () => {
+ canvas.width = image.width;
+ canvas.height = image.height;
+ ctx.drawImage(image, 0, 0);
+ resolve();
+ };
+ image.onerror = () => reject(new Error('图片加载失败'));
+ });
+
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ const code = jsQR(imageData.data, imageData.width, imageData.height);
+
+ if (code) {
+ setParsedUrl(code.data);
+ showMessage('二维码解析成功', { severity: 'success', autoHideDuration: 1000 });
+ } else {
+ showMessage('未检测到二维码', { 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 {
+ setQrCodeFile(file);
+ setParseError('');
+ setParsedUrl('');
+ 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]);
+
+ return (
+ onExpandedChange(isExpanded)}
+ sx={{
+ borderRadius: 4,
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
+ '&:before': { display: 'none' },
+ }}
+ >
+ } sx={{ borderBottom: 'none' }}>
+
+
+
+ 二维码转 URL
+
+
+
+
+
+
+
+
+
+
+ : }
+ onClick={parseQrCode}
+ disabled={parsing}
+ sx={{
+ py: 1.2,
+ borderRadius: 3,
+ bgcolor: qrCodePageStyles.successColor,
+ fontWeight: 700,
+ '&:hover': {
+ bgcolor: qrCodePageStyles.successDark,
+ },
+ }}
+ >
+ {parsing ? '解析中...' : '解析二维码'}
+
+
+
+
+
+
+ ),
+ },
+ }}
+ sx={qrCodePageStyles.INPUT_STYLE}
+ />
+
+
+ {parseError && (
+
+ {parseError}
+
+ )}
+
+
+
+ );
+};
+
+export default QrCodeToUrlSection;
diff --git a/components/QrCodeUploader.tsx b/components/QrCodeUploader.tsx
new file mode 100644
index 0000000..b685894
--- /dev/null
+++ b/components/QrCodeUploader.tsx
@@ -0,0 +1,415 @@
+import React, { useState, useRef, useEffect, useCallback } from 'react';
+import {
+ Box,
+ Typography,
+ Paper,
+ CircularProgress,
+ Alert,
+ IconButton,
+ 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 jsQR from 'jsqr';
+import GlobalSnackbar, { useSnackbar } from './GlobalSnackbar';
+import CopyButton from './CopyButton';
+
+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 = ({
+ 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 } = useSnackbar({ autoHideDuration: 3000 });
+ const theme = useTheme();
+ const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
+
+ const [file, setFile] = useState(null);
+ const [preview, setPreview] = useState(null);
+ const [uploading, setUploading] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState(null);
+ const [dragging, setDragging] = useState(false);
+
+ const fileInputRef = useRef(null);
+ const uploadAreaRef = useRef(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 canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+
+ if (!ctx) {
+ throw new Error('无法创建 canvas 上下文');
+ }
+
+ const image = new Image();
+ image.src = URL.createObjectURL(file);
+
+ await new Promise((resolve, reject) => {
+ const timeoutId = setTimeout(() => {
+ reject(new Error('图片加载超时'));
+ }, timeout);
+
+ image.onload = () => {
+ clearTimeout(timeoutId);
+ canvas.width = image.width;
+ canvas.height = image.height;
+ ctx.drawImage(image, 0, 0);
+ resolve();
+ };
+
+ image.onerror = () => {
+ clearTimeout(timeoutId);
+ reject(new Error('图片加载失败'));
+ };
+ });
+
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ const code = jsQR(imageData.data, imageData.width, imageData.height);
+
+ clearInterval(progressInterval);
+ setProgress(100);
+
+ if (code) {
+ setResult(code.data);
+ showMessage('二维码解析成功', { severity: 'success' });
+ if (onQrCodeDetected) {
+ onQrCodeDetected(code.data);
+ }
+ } else {
+ setError('未检测到二维码');
+ showMessage('未检测到二维码', { 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);
+ },
+ [supportedFormats, maxFileSize, showPreview, showMessage, processFile],
+ );
+
+ // 处理文件选择
+ const handleFileSelect = (e: React.ChangeEvent) => {
+ const selectedFile = e.target.files?.[0];
+ if (selectedFile) {
+ handleFile(selectedFile);
+ }
+ };
+
+ // 处理拖拽事件
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ setDragging(true);
+ };
+
+ const handleDragLeave = () => {
+ setDragging(false);
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ 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 (
+
+ {/* 上传区域 */}
+ fileInputRef.current?.click()}
+ >
+
+
+ {!file && !uploading ? (
+
+
+
+ 点击、拖拽或粘贴上传二维码图片
+
+
+ 支持 {supportedFormats.map((f) => f.split('/')[1].toUpperCase()).join(', ')} 格式
+
+
+ 最大文件大小: {(maxFileSize / (1024 * 1024)).toFixed(1)}MB
+
+
+ ) : file && showPreview && preview ? (
+
+
+ {
+ 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)',
+ },
+ }}
+ >
+
+
+
+ {file.name}
+
+
+ ) : uploading && showProgress ? (
+
+
+
+ 处理中...
+
+ {progress > 0 && (
+
+
+
+
+
+ {progress}%
+
+
+ )}
+
+ ) : null}
+
+
+ {/* 结果展示 */}
+ {(result || error) && (
+
+ {result && (
+
+
+
+
+
+ 二维码内容
+
+
+
+ {result}
+
+
+
+
+
+
+ )}
+
+ {error && (
+ }>
+ {error}
+
+ )}
+
+ )}
+
+
+
+ );
+};
+
+export default QrCodeUploader;
diff --git a/components/TemplateManager.tsx b/components/TemplateManager.tsx
new file mode 100644
index 0000000..9ffefda
--- /dev/null
+++ b/components/TemplateManager.tsx
@@ -0,0 +1,117 @@
+import React from 'react';
+import {
+ Box,
+ Typography,
+ Paper,
+ List,
+ ListItem,
+ ListItemText,
+ ListItemIcon,
+ Collapse,
+ Button,
+ Stack,
+ CircularProgress,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import ExpandLessIcon from '@mui/icons-material/ExpandLess';
+import FolderIcon from '@mui/icons-material/Folder';
+import DownloadIcon from '@mui/icons-material/Download';
+import UploadIcon from '@mui/icons-material/Upload';
+import { DataTemplate } from '@/utils/dataTemplate';
+
+interface TemplateManagerProps {
+ templates: DataTemplate[];
+ showTemplates: boolean;
+ templateLoading: boolean;
+ onToggleShowTemplates: () => void;
+ onLoadTemplates: () => void;
+ onExportTemplates: () => void;
+ onImportTemplates: () => void;
+}
+
+const TemplateManager: React.FC = ({
+ templates,
+ showTemplates,
+ templateLoading,
+ onToggleShowTemplates,
+ onLoadTemplates,
+ onExportTemplates,
+ onImportTemplates,
+}) => {
+ const handleToggle = () => {
+ onToggleShowTemplates();
+ if (!showTemplates) onLoadTemplates();
+ };
+
+ return (
+
+
+
+ 模板管理 ({templates.length})
+
+ {showTemplates ? : }
+
+
+
+
+ }
+ onClick={onExportTemplates}
+ variant="outlined"
+ >
+ 导出
+
+ }
+ onClick={onImportTemplates}
+ variant="outlined"
+ >
+ 导入
+
+
+ {templateLoading ? (
+
+
+
+ ) : templates.length === 0 ? (
+
+ 暂无模板,请先在其他页面创建模板
+
+ ) : (
+
+ {templates.map((template) => (
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ );
+};
+
+export default TemplateManager;
diff --git a/components/UrlEntryForm.tsx b/components/UrlEntryForm.tsx
new file mode 100644
index 0000000..1d7c280
--- /dev/null
+++ b/components/UrlEntryForm.tsx
@@ -0,0 +1,126 @@
+import { useState } from 'react';
+import { Box, TextField, Alert, Stack } from '@mui/material';
+import AddIcon from '@mui/icons-material/Add';
+import Button from '@/components/Button';
+import type { OpenUrlEntry } from '@/types/storage';
+import type { SnackbarOptions } from '@/components/GlobalSnackbar';
+import { openUrlPageStyles } from '@/config/pageTheme';
+
+interface UrlEntryFormProps {
+ onAddEntry: (entry: OpenUrlEntry) => void;
+ showMessage: (message: string, options?: SnackbarOptions) => void;
+}
+
+const UrlEntryForm = ({ onAddEntry, showMessage }: UrlEntryFormProps) => {
+ const [newName, setNewName] = useState('');
+ const [newUrl, setNewUrl] = useState('');
+
+ const showMixedContentWarning =
+ newUrl.startsWith('http://') && !newUrl.includes('localhost') && !newUrl.includes('127.0.0.1');
+
+ const isValidUrl = (url: string) => {
+ if (!url.trim()) return false;
+ try {
+ new URL(url);
+ return true;
+ } catch {
+ return false;
+ }
+ };
+
+ const handleAddEntry = () => {
+ if (!newName.trim()) {
+ showMessage('请输入名称', { severity: 'error' });
+ return;
+ }
+ if (!isValidUrl(newUrl)) {
+ showMessage('请输入有效的 URL', { severity: 'error' });
+ return;
+ }
+
+ onAddEntry({ name: newName.trim(), url: newUrl.trim() });
+ setNewName('');
+ setNewUrl('');
+ showMessage('添加成功', { severity: 'success' });
+ };
+
+ return (
+
+
+ setNewName(e.target.value)}
+ fullWidth
+ variant="outlined"
+ sx={openUrlPageStyles.INPUT_STYLE}
+ slotProps={{
+ inputLabel: {
+ shrink: true,
+ },
+ }}
+ />
+ setNewUrl(e.target.value)}
+ fullWidth
+ variant="outlined"
+ sx={openUrlPageStyles.INPUT_STYLE}
+ slotProps={{
+ inputLabel: {
+ shrink: true,
+ },
+ }}
+ />
+
+ {showMixedContentWarning && (
+
+ 混合内容警告:当前 HTTPS 页面无法加载 HTTP 资源。
+
+ )}
+
+ }
+ sx={{
+ py: 1.2,
+ borderRadius: 4,
+ bgcolor: openUrlPageStyles.themeColor,
+ fontWeight: 800,
+ boxShadow: 'none',
+ '&:hover': {
+ bgcolor: 'rgba(25, 118, 210, 0.85)',
+ boxShadow: '0 8px 24px rgba(25, 118, 210, 0.2)',
+ },
+ }}
+ >
+ 添加快捷方式
+
+
+
+ );
+};
+
+export default UrlEntryForm;
diff --git a/components/UrlEntryItem.tsx b/components/UrlEntryItem.tsx
new file mode 100644
index 0000000..58478d8
--- /dev/null
+++ b/components/UrlEntryItem.tsx
@@ -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 });
+ window.close();
+ };
+
+ const handleDelete = () => {
+ onDelete(index);
+ };
+
+ return (
+
+
+
+
+ {entry.name}
+
+
+ {entry.url}
+
+
+
+
+ handleOpenInSidebar(entry)}
+ sx={{
+ color: openUrlPageStyles.themeColor,
+ bgcolor: alpha(openUrlPageStyles.themeColor, 0.05),
+ '&:hover': { bgcolor: openUrlPageStyles.themeColor, color: '#fff' },
+ }}
+ >
+
+
+
+
+ handleOpenInNewTab(entry)}
+ sx={{
+ color: 'grey.500',
+ bgcolor: 'grey.100',
+ '&:hover': { bgcolor: 'grey.600', color: '#fff' },
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ {!isLast && }
+
+ );
+};
+
+export default UrlEntryItem;
diff --git a/components/UrlEntryList.tsx b/components/UrlEntryList.tsx
new file mode 100644
index 0000000..6a195ea
--- /dev/null
+++ b/components/UrlEntryList.tsx
@@ -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 (
+
+
+
+ 暂无快捷方式,请在上方添加
+
+
+ );
+ }
+
+ return (
+
+ {entries.map((entry, index) => (
+
+ ))}
+
+ );
+};
+
+export default UrlEntryList;
diff --git a/components/UrlToQrCodeSection.tsx b/components/UrlToQrCodeSection.tsx
new file mode 100644
index 0000000..ca6f16b
--- /dev/null
+++ b/components/UrlToQrCodeSection.tsx
@@ -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) => {
+ 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 (
+ onExpandedChange(isExpanded)}
+ sx={{
+ borderRadius: 4,
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
+ '&:before': { display: 'none' },
+ }}
+ >
+ } sx={{ borderBottom: 'none' }}>
+
+
+
+ URL 转二维码
+
+
+
+
+
+
+
+ : }
+ onClick={generateQrCode}
+ disabled={generating}
+ sx={{
+ py: 1.2,
+ borderRadius: 3,
+ bgcolor: qrCodePageStyles.successColor,
+ fontWeight: 700,
+ '&:hover': {
+ bgcolor: qrCodePageStyles.successDark,
+ },
+ }}
+ >
+ {generating ? '生成中...' : '生成二维码'}
+
+
+
+ {qrCodeDataUrl ? (
+
+
+
+ }
+ onClick={downloadQrCode}
+ sx={{
+ borderRadius: 2,
+ borderColor: qrCodePageStyles.successColor,
+ color: qrCodePageStyles.successColor,
+ '&:hover': {
+ borderColor: qrCodePageStyles.successDark,
+ bgcolor: 'rgba(76, 175, 80, 0.05)',
+ },
+ }}
+ >
+ 下载二维码
+
+ }
+ onClick={copyQrCode}
+ sx={{
+ borderRadius: 2,
+ bgcolor: qrCodePageStyles.successColor,
+ '&:hover': {
+ bgcolor: qrCodePageStyles.successDark,
+ },
+ }}
+ >
+ 复制二维码
+
+
+
+ ) : (
+
+ 二维码将显示在这里
+
+ )}
+
+
+
+
+ );
+};
+
+export default UrlToQrCodeSection;
diff --git a/components/__tests__/Button.test.tsx b/components/__tests__/Button.test.tsx
index c399c35..5891292 100644
--- a/components/__tests__/Button.test.tsx
+++ b/components/__tests__/Button.test.tsx
@@ -42,7 +42,11 @@ describe('Button Component', () => {
it('should not call onClick when disabled', () => {
const handleClick = vi.fn();
- render();
+ render(
+ ,
+ );
fireEvent.click(screen.getByRole('button', { name: /disabled button/i }));
expect(handleClick).not.toHaveBeenCalled();
@@ -70,4 +74,4 @@ describe('Button Component', () => {
expect(button).toBeDisabled();
});
});
-});
\ No newline at end of file
+});
diff --git a/config/__tests__/routes.test.ts b/config/__tests__/routes.test.ts
index 34fbfe6..b579c3d 100644
--- a/config/__tests__/routes.test.ts
+++ b/config/__tests__/routes.test.ts
@@ -9,8 +9,8 @@ import {
describe('routes', () => {
describe('ROUTES', () => {
- it('should have 6 routes defined', () => {
- expect(ROUTES).toHaveLength(6);
+ it('should have 7 routes defined', () => {
+ expect(ROUTES).toHaveLength(7);
});
it('should have all required properties for each route', () => {
@@ -18,11 +18,14 @@ describe('routes', () => {
expect(route).toHaveProperty('key');
expect(route).toHaveProperty('label');
expect(route).toHaveProperty('defaultVisible');
- expect(route).toHaveProperty('component');
+ expect(route).toHaveProperty('components');
expect(typeof route.key).toBe('string');
expect(typeof route.label).toBe('string');
expect(typeof route.defaultVisible).toBe('boolean');
- expect(typeof route.component).toBe('function');
+ expect(typeof route.components).toBe('object');
+ expect(route.components).toHaveProperty('popup');
+ expect(route.components).toHaveProperty('sidepanel');
+ expect(route.components).toHaveProperty('detached');
});
});
@@ -101,12 +104,13 @@ describe('routes', () => {
describe('getAllRouteKeys', () => {
it('should return all route keys', () => {
const allKeys = getAllRouteKeys();
- expect(allKeys).toHaveLength(6);
+ expect(allKeys).toHaveLength(7);
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');
});
});
@@ -122,17 +126,18 @@ describe('routes', () => {
expect(pageOrder).not.toContain('openUrlViewer');
});
- it('should include timestamp, storageCleaner, openUrl, qrCode in page order', () => {
+ 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 4 items in page order', () => {
+ it('should have 5 items in page order', () => {
const pageOrder = getDefaultPageOrder();
- expect(pageOrder).toHaveLength(4);
+ expect(pageOrder).toHaveLength(5);
});
});
-});
\ No newline at end of file
+});
diff --git a/config/pageTheme.ts b/config/pageTheme.ts
index b71ab93..30044f8 100644
--- a/config/pageTheme.ts
+++ b/config/pageTheme.ts
@@ -8,22 +8,73 @@ export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as c
export type UnitType = 'ms' | 's';
export type ZoneType = (typeof ZONES)[number];
+/**
+ * 符合 WCAG AA 标准(4.5:1 对比度)的主题颜色体系
+ * 所有颜色都经过对比度计算,确保可访问性
+ */
export const THEME_COLORS = {
- primary: '#2196f3',
- success: '#4caf50',
- warning: '#ff9800',
- error: '#f44336',
- purple: '#9c27b0',
+ // 主要颜色 - 蓝色系
+ // 主色 #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.5,
+ borderRadius: 3,
border: '1px solid',
borderColor: 'grey.100',
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
@@ -56,11 +107,14 @@ export const timestampPageStyles = {
buttonHover: `0 8px 24px ${alpha(THEME_COLORS.primary, 0.2)}`,
} as const;
+/**
+ * 打开 URL 页面样式
+ */
export const openUrlPageStyles = {
INPUT_STYLE: {
'& .MuiOutlinedInput-root': {
bgcolor: 'background.paper',
- borderRadius: 3.5,
+ borderRadius: 3,
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'& fieldset': {
border: '1px solid',
@@ -99,26 +153,75 @@ export const openUrlPageStyles = {
errorBg: alpha(THEME_COLORS.error, 0.05),
} as const;
+/**
+ * 存储清理页面样式
+ */
export const storageCleanerPageStyles = {
warningColor: THEME_COLORS.warning,
- warningDark: '#f57c00',
+ 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: '#388e3c',
+ primaryDark: THEME_COLORS.successDark,
successColor: THEME_COLORS.success,
- successDark: '#388e3c',
+ successDark: THEME_COLORS.successDark,
white: THEME_COLORS.white,
black: THEME_COLORS.black,
+ INPUT_STYLE: {
+ '& .MuiOutlinedInput-root': {
+ borderRadius: 3,
+ '& fieldset': {
+ borderColor: THEME_COLORS.success,
+ },
+ '&:hover fieldset': {
+ borderColor: THEME_COLORS.success,
+ },
+ '&.Mui-focused fieldset': {
+ borderColor: THEME_COLORS.success,
+ },
+ },
+ '& .MuiInputLabel-root': {
+ fontSize: '0.85rem',
+ fontWeight: 700,
+ color: 'text.secondary',
+ '&.Mui-focused': { color: THEME_COLORS.success },
+ },
+ },
} as const;
+/**
+ * 仪表盘页面样式
+ */
export const dashboardPageStyles = {
primaryColor: THEME_COLORS.primary,
backgroundColor: '#f5f5f5',
cardBackgroundColor: '#ffffff',
} as const;
+
+/**
+ * 表单识别页面样式
+ * 使用语义化的颜色命名:valid(有效)、invalid(无效)、clear(清除)
+ */
+export const formRecognizerPageStyles = {
+ 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;
diff --git a/entrypoints/background.ts b/entrypoints/background.ts
index dc78b04..6eea74b 100644
--- a/entrypoints/background.ts
+++ b/entrypoints/background.ts
@@ -41,6 +41,33 @@ export default defineBackground(() => {
} else {
// 从扩展其他部分发送的消息
console.log('收到来自扩展的消息:', message.action);
+
+ // 处理刷新标签页请求
+ if (message.action === 'reloadTab' && message.tabId !== undefined) {
+ const tabId = message.tabId;
+ const delay = message.delay || 0;
+
+ const executeReload = () => {
+ chrome.tabs
+ .reload(tabId)
+ .then(() => {
+ console.log('标签页刷新成功:', tabId);
+ })
+ .catch((err) => {
+ console.error('刷新标签页失败:', err.message);
+ });
+ };
+
+ if (delay > 0) {
+ setTimeout(executeReload, delay);
+ } else {
+ executeReload();
+ }
+
+ sendResponse({ success: true, message: '刷新请求已接收' });
+ return;
+ }
+
sendResponse({ success: true, message: '消息已收到' });
}
} catch (error) {
diff --git a/entrypoints/popup/pages/FormRecognizerPage.tsx b/entrypoints/popup/pages/FormRecognizerPage.tsx
index 0c08ea6..da047b5 100644
--- a/entrypoints/popup/pages/FormRecognizerPage.tsx
+++ b/entrypoints/popup/pages/FormRecognizerPage.tsx
@@ -1,68 +1,223 @@
-import { useState } from 'react';
-import {
- Box,
- Typography,
- Container,
- Button,
- Paper,
- CircularProgress,
- Stack,
- Switch,
- FormControlLabel,
-} from '@mui/material';
-import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
-import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
-import ClearAllIcon from '@mui/icons-material/ClearAll';
+import { useState, useRef } from 'react';
+import { Box, Typography, Container, Button, CircularProgress } from '@mui/material';
+import InputIcon from '@mui/icons-material/Input';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
-import { dashboardPageStyles } from '@/config/pageTheme';
+import { dashboardPageStyles, formRecognizerPageStyles } from '@/config/pageTheme';
+import { MessageAction, sendMessageToContent, injectContentScript } from '@/utils/messages';
+import { DataTemplateManager, type DataTemplate } from '@/utils/dataTemplate';
+import FieldList from '@/components/FieldList';
+import OperationHistory from '@/components/OperationHistory';
+import TemplateManager from '@/components/TemplateManager';
+import MainActions from '@/components/MainActions';
+import OptionsPanel from '@/components/OptionsPanel';
+import FeatureDescription from '@/components/FeatureDescription';
+
+// 字段数据接口
+interface FieldData {
+ id: string;
+ fieldType: string;
+ label: string | null;
+ placeholder: string;
+ name: string;
+ value: string;
+ isSelected: boolean;
+ generatedValue: string;
+}
const FormRecognizerPage = () => {
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
const [loading, setLoading] = useState(false);
const [includeHidden, setIncludeHidden] = useState(false);
+ const isProcessingRef = useRef(false);
+ const [fields, setFields] = useState([]);
+ const [scanning, setScanning] = useState(false);
+ const [showFields, setShowFields] = useState(false);
+ const [operationHistory, setOperationHistory] = useState<
+ Array<{
+ time: string;
+ type: string;
+ content: string;
+ result: string;
+ }>
+ >([]);
+ const [showHistory, setShowHistory] = useState(false);
+ const [templates, setTemplates] = useState([]);
+ const [showTemplates, setShowTemplates] = useState(false);
+ const [templateLoading, setTemplateLoading] = useState(false);
- interface MessagePayload {
- includeHidden?: boolean;
- }
-
- const sendMessageToContent = async (action: string, payload?: MessagePayload) => {
- setLoading(true);
+ // 扫描表单字段
+ const handleScanFields = async () => {
+ setScanning(true);
try {
- const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
- if (!tab.id) {
- showMessage('无法获取当前标签页', { severity: 'error' });
- return;
+ 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);
+ }
}
- const response = await chrome.tabs.sendMessage(tab.id, { action, ...payload });
- if (response.success) {
- showMessage(response.message, { severity: 'success' });
+ if (response.success && response.fields) {
+ setFields(response.fields as FieldData[]);
+ showMessage(`扫描完成,发现 ${response.totalCount} 个可填充字段`, { severity: 'success' });
+ addOperationHistory('扫描', `扫描表单字段,发现 ${response.totalCount} 个字段`, '成功');
} else {
- showMessage(response.message, { severity: 'error' });
+ showMessage(response.message || '扫描失败', { severity: 'error' });
}
} catch (error) {
- console.error('发送消息失败:', error);
- showMessage('请确保当前页面已加载完成', { severity: 'error' });
+ console.error('扫描失败:', error);
+ showMessage('扫描失败,请确保页面已加载', { severity: 'error' });
} finally {
- setLoading(false);
+ setScanning(false);
}
};
+ // 添加操作历史记录
+ const addOperationHistory = (type: string, content: string, result: string) => {
+ const newEntry = {
+ time: new Date().toLocaleString('zh-CN'),
+ type,
+ content,
+ result,
+ };
+ setOperationHistory((prev) => [newEntry, ...prev].slice(0, 50)); // 最多保留50条记录
+ };
+
+ // 加载模板列表
+ const loadTemplates = async () => {
+ setTemplateLoading(true);
+ try {
+ const allTemplates = await DataTemplateManager.getAllTemplates();
+ setTemplates(allTemplates);
+ } catch (error) {
+ console.error('加载模板失败:', error);
+ showMessage('加载模板失败', { severity: 'error' });
+ } finally {
+ setTemplateLoading(false);
+ }
+ };
+
+ // 导出模板
+ const handleExportTemplates = async () => {
+ const allTemplates = await DataTemplateManager.getAllTemplates();
+ if (allTemplates.length === 0) {
+ showMessage('没有可导出的模板', { severity: 'warning' });
+ return;
+ }
+ const jsonStr = DataTemplateManager.exportTemplates(allTemplates);
+ const blob = new Blob([jsonStr], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `templates_${new Date().toISOString().split('T')[0]}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ showMessage(`已导出 ${allTemplates.length} 个模板`, { severity: 'success' });
+ addOperationHistory('导出', `导出 ${allTemplates.length} 个模板`, '成功');
+ };
+
+ // 导入模板
+ const handleImportTemplates = () => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.json';
+ input.onchange = async (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = async (event) => {
+ const content = event.target?.result as string;
+ const success = await DataTemplateManager.importTemplates(content);
+ if (success) {
+ showMessage('模板导入成功', { severity: 'success' });
+ addOperationHistory('导入', '导入模板', '成功');
+ loadTemplates();
+ } else {
+ showMessage('模板导入失败,请检查文件格式', { severity: 'error' });
+ }
+ };
+ reader.readAsText(file);
+ };
+ input.click();
+ };
+
+ const sendMessageWithHandler = async (
+ action: MessageAction,
+ payload?: { includeHidden?: boolean },
+ ) => {
+ // 防抖处理:防止快速点击导致多次请求
+ if (isProcessingRef.current) {
+ showMessage('操作进行中,请稍候...', { severity: 'warning' });
+ return;
+ }
+
+ setLoading(true);
+ isProcessingRef.current = true;
+ try {
+ let response = await sendMessageToContent(action, payload);
+
+ // 如果连接失败,尝试注入内容脚本
+ if (!response.success && response.message && response.message.includes('无法连接')) {
+ showMessage('正在注入内容脚本...', { severity: 'info' });
+ const injected = await injectContentScript();
+ if (injected) {
+ // 注入成功后再次尝试
+ response = await sendMessageToContent(action, payload);
+ } else {
+ showMessage('内容脚本注入失败,请刷新页面后重试', { severity: 'error' });
+ return;
+ }
+ }
+
+ if (response.success) {
+ showMessage(response.message || '操作成功', { severity: 'success' });
+ } else {
+ // 增强错误提示信息
+ const errorMsg = response.message || '操作失败';
+ const errorDetails = getErrorDetails(errorMsg);
+ showMessage(errorDetails, { severity: 'error' });
+ }
+ } catch (error) {
+ console.error('发送消息失败:', error);
+ const errorMessage = error instanceof Error ? error.message : '未知错误';
+ showMessage(`操作失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
+ } finally {
+ setLoading(false);
+ isProcessingRef.current = false;
+ }
+ };
+
+ // 获取详细的错误信息
+ const getErrorDetails = (baseMsg: string): string => {
+ if (baseMsg.includes('标签页')) {
+ return `${baseMsg},请确保已打开网页页面`;
+ }
+ if (baseMsg.includes('注入')) {
+ return `${baseMsg},请检查页面是否支持内容脚本`;
+ }
+ return baseMsg;
+ };
+
const handleFillValidData = () => {
- sendMessageToContent('fillValidData', { includeHidden });
+ sendMessageWithHandler(MessageAction.FILL_VALID_DATA, { includeHidden });
+ addOperationHistory('填充', '填充有效数据', '成功');
};
const handleFillInvalidData = () => {
- sendMessageToContent('fillInvalidData', { includeHidden });
+ sendMessageWithHandler(MessageAction.FILL_INVALID_DATA, { includeHidden });
+ addOperationHistory('填充', '填充异常数据', '成功');
};
const handleClearAllFields = () => {
- sendMessageToContent('clearAllFields');
+ sendMessageWithHandler(MessageAction.CLEAR_ALL_FIELDS);
+ addOperationHistory('清空', '清空所有表单字段', '成功');
};
return (
-
+
Dummy Data Generator
@@ -72,117 +227,59 @@ const FormRecognizerPage = () => {
- {/* 主要操作按钮 */}
-
- :
- }
- onClick={handleFillValidData}
- disabled={loading}
- fullWidth
- sx={{
- py: 1.2,
- borderRadius: 3,
- bgcolor: '#4caf50',
- fontWeight: 700,
- '&:hover': {
- bgcolor: '#388e3c',
- },
- }}
- >
- {loading ? '填充中...' : '一键填充(有效数据)'}
-
+ {/* 扫描按钮 */}
+ : }
+ >
+ {scanning ? '扫描中...' : '扫描表单字段'}
+
- :
- }
- onClick={handleFillInvalidData}
- disabled={loading}
- fullWidth
- sx={{
- py: 1.2,
- borderRadius: 3,
- bgcolor: '#ff9800',
- fontWeight: 700,
- '&:hover': {
- bgcolor: '#f57c00',
- },
- }}
- >
- {loading ? '填充中...' : '一键填充(异常数据)'}
-
+ setShowFields(!showFields)}
+ />
- : }
- onClick={handleClearAllFields}
- disabled={loading}
- fullWidth
- sx={{
- py: 1.2,
- borderRadius: 3,
- borderColor: '#f44336',
- color: '#f44336',
- fontWeight: 700,
- '&:hover': {
- borderColor: '#d32f2f',
- bgcolor: 'rgba(244, 67, 54, 0.05)',
- },
- }}
- >
- {loading ? '清空ing...' : '一键清空所有表单'}
-
-
+
- {/* 选项设置 */}
-
-
-
- 填充选项
-
-
-
- setIncludeHidden(e.target.checked)}
- color="primary"
- />
- }
- label="包含隐藏字段"
- sx={{ width: '100%' }}
- />
-
-
+
- {/* 功能说明 */}
-
-
-
- 功能说明
-
-
-
-
- 有效数据模式:生成符合格式要求的测试数据,适用于正常功能测试。
-
-
- 异常数据模式:生成边界值或格式错误的数据,适用于异常场景测试。
-
-
- 一键清空:快速清空当前页面所有表单字段的值。
-
-
- 支持的字段类型:
- 文本、邮箱、手机号、数字、日期、文本域、密码、身份证号等。
-
-
-
+ setShowHistory(!showHistory)}
+ />
+
+ setShowTemplates(!showTemplates)}
+ onLoadTemplates={loadTemplates}
+ onExportTemplates={handleExportTemplates}
+ onImportTemplates={handleImportTemplates}
+ />
+
+
diff --git a/entrypoints/popup/pages/OpenUrlPage.tsx b/entrypoints/popup/pages/OpenUrlPage.tsx
index bca0838..59183e5 100644
--- a/entrypoints/popup/pages/OpenUrlPage.tsx
+++ b/entrypoints/popup/pages/OpenUrlPage.tsx
@@ -1,101 +1,20 @@
-import { useState, useEffect, useCallback, Fragment } from 'react';
-import {
- Box,
- TextField,
- Alert,
- List,
- ListItem,
- IconButton,
- Typography,
- Divider,
- Container,
- Stack,
- alpha,
- Tooltip,
-} 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 AddIcon from '@mui/icons-material/Add';
+import { Box, Typography, Container, Stack, alpha } from '@mui/material';
import LanguageIcon from '@mui/icons-material/Language';
-import LinkIcon from '@mui/icons-material/Link';
-import Button from '@/components/Button';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
-import { storageUtil } from '@/utils/chromeStorage';
-import type { OpenUrlPreferences, OpenUrlEntry } from '@/types/storage';
+import UrlEntryForm from '@/components/UrlEntryForm';
+import UrlEntryList from '@/components/UrlEntryList';
+import { useUrlPreferences } from '@/utils/useUrlPreferences';
+import type { OpenUrlEntry } from '@/types/storage';
import { openUrlPageStyles, dashboardPageStyles } from '@/config/pageTheme';
const THEME_COLOR = openUrlPageStyles.themeColor;
-const DEFAULT_PREFERENCES: OpenUrlPreferences = {
- entries: [],
-};
-
export default function OpenUrlPage() {
- const [entries, setEntries] = useState(DEFAULT_PREFERENCES.entries);
- const [newName, setNewName] = useState('');
- const [newUrl, setNewUrl] = useState('');
- const [isLoaded, setIsLoaded] = useState(false);
+ const { entries, setEntries, isLoaded } = useUrlPreferences();
const { snackbarProps, showMessage } = useSnackbar();
- 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;
- }
- };
-
- useEffect(() => {
- const loadPreferences = async () => {
- try {
- const saved = await storageUtil.get('openUrl/preferences', DEFAULT_PREFERENCES);
- if (saved && saved.entries) {
- setEntries(saved.entries);
- }
- } catch (error) {
- console.error('Failed to load Open Url preferences:', error);
- } finally {
- setIsLoaded(true);
- }
- };
- loadPreferences();
- }, []);
-
- const savePreferences = useCallback(() => {
- const preferences: OpenUrlPreferences = { entries };
- storageUtil.set('openUrl/preferences', preferences).catch((error) => {
- console.error('Failed to save Open Url preferences:', error);
- });
- }, [entries]);
-
- useEffect(() => {
- if (!isLoaded) return;
- const timer = setTimeout(() => {
- savePreferences();
- }, 500);
- return () => clearTimeout(timer);
- }, [entries, isLoaded, savePreferences]);
-
- const handleAddEntry = () => {
- if (!newName.trim()) {
- showMessage('请输入名称', { severity: 'error' });
- return;
- }
- if (!isValidUrl(newUrl)) {
- showMessage('请输入有效的 URL', { severity: 'error' });
- return;
- }
-
- setEntries([...entries, { name: newName.trim(), url: newUrl.trim() }]);
- setNewName('');
- setNewUrl('');
- showMessage('添加成功', { severity: 'success' });
+ const handleAddEntry = (entry: OpenUrlEntry) => {
+ setEntries([...entries, entry]);
};
const handleDeleteEntry = (index: number) => {
@@ -105,44 +24,15 @@ export default function OpenUrlPage() {
showMessage('删除成功', { severity: 'success' });
};
- 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 });
- window.close();
- };
+ if (!isLoaded) {
+ return (
+
+
+ 加载中...
+
+
+ );
+ }
return (
@@ -175,81 +65,7 @@ export default function OpenUrlPage() {
{/* Form Section */}
-
-
- setNewName(e.target.value)}
- fullWidth
- variant="outlined"
- sx={openUrlPageStyles.INPUT_STYLE}
- slotProps={{
- inputLabel: {
- shrink: true,
- },
- }}
- />
- setNewUrl(e.target.value)}
- fullWidth
- variant="outlined"
- sx={openUrlPageStyles.INPUT_STYLE}
- slotProps={{
- inputLabel: {
- shrink: true,
- },
- }}
- />
-
- {showMixedContentWarning && (
-
- 混合内容警告:当前 HTTPS 页面无法加载 HTTP 资源。
-
- )}
-
- }
- sx={{
- py: 1.2,
- borderRadius: 4,
- bgcolor: THEME_COLOR,
- fontWeight: 800,
- boxShadow: 'none',
- '&:hover': {
- bgcolor: alpha(THEME_COLOR, 0.85),
- boxShadow: `0 8px 24px ${alpha(THEME_COLOR, 0.2)}`,
- },
- }}
- >
- 添加快捷方式
-
-
-
+
{/* List Section */}
@@ -260,119 +76,11 @@ export default function OpenUrlPage() {
已保存的快捷方式 ({entries.length})
- {entries.length === 0 ? (
-
-
-
- 暂无快捷方式,请在上方添加
-
-
- ) : (
-
- {entries.map((entry, index) => (
-
-
-
-
- {entry.name}
-
-
- {entry.url}
-
-
-
-
- handleOpenInSidebar(entry)}
- sx={{
- color: THEME_COLOR,
- bgcolor: alpha(THEME_COLOR, 0.05),
- '&:hover': { bgcolor: THEME_COLOR, color: '#fff' },
- }}
- >
-
-
-
-
- handleOpenInNewTab(entry)}
- sx={{
- color: 'grey.500',
- bgcolor: 'grey.100',
- '&:hover': { bgcolor: 'grey.600', color: '#fff' },
- }}
- >
-
-
-
-
- handleDeleteEntry(index)}
- sx={{
- color: 'error.main',
- '&:hover': { color: 'error.dark', bgcolor: alpha('#f44336', 0.05) },
- }}
- >
-
-
-
-
-
- {index < entries.length - 1 && }
-
- ))}
-
- )}
+
diff --git a/entrypoints/popup/pages/QrCodePage.tsx b/entrypoints/popup/pages/QrCodePage.tsx
index 6b055d7..fb1d126 100644
--- a/entrypoints/popup/pages/QrCodePage.tsx
+++ b/entrypoints/popup/pages/QrCodePage.tsx
@@ -1,90 +1,21 @@
-import { useState, useRef, useEffect } from 'react';
-import {
- Box,
- Typography,
- TextField,
- Button,
- Stack,
- Alert,
- InputAdornment,
- CircularProgress,
- Accordion,
- AccordionSummary,
- AccordionDetails,
-} from '@mui/material';
-import { Container, alpha } from '@mui/system';
+import { Box, Typography, Stack, Container, CircularProgress } from '@mui/material';
+import { alpha } from '@mui/system';
import QrCodeIcon from '@mui/icons-material/QrCode';
-import ImageIcon from '@mui/icons-material/Image';
-import LinkIcon from '@mui/icons-material/Link';
-import DownloadIcon from '@mui/icons-material/Download';
-import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
-import ContentCopyIcon from '@mui/icons-material/ContentCopy';
-import qrcode from 'qrcode';
-import jsQR from 'jsqr';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
-import CopyButton from '@/components/CopyButton';
-import { storageUtil } from '@/utils/chromeStorage';
+import UrlToQrCodeSection from '@/components/UrlToQrCodeSection';
+import QrCodeToUrlSection from '@/components/QrCodeToUrlSection';
+import { useStorageState } from '@/utils/useStorageState';
import { qrCodePageStyles, dashboardPageStyles } from '@/config/pageTheme';
const QrCodePage = () => {
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
- const [isInitialized, setIsInitialized] = useState(false);
- // URL 转二维码状态
- const [urlInput, setUrlInput] = useState('');
- const [urlError, setUrlError] = useState('');
- const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
- const [generating, setGenerating] = useState(false);
-
- // 二维码转 URL 状态
- const [qrCodeFile, setQrCodeFile] = useState(null);
- const [parsedUrl, setParsedUrl] = useState('');
- const [parseError, setParseError] = useState('');
- const [parsing, setParsing] = useState(false);
-
- // 卡片展开状态
- const [urlExpanded, setUrlExpanded] = useState(true);
- const [qrExpanded, setQrExpanded] = useState(false);
-
- // 引用
- const qrCodeRef = useRef(null);
-
- // 从存储加载状态
- useEffect(() => {
- const loadState = async () => {
- try {
- const savedUrlExpanded = await storageUtil.get('qrCode/urlExpanded', true);
- const savedQrExpanded = await storageUtil.get('qrCode/qrExpanded', false);
- setUrlExpanded(savedUrlExpanded ?? true);
- setQrExpanded(savedQrExpanded ?? false);
- } catch (error) {
- console.error('加载状态失败:', error);
- } finally {
- setIsInitialized(true);
- }
- };
-
- loadState();
- }, []);
-
- // 保存状态到存储(仅在初始化完成后保存)
- useEffect(() => {
- if (!isInitialized) return;
-
- const saveState = async () => {
- try {
- await storageUtil.set('qrCode/urlExpanded', urlExpanded);
- await storageUtil.set('qrCode/qrExpanded', qrExpanded);
- } catch (error) {
- console.error('保存状态失败:', error);
- }
- };
-
- saveState();
- }, [urlExpanded, qrExpanded, isInitialized]);
+ // 使用自定义钩子管理展开状态
+ const [urlExpanded, setUrlExpanded, urlInitialized] = useStorageState('qrCode/urlExpanded', true);
+ const [qrExpanded, setQrExpanded, qrInitialized] = useStorageState('qrCode/qrExpanded', false);
// 初始化未完成时显示加载状态
- if (!isInitialized) {
+ if (!urlInitialized || !qrInitialized) {
return (
{
);
}
- // 处理 URL 输入变化
- const handleUrlInputChange = (e: React.ChangeEvent) => {
- setUrlInput(e.target.value);
- setUrlError('');
- };
-
- // 处理文件选择
- const handleFileChange = (e: React.ChangeEvent) => {
- // 只有当用户实际选择了文件时才更新状态
- // 如果用户取消选择,保持原有状态不变
- if (e.target.files && e.target.files.length > 0) {
- const file = e.target.files[0];
- setQrCodeFile(file);
- setParseError('');
- setParsedUrl('');
- }
- };
-
- // 生成二维码
- const generateQrCode = async () => {
- if (!urlInput) {
- setUrlError('请输入 URL');
- return;
- }
-
- try {
- setGenerating(true);
- setUrlError('');
-
- // 验证 URL 格式
- 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 parseQrCode = async () => {
- if (!qrCodeFile) {
- showMessage('请选择二维码图片', { severity: 'error', autoHideDuration: 300 });
- return;
- }
-
- try {
- setParsing(true);
- setParseError('');
- setParsedUrl('');
-
- // 读取文件并解析
- const canvas = document.createElement('canvas');
- const ctx = canvas.getContext('2d');
-
- if (!ctx) {
- throw new Error('无法创建 canvas 上下文');
- }
-
- const image = new Image();
- image.src = URL.createObjectURL(qrCodeFile);
-
- await new Promise((resolve, reject) => {
- image.onload = () => {
- canvas.width = image.width;
- canvas.height = image.height;
- ctx.drawImage(image, 0, 0);
- resolve();
- };
- image.onerror = () => reject(new Error('图片加载失败'));
- });
-
- const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
- const code = jsQR(imageData.data, imageData.width, imageData.height);
-
- if (code) {
- setParsedUrl(code.data);
- showMessage('二维码解析成功', { severity: 'success', autoHideDuration: 1000 });
- } else {
- showMessage('未检测到二维码', { severity: 'error', autoHideDuration: 1000 });
- }
- } catch (error) {
- console.error('解析二维码失败:', error);
- showMessage('解析二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
- } finally {
- setParsing(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 {
- // 将 data URL 转换为 Blob
- const response = await fetch(qrCodeDataUrl);
- const blob = await response.blob();
-
- // 使用 Clipboard API 写入图像
- 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 (
@@ -271,285 +63,17 @@ const QrCodePage = () => {
- {/* URL 转二维码 */}
- setUrlExpanded(isExpanded)}
- sx={{
- borderRadius: 4,
- boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
- '&:before': { display: 'none' },
- }}
- >
- } sx={{ borderBottom: 'none' }}>
-
-
-
- URL 转二维码
-
-
-
-
-
-
+ onExpandedChange={setUrlExpanded}
+ showMessage={showMessage}
+ />
- :
- }
- onClick={generateQrCode}
- disabled={generating}
- sx={{
- py: 1.2,
- borderRadius: 3,
- bgcolor: qrCodePageStyles.successColor,
- fontWeight: 700,
- '&:hover': {
- bgcolor: qrCodePageStyles.successDark,
- },
- }}
- >
- {generating ? '生成中...' : '生成二维码'}
-
-
- {/* 二维码显示区域 */}
-
- {qrCodeDataUrl ? (
-
-
-
- }
- onClick={downloadQrCode}
- sx={{
- borderRadius: 2,
- borderColor: qrCodePageStyles.successColor,
- color: qrCodePageStyles.successColor,
- '&:hover': {
- borderColor: qrCodePageStyles.successDark,
- bgcolor: alpha(qrCodePageStyles.successColor, 0.05),
- },
- }}
- >
- 下载二维码
-
- }
- onClick={copyQrCode}
- sx={{
- borderRadius: 2,
- bgcolor: qrCodePageStyles.successColor,
- '&:hover': {
- bgcolor: qrCodePageStyles.successDark,
- },
- }}
- >
- 复制二维码
-
-
-
- ) : (
-
- 二维码将显示在这里
-
- )}
-
-
-
-
-
- {/* 二维码转 URL */}
- setQrExpanded(isExpanded)}
- sx={{
- borderRadius: 4,
- boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
- '&:before': { display: 'none' },
- }}
- >
- } sx={{ borderBottom: 'none' }}>
-
-
-
- 二维码转 URL
-
-
-
-
-
-
-
-
-
-
- :
- }
- onClick={parseQrCode}
- disabled={parsing}
- sx={{
- py: 1.2,
- borderRadius: 3,
- bgcolor: qrCodePageStyles.successColor,
- fontWeight: 700,
- '&:hover': {
- bgcolor: qrCodePageStyles.successDark,
- },
- }}
- >
- {parsing ? '解析中...' : '解析二维码'}
-
-
- {/* 解析结果显示 */}
-
-
-
-
- ),
- },
- }}
- sx={{
- '& .MuiOutlinedInput-root': {
- borderRadius: 3,
- },
- }}
- />
-
-
- {parseError && (
-
- {parseError}
-
- )}
-
-
-
+ onExpandedChange={setQrExpanded}
+ showMessage={showMessage}
+ />
diff --git a/entrypoints/popup/pages/StorageCleanerPage.tsx b/entrypoints/popup/pages/StorageCleanerPage.tsx
index f3f96cc..e6c8517 100644
--- a/entrypoints/popup/pages/StorageCleanerPage.tsx
+++ b/entrypoints/popup/pages/StorageCleanerPage.tsx
@@ -1,227 +1,36 @@
-import { useState, useEffect, useCallback, useRef } from 'react';
-import {
- Typography,
- Box,
- Checkbox,
- Alert,
- Divider,
- Container,
- Stack,
- Switch,
- Grid,
- CircularProgress,
-} from '@mui/material';
-import WarningIcon from '@mui/icons-material/Warning';
-import StorageIcon from '@mui/icons-material/Storage';
+import { Box, Container, CircularProgress } from '@mui/material';
import Button from '@/components/Button';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import StorageCleanerConfirm from '@/components/StorageCleanerConfirm';
-import { storageUtil } from '@/utils/chromeStorage';
-import type {
- StorageCleanerOptions,
- CleaningResult,
- StorageCleanerPreferences,
-} from '@/types/storage';
-import {
- getCurrentTab,
- isRestrictedUrl,
- clearStorage,
- formatCleaningResult,
- getCookieSize,
- getLocalStorageSize,
- getSessionStorageSize,
- getIndexedDBSize,
- getCacheStorageSize,
- getServiceWorkerCount,
- formatSize,
-} from '@/utils/storageCleaner';
import { storageCleanerPageStyles } from '@/config/pageTheme';
-
-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,
-};
+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 [domain, setDomain] = useState('');
- const [error, setError] = useState('');
- const [isInitializing, setIsInitializing] = useState(true);
- const [options, setOptions] = useState(DEFAULT_OPTIONS);
- const [sizes, setSizes] = useState>({});
- const [autoRefresh, setAutoRefresh] = useState(true);
- const [loading, setLoading] = useState(false);
- const [result, setResult] = useState(null);
- const [showConfirm, setShowConfirm] = useState(false);
- const { snackbarProps, showMessage } = useSnackbar();
- const reloadTimeoutRef = useRef(null);
- const resultTimeoutRef = useRef(null);
-
- useEffect(() => {
- return () => {
- if (reloadTimeoutRef.current) clearTimeout(reloadTimeoutRef.current);
- if (resultTimeoutRef.current) clearTimeout(resultTimeoutRef.current);
- };
- }, []);
-
- const loadInfo = useCallback(async () => {
- try {
- const tab = await getCurrentTab();
- 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 (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 {
- setIsInitializing(false);
- }
- }, []);
-
- const loadInfoRef = useRef(loadInfo);
- loadInfoRef.current = loadInfo;
-
- useEffect(() => {
- loadInfoRef.current();
-
- const handleTabChange = () => loadInfoRef.current();
- const handleTabUpdated = (_tabId: number, changeInfo: { status?: string; url?: string }) => {
- if (changeInfo.status === 'complete' || changeInfo.url) {
- loadInfoRef.current();
- }
- };
-
- 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);
- };
- }, []);
-
- 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 allSelected = Object.values(options).every(Boolean);
- const someSelected = Object.values(options).some(Boolean) && !allSelected;
-
- 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('无法获取当前标签页');
- return;
- }
- setLoading(true);
- try {
- const cleaningResult = await clearStorage(tab.id, tab.url, options);
- setResult(cleaningResult);
-
- // 5秒后自动清除结果提示
- if (resultTimeoutRef.current) clearTimeout(resultTimeoutRef.current);
- resultTimeoutRef.current = setTimeout(() => {
- setResult(null);
- }, 5000);
-
- if (autoRefresh && cleaningResult.success && tab.id !== undefined) {
- showMessage('清理成功,即将刷新页面');
- reloadTimeoutRef.current = setTimeout(() => {
- chrome.tabs.reload(tab.id!);
- }, 1500);
- } else {
- loadInfo();
- }
- } catch (err) {
- showMessage(`清理失败: ${String(err)}`, { severity: 'error' });
- } finally {
- setLoading(false);
- setShowConfirm(false);
- }
- }, [options, autoRefresh, showMessage, loadInfo]);
+ const { snackbarProps } = useSnackbar();
+ const {
+ domain,
+ error,
+ isInitializing,
+ options,
+ sizes,
+ autoRefresh,
+ loading,
+ result,
+ showConfirm,
+ setShowConfirm,
+ totalSize,
+ allSelected,
+ someSelected,
+ handleAutoRefreshChange,
+ handleOptionChange,
+ handleSelectAll,
+ handleClean,
+ } = useStorageCleaner();
if (isInitializing) {
return (
@@ -232,398 +41,25 @@ export default function StorageCleanerPage() {
}
if (error) {
- return (
-
-
-
-
-
- {error}
-
-
- 存储清理功能仅适用于标准网页
-
-
-
-
- );
+ return ;
}
- // 这里的总大小仅包含以字节计算的项
- const totalSize =
- (sizes.cookies || 0) +
- (sizes.localStorage || 0) +
- (sizes.sessionStorage || 0) +
- (sizes.indexedDB || 0);
-
- const OptionItem = ({
- label,
- checked,
- size,
- isCount = false,
- onChange,
- }: {
- label: string;
- checked: boolean;
- size?: number;
- isCount?: boolean;
- onChange: () => void;
- }) => (
-
-
-
- {label}
-
- {size !== undefined && size > 0 ? (
-
- {isCount ? `${size} 个` : formatSize(size)}
-
- ) : (
-
- 无数据
-
- )}
-
-
-
- );
-
return (
- {/* Domain Header */}
-
-
-
-
-
-
-
- 存储清理
-
- {totalSize > 0 && (
-
- 已占用 {formatSize(totalSize)}
-
- )}
-
-
- {domain || '加载中...'}
-
-
-
+
- {/* Storage Options Grid */}
-
-
-
- handleOptionChange('localStorage')}
- />
-
-
- handleOptionChange('sessionStorage')}
- />
-
-
- handleOptionChange('indexedDB')}
- />
-
-
- handleOptionChange('cookies')}
- />
-
-
- handleOptionChange('cacheStorage')}
- />
-
-
- handleOptionChange('serviceWorkers')}
- />
-
-
-
-
-
- 全选所有项
-
- handleSelectAll(e.target.checked)}
- color="warning"
- sx={{
- p: 0.6,
- '& .MuiSvgIcon-root': {
- fontSize: 18,
- transition: 'transform 0.2s',
- },
- '&:hover .MuiSvgIcon-root': {
- transform: 'scale(1.1)',
- },
- }}
- />
-
-
+
- {/* Auto Refresh Toggle */}
-
-
- 清理后自动刷新页面
-
- handleAutoRefreshChange(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)',
- },
- }}
- />
-
+
- {/* Primary Action */}
- {/* Result & Refresh Secondary Action */}
- {result && (
-
-
- {result.success ? formatCleaningResult(result) : result.error || '清理失败'}
-
-
- )}
+
void;
- onUnitChange: (u: UnitType) => void;
- showMessage?: (message: string, options?: { severity: 'success' | 'error' }) => 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);
- }, [now]);
-
- return (
-
-
-
- 当前时间戳
-
-
- {displayVal}
-
-
-
-
- {/* 胶囊式单位切换器 */}
-
- {(['ms', 's'] as const).map((u) => (
- 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('#2196f3', 0.4),
- boxShadow: unit === u ? '0 2px 6px rgba(33, 150, 243, 0.2)' : 'none',
- }}
- >
- {u.toUpperCase()}
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-});
-
-LiveClock.displayName = 'LiveClock';
-
-// ================= 子组件:多维度结果展示 =================
-interface ResultViewProps {
- result: string;
- mode: 'ts2dt' | 'dt2ts';
- unit: UnitType;
- zone: string;
- showMessage?: (message: string, options?: { severity: 'success' | 'error' }) => 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 (
-
-
-
- 转换结果
-
-
-
-
- {result}
-
-
-
-
-
- {[
- { label: '相对时间', value: extraInfo?.relative },
- { label: 'ISO 8601', value: extraInfo?.iso },
- { label: 'UTC 时间', value: extraInfo?.utc },
- ].map((item) => (
-
-
- {item.label}
-
-
-
- {item.value}
-
- {item.value && (
-
- )}
-
-
- ))}
-
-
-
- );
-});
-
-ResultView.displayName = 'ResultView';
-
-// ================= 主页面组件 =================
export default function TimestampPage() {
- 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('ms');
- const [zone, setZone] = useState('Asia/Shanghai');
- const [result, setResult] = useState('');
- const [error, setError] = useState('');
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
-
- 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 {
+ mode,
+ tsInput,
+ dtInput,
+ unit,
+ zone,
+ result,
+ error,
+ setMode,
+ setTsInput,
+ setDtInput,
+ setUnit,
+ setZone,
+ handleUseNow,
+ convert,
+ } = useTimestampConverter();
return (
-
-
+
+
{/* Header with Icon */}
@@ -420,11 +103,7 @@ export default function TimestampPage() {
{(['ts2dt', 'dt2ts'] as const).map((m) => (
{
- setMode(m);
- setError('');
- setResult('');
- }}
+ onClick={() => setMode(m)}
sx={{
flex: 1,
py: 1,
@@ -446,7 +125,7 @@ export default function TimestampPage() {
{/* Input Area */}
{
const val = e.target.value;
@@ -455,7 +134,6 @@ export default function TimestampPage() {
} else {
setDtInput(val);
}
- setError('');
}}
error={!!error}
helperText={error}
@@ -502,7 +180,7 @@ export default function TimestampPage() {