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 ( + + + + + + + + ); +}; + +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 + + + + + + + + + + + + + + + + + ), + }, + }} + 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 ? ( + + QR Code 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 ? : } + + + + + + + + {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 资源。 + + )} + + + + + ); +}; + +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 转二维码 + + + + + + + + + + + {qrCodeDataUrl ? ( + + QR Code + + + + + + ) : ( + + 二维码将显示在这里 + + )} + + + + + ); +}; + +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 = () => { - {/* 主要操作按钮 */} - - + {/* 扫描按钮 */} + - + setShowFields(!showFields)} + /> - - + - {/* 选项设置 */} - - - - 填充选项 - - - - 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 资源。 - - )} - - - - + {/* 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} + /> - - - {/* 二维码显示区域 */} - - {qrCodeDataUrl ? ( - - QR Code - - - - - - ) : ( - - 二维码将显示在这里 - - )} - - - - - - {/* 二维码转 URL */} - setQrExpanded(isExpanded)} - sx={{ - borderRadius: 4, - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)', - '&:before': { display: 'none' }, - }} - > - } sx={{ borderBottom: 'none' }}> - - - - 二维码转 URL - - - - - - - - - - - - - {/* 解析结果显示 */} - - - - - ), - }, - }} - 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() {