Develop fill form (#11)
* 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 工作流,支持自动化构建与发布
This commit is contained in:
@@ -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
|
||||
@@ -1,31 +0,0 @@
|
||||
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
|
||||
|
||||
name: Node.js CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
- run: npm install
|
||||
- run: npm run build --if-present
|
||||
# - run: npm test
|
||||
@@ -0,0 +1,118 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ── Phase 1: 全量 CI 检查 ────────────────────────────────────────────
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
|
||||
typecheck:
|
||||
name: TypeScript Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run TypeScript type check
|
||||
run: npm run compile
|
||||
|
||||
test:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
# ── Phase 2: 打包 & 发布 ─────────────────────────────────────────────
|
||||
release:
|
||||
name: Package & Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, typecheck, test]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Package Chrome extension
|
||||
run: npm run zip
|
||||
|
||||
- name: Package Firefox extension
|
||||
run: npm run zip:firefox
|
||||
|
||||
- name: Find zip artifacts
|
||||
id: find_zips
|
||||
run: |
|
||||
CHROME_ZIP=$(find .output -name "*.zip" | grep -v firefox | head -1)
|
||||
FIREFOX_ZIP=$(find .output -name "*.zip" | grep firefox | head -1)
|
||||
echo "chrome_zip=$CHROME_ZIP" >> "$GITHUB_OUTPUT"
|
||||
echo "firefox_zip=$FIREFOX_ZIP" >> "$GITHUB_OUTPUT"
|
||||
echo "Found Chrome zip: $CHROME_ZIP"
|
||||
echo "Found Firefox zip: $FIREFOX_ZIP"
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: "v${{ steps.version.outputs.version }}"
|
||||
tag_name: ${{ github.ref_name }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
${{ steps.find_zips.outputs.chrome_zip }}
|
||||
${{ steps.find_zips.outputs.firefox_zip }}
|
||||
@@ -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 的「持续集成与发布」章节。
|
||||
@@ -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` — 自动发布
|
||||
|
||||
## 权限说明
|
||||
|
||||
扩展请求以下权限:
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Paper } from '@mui/material';
|
||||
|
||||
const FeatureDescription: React.FC = () => {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden' }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', px: 2, py: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
功能说明
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<strong>有效数据模式:</strong>生成符合格式要求的测试数据,适用于正常功能测试。
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<strong>异常数据模式:</strong>生成边界值或格式错误的数据,适用于异常场景测试。
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<strong>一键清空:</strong>快速清空当前页面所有表单字段的值。
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>支持的字段类型:</strong>
|
||||
文本、邮箱、手机号、数字、日期、文本域、密码、身份证号等。
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeatureDescription;
|
||||
@@ -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<string, string> = {
|
||||
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<FieldListProps> = ({ fields, showFields, onToggleShowFields }) => {
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={onToggleShowFields}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
已识别字段 ({fields.length})
|
||||
</Typography>
|
||||
{showFields ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</Box>
|
||||
<Collapse in={showFields}>
|
||||
<List dense sx={{ maxHeight: 300, overflow: 'auto' }}>
|
||||
{fields.map((field, index) => (
|
||||
<ListItem key={field.id} sx={{ py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<InputIcon fontSize="small" color="action" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{field.label || field.name || field.placeholder || `字段 ${index + 1}`}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={FIELD_TYPE_NAMES[field.fieldType] || '未知'}
|
||||
size="small"
|
||||
color={FIELD_TYPE_COLORS[field.fieldType] || 'default'}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={field.placeholder || field.name}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldList;
|
||||
@@ -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<MainActionsProps> = ({
|
||||
loading,
|
||||
onFillValidData,
|
||||
onFillInvalidData,
|
||||
onClearAllFields,
|
||||
}) => {
|
||||
return (
|
||||
<Stack spacing={2} sx={{ mb: 4 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />}
|
||||
onClick={onFillValidData}
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
sx={{
|
||||
...formRecognizerPageStyles.buttonStyle,
|
||||
bgcolor: formRecognizerPageStyles.validColor,
|
||||
'&:hover': {
|
||||
bgcolor: formRecognizerPageStyles.validDark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{loading ? '填充中...' : '一键填充(有效数据)'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : <ErrorOutlineIcon />}
|
||||
onClick={onFillInvalidData}
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
sx={{
|
||||
...formRecognizerPageStyles.buttonStyle,
|
||||
bgcolor: formRecognizerPageStyles.invalidColor,
|
||||
'&:hover': {
|
||||
bgcolor: formRecognizerPageStyles.invalidDark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{loading ? '填充中...' : '一键填充(异常数据)'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : <ClearAllIcon />}
|
||||
onClick={onClearAllFields}
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
sx={{
|
||||
...formRecognizerPageStyles.buttonStyle,
|
||||
borderColor: formRecognizerPageStyles.clearColor,
|
||||
color: formRecognizerPageStyles.clearColor,
|
||||
'&:hover': {
|
||||
borderColor: formRecognizerPageStyles.clearDark,
|
||||
bgcolor: formRecognizerPageStyles.clearBg,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{loading ? '清空中...' : '一键清空所有表单'}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainActions;
|
||||
@@ -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<OperationHistoryProps> = ({
|
||||
history,
|
||||
showHistory,
|
||||
onToggleShowHistory,
|
||||
}) => {
|
||||
if (history.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 4 }}>
|
||||
<Box
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={onToggleShowHistory}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
操作历史 ({history.length})
|
||||
</Typography>
|
||||
{showHistory ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</Box>
|
||||
<Collapse in={showHistory}>
|
||||
<List dense sx={{ maxHeight: 300, overflow: 'auto' }}>
|
||||
{history.map((item, index) => (
|
||||
<Box key={index}>
|
||||
{index > 0 && <Divider />}
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip label={item.type} size="small" color="primary" variant="outlined" />
|
||||
<Typography variant="body2">{item.content}</Typography>
|
||||
</Box>
|
||||
}
|
||||
secondary={`${item.time} · ${item.result}`}
|
||||
/>
|
||||
</ListItem>
|
||||
</Box>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default OperationHistory;
|
||||
@@ -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<OptionsPanelProps> = ({ includeHidden, onIncludeHiddenChange }) => {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 4 }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', px: 2, py: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
填充选项
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={includeHidden}
|
||||
onChange={(e) => onIncludeHiddenChange(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="包含隐藏字段"
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default OptionsPanel;
|
||||
@@ -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<File | null>(null);
|
||||
const [parsedUrl, setParsedUrl] = useState('');
|
||||
const [parseError, setParseError] = useState('');
|
||||
const [parsing, setParsing] = useState(false);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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<void>((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 (
|
||||
<Accordion
|
||||
expanded={expanded}
|
||||
onChange={(_, isExpanded) => onExpandedChange(isExpanded)}
|
||||
sx={{
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
'&:before': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ borderBottom: 'none' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<LinkIcon color="success" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
二维码转 URL
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={3}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 200,
|
||||
border: '2px dashed',
|
||||
borderColor: qrCodeFile ? qrCodePageStyles.successColor : 'grey.200',
|
||||
borderRadius: 3,
|
||||
p: 4,
|
||||
bgcolor: qrCodeFile ? 'rgba(76, 175, 80, 0.05)' : 'grey.50',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
borderColor: qrCodePageStyles.successColor,
|
||||
bgcolor: 'rgba(76, 175, 80, 0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
style={{
|
||||
display: 'none',
|
||||
}}
|
||||
id="qr-code-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="qr-code-upload"
|
||||
style={{ cursor: 'pointer', textAlign: 'center', width: '100%' }}
|
||||
>
|
||||
{qrCodeFile ? (
|
||||
<Box sx={{ textAlign: 'center', width: '100%', position: 'relative' }}>
|
||||
<Box sx={{ position: 'relative', display: 'inline-block' }}>
|
||||
<img
|
||||
src={URL.createObjectURL(qrCodeFile)}
|
||||
alt="QR Code Preview"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 160,
|
||||
borderRadius: 8,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setQrCodeFile(null);
|
||||
setParsedUrl('');
|
||||
setParseError('');
|
||||
showMessage('图片已清除', {
|
||||
severity: 'success',
|
||||
autoHideDuration: 1000,
|
||||
});
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
minWidth: '32px',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'rgba(244, 67, 54, 0.9)',
|
||||
color: 'white',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(211, 47, 47, 0.95)',
|
||||
transform: 'scale(1.1)',
|
||||
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.95)',
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 700,
|
||||
fontSize: '16px',
|
||||
lineHeight: 1,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
{qrCodeFile.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
点击更换图片
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<ImageIcon sx={{ fontSize: 48, color: 'grey.300', mb: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
点击、拖拽或粘贴上传二维码图片
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
支持 PNG、JPG、WEBP 格式
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={parsing ? <CircularProgress size={16} color="inherit" /> : <LinkIcon />}
|
||||
onClick={parseQrCode}
|
||||
disabled={parsing}
|
||||
sx={{
|
||||
py: 1.2,
|
||||
borderRadius: 3,
|
||||
bgcolor: qrCodePageStyles.successColor,
|
||||
fontWeight: 700,
|
||||
'&:hover': {
|
||||
bgcolor: qrCodePageStyles.successDark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{parsing ? '解析中...' : '解析二维码'}
|
||||
</Button>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="解析结果"
|
||||
value={parsedUrl}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
slotProps={{
|
||||
input: {
|
||||
readOnly: true,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<CopyButton
|
||||
text={parsedUrl}
|
||||
tooltip="复制"
|
||||
size="small"
|
||||
color={qrCodePageStyles.primaryColor}
|
||||
showMessage={showMessage}
|
||||
/>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={qrCodePageStyles.INPUT_STYLE}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{parseError && (
|
||||
<Alert severity="error" sx={{ borderRadius: 3 }}>
|
||||
{parseError}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
export default QrCodeToUrlSection;
|
||||
@@ -0,0 +1,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<QrCodeUploaderProps> = ({
|
||||
onQrCodeDetected,
|
||||
supportedFormats = ['image/png', 'image/jpeg', 'image/webp'],
|
||||
maxFileSize = 5 * 1024 * 1024, // 5MB
|
||||
timeout = 10000, // 10 seconds
|
||||
showPreview = true,
|
||||
showProgress = true,
|
||||
className,
|
||||
}) => {
|
||||
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 3000 });
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const uploadAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 清理预览 URL
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (preview) {
|
||||
URL.revokeObjectURL(preview);
|
||||
}
|
||||
};
|
||||
}, [preview]);
|
||||
|
||||
// 处理文件
|
||||
const processFile = useCallback(
|
||||
async (file: File) => {
|
||||
setUploading(true);
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
// 模拟上传进度
|
||||
const progressInterval = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
if (prev >= 90) {
|
||||
clearInterval(progressInterval);
|
||||
return prev;
|
||||
}
|
||||
return prev + 10;
|
||||
});
|
||||
}, 200);
|
||||
|
||||
// 读取文件并解析二维码
|
||||
const 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<void>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error('图片加载超时'));
|
||||
}, timeout);
|
||||
|
||||
image.onload = () => {
|
||||
clearTimeout(timeoutId);
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
ctx.drawImage(image, 0, 0);
|
||||
resolve();
|
||||
};
|
||||
|
||||
image.onerror = () => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error('图片加载失败'));
|
||||
};
|
||||
});
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
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<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
handleFile(selectedFile);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理拖拽事件
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const droppedFile = e.dataTransfer.files?.[0];
|
||||
if (droppedFile) {
|
||||
handleFile(droppedFile);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听粘贴事件
|
||||
useEffect(() => {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
const pastedFile = items[i].getAsFile();
|
||||
if (pastedFile) {
|
||||
handleFile(pastedFile);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('paste', handlePaste);
|
||||
return () => document.removeEventListener('paste', handlePaste);
|
||||
}, [handleFile]);
|
||||
|
||||
// 清除文件
|
||||
const handleClear = () => {
|
||||
setFile(null);
|
||||
setPreview(null);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className={className}>
|
||||
{/* 上传区域 */}
|
||||
<Paper
|
||||
ref={uploadAreaRef}
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: isMobile ? 3 : 4,
|
||||
borderRadius: 4,
|
||||
border: `2px dashed ${dragging ? 'primary.main' : 'grey.300'}`,
|
||||
bgcolor: dragging ? 'primary.lighter' : 'grey.50',
|
||||
transition: 'all 0.2s ease',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={supportedFormats.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{!file && !uploading ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<ImageIcon sx={{ fontSize: isMobile ? 36 : 48, color: 'grey.400', mb: 2 }} />
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
|
||||
点击、拖拽或粘贴上传二维码图片
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
支持 {supportedFormats.map((f) => f.split('/')[1].toUpperCase()).join(', ')} 格式
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
||||
最大文件大小: {(maxFileSize / (1024 * 1024)).toFixed(1)}MB
|
||||
</Typography>
|
||||
</Box>
|
||||
) : file && showPreview && preview ? (
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<img
|
||||
src={preview}
|
||||
alt="QR Code Preview"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 200,
|
||||
borderRadius: 8,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClear();
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
right: -8,
|
||||
bgcolor: 'rgba(244, 67, 54, 0.9)',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(211, 47, 47, 0.95)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ClearIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
{file.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : uploading && showProgress ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<CircularProgress size={48} sx={{ mb: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
处理中...
|
||||
</Typography>
|
||||
{progress > 0 && (
|
||||
<Box sx={{ width: '80%', mt: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
height: 8,
|
||||
bgcolor: 'grey.200',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
height: '100%',
|
||||
bgcolor: 'primary.main',
|
||||
width: `${progress}%`,
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 1, display: 'block' }}
|
||||
>
|
||||
{progress}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
</Paper>
|
||||
|
||||
{/* 结果展示 */}
|
||||
{(result || error) && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{result && (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'success.light',
|
||||
bgcolor: 'success.lighter',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<CheckCircleIcon sx={{ color: 'success.main', mt: 0.5 }} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
二维码内容
|
||||
</Typography>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
wordBreak: 'break-all',
|
||||
pr: 8,
|
||||
}}
|
||||
>
|
||||
{result}
|
||||
</Typography>
|
||||
<CopyButton
|
||||
text={result}
|
||||
tooltip="复制"
|
||||
size="small"
|
||||
color="success"
|
||||
showMessage={showMessage}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ borderRadius: 4 }} icon={<ErrorIcon />}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<GlobalSnackbar {...snackbarProps} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default QrCodeUploader;
|
||||
@@ -0,0 +1,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<TemplateManagerProps> = ({
|
||||
templates,
|
||||
showTemplates,
|
||||
templateLoading,
|
||||
onToggleShowTemplates,
|
||||
onLoadTemplates,
|
||||
onExportTemplates,
|
||||
onImportTemplates,
|
||||
}) => {
|
||||
const handleToggle = () => {
|
||||
onToggleShowTemplates();
|
||||
if (!showTemplates) onLoadTemplates();
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 4 }}>
|
||||
<Box
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
模板管理 ({templates.length})
|
||||
</Typography>
|
||||
{showTemplates ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</Box>
|
||||
<Collapse in={showTemplates}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={onExportTemplates}
|
||||
variant="outlined"
|
||||
>
|
||||
导出
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<UploadIcon />}
|
||||
onClick={onImportTemplates}
|
||||
variant="outlined"
|
||||
>
|
||||
导入
|
||||
</Button>
|
||||
</Stack>
|
||||
{templateLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||
<CircularProgress size={20} />
|
||||
</Box>
|
||||
) : templates.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 2 }}>
|
||||
暂无模板,请先在其他页面创建模板
|
||||
</Typography>
|
||||
) : (
|
||||
<List dense sx={{ maxHeight: 200, overflow: 'auto' }}>
|
||||
{templates.map((template) => (
|
||||
<ListItem key={template.id} sx={{ py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<FolderIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={template.name}
|
||||
secondary={`${template.fields.length} 个字段 · ${new Date(
|
||||
template.updatedAt,
|
||||
).toLocaleDateString('zh-CN')}`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateManager;
|
||||
@@ -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<string>('');
|
||||
const [newUrl, setNewUrl] = useState<string>('');
|
||||
|
||||
const showMixedContentWarning =
|
||||
newUrl.startsWith('http://') && !newUrl.includes('localhost') && !newUrl.includes('127.0.0.1');
|
||||
|
||||
const isValidUrl = (url: string) => {
|
||||
if (!url.trim()) return false;
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddEntry = () => {
|
||||
if (!newName.trim()) {
|
||||
showMessage('请输入名称', { severity: 'error' });
|
||||
return;
|
||||
}
|
||||
if (!isValidUrl(newUrl)) {
|
||||
showMessage('请输入有效的 URL', { severity: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
onAddEntry({ name: newName.trim(), url: newUrl.trim() });
|
||||
setNewName('');
|
||||
setNewUrl('');
|
||||
showMessage('添加成功', { severity: 'success' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
p: 2,
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
mb: 3,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="环境名称"
|
||||
placeholder="例如: 本地文档"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
sx={openUrlPageStyles.INPUT_STYLE}
|
||||
slotProps={{
|
||||
inputLabel: {
|
||||
shrink: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="目标 URL"
|
||||
placeholder="例如: http://localhost:8000/docs"
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
sx={openUrlPageStyles.INPUT_STYLE}
|
||||
slotProps={{
|
||||
inputLabel: {
|
||||
shrink: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{showMixedContentWarning && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
'& .MuiAlert-message': { fontSize: '0.7rem', fontWeight: 600, lineHeight: 1.4 },
|
||||
}}
|
||||
>
|
||||
混合内容警告:当前 HTTPS 页面无法加载 HTTP 资源。
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleAddEntry}
|
||||
disabled={!newName.trim() || !isValidUrl(newUrl)}
|
||||
fullWidth
|
||||
startIcon={<AddIcon />}
|
||||
sx={{
|
||||
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)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
添加快捷方式
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UrlEntryForm;
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Box, ListItem, Typography, Stack, Divider, Tooltip, IconButton } from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import type { OpenUrlEntry } from '@/types/storage';
|
||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||
import { openUrlPageStyles } from '@/config/pageTheme';
|
||||
|
||||
interface UrlEntryItemProps {
|
||||
entry: OpenUrlEntry;
|
||||
index: number;
|
||||
isLast: boolean;
|
||||
onDelete: (index: number) => void;
|
||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||
}
|
||||
|
||||
const UrlEntryItem = ({ entry, index, isLast, onDelete, showMessage }: UrlEntryItemProps) => {
|
||||
const handleOpenInSidebar = async (entry: OpenUrlEntry) => {
|
||||
try {
|
||||
// 存储目标 URL
|
||||
await storageUtil.set('openUrl/currentUrl', entry.url);
|
||||
// 直接设置侧边栏的路由,而不是通过 syncNavigation 影响弹窗路由
|
||||
await storageUtil.set('app/sidepanelRoute', 'openUrlViewer');
|
||||
|
||||
const [currentTab] = await chrome.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
});
|
||||
const tabId = currentTab.id;
|
||||
if (!tabId) {
|
||||
showMessage('无法获取当前标签页', { severity: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
await chrome.sidePanel.setOptions({
|
||||
tabId,
|
||||
path: 'sidepanel.html',
|
||||
enabled: true,
|
||||
});
|
||||
await chrome.sidePanel.open({ windowId: currentTab.windowId });
|
||||
|
||||
// 仅当在 Popup 中时才关闭窗口,防止在侧边栏内点击预览时导致侧边栏关闭
|
||||
if (window.location.pathname.includes('popup.html')) {
|
||||
window.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to open side panel:', error);
|
||||
showMessage(`打开失败: ${(error as Error).message}`, { severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenInNewTab = (entry: OpenUrlEntry) => {
|
||||
chrome.tabs.create({ url: entry.url });
|
||||
window.close();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
onDelete(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ListItem
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
transition: 'background-color 0.2s',
|
||||
'&:hover': { bgcolor: 'grey.50' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800, color: 'text.primary' }} noWrap>
|
||||
{entry.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
sx={{
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 500,
|
||||
display: 'block',
|
||||
mt: 0.2,
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{entry.url}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<Tooltip title="在侧边栏预览">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleOpenInSidebar(entry)}
|
||||
sx={{
|
||||
color: openUrlPageStyles.themeColor,
|
||||
bgcolor: alpha(openUrlPageStyles.themeColor, 0.05),
|
||||
'&:hover': { bgcolor: openUrlPageStyles.themeColor, color: '#fff' },
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="新标签页打开">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleOpenInNewTab(entry)}
|
||||
sx={{
|
||||
color: 'grey.500',
|
||||
bgcolor: 'grey.100',
|
||||
'&:hover': { bgcolor: 'grey.600', color: '#fff' },
|
||||
}}
|
||||
>
|
||||
<OpenInNewIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleDelete}
|
||||
sx={{
|
||||
color: 'error.main',
|
||||
'&:hover': { color: 'error.dark', bgcolor: alpha('#f44336', 0.05) },
|
||||
}}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
{!isLast && <Divider sx={{ mx: 2, borderColor: 'grey.50' }} />}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default UrlEntryItem;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Box, List, Typography } from '@mui/material';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import UrlEntryItem from './UrlEntryItem';
|
||||
import type { OpenUrlEntry } from '@/types/storage';
|
||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||
|
||||
interface UrlEntryListProps {
|
||||
entries: OpenUrlEntry[];
|
||||
onDeleteEntry: (index: number) => void;
|
||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||
}
|
||||
|
||||
const UrlEntryList = ({ entries, onDeleteEntry, showMessage }: UrlEntryListProps) => {
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 4,
|
||||
bgcolor: 'grey.50',
|
||||
borderRadius: 4,
|
||||
border: '1px dashed',
|
||||
borderColor: 'grey.200',
|
||||
}}
|
||||
>
|
||||
<LinkIcon sx={{ color: 'grey.300', fontSize: 40, mb: 1 }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.disabled"
|
||||
sx={{ display: 'block', fontWeight: 600 }}
|
||||
>
|
||||
暂无快捷方式,请在上方添加
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
disablePadding
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{entries.map((entry, index) => (
|
||||
<UrlEntryItem
|
||||
key={index}
|
||||
entry={entry}
|
||||
index={index}
|
||||
isLast={index === entries.length - 1}
|
||||
onDelete={onDeleteEntry}
|
||||
showMessage={showMessage}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default UrlEntryList;
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Stack,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import qrcode from 'qrcode';
|
||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||
|
||||
interface UrlToQrCodeSectionProps {
|
||||
expanded: boolean;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||
}
|
||||
|
||||
const UrlToQrCodeSection = ({
|
||||
expanded,
|
||||
onExpandedChange,
|
||||
showMessage,
|
||||
}: UrlToQrCodeSectionProps) => {
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
const [urlError, setUrlError] = useState('');
|
||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUrlInput(e.target.value);
|
||||
setUrlError('');
|
||||
};
|
||||
|
||||
const generateQrCode = async () => {
|
||||
if (!urlInput) {
|
||||
setUrlError('请输入 URL');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setGenerating(true);
|
||||
setUrlError('');
|
||||
|
||||
let url = urlInput;
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
|
||||
const dataUrl = await qrcode.toDataURL(url, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: qrCodePageStyles.black,
|
||||
light: qrCodePageStyles.white,
|
||||
},
|
||||
});
|
||||
|
||||
setQrCodeDataUrl(dataUrl);
|
||||
showMessage('二维码生成成功', { severity: 'success', autoHideDuration: 1000 });
|
||||
} catch (error) {
|
||||
console.error('生成二维码失败:', error);
|
||||
showMessage('生成二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadQrCode = () => {
|
||||
if (!qrCodeDataUrl) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = qrCodeDataUrl;
|
||||
link.download = 'qrcode.png';
|
||||
link.click();
|
||||
showMessage('二维码下载成功', { severity: 'success', autoHideDuration: 300 });
|
||||
};
|
||||
|
||||
const copyQrCode = async () => {
|
||||
if (!qrCodeDataUrl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(qrCodeDataUrl);
|
||||
const blob = await response.blob();
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'image/png': blob,
|
||||
}),
|
||||
]);
|
||||
|
||||
showMessage('二维码已复制到剪贴板', { severity: 'success', autoHideDuration: 1000 });
|
||||
} catch (error) {
|
||||
console.error('复制二维码失败:', error);
|
||||
showMessage('复制二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
expanded={expanded}
|
||||
onChange={(_, isExpanded) => onExpandedChange(isExpanded)}
|
||||
sx={{
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
'&:before': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ borderBottom: 'none' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<QrCodeIcon color="primary" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
URL 转二维码
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={3}>
|
||||
<TextField
|
||||
label="输入 URL"
|
||||
placeholder="https://example.com"
|
||||
value={urlInput}
|
||||
onChange={handleUrlInputChange}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!urlError}
|
||||
helperText={urlError}
|
||||
sx={qrCodePageStyles.INPUT_STYLE}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={generating ? <CircularProgress size={16} color="inherit" /> : <QrCodeIcon />}
|
||||
onClick={generateQrCode}
|
||||
disabled={generating}
|
||||
sx={{
|
||||
py: 1.2,
|
||||
borderRadius: 3,
|
||||
bgcolor: qrCodePageStyles.successColor,
|
||||
fontWeight: 700,
|
||||
'&:hover': {
|
||||
bgcolor: qrCodePageStyles.successDark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{generating ? '生成中...' : '生成二维码'}
|
||||
</Button>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: 200,
|
||||
border: '2px dashed',
|
||||
borderColor: 'grey.200',
|
||||
borderRadius: 3,
|
||||
p: 2,
|
||||
bgcolor: 'grey.50',
|
||||
}}
|
||||
>
|
||||
{qrCodeDataUrl ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={qrCodeDataUrl}
|
||||
alt="QR Code"
|
||||
style={{ maxWidth: '100%', height: 'auto', display: 'block' }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={downloadQrCode}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
borderColor: qrCodePageStyles.successColor,
|
||||
color: qrCodePageStyles.successColor,
|
||||
'&:hover': {
|
||||
borderColor: qrCodePageStyles.successDark,
|
||||
bgcolor: 'rgba(76, 175, 80, 0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
下载二维码
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<ContentCopyIcon />}
|
||||
onClick={copyQrCode}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
bgcolor: qrCodePageStyles.successColor,
|
||||
'&:hover': {
|
||||
bgcolor: qrCodePageStyles.successDark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
复制二维码
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||
二维码将显示在这里
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
export default UrlToQrCodeSection;
|
||||
@@ -42,7 +42,11 @@ describe('Button Component', () => {
|
||||
|
||||
it('should not call onClick when disabled', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<Button onClick={handleClick} disabled>Disabled Button</Button>);
|
||||
render(
|
||||
<Button onClick={handleClick} disabled>
|
||||
Disabled Button
|
||||
</Button>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /disabled button/i }));
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
@@ -70,4 +74,4 @@ describe('Button Component', () => {
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+113
-10
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<FieldData[]>([]);
|
||||
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<DataTemplate[]>([]);
|
||||
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 (
|
||||
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 4 }}>
|
||||
<Container maxWidth="sm" sx={{ py: 3, px: 2 }}>
|
||||
<Container maxWidth="sm" sx={{ py: 3, px: 2, bgcolor: '#f5f5f5' }}>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" component="h1" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
Dummy Data Generator
|
||||
@@ -72,117 +227,59 @@ const FormRecognizerPage = () => {
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 主要操作按钮 */}
|
||||
<Stack spacing={2} sx={{ mb: 4 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={
|
||||
loading ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />
|
||||
}
|
||||
onClick={handleFillValidData}
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
sx={{
|
||||
py: 1.2,
|
||||
borderRadius: 3,
|
||||
bgcolor: '#4caf50',
|
||||
fontWeight: 700,
|
||||
'&:hover': {
|
||||
bgcolor: '#388e3c',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{loading ? '填充中...' : '一键填充(有效数据)'}
|
||||
</Button>
|
||||
{/* 扫描按钮 */}
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleScanFields}
|
||||
disabled={scanning}
|
||||
fullWidth
|
||||
sx={{
|
||||
...formRecognizerPageStyles.buttonStyle,
|
||||
mb: 2,
|
||||
borderColor: formRecognizerPageStyles.validColor,
|
||||
color: formRecognizerPageStyles.validColor,
|
||||
'&:hover': {
|
||||
borderColor: formRecognizerPageStyles.validDark,
|
||||
bgcolor: 'rgba(76, 175, 80, 0.05)',
|
||||
},
|
||||
}}
|
||||
startIcon={scanning ? <CircularProgress size={16} color="inherit" /> : <InputIcon />}
|
||||
>
|
||||
{scanning ? '扫描中...' : '扫描表单字段'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={
|
||||
loading ? <CircularProgress size={16} color="inherit" /> : <ErrorOutlineIcon />
|
||||
}
|
||||
onClick={handleFillInvalidData}
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
sx={{
|
||||
py: 1.2,
|
||||
borderRadius: 3,
|
||||
bgcolor: '#ff9800',
|
||||
fontWeight: 700,
|
||||
'&:hover': {
|
||||
bgcolor: '#f57c00',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{loading ? '填充中...' : '一键填充(异常数据)'}
|
||||
</Button>
|
||||
<FieldList
|
||||
fields={fields}
|
||||
showFields={showFields}
|
||||
onToggleShowFields={() => setShowFields(!showFields)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : <ClearAllIcon />}
|
||||
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...' : '一键清空所有表单'}
|
||||
</Button>
|
||||
</Stack>
|
||||
<MainActions
|
||||
loading={loading}
|
||||
onFillValidData={handleFillValidData}
|
||||
onFillInvalidData={handleFillInvalidData}
|
||||
onClearAllFields={handleClearAllFields}
|
||||
/>
|
||||
|
||||
{/* 选项设置 */}
|
||||
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 4 }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', px: 2, py: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
填充选项
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={includeHidden}
|
||||
onChange={(e) => setIncludeHidden(e.target.checked)}
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="包含隐藏字段"
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
<OptionsPanel includeHidden={includeHidden} onIncludeHiddenChange={setIncludeHidden} />
|
||||
|
||||
{/* 功能说明 */}
|
||||
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden' }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', px: 2, py: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
功能说明
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<strong>有效数据模式:</strong>生成符合格式要求的测试数据,适用于正常功能测试。
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<strong>异常数据模式:</strong>生成边界值或格式错误的数据,适用于异常场景测试。
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<strong>一键清空:</strong>快速清空当前页面所有表单字段的值。
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>支持的字段类型:</strong>
|
||||
文本、邮箱、手机号、数字、日期、文本域、密码、身份证号等。
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
<OperationHistory
|
||||
history={operationHistory}
|
||||
showHistory={showHistory}
|
||||
onToggleShowHistory={() => setShowHistory(!showHistory)}
|
||||
/>
|
||||
|
||||
<TemplateManager
|
||||
templates={templates}
|
||||
showTemplates={showTemplates}
|
||||
templateLoading={templateLoading}
|
||||
onToggleShowTemplates={() => setShowTemplates(!showTemplates)}
|
||||
onLoadTemplates={loadTemplates}
|
||||
onExportTemplates={handleExportTemplates}
|
||||
onImportTemplates={handleImportTemplates}
|
||||
/>
|
||||
|
||||
<FeatureDescription />
|
||||
|
||||
<GlobalSnackbar {...snackbarProps} />
|
||||
</Container>
|
||||
|
||||
@@ -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<OpenUrlEntry[]>(DEFAULT_PREFERENCES.entries);
|
||||
const [newName, setNewName] = useState<string>('');
|
||||
const [newUrl, setNewUrl] = useState<string>('');
|
||||
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 (
|
||||
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
|
||||
<Container sx={{ py: 2 }}>
|
||||
<Typography>加载中...</Typography>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
|
||||
@@ -175,81 +65,7 @@ export default function OpenUrlPage() {
|
||||
</Stack>
|
||||
|
||||
{/* Form Section */}
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
p: 2,
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
mb: 3,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="环境名称"
|
||||
placeholder="例如: 本地文档"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
sx={openUrlPageStyles.INPUT_STYLE}
|
||||
slotProps={{
|
||||
inputLabel: {
|
||||
shrink: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="目标 URL"
|
||||
placeholder="例如: http://localhost:8000/docs"
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
sx={openUrlPageStyles.INPUT_STYLE}
|
||||
slotProps={{
|
||||
inputLabel: {
|
||||
shrink: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{showMixedContentWarning && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
'& .MuiAlert-message': { fontSize: '0.7rem', fontWeight: 600, lineHeight: 1.4 },
|
||||
}}
|
||||
>
|
||||
混合内容警告:当前 HTTPS 页面无法加载 HTTP 资源。
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleAddEntry}
|
||||
disabled={!newName.trim() || !isValidUrl(newUrl)}
|
||||
fullWidth
|
||||
startIcon={<AddIcon />}
|
||||
sx={{
|
||||
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)}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
添加快捷方式
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
<UrlEntryForm onAddEntry={handleAddEntry} showMessage={showMessage} />
|
||||
|
||||
{/* List Section */}
|
||||
<Box>
|
||||
@@ -260,119 +76,11 @@ export default function OpenUrlPage() {
|
||||
已保存的快捷方式 ({entries.length})
|
||||
</Typography>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 4,
|
||||
bgcolor: 'grey.50',
|
||||
borderRadius: 4,
|
||||
border: '1px dashed',
|
||||
borderColor: 'grey.200',
|
||||
}}
|
||||
>
|
||||
<LinkIcon sx={{ color: 'grey.300', fontSize: 40, mb: 1 }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.disabled"
|
||||
sx={{ display: 'block', fontWeight: 600 }}
|
||||
>
|
||||
暂无快捷方式,请在上方添加
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<List
|
||||
disablePadding
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{entries.map((entry, index) => (
|
||||
<Fragment key={index}>
|
||||
<ListItem
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
transition: 'background-color 0.2s',
|
||||
'&:hover': { bgcolor: 'grey.50' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 800, color: 'text.primary' }}
|
||||
noWrap
|
||||
>
|
||||
{entry.name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
sx={{
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 500,
|
||||
display: 'block',
|
||||
mt: 0.2,
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{entry.url}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<Tooltip title="在侧边栏预览">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleOpenInSidebar(entry)}
|
||||
sx={{
|
||||
color: THEME_COLOR,
|
||||
bgcolor: alpha(THEME_COLOR, 0.05),
|
||||
'&:hover': { bgcolor: THEME_COLOR, color: '#fff' },
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="新标签页打开">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleOpenInNewTab(entry)}
|
||||
sx={{
|
||||
color: 'grey.500',
|
||||
bgcolor: 'grey.100',
|
||||
'&:hover': { bgcolor: 'grey.600', color: '#fff' },
|
||||
}}
|
||||
>
|
||||
<OpenInNewIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDeleteEntry(index)}
|
||||
sx={{
|
||||
color: 'error.main',
|
||||
'&:hover': { color: 'error.dark', bgcolor: alpha('#f44336', 0.05) },
|
||||
}}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</ListItem>
|
||||
{index < entries.length - 1 && <Divider sx={{ mx: 2, borderColor: 'grey.50' }} />}
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
<UrlEntryList
|
||||
entries={entries}
|
||||
onDeleteEntry={handleDeleteEntry}
|
||||
showMessage={showMessage}
|
||||
/>
|
||||
</Box>
|
||||
</Container>
|
||||
<GlobalSnackbar {...snackbarProps} />
|
||||
|
||||
@@ -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<File | null>(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<HTMLDivElement>(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 (
|
||||
<Container
|
||||
sx={{
|
||||
@@ -101,145 +32,6 @@ const QrCodePage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// 处理 URL 输入变化
|
||||
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUrlInput(e.target.value);
|
||||
setUrlError('');
|
||||
};
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// 只有当用户实际选择了文件时才更新状态
|
||||
// 如果用户取消选择,保持原有状态不变
|
||||
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<void>((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 (
|
||||
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
|
||||
<Container sx={{ py: 2, maxWidth: 400 }}>
|
||||
@@ -271,285 +63,17 @@ const QrCodePage = () => {
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={3}>
|
||||
{/* URL 转二维码 */}
|
||||
<Accordion
|
||||
<UrlToQrCodeSection
|
||||
expanded={urlExpanded}
|
||||
onChange={(_, isExpanded) => setUrlExpanded(isExpanded)}
|
||||
sx={{
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
'&:before': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ borderBottom: 'none' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<QrCodeIcon color="primary" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
URL 转二维码
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={3}>
|
||||
<TextField
|
||||
label="输入 URL"
|
||||
placeholder="https://example.com"
|
||||
value={urlInput}
|
||||
onChange={handleUrlInputChange}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
error={!!urlError}
|
||||
helperText={urlError}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 3,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
onExpandedChange={setUrlExpanded}
|
||||
showMessage={showMessage}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={
|
||||
generating ? <CircularProgress size={16} color="inherit" /> : <QrCodeIcon />
|
||||
}
|
||||
onClick={generateQrCode}
|
||||
disabled={generating}
|
||||
sx={{
|
||||
py: 1.2,
|
||||
borderRadius: 3,
|
||||
bgcolor: qrCodePageStyles.successColor,
|
||||
fontWeight: 700,
|
||||
'&:hover': {
|
||||
bgcolor: qrCodePageStyles.successDark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{generating ? '生成中...' : '生成二维码'}
|
||||
</Button>
|
||||
|
||||
{/* 二维码显示区域 */}
|
||||
<Box
|
||||
ref={qrCodeRef}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: 200,
|
||||
border: '2px dashed',
|
||||
borderColor: 'grey.200',
|
||||
borderRadius: 3,
|
||||
p: 2,
|
||||
bgcolor: 'grey.50',
|
||||
}}
|
||||
>
|
||||
{qrCodeDataUrl ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={qrCodeDataUrl}
|
||||
alt="QR Code"
|
||||
style={{ maxWidth: '100%', height: 'auto', display: 'block' }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={downloadQrCode}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
borderColor: qrCodePageStyles.successColor,
|
||||
color: qrCodePageStyles.successColor,
|
||||
'&:hover': {
|
||||
borderColor: qrCodePageStyles.successDark,
|
||||
bgcolor: alpha(qrCodePageStyles.successColor, 0.05),
|
||||
},
|
||||
}}
|
||||
>
|
||||
下载二维码
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<ContentCopyIcon />}
|
||||
onClick={copyQrCode}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
bgcolor: qrCodePageStyles.successColor,
|
||||
'&:hover': {
|
||||
bgcolor: qrCodePageStyles.successDark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
复制二维码
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||
二维码将显示在这里
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* 二维码转 URL */}
|
||||
<Accordion
|
||||
<QrCodeToUrlSection
|
||||
expanded={qrExpanded}
|
||||
onChange={(_, isExpanded) => setQrExpanded(isExpanded)}
|
||||
sx={{
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
'&:before': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ borderBottom: 'none' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<LinkIcon color="success" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
二维码转 URL
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Stack spacing={3}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 200,
|
||||
border: '2px dashed',
|
||||
borderColor: qrCodeFile ? qrCodePageStyles.successColor : 'grey.200',
|
||||
borderRadius: 3,
|
||||
p: 4,
|
||||
bgcolor: qrCodeFile ? alpha(qrCodePageStyles.successColor, 0.05) : 'grey.50',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
borderColor: qrCodePageStyles.successColor,
|
||||
bgcolor: alpha(qrCodePageStyles.successColor, 0.05),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
style={{
|
||||
display: 'none',
|
||||
}}
|
||||
id="qr-code-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="qr-code-upload"
|
||||
style={{ cursor: 'pointer', textAlign: 'center', width: '100%' }}
|
||||
>
|
||||
{qrCodeFile ? (
|
||||
<Box sx={{ textAlign: 'center', width: '100%' }}>
|
||||
<img
|
||||
src={URL.createObjectURL(qrCodeFile)}
|
||||
alt="QR Code Preview"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 160,
|
||||
borderRadius: 8,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
{qrCodeFile.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
点击更换图片
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<ImageIcon sx={{ fontSize: 48, color: 'grey.300', mb: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
点击或拖拽上传二维码图片
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
支持 PNG、JPG、WEBP 格式
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={
|
||||
parsing ? <CircularProgress size={16} color="inherit" /> : <LinkIcon />
|
||||
}
|
||||
onClick={parseQrCode}
|
||||
disabled={parsing}
|
||||
sx={{
|
||||
py: 1.2,
|
||||
borderRadius: 3,
|
||||
bgcolor: qrCodePageStyles.successColor,
|
||||
fontWeight: 700,
|
||||
'&:hover': {
|
||||
bgcolor: qrCodePageStyles.successDark,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{parsing ? '解析中...' : '解析二维码'}
|
||||
</Button>
|
||||
|
||||
{/* 解析结果显示 */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="解析结果"
|
||||
value={parsedUrl}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
slotProps={{
|
||||
input: {
|
||||
readOnly: true,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<CopyButton
|
||||
text={parsedUrl}
|
||||
tooltip="复制"
|
||||
size="small"
|
||||
color={qrCodePageStyles.primaryColor}
|
||||
showMessage={showMessage}
|
||||
/>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 3,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{parseError && (
|
||||
<Alert severity="error" sx={{ borderRadius: 3 }}>
|
||||
{parseError}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
onExpandedChange={setQrExpanded}
|
||||
showMessage={showMessage}
|
||||
/>
|
||||
</Stack>
|
||||
<GlobalSnackbar {...snackbarProps} />
|
||||
</Container>
|
||||
|
||||
@@ -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<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [isInitializing, setIsInitializing] = useState<boolean>(true);
|
||||
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
||||
const [sizes, setSizes] = useState<Record<string, number>>({});
|
||||
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [result, setResult] = useState<CleaningResult | null>(null);
|
||||
const [showConfirm, setShowConfirm] = useState<boolean>(false);
|
||||
const { snackbarProps, showMessage } = useSnackbar();
|
||||
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const resultTimeoutRef = useRef<NodeJS.Timeout | null>(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 (
|
||||
<Container
|
||||
sx={{
|
||||
py: 8,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '400px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: '100%', maxWidth: 320 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 4,
|
||||
p: 4,
|
||||
boxShadow: '0 8px 24px rgba(244, 67, 54, 0.15)',
|
||||
border: '1px solid rgba(244, 67, 54, 0.2)',
|
||||
bgcolor: 'rgba(244, 67, 54, 0.05)',
|
||||
}}
|
||||
>
|
||||
<WarningIcon sx={{ fontSize: 36, color: 'error.main', mb: 2 }} />
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="error.main"
|
||||
sx={{
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.4,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
存储清理功能仅适用于标准网页
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
return <ErrorDisplay error={error} />;
|
||||
}
|
||||
|
||||
// 这里的总大小仅包含以字节计算的项
|
||||
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;
|
||||
}) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
py: 1,
|
||||
px: 1.5,
|
||||
borderRadius: 3,
|
||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
bgcolor: checked ? 'rgba(255, 152, 0, 0.05)' : 'transparent',
|
||||
border: `1px solid ${checked ? 'rgba(255, 152, 0, 0.2)' : 'transparent'}`,
|
||||
'&:hover': {
|
||||
bgcolor: checked ? 'rgba(255, 152, 0, 0.1)' : 'rgba(0, 0, 0, 0.02)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0, mr: 1.5 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={700}
|
||||
color={checked ? storageCleanerPageStyles.warningColor : 'text.primary'}
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
display: 'block',
|
||||
lineHeight: 1.2,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
transition: 'color 0.2s',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
{size !== undefined && size > 0 ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 600,
|
||||
display: 'block',
|
||||
mt: 0.3,
|
||||
lineHeight: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{isCount ? `${size} 个` : formatSize(size)}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'grey.400',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 500,
|
||||
display: 'block',
|
||||
mt: 0.3,
|
||||
lineHeight: 1,
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
无数据
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
color="warning"
|
||||
sx={{
|
||||
p: 0.6,
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: 18,
|
||||
transition: 'transform 0.2s',
|
||||
},
|
||||
'&:hover .MuiSvgIcon-root': {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ bgcolor: '#f5f5f5', minHeight: '100%', pb: 2 }}>
|
||||
<Container sx={{ py: 2 }}>
|
||||
{/* Domain Header */}
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.2,
|
||||
borderRadius: 3,
|
||||
bgcolor: 'rgba(255, 152, 0, 0.1)',
|
||||
color: storageCleanerPageStyles.warningColor,
|
||||
display: 'flex',
|
||||
boxShadow: '0 2px 8px rgba(255, 152, 0, 0.15)',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(255, 152, 0, 0.15)',
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<StorageIcon sx={{ fontSize: 22 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={900}
|
||||
sx={{
|
||||
letterSpacing: '-0.5px',
|
||||
lineHeight: 1.2,
|
||||
fontSize: '1rem',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
存储清理
|
||||
</Typography>
|
||||
{totalSize > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'rgba(255, 152, 0, 0.15)',
|
||||
color: storageCleanerPageStyles.warningColor,
|
||||
px: 1.5,
|
||||
py: 0.3,
|
||||
borderRadius: 2,
|
||||
fontWeight: 800,
|
||||
fontSize: '0.7rem',
|
||||
boxShadow: '0 2px 4px rgba(255, 152, 0, 0.2)',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(255, 152, 0, 0.25)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
已占用 {formatSize(totalSize)}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
display: 'block',
|
||||
maxWidth: 240,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
mt: 0.3,
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{domain || '加载中...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<DomainHeader domain={domain} totalSize={totalSize} />
|
||||
|
||||
{/* Storage Options Grid */}
|
||||
<Box
|
||||
sx={{
|
||||
mb: 3,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
borderRadius: 4,
|
||||
p: 1.2,
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid size={6}>
|
||||
<OptionItem
|
||||
label="LocalStorage"
|
||||
checked={options.localStorage}
|
||||
size={sizes.localStorage}
|
||||
onChange={() => handleOptionChange('localStorage')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<OptionItem
|
||||
label="Session Storage"
|
||||
checked={options.sessionStorage}
|
||||
size={sizes.sessionStorage}
|
||||
onChange={() => handleOptionChange('sessionStorage')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<OptionItem
|
||||
label="IndexedDB"
|
||||
checked={options.indexedDB}
|
||||
size={sizes.indexedDB}
|
||||
onChange={() => handleOptionChange('indexedDB')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<OptionItem
|
||||
label="Cookies"
|
||||
checked={options.cookies}
|
||||
size={sizes.cookies}
|
||||
onChange={() => handleOptionChange('cookies')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<OptionItem
|
||||
label="Cache Storage"
|
||||
checked={options.cacheStorage}
|
||||
size={sizes.cacheStorage}
|
||||
isCount
|
||||
onChange={() => handleOptionChange('cacheStorage')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<OptionItem
|
||||
label="Service Workers"
|
||||
checked={options.serviceWorkers}
|
||||
size={sizes.serviceWorkers}
|
||||
isCount
|
||||
onChange={() => handleOptionChange('serviceWorkers')}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Divider sx={{ my: 1.2, borderColor: 'grey.100' }} />
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
px: 1.5,
|
||||
py: 0.6,
|
||||
bgcolor: 'rgba(0, 0, 0, 0.02)',
|
||||
borderRadius: 2,
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(0, 0, 0, 0.04)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={700}
|
||||
sx={{ color: 'text.secondary', fontSize: '0.7rem' }}
|
||||
>
|
||||
全选所有项
|
||||
</Typography>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={(e) => 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)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<StorageOptionsGrid
|
||||
options={options}
|
||||
sizes={sizes}
|
||||
allSelected={allSelected}
|
||||
someSelected={someSelected}
|
||||
onOptionChange={handleOptionChange}
|
||||
onSelectAll={handleSelectAll}
|
||||
/>
|
||||
|
||||
{/* Auto Refresh Toggle */}
|
||||
<Box
|
||||
sx={{
|
||||
mb: 3,
|
||||
p: 1.5,
|
||||
borderRadius: 4,
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" fontWeight={700} sx={{ fontSize: '0.8rem' }}>
|
||||
清理后自动刷新页面
|
||||
</Typography>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => 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)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<AutoRefreshToggle autoRefresh={autoRefresh} onChange={handleAutoRefreshChange} />
|
||||
|
||||
{/* Primary Action */}
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => setShowConfirm(true)}
|
||||
@@ -650,31 +86,7 @@ export default function StorageCleanerPage() {
|
||||
{loading ? '正在清理...' : '立即清理'}
|
||||
</Button>
|
||||
|
||||
{/* Result & Refresh Secondary Action */}
|
||||
{result && (
|
||||
<Box sx={{ mt: 3, animation: 'fadeIn 0.3s ease-in-out' }}>
|
||||
<Alert
|
||||
severity={result.success ? 'success' : 'error'}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
py: 1,
|
||||
px: 2,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
||||
'& .MuiAlert-message': {
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
'& .MuiAlert-icon': {
|
||||
fontSize: '1.2rem',
|
||||
mr: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
<CleaningResult result={result} />
|
||||
</Container>
|
||||
|
||||
<StorageCleanerConfirm
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import dayjs from '@/utils/dayjs';
|
||||
import {
|
||||
TextField,
|
||||
Select,
|
||||
@@ -7,362 +5,47 @@ import {
|
||||
Stack,
|
||||
Typography,
|
||||
Box,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Container,
|
||||
Fade,
|
||||
Divider,
|
||||
alpha,
|
||||
} from '@mui/material';
|
||||
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import Button from '@/components/Button';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { DATE_FORMAT, ZONES, timestampPageStyles } from '@/config/pageTheme';
|
||||
import type { UnitType, ZoneType } from '@/config/pageTheme';
|
||||
import { ZONES, globalStyles, timestampPageStyles } from '@/config/pageTheme';
|
||||
import LiveClock from './components/LiveClock';
|
||||
import ResultView from './components/ResultView';
|
||||
import { useTimestampConverter } from './hooks/useTimestampConverter';
|
||||
|
||||
// ================= 子组件:实时时钟 (优化交互) =================
|
||||
interface LiveClockProps {
|
||||
unit: UnitType;
|
||||
onUseNow: (val: number) => 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 (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
p: 1.8,
|
||||
mb: 2.5,
|
||||
bgcolor: alpha('#2196f3', 0.04),
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: alpha('#2196f3', 0.1),
|
||||
}}
|
||||
>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
fontWeight: 800,
|
||||
fontSize: '0.6rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
当前时间戳
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
color: 'text.primary',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '1.2rem',
|
||||
letterSpacing: '-0.5px',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{displayVal}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
{/* 胶囊式单位切换器 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
p: 0.4,
|
||||
bgcolor: alpha('#2196f3', 0.08),
|
||||
borderRadius: 2.5,
|
||||
border: '1px solid',
|
||||
borderColor: alpha('#2196f3', 0.1),
|
||||
}}
|
||||
>
|
||||
{(['ms', 's'] as const).map((u) => (
|
||||
<Box
|
||||
key={u}
|
||||
onClick={() => onUnitChange(u)}
|
||||
sx={{
|
||||
px: 1.2,
|
||||
py: 0.35,
|
||||
borderRadius: 2,
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 900,
|
||||
transition: 'all 0.2s',
|
||||
bgcolor: unit === u ? '#fff' : 'transparent',
|
||||
color: unit === u ? 'primary.main' : alpha('#2196f3', 0.4),
|
||||
boxShadow: unit === u ? '0 2px 6px rgba(33, 150, 243, 0.2)' : 'none',
|
||||
}}
|
||||
>
|
||||
{u.toUpperCase()}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{ mx: 0.5, my: 1, borderColor: alpha('#2196f3', 0.1) }}
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<Tooltip title="填充到下方">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleUseNow}
|
||||
sx={{
|
||||
color: timestampPageStyles.primaryColor,
|
||||
bgcolor: '#fff',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
|
||||
'&:hover': { bgcolor: timestampPageStyles.primaryColor, color: '#fff' },
|
||||
}}
|
||||
>
|
||||
<AccessTimeIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<CopyButton
|
||||
text={displayVal}
|
||||
tooltip="复制时间戳"
|
||||
size="small"
|
||||
color={timestampPageStyles.primaryColor}
|
||||
showMessage={showMessage}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
LiveClock.displayName = 'LiveClock';
|
||||
|
||||
// ================= 子组件:多维度结果展示 =================
|
||||
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 (
|
||||
<Fade in={!!result}>
|
||||
<Box sx={{ mt: 3, pt: 2.5, borderTop: '1px solid', borderColor: 'grey.50' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
mb: 1.2,
|
||||
display: 'block',
|
||||
fontWeight: 800,
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
转换结果
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: alpha('#2196f3', 0.05),
|
||||
p: 2,
|
||||
borderRadius: 4,
|
||||
position: 'relative',
|
||||
mb: 2.5,
|
||||
border: '1px solid',
|
||||
borderColor: alpha('#2196f3', 0.1),
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
color: 'primary.main',
|
||||
wordBreak: 'break-all',
|
||||
pr: 4,
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
>
|
||||
{result}
|
||||
</Typography>
|
||||
<CopyButton
|
||||
text={result}
|
||||
tooltip="复制结果"
|
||||
size="small"
|
||||
color={timestampPageStyles.primaryColor}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={1.2}>
|
||||
{[
|
||||
{ label: '相对时间', value: extraInfo?.relative },
|
||||
{ label: 'ISO 8601', value: extraInfo?.iso },
|
||||
{ label: 'UTC 时间', value: extraInfo?.utc },
|
||||
].map((item) => (
|
||||
<Box
|
||||
key={item.label}
|
||||
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: 1 }}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'text.disabled', fontWeight: 700, fontSize: '0.65rem' }}
|
||||
>
|
||||
{item.label}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: 'text.secondary',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.65rem',
|
||||
}}
|
||||
>
|
||||
{item.value}
|
||||
</Typography>
|
||||
{item.value && (
|
||||
<CopyButton
|
||||
text={item.value}
|
||||
tooltip="复制"
|
||||
size="small"
|
||||
color="primary"
|
||||
showMessage={showMessage}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
});
|
||||
|
||||
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<UnitType>('ms');
|
||||
const [zone, setZone] = useState<ZoneType>('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 (
|
||||
<Box sx={{ bgcolor: '#f5f5f5', minHeight: '100%', pb: 3 }}>
|
||||
<Container sx={{ py: 2 }}>
|
||||
<Box sx={{ bgcolor: globalStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
|
||||
<Container sx={{ py: 2, bgcolor: globalStyles.backgroundColor }}>
|
||||
{/* Header with Icon */}
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
borderRadius: 2.5,
|
||||
bgcolor: alpha('#2196f3', 0.1),
|
||||
color: 'primary.main',
|
||||
bgcolor: alpha(timestampPageStyles.primaryColor, 0.1),
|
||||
color: timestampPageStyles.primaryColor,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
@@ -420,11 +103,7 @@ export default function TimestampPage() {
|
||||
{(['ts2dt', 'dt2ts'] as const).map((m) => (
|
||||
<Box
|
||||
key={m}
|
||||
onClick={() => {
|
||||
setMode(m);
|
||||
setError('');
|
||||
setResult('');
|
||||
}}
|
||||
onClick={() => setMode(m)}
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 1,
|
||||
@@ -446,7 +125,7 @@ export default function TimestampPage() {
|
||||
{/* Input Area */}
|
||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||
<TextField
|
||||
placeholder={mode === 'ts2dt' ? '输入时间戳...' : DATE_FORMAT}
|
||||
placeholder={mode === 'ts2dt' ? '输入时间戳...' : 'YYYY-MM-DD HH:mm:ss'}
|
||||
value={mode === 'ts2dt' ? tsInput : dtInput}
|
||||
onChange={(e) => {
|
||||
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() {
|
||||
<Select
|
||||
fullWidth
|
||||
value={zone}
|
||||
onChange={(e) => setZone(e.target.value as ZoneType)}
|
||||
onChange={(e) => setZone(e.target.value as typeof zone)}
|
||||
sx={{ ...timestampPageStyles.INPUT_STYLE, flex: 1 }}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
@@ -533,7 +211,7 @@ export default function TimestampPage() {
|
||||
boxShadow: 'none',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.dark',
|
||||
boxShadow: `0 8px 24px ${alpha('#2196f3', 0.2)}`,
|
||||
boxShadow: `0 8px 24px ${alpha(timestampPageStyles.primaryColor, 0.2)}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Box, Switch, Typography } from '@mui/material';
|
||||
|
||||
interface AutoRefreshToggleProps {
|
||||
autoRefresh: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export default function AutoRefreshToggle({ autoRefresh, onChange }: AutoRefreshToggleProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mb: 3,
|
||||
p: 1.5,
|
||||
borderRadius: 4,
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" fontWeight={700} sx={{ fontSize: '0.8rem' }}>
|
||||
清理后自动刷新页面
|
||||
</Typography>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
color="warning"
|
||||
sx={{
|
||||
'& .MuiSwitch-track': {
|
||||
borderRadius: 20,
|
||||
},
|
||||
'& .MuiSwitch-thumb': {
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
'&:hover .MuiSwitch-thumb': {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Box, Alert } from '@mui/material';
|
||||
import type { CleaningResult } from '@/types/storage';
|
||||
import { formatCleaningResult } from '@/utils/storageCleaner';
|
||||
|
||||
interface CleaningResultProps {
|
||||
result: CleaningResult | null;
|
||||
}
|
||||
|
||||
export default function CleaningResult({ result }: CleaningResultProps) {
|
||||
if (!result) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3, animation: 'fadeIn 0.3s ease-in-out' }}>
|
||||
<Alert
|
||||
severity={result.success ? 'success' : 'error'}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
py: 1,
|
||||
px: 2,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
||||
'& .MuiAlert-message': {
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
'& .MuiAlert-icon': {
|
||||
fontSize: '1.2rem',
|
||||
mr: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import { formatSize } from '@/utils/storageCleaner';
|
||||
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
||||
|
||||
interface DomainHeaderProps {
|
||||
domain: string;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
export default function DomainHeader({ domain, totalSize }: DomainHeaderProps) {
|
||||
return (
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.2,
|
||||
borderRadius: 3,
|
||||
bgcolor: 'rgba(255, 152, 0, 0.1)',
|
||||
color: storageCleanerPageStyles.warningColor,
|
||||
display: 'flex',
|
||||
boxShadow: '0 2px 8px rgba(255, 152, 0, 0.15)',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(255, 152, 0, 0.15)',
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<StorageIcon sx={{ fontSize: 22 }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={900}
|
||||
sx={{
|
||||
letterSpacing: '-0.5px',
|
||||
lineHeight: 1.2,
|
||||
fontSize: '1rem',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
存储清理
|
||||
</Typography>
|
||||
{totalSize > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'rgba(255, 152, 0, 0.15)',
|
||||
color: storageCleanerPageStyles.warningColor,
|
||||
px: 1.5,
|
||||
py: 0.3,
|
||||
borderRadius: 2,
|
||||
fontWeight: 800,
|
||||
fontSize: '0.7rem',
|
||||
boxShadow: '0 2px 4px rgba(255, 152, 0, 0.2)',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(255, 152, 0, 0.25)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
已占用 {formatSize(totalSize)}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
display: 'block',
|
||||
maxWidth: 240,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
mt: 0.3,
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{domain || '加载中...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Box, Container, Typography } from '@mui/material';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export default function ErrorDisplay({ error }: ErrorDisplayProps) {
|
||||
return (
|
||||
<Container
|
||||
sx={{
|
||||
py: 8,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '400px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: '100%', maxWidth: 320 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 4,
|
||||
p: 4,
|
||||
boxShadow: '0 8px 24px rgba(244, 67, 54, 0.15)',
|
||||
border: '1px solid rgba(244, 67, 54, 0.2)',
|
||||
bgcolor: 'rgba(244, 67, 54, 0.05)',
|
||||
}}
|
||||
>
|
||||
<WarningIcon sx={{ fontSize: 36, color: 'error.main', mb: 2 }} />
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="error.main"
|
||||
sx={{
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.4,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
存储清理功能仅适用于标准网页
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Stack, Typography, Box, IconButton, Tooltip, Divider, alpha } from '@mui/material';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { timestampPageStyles } from '@/config/pageTheme';
|
||||
import type { UnitType } from '@/config/pageTheme';
|
||||
|
||||
interface LiveClockProps {
|
||||
unit: UnitType;
|
||||
onUseNow: (val: number) => 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);
|
||||
showMessageRef.current?.('已使用当前时间戳', { severity: 'success' });
|
||||
}, [now, showMessageRef]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
p: 1.8,
|
||||
mb: 2.5,
|
||||
bgcolor: alpha(timestampPageStyles.primaryColor, 0.04),
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
|
||||
}}
|
||||
>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: timestampPageStyles.primaryColor,
|
||||
fontWeight: 800,
|
||||
fontSize: '0.6rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
当前时间戳
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
color: timestampPageStyles.primaryColor,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '1.2rem',
|
||||
letterSpacing: '-0.5px',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{displayVal}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
{/* 胶囊式单位切换器 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
p: 0.4,
|
||||
bgcolor: alpha(timestampPageStyles.primaryColor, 0.08),
|
||||
borderRadius: 2.5,
|
||||
border: '1px solid',
|
||||
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
|
||||
}}
|
||||
>
|
||||
{(['ms', 's'] as const).map((u) => (
|
||||
<Box
|
||||
key={u}
|
||||
onClick={() => onUnitChange(u)}
|
||||
sx={{
|
||||
px: 1.2,
|
||||
py: 0.35,
|
||||
borderRadius: 2,
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 900,
|
||||
transition: 'all 0.2s',
|
||||
bgcolor: unit === u ? '#fff' : 'transparent',
|
||||
color: unit === u ? 'primary.main' : alpha(timestampPageStyles.primaryColor, 0.4),
|
||||
boxShadow: unit === u ? '0 2px 6px rgba(33, 150, 243, 0.2)' : 'none',
|
||||
}}
|
||||
>
|
||||
{u.toUpperCase()}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{ mx: 0.5, my: 1, borderColor: alpha(timestampPageStyles.primaryColor, 0.1) }}
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<Tooltip title="填充到下方">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleUseNow}
|
||||
sx={{
|
||||
color: timestampPageStyles.primaryColor,
|
||||
bgcolor: '#fff',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
|
||||
'&:hover': { bgcolor: timestampPageStyles.primaryColor, color: '#fff' },
|
||||
}}
|
||||
>
|
||||
<AccessTimeIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<CopyButton
|
||||
text={displayVal}
|
||||
tooltip="复制时间戳"
|
||||
size="small"
|
||||
color={timestampPageStyles.primaryColor}
|
||||
showMessage={showMessage}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
LiveClock.displayName = 'LiveClock';
|
||||
|
||||
export default LiveClock;
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Box, Checkbox, Typography } from '@mui/material';
|
||||
import { formatSize } from '@/utils/storageCleaner';
|
||||
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
||||
|
||||
interface OptionItemProps {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
size?: number;
|
||||
isCount?: boolean;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export default function OptionItem({
|
||||
label,
|
||||
checked,
|
||||
size,
|
||||
isCount = false,
|
||||
onChange,
|
||||
}: OptionItemProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
py: 1,
|
||||
px: 1.5,
|
||||
borderRadius: 3,
|
||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
bgcolor: checked ? 'rgba(255, 152, 0, 0.05)' : 'transparent',
|
||||
border: `1px solid ${checked ? 'rgba(255, 152, 0, 0.2)' : 'transparent'}`,
|
||||
'&:hover': {
|
||||
bgcolor: checked ? 'rgba(255, 152, 0, 0.1)' : 'rgba(0, 0, 0, 0.02)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0, mr: 1.5 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={700}
|
||||
color={checked ? storageCleanerPageStyles.warningColor : 'text.primary'}
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
display: 'block',
|
||||
lineHeight: 1.2,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
transition: 'color 0.2s',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
{size !== undefined && size > 0 ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 600,
|
||||
display: 'block',
|
||||
mt: 0.3,
|
||||
lineHeight: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{isCount ? `${size} 个` : formatSize(size)}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'grey.400',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 500,
|
||||
display: 'block',
|
||||
mt: 0.3,
|
||||
lineHeight: 1,
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
无数据
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
color="warning"
|
||||
sx={{
|
||||
p: 0.6,
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: 18,
|
||||
transition: 'transform 0.2s',
|
||||
},
|
||||
'&:hover .MuiSvgIcon-root': {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Typography, Box, Fade, Stack, alpha } from '@mui/material';
|
||||
import dayjs from '@/utils/dayjs';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { DATE_FORMAT, timestampPageStyles } from '@/config/pageTheme';
|
||||
import type { UnitType } from '@/config/pageTheme';
|
||||
|
||||
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 (
|
||||
<Fade in={!!result}>
|
||||
<Box sx={{ mt: 3, pt: 2.5, borderTop: '1px solid', borderColor: 'grey.50' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
mb: 1.2,
|
||||
display: 'block',
|
||||
fontWeight: 800,
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
>
|
||||
转换结果
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: alpha(timestampPageStyles.primaryColor, 0.05),
|
||||
p: 2,
|
||||
borderRadius: 4,
|
||||
position: 'relative',
|
||||
mb: 2.5,
|
||||
border: '1px solid',
|
||||
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 700,
|
||||
color: timestampPageStyles.primaryColor,
|
||||
wordBreak: 'break-all',
|
||||
pr: 4,
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
>
|
||||
{result}
|
||||
</Typography>
|
||||
<CopyButton
|
||||
text={result}
|
||||
tooltip="复制结果"
|
||||
size="small"
|
||||
color={timestampPageStyles.primaryColor}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
showMessage={showMessage}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Stack
|
||||
spacing={1.2}
|
||||
sx={{
|
||||
bgcolor: alpha(timestampPageStyles.primaryColor, 0.05),
|
||||
p: 2,
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: '相对时间', value: extraInfo?.relative },
|
||||
{ label: 'ISO 8601', value: extraInfo?.iso },
|
||||
{ label: 'UTC 时间', value: extraInfo?.utc },
|
||||
].map((item) => (
|
||||
<Box
|
||||
key={item.label}
|
||||
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: 1 }}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'text.disabled', fontWeight: 700, fontSize: '0.65rem' }}
|
||||
>
|
||||
{item.label}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: timestampPageStyles.primaryColor,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.65rem',
|
||||
}}
|
||||
>
|
||||
{item.value}
|
||||
</Typography>
|
||||
{item.value && (
|
||||
<CopyButton
|
||||
text={item.value}
|
||||
tooltip="复制"
|
||||
size="small"
|
||||
color={timestampPageStyles.primaryColor}
|
||||
showMessage={showMessage}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Fade>
|
||||
);
|
||||
});
|
||||
|
||||
ResultView.displayName = 'ResultView';
|
||||
|
||||
export default ResultView;
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Box, Checkbox, Divider, Grid, Typography } from '@mui/material';
|
||||
import type { StorageCleanerOptions } from '@/types/storage';
|
||||
import OptionItem from './OptionItem';
|
||||
|
||||
interface StorageOptionsGridProps {
|
||||
options: StorageCleanerOptions;
|
||||
sizes: Record<string, number>;
|
||||
allSelected: boolean;
|
||||
someSelected: boolean;
|
||||
onOptionChange: (key: keyof StorageCleanerOptions) => void;
|
||||
onSelectAll: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export default function StorageOptionsGrid({
|
||||
options,
|
||||
sizes,
|
||||
allSelected,
|
||||
someSelected,
|
||||
onOptionChange,
|
||||
onSelectAll,
|
||||
}: StorageOptionsGridProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mb: 3,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
borderRadius: 4,
|
||||
p: 1.2,
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid size={6}>
|
||||
<OptionItem
|
||||
label="LocalStorage"
|
||||
checked={options.localStorage}
|
||||
size={sizes.localStorage}
|
||||
onChange={() => onOptionChange('localStorage')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<OptionItem
|
||||
label="Session Storage"
|
||||
checked={options.sessionStorage}
|
||||
size={sizes.sessionStorage}
|
||||
onChange={() => onOptionChange('sessionStorage')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<OptionItem
|
||||
label="IndexedDB"
|
||||
checked={options.indexedDB}
|
||||
size={sizes.indexedDB}
|
||||
onChange={() => onOptionChange('indexedDB')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<OptionItem
|
||||
label="Cookies"
|
||||
checked={options.cookies}
|
||||
size={sizes.cookies}
|
||||
onChange={() => onOptionChange('cookies')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<OptionItem
|
||||
label="Cache Storage"
|
||||
checked={options.cacheStorage}
|
||||
size={sizes.cacheStorage}
|
||||
isCount
|
||||
onChange={() => onOptionChange('cacheStorage')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<OptionItem
|
||||
label="Service Workers"
|
||||
checked={options.serviceWorkers}
|
||||
size={sizes.serviceWorkers}
|
||||
isCount
|
||||
onChange={() => onOptionChange('serviceWorkers')}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Divider sx={{ my: 1.2, borderColor: 'grey.100' }} />
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
px: 1.5,
|
||||
py: 0.6,
|
||||
bgcolor: 'rgba(0, 0, 0, 0.02)',
|
||||
borderRadius: 2,
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(0, 0, 0, 0.04)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={700}
|
||||
sx={{ color: 'text.secondary', fontSize: '0.7rem' }}
|
||||
>
|
||||
全选所有项
|
||||
</Typography>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={(e) => onSelectAll(e.target.checked)}
|
||||
color="warning"
|
||||
sx={{
|
||||
p: 0.6,
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: 18,
|
||||
transition: 'transform 0.2s',
|
||||
},
|
||||
'&:hover .MuiSvgIcon-root': {
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import dayjs from '@/utils/dayjs';
|
||||
import { DATE_FORMAT } from '@/config/pageTheme';
|
||||
import type { UnitType, ZoneType } from '@/config/pageTheme';
|
||||
|
||||
export interface UseTimestampConverterReturn {
|
||||
// State
|
||||
mode: 'ts2dt' | 'dt2ts';
|
||||
tsInput: string;
|
||||
dtInput: string;
|
||||
unit: UnitType;
|
||||
zone: ZoneType;
|
||||
result: string;
|
||||
error: string;
|
||||
|
||||
// Actions
|
||||
setMode: (mode: 'ts2dt' | 'dt2ts') => void;
|
||||
setTsInput: (value: string) => void;
|
||||
setDtInput: (value: string) => void;
|
||||
setUnit: (unit: UnitType) => void;
|
||||
setZone: (zone: ZoneType) => void;
|
||||
handleUseNow: (now: number) => void;
|
||||
convert: () => void;
|
||||
}
|
||||
|
||||
export function useTimestampConverter(): UseTimestampConverterReturn {
|
||||
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
|
||||
const [tsInput, setTsInput] = useState(() => String(Date.now()));
|
||||
const [dtInput, setDtInput] = useState(() => dayjs().format(DATE_FORMAT));
|
||||
const [unit, setUnit] = useState<UnitType>('ms');
|
||||
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
|
||||
const [result, setResult] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const convert = useCallback(() => {
|
||||
if (mode === 'ts2dt') {
|
||||
const rawInput = tsInput.trim();
|
||||
if (!rawInput) return;
|
||||
const num = Number(rawInput);
|
||||
if (isNaN(num)) {
|
||||
setError('无效数字');
|
||||
return;
|
||||
}
|
||||
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
|
||||
if (!d.isValid()) {
|
||||
setError('无效时间戳');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setResult(d.tz(zone).format(DATE_FORMAT));
|
||||
} else {
|
||||
const rawInput = dtInput.trim();
|
||||
if (!rawInput) return;
|
||||
const d = dayjs.tz(rawInput, DATE_FORMAT, zone);
|
||||
if (!d.isValid()) {
|
||||
setError('格式错误');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
const ms = d.valueOf();
|
||||
setResult(unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000)));
|
||||
}
|
||||
}, [mode, tsInput, dtInput, unit, zone]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(convert, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [convert]);
|
||||
|
||||
const handleUseNow = useCallback(
|
||||
(now: number) => {
|
||||
if (mode === 'ts2dt') {
|
||||
setTsInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
|
||||
} else {
|
||||
setDtInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
||||
}
|
||||
},
|
||||
[mode, unit, zone],
|
||||
);
|
||||
|
||||
const handleSetMode = useCallback((newMode: 'ts2dt' | 'dt2ts') => {
|
||||
setMode(newMode);
|
||||
setError('');
|
||||
setResult('');
|
||||
}, []);
|
||||
|
||||
const handleSetTsInput = useCallback((value: string) => {
|
||||
setTsInput(value);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const handleSetDtInput = useCallback((value: string) => {
|
||||
setDtInput(value);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
mode,
|
||||
tsInput,
|
||||
dtInput,
|
||||
unit,
|
||||
zone,
|
||||
result,
|
||||
error,
|
||||
setMode: handleSetMode,
|
||||
setTsInput: handleSetTsInput,
|
||||
setDtInput: handleSetDtInput,
|
||||
setUnit,
|
||||
setZone,
|
||||
handleUseNow,
|
||||
convert,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import type {
|
||||
StorageCleanerOptions,
|
||||
CleaningResult,
|
||||
StorageCleanerPreferences,
|
||||
} from '@/types/storage';
|
||||
import {
|
||||
getCurrentTab,
|
||||
isRestrictedUrl,
|
||||
clearStorage,
|
||||
getCookieSize,
|
||||
getLocalStorageSize,
|
||||
getSessionStorageSize,
|
||||
getIndexedDBSize,
|
||||
getCacheStorageSize,
|
||||
getServiceWorkerCount,
|
||||
} from '@/utils/storageCleaner';
|
||||
|
||||
const DEFAULT_OPTIONS: StorageCleanerOptions = {
|
||||
localStorage: true,
|
||||
sessionStorage: true,
|
||||
indexedDB: true,
|
||||
cookies: true,
|
||||
cacheStorage: true,
|
||||
serviceWorkers: true,
|
||||
};
|
||||
|
||||
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
|
||||
autoRefresh: true,
|
||||
selectedTypes: DEFAULT_OPTIONS,
|
||||
};
|
||||
|
||||
export interface UseStorageCleanerReturn {
|
||||
// State
|
||||
domain: string;
|
||||
error: string;
|
||||
isInitializing: boolean;
|
||||
options: StorageCleanerOptions;
|
||||
sizes: Record<string, number>;
|
||||
autoRefresh: boolean;
|
||||
loading: boolean;
|
||||
result: CleaningResult | null;
|
||||
showConfirm: boolean;
|
||||
setShowConfirm: (show: boolean) => void;
|
||||
|
||||
// Computed
|
||||
totalSize: number;
|
||||
allSelected: boolean;
|
||||
someSelected: boolean;
|
||||
|
||||
// Handlers
|
||||
handleAutoRefreshChange: (checked: boolean) => Promise<void>;
|
||||
handleOptionChange: (key: keyof StorageCleanerOptions) => Promise<void>;
|
||||
handleSelectAll: (checked: boolean) => Promise<void>;
|
||||
handleClean: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useStorageCleaner(): UseStorageCleanerReturn {
|
||||
const [domain, setDomain] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [isInitializing, setIsInitializing] = useState<boolean>(true);
|
||||
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
||||
const [sizes, setSizes] = useState<Record<string, number>>({});
|
||||
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [result, setResult] = useState<CleaningResult | null>(null);
|
||||
const [showConfirm, setShowConfirm] = useState<boolean>(false);
|
||||
const { showMessage } = useSnackbar();
|
||||
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const resultTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const requestIdRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const reloadTimeout = reloadTimeoutRef.current;
|
||||
const resultTimeout = resultTimeoutRef.current;
|
||||
return () => {
|
||||
if (reloadTimeout) clearTimeout(reloadTimeout);
|
||||
if (resultTimeout) clearTimeout(resultTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadInfo = useCallback(async () => {
|
||||
const currentRequestId = ++requestIdRef.current;
|
||||
try {
|
||||
const tab = await getCurrentTab();
|
||||
if (currentRequestId !== requestIdRef.current) return;
|
||||
|
||||
if (!tab || !tab.url) {
|
||||
setError('无法获取当前标签页');
|
||||
return;
|
||||
}
|
||||
if (isRestrictedUrl(tab.url)) {
|
||||
setError('存储清理功能不支持此页面');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
const url = tab.url;
|
||||
const tabId = tab.id!;
|
||||
setDomain(new URL(url).hostname);
|
||||
|
||||
const [savedPrefs, cSize, lsSize, ssSize, idbSize, cacheCount, swCount] = await Promise.all([
|
||||
storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES),
|
||||
getCookieSize(url),
|
||||
getLocalStorageSize(tabId),
|
||||
getSessionStorageSize(tabId),
|
||||
getIndexedDBSize(tabId),
|
||||
getCacheStorageSize(tabId),
|
||||
getServiceWorkerCount(tabId),
|
||||
]);
|
||||
|
||||
if (currentRequestId !== requestIdRef.current) return;
|
||||
|
||||
if (savedPrefs) {
|
||||
setAutoRefresh(savedPrefs.autoRefresh ?? DEFAULT_PREFERENCES.autoRefresh);
|
||||
setOptions(savedPrefs.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes);
|
||||
}
|
||||
|
||||
setSizes({
|
||||
cookies: cSize,
|
||||
localStorage: lsSize,
|
||||
sessionStorage: ssSize,
|
||||
indexedDB: idbSize,
|
||||
cacheStorage: cacheCount,
|
||||
serviceWorkers: swCount,
|
||||
});
|
||||
} finally {
|
||||
if (currentRequestId === requestIdRef.current) {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadInfoRef = useRef(loadInfo);
|
||||
loadInfoRef.current = loadInfo;
|
||||
|
||||
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 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('清理成功,即将刷新页面');
|
||||
// 立即发送刷新消息,并在后台处理延迟(或直接刷新)
|
||||
// 这样即使弹窗关闭,后台也能收到指令
|
||||
chrome.runtime.sendMessage({ action: 'reloadTab', tabId: tab.id, delay: 1000 });
|
||||
} else {
|
||||
loadInfo();
|
||||
}
|
||||
} catch (err) {
|
||||
showMessage(`清理失败: ${String(err)}`, { severity: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowConfirm(false);
|
||||
}
|
||||
}, [options, autoRefresh, showMessage, loadInfo]);
|
||||
|
||||
// Computed values
|
||||
// Note: sizes.indexedDB contains navigator.storage.estimate().usage
|
||||
// which includes IndexedDB, Cache, etc.
|
||||
const totalSize = (sizes.cookies || 0) + (sizes.indexedDB || 0);
|
||||
|
||||
const allSelected = Object.values(options).every(Boolean);
|
||||
const someSelected = Object.values(options).some(Boolean) && !allSelected;
|
||||
|
||||
return {
|
||||
domain,
|
||||
error,
|
||||
isInitializing,
|
||||
options,
|
||||
sizes,
|
||||
autoRefresh,
|
||||
loading,
|
||||
result,
|
||||
showConfirm,
|
||||
setShowConfirm,
|
||||
totalSize,
|
||||
allSelected,
|
||||
someSelected,
|
||||
handleAutoRefreshChange,
|
||||
handleOptionChange,
|
||||
handleSelectAll,
|
||||
handleClean,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 数据模板管理工具
|
||||
* 用于创建、编辑、保存和管理自定义测试数据模板
|
||||
*/
|
||||
|
||||
import { FieldType } from './dummyDataGenerator';
|
||||
|
||||
/**
|
||||
* 模板字段接口
|
||||
*/
|
||||
export interface TemplateField {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
fieldType: FieldType;
|
||||
defaultValue: string;
|
||||
rules: TemplateRule[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板规则接口
|
||||
*/
|
||||
export interface TemplateRule {
|
||||
type: 'required' | 'pattern' | 'minLength' | 'maxLength' | 'min' | 'max' | 'custom';
|
||||
value: string | number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据模板接口
|
||||
*/
|
||||
export interface DataTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
fields: TemplateField[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板存储键名
|
||||
*/
|
||||
const TEMPLATE_STORAGE_KEY = 'dataTemplates';
|
||||
|
||||
/**
|
||||
* 数据模板管理类
|
||||
*/
|
||||
export class DataTemplateManager {
|
||||
/**
|
||||
* 获取所有模板
|
||||
*/
|
||||
static async getAllTemplates(): Promise<DataTemplate[]> {
|
||||
try {
|
||||
const stored = await chrome.storage.local.get(TEMPLATE_STORAGE_KEY);
|
||||
return (stored[TEMPLATE_STORAGE_KEY] as DataTemplate[]) || [];
|
||||
} catch (error) {
|
||||
console.error('获取模板失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存模板
|
||||
*/
|
||||
static async saveTemplate(template: DataTemplate): Promise<boolean> {
|
||||
try {
|
||||
const templates = await this.getAllTemplates();
|
||||
const existingIndex = templates.findIndex((t) => t.id === template.id);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
templates[existingIndex] = {
|
||||
...template,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
} else {
|
||||
templates.push({
|
||||
...template,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
await chrome.storage.local.set({ [TEMPLATE_STORAGE_KEY]: templates });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('保存模板失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模板
|
||||
*/
|
||||
static async deleteTemplate(templateId: string): Promise<boolean> {
|
||||
try {
|
||||
const templates = await this.getAllTemplates();
|
||||
const filtered = templates.filter((t) => t.id !== templateId);
|
||||
await chrome.storage.local.set({ [TEMPLATE_STORAGE_KEY]: filtered });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('删除模板失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出模板
|
||||
*/
|
||||
static exportTemplates(templates: DataTemplate[]): string {
|
||||
return JSON.stringify(templates, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入模板
|
||||
*/
|
||||
static async importTemplates(jsonString: string): Promise<boolean> {
|
||||
try {
|
||||
const importedTemplates = JSON.parse(jsonString) as DataTemplate[];
|
||||
if (!Array.isArray(importedTemplates)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingTemplates = await this.getAllTemplates();
|
||||
const mergedTemplates = [...existingTemplates];
|
||||
|
||||
for (const template of importedTemplates) {
|
||||
const existingIndex = mergedTemplates.findIndex((t) => t.id === template.id);
|
||||
if (existingIndex >= 0) {
|
||||
mergedTemplates[existingIndex] = template;
|
||||
} else {
|
||||
mergedTemplates.push(template);
|
||||
}
|
||||
}
|
||||
|
||||
await chrome.storage.local.set({ [TEMPLATE_STORAGE_KEY]: mergedTemplates });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('导入模板失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一ID
|
||||
*/
|
||||
static generateId(): string {
|
||||
return `template_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建空模板
|
||||
*/
|
||||
static createEmptyTemplate(name: string, description: string = ''): DataTemplate {
|
||||
return {
|
||||
id: this.generateId(),
|
||||
name,
|
||||
description,
|
||||
fields: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 数据验证工具
|
||||
* 用于在数据填充前进行格式验证
|
||||
*/
|
||||
|
||||
import { FieldType } from './dummyDataGenerator';
|
||||
|
||||
/**
|
||||
* 验证结果接口
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据验证工具类
|
||||
*/
|
||||
export class DataValidator {
|
||||
/**
|
||||
* 验证邮箱格式
|
||||
*/
|
||||
private static validateEmail(value: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号格式(中国大陆)
|
||||
*/
|
||||
private static validatePhone(value: string): boolean {
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
return phoneRegex.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证身份证号格式(中国大陆)
|
||||
*/
|
||||
private static validateIdCard(value: string): boolean {
|
||||
const idCardRegex =
|
||||
/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/;
|
||||
return idCardRegex.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证日期格式
|
||||
*/
|
||||
private static validateDate(value: string): boolean {
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(value)) return false;
|
||||
const date = new Date(value);
|
||||
return !isNaN(date.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数字格式
|
||||
*/
|
||||
private static validateNumber(value: string): boolean {
|
||||
return !isNaN(Number(value)) && value.trim() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证字段值
|
||||
*/
|
||||
static validateField(fieldType: FieldType, value: string): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
switch (fieldType) {
|
||||
case FieldType.EMAIL:
|
||||
if (!this.validateEmail(value)) {
|
||||
errors.push('邮箱格式不正确');
|
||||
}
|
||||
break;
|
||||
case FieldType.PHONE:
|
||||
if (!this.validatePhone(value)) {
|
||||
errors.push('手机号格式不正确,应为11位数字');
|
||||
}
|
||||
break;
|
||||
case FieldType.ID_CARD:
|
||||
if (!this.validateIdCard(value)) {
|
||||
errors.push('身份证号格式不正确');
|
||||
}
|
||||
break;
|
||||
case FieldType.DATE:
|
||||
if (!this.validateDate(value)) {
|
||||
errors.push('日期格式不正确,应为YYYY-MM-DD');
|
||||
}
|
||||
break;
|
||||
case FieldType.NUMBER:
|
||||
if (!this.validateNumber(value)) {
|
||||
errors.push('数字格式不正确');
|
||||
}
|
||||
break;
|
||||
case FieldType.NAME:
|
||||
if (value.length < 2 || value.length > 50) {
|
||||
errors.push('姓名长度应在2-50个字符之间');
|
||||
}
|
||||
break;
|
||||
case FieldType.PASSWORD:
|
||||
if (value.length < 6) {
|
||||
errors.push('密码长度不能少于6个字符');
|
||||
}
|
||||
break;
|
||||
case FieldType.TEXT:
|
||||
case FieldType.TEXTarea:
|
||||
if (value.length > 10000) {
|
||||
errors.push('文本长度不能超过10000个字符');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// 未知类型不做验证
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量验证字段
|
||||
*/
|
||||
static validateFields(fields: Array<{ fieldType: FieldType; value: string }>): ValidationResult {
|
||||
const allErrors: string[] = [];
|
||||
|
||||
fields.forEach((field, index) => {
|
||||
const result = this.validateField(field.fieldType, field.value);
|
||||
if (!result.isValid) {
|
||||
allErrors.push(`字段 ${index + 1}: ${result.errors.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: allErrors.length === 0,
|
||||
errors: allErrors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段类型的验证规则描述
|
||||
*/
|
||||
static getValidationRules(fieldType: FieldType): string[] {
|
||||
switch (fieldType) {
|
||||
case FieldType.EMAIL:
|
||||
return ['格式: user@domain.com'];
|
||||
case FieldType.PHONE:
|
||||
return ['格式: 11位中国大陆手机号', '以1开头,第二位为3-9'];
|
||||
case FieldType.ID_CARD:
|
||||
return ['格式: 18位身份证号', '前6位为地区码', '中间8位为生日', '最后1位为校验码'];
|
||||
case FieldType.DATE:
|
||||
return ['格式: YYYY-MM-DD', '例如: 2024-01-01'];
|
||||
case FieldType.NUMBER:
|
||||
return ['格式: 整数或浮点数'];
|
||||
case FieldType.NAME:
|
||||
return ['长度: 2-50个字符'];
|
||||
case FieldType.PASSWORD:
|
||||
return ['长度: 至少6个字符'];
|
||||
case FieldType.TEXT:
|
||||
case FieldType.TEXTarea:
|
||||
return ['长度: 不超过10000个字符'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export class DummyDataGenerator {
|
||||
* 生成有效邮箱
|
||||
*/
|
||||
static generateValidEmail(): string {
|
||||
return faker.internet.email();
|
||||
return fakerZH_CN.internet.email();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,14 +77,14 @@ export class DummyDataGenerator {
|
||||
* 生成短文本
|
||||
*/
|
||||
static generateShortText(): string {
|
||||
return faker.lorem.sentence({ min: 3, max: 6 });
|
||||
return fakerZH_CN.lorem.sentence({ min: 3, max: 6 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成长文本
|
||||
*/
|
||||
static generateLongText(): string {
|
||||
return faker.lorem.paragraphs(5);
|
||||
return fakerZH_CN.lorem.paragraphs(5);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,42 +106,42 @@ export class DummyDataGenerator {
|
||||
* 生成随机数字
|
||||
*/
|
||||
static generateNumber(): number {
|
||||
return faker.number.int(10000);
|
||||
return fakerZH_CN.number.int(10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机浮点数
|
||||
*/
|
||||
static generateFloat(): number {
|
||||
return faker.number.float({ max: 10000 });
|
||||
return fakerZH_CN.number.float({ max: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机负数
|
||||
*/
|
||||
static generateNegativeNumber(): number {
|
||||
return -faker.number.int(10000);
|
||||
return -fakerZH_CN.number.int(10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机日期
|
||||
*/
|
||||
static generateDate(): string {
|
||||
return faker.date.recent({ days: 365 }).toISOString().split('T')[0];
|
||||
return fakerZH_CN.date.recent({ days: 365 }).toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成过去的日期
|
||||
*/
|
||||
static generatePastDate(): string {
|
||||
return faker.date.past({ years: 1 }).toISOString().split('T')[0];
|
||||
return fakerZH_CN.date.past({ years: 1 }).toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成未来的日期
|
||||
*/
|
||||
static generateFutureDate(): string {
|
||||
return faker.date.future({ years: 1 }).toISOString().split('T')[0];
|
||||
return fakerZH_CN.date.future({ years: 1 }).toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+6
-1
@@ -4,6 +4,9 @@ import { FormFieldInfo, FillMode } from './dummyDataGenerator';
|
||||
* 消息动作类型
|
||||
*/
|
||||
export enum MessageAction {
|
||||
// 标签页操作
|
||||
RELOAD_TAB = 'reloadTab',
|
||||
|
||||
// 表单相关操作
|
||||
SCAN_FORM_FIELDS = 'scanFormFields',
|
||||
FILL_VALID_DATA = 'fillValidData',
|
||||
@@ -22,7 +25,9 @@ export enum MessageAction {
|
||||
* 消息载荷接口
|
||||
*/
|
||||
export interface MessagePayload {
|
||||
action: MessageAction;
|
||||
action: MessageAction | string;
|
||||
tabId?: number;
|
||||
delay?: number;
|
||||
fields?: Omit<FormFieldInfo, 'element'>[];
|
||||
mode?: FillMode;
|
||||
includeHidden?: boolean;
|
||||
|
||||
+54
-30
@@ -11,26 +11,26 @@ const RESTRICTED_PROTOCOLS = [
|
||||
] as const;
|
||||
|
||||
export async function getCurrentTab() {
|
||||
// First, try the active tab in the last focused window (works for standard popups and side panels)
|
||||
const [lastFocusedTab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
|
||||
// For popup pages, we need to get the active tab from the browser window that triggered the popup.
|
||||
// We should ONLY care about the currently active tab in the last focused window.
|
||||
// If it's a restricted URL, we return it anyway and let the caller handle the error display.
|
||||
|
||||
// If the tab is valid and NOT an extension page/restricted URL, use it
|
||||
if (lastFocusedTab && !isRestrictedUrl(lastFocusedTab.url)) {
|
||||
return lastFocusedTab;
|
||||
}
|
||||
|
||||
// Fallback: If we're in a standalone extension window (which is focused),
|
||||
// find the active tab in the most recently focused 'normal' browser window.
|
||||
const [normalTab] = await chrome.tabs.query({
|
||||
const [tab] = await chrome.tabs.query({
|
||||
active: true,
|
||||
windowType: 'normal',
|
||||
lastFocusedWindow: true,
|
||||
});
|
||||
if (normalTab) return normalTab;
|
||||
|
||||
// Final fallback: any active normal tab (if multiple windows exist, it returns all active tabs)
|
||||
const normalTabs = await chrome.tabs.query({ active: true, windowType: 'normal' });
|
||||
return normalTabs[0];
|
||||
if (tab) {
|
||||
return tab;
|
||||
}
|
||||
|
||||
// Fallback for cases where lastFocusedWindow might not work as expected (e.g. certain sidepanel scenarios)
|
||||
const [fallbackTab] = await chrome.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
});
|
||||
|
||||
return fallbackTab;
|
||||
}
|
||||
|
||||
export function isRestrictedUrl(url?: string): boolean {
|
||||
@@ -42,7 +42,11 @@ export async function getCookieSize(url: string): Promise<number> {
|
||||
try {
|
||||
const cookies = await chrome.cookies.getAll({ url });
|
||||
// 估算:名称 + 值 + 域名 + 路径 的长度
|
||||
return cookies.reduce((acc, c) => acc + c.name.length + c.value.length + (c.domain?.length || 0) + (c.path?.length || 0), 0);
|
||||
return cookies.reduce(
|
||||
(acc, c) =>
|
||||
acc + c.name.length + c.value.length + (c.domain?.length || 0) + (c.path?.length || 0),
|
||||
0,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to get cookie size:', error);
|
||||
return 0;
|
||||
@@ -74,7 +78,10 @@ export async function getSessionStorageSize(tabId: number): Promise<number> {
|
||||
target: { tabId },
|
||||
func: () => {
|
||||
try {
|
||||
return Object.entries(sessionStorage).reduce((acc, [k, v]) => acc + k.length + v.length, 0);
|
||||
return Object.entries(sessionStorage).reduce(
|
||||
(acc, [k, v]) => acc + k.length + v.length,
|
||||
0,
|
||||
);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
@@ -172,8 +179,10 @@ export async function clearCookies(url: string): Promise<StorageCleanResult> {
|
||||
try {
|
||||
const cookies = await chrome.cookies.getAll({ url });
|
||||
for (const cookie of cookies) {
|
||||
const protocol = cookie.secure ? 'https:' : 'http:';
|
||||
const cookieUrl = `${protocol}//${cookie.domain}${cookie.path}`;
|
||||
await chrome.cookies.remove({
|
||||
url,
|
||||
url: cookieUrl,
|
||||
name: cookie.name,
|
||||
storeId: cookie.storeId,
|
||||
});
|
||||
@@ -233,15 +242,32 @@ export async function injectClearIndexedDB(tabId: number): Promise<StorageCleanR
|
||||
for (const db of databases) {
|
||||
if (db.name) {
|
||||
const dbName = db.name as string;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const deleteReq = indexedDB.deleteDatabase(dbName);
|
||||
deleteReq.onblocked = () => {
|
||||
console.warn('IndexedDB delete blocked:', dbName);
|
||||
};
|
||||
deleteReq.onsuccess = () => resolve();
|
||||
deleteReq.onerror = () => reject();
|
||||
});
|
||||
count++;
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const deleteReq = indexedDB.deleteDatabase(dbName);
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn('IndexedDB delete timeout:', dbName);
|
||||
resolve(); // Timeout, move to next
|
||||
}, 5000);
|
||||
|
||||
deleteReq.onblocked = () => {
|
||||
console.warn('IndexedDB delete blocked:', dbName);
|
||||
clearTimeout(timeout);
|
||||
resolve(); // Blocked, move to next
|
||||
};
|
||||
deleteReq.onsuccess = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
deleteReq.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Failed to delete ${dbName}`));
|
||||
};
|
||||
});
|
||||
count++;
|
||||
} catch (e) {
|
||||
console.error('Delete DB error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { count };
|
||||
@@ -287,9 +313,7 @@ export async function injectClearCacheStorage(tabId: number): Promise<StorageCle
|
||||
}
|
||||
}
|
||||
|
||||
export async function injectUnregisterServiceWorkers(
|
||||
tabId: number,
|
||||
): Promise<StorageCleanResult> {
|
||||
export async function injectUnregisterServiceWorkers(tabId: number): Promise<StorageCleanResult> {
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
|
||||
export const useStorageState = (
|
||||
key: 'qrCode/urlExpanded' | 'qrCode/qrExpanded',
|
||||
defaultValue: boolean,
|
||||
) => {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadState = async () => {
|
||||
try {
|
||||
const savedValue = await storageUtil.get(key, defaultValue);
|
||||
setValue(savedValue ?? defaultValue);
|
||||
} catch (error) {
|
||||
console.error(`加载状态失败 (${key}):`, error);
|
||||
} finally {
|
||||
setIsInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
loadState();
|
||||
}, [key, defaultValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
const saveState = async () => {
|
||||
try {
|
||||
await storageUtil.set(key, value);
|
||||
} catch (error) {
|
||||
console.error(`保存状态失败 (${key}):`, error);
|
||||
}
|
||||
};
|
||||
|
||||
saveState();
|
||||
}, [value, isInitialized, key]);
|
||||
|
||||
return [value, setValue, isInitialized] as const;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import type { OpenUrlPreferences, OpenUrlEntry } from '@/types/storage';
|
||||
|
||||
const DEFAULT_PREFERENCES: OpenUrlPreferences = {
|
||||
entries: [],
|
||||
};
|
||||
|
||||
export const useUrlPreferences = () => {
|
||||
const [entries, setEntries] = useState<OpenUrlEntry[]>(DEFAULT_PREFERENCES.entries);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadPreferences = async () => {
|
||||
try {
|
||||
const saved = await storageUtil.get('openUrl/preferences', DEFAULT_PREFERENCES);
|
||||
if (saved && saved.entries) {
|
||||
setEntries(saved.entries);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load Open Url preferences:', error);
|
||||
} finally {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
};
|
||||
loadPreferences();
|
||||
}, []);
|
||||
|
||||
const savePreferences = useCallback(() => {
|
||||
const preferences: OpenUrlPreferences = { entries };
|
||||
storageUtil.set('openUrl/preferences', preferences).catch((error) => {
|
||||
console.error('Failed to save Open Url preferences:', error);
|
||||
});
|
||||
}, [entries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoaded) return;
|
||||
const timer = setTimeout(() => {
|
||||
savePreferences();
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [entries, isLoaded, savePreferences]);
|
||||
|
||||
return { entries, setEntries, isLoaded };
|
||||
};
|
||||
Reference in New Issue
Block a user