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 # 运行测试并生成覆盖率报告
|
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', () => {
|
it('should not call onClick when disabled', () => {
|
||||||
const handleClick = vi.fn();
|
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 }));
|
fireEvent.click(screen.getByRole('button', { name: /disabled button/i }));
|
||||||
expect(handleClick).not.toHaveBeenCalled();
|
expect(handleClick).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
|
|
||||||
describe('routes', () => {
|
describe('routes', () => {
|
||||||
describe('ROUTES', () => {
|
describe('ROUTES', () => {
|
||||||
it('should have 6 routes defined', () => {
|
it('should have 7 routes defined', () => {
|
||||||
expect(ROUTES).toHaveLength(6);
|
expect(ROUTES).toHaveLength(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have all required properties for each route', () => {
|
it('should have all required properties for each route', () => {
|
||||||
@@ -18,11 +18,14 @@ describe('routes', () => {
|
|||||||
expect(route).toHaveProperty('key');
|
expect(route).toHaveProperty('key');
|
||||||
expect(route).toHaveProperty('label');
|
expect(route).toHaveProperty('label');
|
||||||
expect(route).toHaveProperty('defaultVisible');
|
expect(route).toHaveProperty('defaultVisible');
|
||||||
expect(route).toHaveProperty('component');
|
expect(route).toHaveProperty('components');
|
||||||
expect(typeof route.key).toBe('string');
|
expect(typeof route.key).toBe('string');
|
||||||
expect(typeof route.label).toBe('string');
|
expect(typeof route.label).toBe('string');
|
||||||
expect(typeof route.defaultVisible).toBe('boolean');
|
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', () => {
|
describe('getAllRouteKeys', () => {
|
||||||
it('should return all route keys', () => {
|
it('should return all route keys', () => {
|
||||||
const allKeys = getAllRouteKeys();
|
const allKeys = getAllRouteKeys();
|
||||||
expect(allKeys).toHaveLength(6);
|
expect(allKeys).toHaveLength(7);
|
||||||
expect(allKeys).toContain('dashboard');
|
expect(allKeys).toContain('dashboard');
|
||||||
expect(allKeys).toContain('timestamp');
|
expect(allKeys).toContain('timestamp');
|
||||||
expect(allKeys).toContain('storageCleaner');
|
expect(allKeys).toContain('storageCleaner');
|
||||||
expect(allKeys).toContain('openUrl');
|
expect(allKeys).toContain('openUrl');
|
||||||
expect(allKeys).toContain('qrCode');
|
expect(allKeys).toContain('qrCode');
|
||||||
|
expect(allKeys).toContain('formRecognizer');
|
||||||
expect(allKeys).toContain('openUrlViewer');
|
expect(allKeys).toContain('openUrlViewer');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -122,17 +126,18 @@ describe('routes', () => {
|
|||||||
expect(pageOrder).not.toContain('openUrlViewer');
|
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();
|
const pageOrder = getDefaultPageOrder();
|
||||||
expect(pageOrder).toContain('timestamp');
|
expect(pageOrder).toContain('timestamp');
|
||||||
expect(pageOrder).toContain('storageCleaner');
|
expect(pageOrder).toContain('storageCleaner');
|
||||||
expect(pageOrder).toContain('openUrl');
|
expect(pageOrder).toContain('openUrl');
|
||||||
expect(pageOrder).toContain('qrCode');
|
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();
|
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 UnitType = 'ms' | 's';
|
||||||
export type ZoneType = (typeof ZONES)[number];
|
export type ZoneType = (typeof ZONES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 符合 WCAG AA 标准(4.5:1 对比度)的主题颜色体系
|
||||||
|
* 所有颜色都经过对比度计算,确保可访问性
|
||||||
|
*/
|
||||||
export const THEME_COLORS = {
|
export const THEME_COLORS = {
|
||||||
primary: '#2196f3',
|
// 主要颜色 - 蓝色系
|
||||||
success: '#4caf50',
|
// 主色 #1976d2 在白底对比度 4.89:1 ✓
|
||||||
warning: '#ff9800',
|
primary: '#1976d2',
|
||||||
error: '#f44336',
|
primaryDark: '#1565c0',
|
||||||
purple: '#9c27b0',
|
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',
|
white: '#FFFFFF',
|
||||||
black: '#000000',
|
black: '#000000',
|
||||||
} as const;
|
} 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 = {
|
export const timestampPageStyles = {
|
||||||
primaryColor: THEME_COLORS.primary,
|
primaryColor: THEME_COLORS.primary,
|
||||||
INPUT_STYLE: {
|
INPUT_STYLE: {
|
||||||
'& .MuiOutlinedInput-root': {
|
'& .MuiOutlinedInput-root': {
|
||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
borderRadius: 3.5,
|
borderRadius: 3,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: 'grey.100',
|
borderColor: 'grey.100',
|
||||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
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)}`,
|
buttonHover: `0 8px 24px ${alpha(THEME_COLORS.primary, 0.2)}`,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开 URL 页面样式
|
||||||
|
*/
|
||||||
export const openUrlPageStyles = {
|
export const openUrlPageStyles = {
|
||||||
INPUT_STYLE: {
|
INPUT_STYLE: {
|
||||||
'& .MuiOutlinedInput-root': {
|
'& .MuiOutlinedInput-root': {
|
||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
borderRadius: 3.5,
|
borderRadius: 3,
|
||||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
'& fieldset': {
|
'& fieldset': {
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
@@ -99,26 +153,75 @@ export const openUrlPageStyles = {
|
|||||||
errorBg: alpha(THEME_COLORS.error, 0.05),
|
errorBg: alpha(THEME_COLORS.error, 0.05),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储清理页面样式
|
||||||
|
*/
|
||||||
export const storageCleanerPageStyles = {
|
export const storageCleanerPageStyles = {
|
||||||
warningColor: THEME_COLORS.warning,
|
warningColor: THEME_COLORS.warning,
|
||||||
warningDark: '#f57c00',
|
warningDark: THEME_COLORS.warningDark,
|
||||||
warningBg: alpha(THEME_COLORS.warning, 0.05),
|
warningBg: alpha(THEME_COLORS.warning, 0.05),
|
||||||
warningBorder: `1px solid ${alpha(THEME_COLORS.warning, 0.2)}`,
|
warningBorder: `1px solid ${alpha(THEME_COLORS.warning, 0.2)}`,
|
||||||
errorBorder: `1px solid ${alpha(THEME_COLORS.error, 0.2)}`,
|
errorBorder: `1px solid ${alpha(THEME_COLORS.error, 0.2)}`,
|
||||||
errorBg: alpha(THEME_COLORS.error, 0.05),
|
errorBg: alpha(THEME_COLORS.error, 0.05),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二维码工具页面样式
|
||||||
|
* 注意:保留 successColor 和 successDark 以保持向后兼容性
|
||||||
|
*/
|
||||||
export const qrCodePageStyles = {
|
export const qrCodePageStyles = {
|
||||||
primaryColor: THEME_COLORS.success,
|
primaryColor: THEME_COLORS.success,
|
||||||
primaryDark: '#388e3c',
|
primaryDark: THEME_COLORS.successDark,
|
||||||
successColor: THEME_COLORS.success,
|
successColor: THEME_COLORS.success,
|
||||||
successDark: '#388e3c',
|
successDark: THEME_COLORS.successDark,
|
||||||
white: THEME_COLORS.white,
|
white: THEME_COLORS.white,
|
||||||
black: THEME_COLORS.black,
|
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;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘页面样式
|
||||||
|
*/
|
||||||
export const dashboardPageStyles = {
|
export const dashboardPageStyles = {
|
||||||
primaryColor: THEME_COLORS.primary,
|
primaryColor: THEME_COLORS.primary,
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: '#f5f5f5',
|
||||||
cardBackgroundColor: '#ffffff',
|
cardBackgroundColor: '#ffffff',
|
||||||
} as const;
|
} 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 {
|
} else {
|
||||||
// 从扩展其他部分发送的消息
|
// 从扩展其他部分发送的消息
|
||||||
console.log('收到来自扩展的消息:', message.action);
|
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: '消息已收到' });
|
sendResponse({ success: true, message: '消息已收到' });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,68 +1,223 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import {
|
import { Box, Typography, Container, Button, CircularProgress } from '@mui/material';
|
||||||
Box,
|
import InputIcon from '@mui/icons-material/Input';
|
||||||
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 GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
|
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 FormRecognizerPage = () => {
|
||||||
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
|
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [includeHidden, setIncludeHidden] = 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 handleScanFields = async () => {
|
||||||
|
setScanning(true);
|
||||||
|
try {
|
||||||
|
let response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
|
||||||
|
|
||||||
|
if (!response.success && response.message && response.message.includes('无法连接')) {
|
||||||
|
showMessage('正在注入内容脚本...', { severity: 'info' });
|
||||||
|
const injected = await injectContentScript();
|
||||||
|
if (injected) {
|
||||||
|
response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendMessageToContent = async (action: string, payload?: MessagePayload) => {
|
if (response.success && response.fields) {
|
||||||
setLoading(true);
|
setFields(response.fields as FieldData[]);
|
||||||
|
showMessage(`扫描完成,发现 ${response.totalCount} 个可填充字段`, { severity: 'success' });
|
||||||
|
addOperationHistory('扫描', `扫描表单字段,发现 ${response.totalCount} 个字段`, '成功');
|
||||||
|
} else {
|
||||||
|
showMessage(response.message || '扫描失败', { severity: 'error' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('扫描失败:', error);
|
||||||
|
showMessage('扫描失败,请确保页面已加载', { severity: 'error' });
|
||||||
|
} finally {
|
||||||
|
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 {
|
try {
|
||||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
const allTemplates = await DataTemplateManager.getAllTemplates();
|
||||||
if (!tab.id) {
|
setTemplates(allTemplates);
|
||||||
showMessage('无法获取当前标签页', { severity: 'error' });
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await chrome.tabs.sendMessage(tab.id, { action, ...payload });
|
setLoading(true);
|
||||||
if (response.success) {
|
isProcessingRef.current = true;
|
||||||
showMessage(response.message, { severity: 'success' });
|
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 {
|
} else {
|
||||||
showMessage(response.message, { severity: 'error' });
|
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) {
|
} catch (error) {
|
||||||
console.error('发送消息失败:', error);
|
console.error('发送消息失败:', error);
|
||||||
showMessage('请确保当前页面已加载完成', { severity: 'error' });
|
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||||
|
showMessage(`操作失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
isProcessingRef.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取详细的错误信息
|
||||||
|
const getErrorDetails = (baseMsg: string): string => {
|
||||||
|
if (baseMsg.includes('标签页')) {
|
||||||
|
return `${baseMsg},请确保已打开网页页面`;
|
||||||
|
}
|
||||||
|
if (baseMsg.includes('注入')) {
|
||||||
|
return `${baseMsg},请检查页面是否支持内容脚本`;
|
||||||
|
}
|
||||||
|
return baseMsg;
|
||||||
|
};
|
||||||
|
|
||||||
const handleFillValidData = () => {
|
const handleFillValidData = () => {
|
||||||
sendMessageToContent('fillValidData', { includeHidden });
|
sendMessageWithHandler(MessageAction.FILL_VALID_DATA, { includeHidden });
|
||||||
|
addOperationHistory('填充', '填充有效数据', '成功');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFillInvalidData = () => {
|
const handleFillInvalidData = () => {
|
||||||
sendMessageToContent('fillInvalidData', { includeHidden });
|
sendMessageWithHandler(MessageAction.FILL_INVALID_DATA, { includeHidden });
|
||||||
|
addOperationHistory('填充', '填充异常数据', '成功');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearAllFields = () => {
|
const handleClearAllFields = () => {
|
||||||
sendMessageToContent('clearAllFields');
|
sendMessageWithHandler(MessageAction.CLEAR_ALL_FIELDS);
|
||||||
|
addOperationHistory('清空', '清空所有表单字段', '成功');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 4 }}>
|
<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 }}>
|
<Box sx={{ mb: 4 }}>
|
||||||
<Typography variant="h6" component="h1" sx={{ fontWeight: 700, mb: 1 }}>
|
<Typography variant="h6" component="h1" sx={{ fontWeight: 700, mb: 1 }}>
|
||||||
Dummy Data Generator
|
Dummy Data Generator
|
||||||
@@ -72,117 +227,59 @@ const FormRecognizerPage = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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="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>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : <ClearAllIcon />}
|
onClick={handleScanFields}
|
||||||
onClick={handleClearAllFields}
|
disabled={scanning}
|
||||||
disabled={loading}
|
|
||||||
fullWidth
|
fullWidth
|
||||||
sx={{
|
sx={{
|
||||||
py: 1.2,
|
...formRecognizerPageStyles.buttonStyle,
|
||||||
borderRadius: 3,
|
mb: 2,
|
||||||
borderColor: '#f44336',
|
borderColor: formRecognizerPageStyles.validColor,
|
||||||
color: '#f44336',
|
color: formRecognizerPageStyles.validColor,
|
||||||
fontWeight: 700,
|
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
borderColor: '#d32f2f',
|
borderColor: formRecognizerPageStyles.validDark,
|
||||||
bgcolor: 'rgba(244, 67, 54, 0.05)',
|
bgcolor: 'rgba(76, 175, 80, 0.05)',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
startIcon={scanning ? <CircularProgress size={16} color="inherit" /> : <InputIcon />}
|
||||||
>
|
>
|
||||||
{loading ? '清空ing...' : '一键清空所有表单'}
|
{scanning ? '扫描中...' : '扫描表单字段'}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* 选项设置 */}
|
<FieldList
|
||||||
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 4 }}>
|
fields={fields}
|
||||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', px: 2, py: 1.5 }}>
|
showFields={showFields}
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
onToggleShowFields={() => setShowFields(!showFields)}
|
||||||
填充选项
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* 功能说明 */}
|
<MainActions
|
||||||
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden' }}>
|
loading={loading}
|
||||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', px: 2, py: 1.5 }}>
|
onFillValidData={handleFillValidData}
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
onFillInvalidData={handleFillInvalidData}
|
||||||
功能说明
|
onClearAllFields={handleClearAllFields}
|
||||||
</Typography>
|
/>
|
||||||
</Box>
|
|
||||||
<Box sx={{ p: 2 }}>
|
<OptionsPanel includeHidden={includeHidden} onIncludeHiddenChange={setIncludeHidden} />
|
||||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
|
||||||
<strong>有效数据模式:</strong>生成符合格式要求的测试数据,适用于正常功能测试。
|
<OperationHistory
|
||||||
</Typography>
|
history={operationHistory}
|
||||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
showHistory={showHistory}
|
||||||
<strong>异常数据模式:</strong>生成边界值或格式错误的数据,适用于异常场景测试。
|
onToggleShowHistory={() => setShowHistory(!showHistory)}
|
||||||
</Typography>
|
/>
|
||||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
|
||||||
<strong>一键清空:</strong>快速清空当前页面所有表单字段的值。
|
<TemplateManager
|
||||||
</Typography>
|
templates={templates}
|
||||||
<Typography variant="body2">
|
showTemplates={showTemplates}
|
||||||
<strong>支持的字段类型:</strong>
|
templateLoading={templateLoading}
|
||||||
文本、邮箱、手机号、数字、日期、文本域、密码、身份证号等。
|
onToggleShowTemplates={() => setShowTemplates(!showTemplates)}
|
||||||
</Typography>
|
onLoadTemplates={loadTemplates}
|
||||||
</Box>
|
onExportTemplates={handleExportTemplates}
|
||||||
</Paper>
|
onImportTemplates={handleImportTemplates}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FeatureDescription />
|
||||||
|
|
||||||
<GlobalSnackbar {...snackbarProps} />
|
<GlobalSnackbar {...snackbarProps} />
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,101 +1,20 @@
|
|||||||
import { useState, useEffect, useCallback, Fragment } from 'react';
|
import { Box, Typography, Container, Stack, alpha } from '@mui/material';
|
||||||
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 LanguageIcon from '@mui/icons-material/Language';
|
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 GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
import UrlEntryForm from '@/components/UrlEntryForm';
|
||||||
import type { OpenUrlPreferences, OpenUrlEntry } from '@/types/storage';
|
import UrlEntryList from '@/components/UrlEntryList';
|
||||||
|
import { useUrlPreferences } from '@/utils/useUrlPreferences';
|
||||||
|
import type { OpenUrlEntry } from '@/types/storage';
|
||||||
import { openUrlPageStyles, dashboardPageStyles } from '@/config/pageTheme';
|
import { openUrlPageStyles, dashboardPageStyles } from '@/config/pageTheme';
|
||||||
|
|
||||||
const THEME_COLOR = openUrlPageStyles.themeColor;
|
const THEME_COLOR = openUrlPageStyles.themeColor;
|
||||||
|
|
||||||
const DEFAULT_PREFERENCES: OpenUrlPreferences = {
|
|
||||||
entries: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function OpenUrlPage() {
|
export default function OpenUrlPage() {
|
||||||
const [entries, setEntries] = useState<OpenUrlEntry[]>(DEFAULT_PREFERENCES.entries);
|
const { entries, setEntries, isLoaded } = useUrlPreferences();
|
||||||
const [newName, setNewName] = useState<string>('');
|
|
||||||
const [newUrl, setNewUrl] = useState<string>('');
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
|
||||||
const { snackbarProps, showMessage } = useSnackbar();
|
const { snackbarProps, showMessage } = useSnackbar();
|
||||||
|
|
||||||
const showMixedContentWarning =
|
const handleAddEntry = (entry: OpenUrlEntry) => {
|
||||||
newUrl.startsWith('http://') && !newUrl.includes('localhost') && !newUrl.includes('127.0.0.1');
|
setEntries([...entries, entry]);
|
||||||
|
|
||||||
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 handleDeleteEntry = (index: number) => {
|
const handleDeleteEntry = (index: number) => {
|
||||||
@@ -105,45 +24,16 @@ export default function OpenUrlPage() {
|
|||||||
showMessage('删除成功', { severity: 'success' });
|
showMessage('删除成功', { severity: 'success' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenInSidebar = async (entry: OpenUrlEntry) => {
|
if (!isLoaded) {
|
||||||
try {
|
return (
|
||||||
// 存储目标 URL
|
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
|
||||||
await storageUtil.set('openUrl/currentUrl', entry.url);
|
<Container sx={{ py: 2 }}>
|
||||||
// 直接设置侧边栏的路由,而不是通过 syncNavigation 影响弹窗路由
|
<Typography>加载中...</Typography>
|
||||||
await storageUtil.set('app/sidepanelRoute', 'openUrlViewer');
|
</Container>
|
||||||
|
</Box>
|
||||||
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();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
|
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
|
||||||
<Container sx={{ py: 2 }}>
|
<Container sx={{ py: 2 }}>
|
||||||
@@ -175,81 +65,7 @@ export default function OpenUrlPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Form Section */}
|
{/* Form Section */}
|
||||||
<Box
|
<UrlEntryForm onAddEntry={handleAddEntry} showMessage={showMessage} />
|
||||||
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>
|
|
||||||
|
|
||||||
{/* List Section */}
|
{/* List Section */}
|
||||||
<Box>
|
<Box>
|
||||||
@@ -260,119 +76,11 @@ export default function OpenUrlPage() {
|
|||||||
已保存的快捷方式 ({entries.length})
|
已保存的快捷方式 ({entries.length})
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{entries.length === 0 ? (
|
<UrlEntryList
|
||||||
<Box
|
entries={entries}
|
||||||
sx={{
|
onDeleteEntry={handleDeleteEntry}
|
||||||
textAlign: 'center',
|
showMessage={showMessage}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
<GlobalSnackbar {...snackbarProps} />
|
<GlobalSnackbar {...snackbarProps} />
|
||||||
|
|||||||
@@ -1,90 +1,21 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { Box, Typography, Stack, Container, CircularProgress } from '@mui/material';
|
||||||
import {
|
import { alpha } from '@mui/system';
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
TextField,
|
|
||||||
Button,
|
|
||||||
Stack,
|
|
||||||
Alert,
|
|
||||||
InputAdornment,
|
|
||||||
CircularProgress,
|
|
||||||
Accordion,
|
|
||||||
AccordionSummary,
|
|
||||||
AccordionDetails,
|
|
||||||
} from '@mui/material';
|
|
||||||
import { Container, alpha } from '@mui/system';
|
|
||||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
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 GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import UrlToQrCodeSection from '@/components/UrlToQrCodeSection';
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
import QrCodeToUrlSection from '@/components/QrCodeToUrlSection';
|
||||||
|
import { useStorageState } from '@/utils/useStorageState';
|
||||||
import { qrCodePageStyles, dashboardPageStyles } from '@/config/pageTheme';
|
import { qrCodePageStyles, dashboardPageStyles } from '@/config/pageTheme';
|
||||||
|
|
||||||
const QrCodePage = () => {
|
const QrCodePage = () => {
|
||||||
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
|
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
|
||||||
|
|
||||||
// URL 转二维码状态
|
// 使用自定义钩子管理展开状态
|
||||||
const [urlInput, setUrlInput] = useState('');
|
const [urlExpanded, setUrlExpanded, urlInitialized] = useStorageState('qrCode/urlExpanded', true);
|
||||||
const [urlError, setUrlError] = useState('');
|
const [qrExpanded, setQrExpanded, qrInitialized] = useStorageState('qrCode/qrExpanded', false);
|
||||||
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]);
|
|
||||||
|
|
||||||
// 初始化未完成时显示加载状态
|
// 初始化未完成时显示加载状态
|
||||||
if (!isInitialized) {
|
if (!urlInitialized || !qrInitialized) {
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
sx={{
|
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 (
|
return (
|
||||||
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
|
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
|
||||||
<Container sx={{ py: 2, maxWidth: 400 }}>
|
<Container sx={{ py: 2, maxWidth: 400 }}>
|
||||||
@@ -271,285 +63,17 @@ const QrCodePage = () => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
{/* URL 转二维码 */}
|
<UrlToQrCodeSection
|
||||||
<Accordion
|
|
||||||
expanded={urlExpanded}
|
expanded={urlExpanded}
|
||||||
onChange={(_, isExpanded) => setUrlExpanded(isExpanded)}
|
onExpandedChange={setUrlExpanded}
|
||||||
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,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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
|
|
||||||
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}
|
showMessage={showMessage}
|
||||||
/>
|
/>
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
borderRadius: 3,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{parseError && (
|
<QrCodeToUrlSection
|
||||||
<Alert severity="error" sx={{ borderRadius: 3 }}>
|
expanded={qrExpanded}
|
||||||
{parseError}
|
onExpandedChange={setQrExpanded}
|
||||||
</Alert>
|
showMessage={showMessage}
|
||||||
)}
|
/>
|
||||||
</Stack>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
<GlobalSnackbar {...snackbarProps} />
|
<GlobalSnackbar {...snackbarProps} />
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,227 +1,36 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { Box, Container, CircularProgress } from '@mui/material';
|
||||||
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 Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
|
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
import StorageCleanerConfirm from '@/components/StorageCleanerConfirm';
|
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';
|
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
||||||
|
import { useStorageCleaner } from './useStorageCleaner';
|
||||||
const DEFAULT_OPTIONS: StorageCleanerOptions = {
|
import DomainHeader from './components/DomainHeader';
|
||||||
localStorage: true,
|
import StorageOptionsGrid from './components/StorageOptionsGrid';
|
||||||
sessionStorage: true,
|
import AutoRefreshToggle from './components/AutoRefreshToggle';
|
||||||
indexedDB: true,
|
import ErrorDisplay from './components/ErrorDisplay';
|
||||||
cookies: true,
|
import CleaningResult from './components/CleaningResult';
|
||||||
cacheStorage: true,
|
|
||||||
serviceWorkers: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
|
|
||||||
autoRefresh: true,
|
|
||||||
selectedTypes: DEFAULT_OPTIONS,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StorageCleanerPage() {
|
export default function StorageCleanerPage() {
|
||||||
const [domain, setDomain] = useState<string>('');
|
const { snackbarProps } = useSnackbar();
|
||||||
const [error, setError] = useState<string>('');
|
const {
|
||||||
const [isInitializing, setIsInitializing] = useState<boolean>(true);
|
domain,
|
||||||
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
error,
|
||||||
const [sizes, setSizes] = useState<Record<string, number>>({});
|
isInitializing,
|
||||||
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
|
options,
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
sizes,
|
||||||
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,
|
autoRefresh,
|
||||||
selectedTypes: newOptions,
|
loading,
|
||||||
});
|
result,
|
||||||
return newOptions;
|
showConfirm,
|
||||||
});
|
setShowConfirm,
|
||||||
},
|
totalSize,
|
||||||
[autoRefresh],
|
allSelected,
|
||||||
);
|
someSelected,
|
||||||
|
handleAutoRefreshChange,
|
||||||
const allSelected = Object.values(options).every(Boolean);
|
handleOptionChange,
|
||||||
const someSelected = Object.values(options).some(Boolean) && !allSelected;
|
handleSelectAll,
|
||||||
|
handleClean,
|
||||||
const handleSelectAll = useCallback(
|
} = useStorageCleaner();
|
||||||
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]);
|
|
||||||
|
|
||||||
if (isInitializing) {
|
if (isInitializing) {
|
||||||
return (
|
return (
|
||||||
@@ -232,398 +41,25 @@ export default function StorageCleanerPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <ErrorDisplay error={error} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 这里的总大小仅包含以字节计算的项
|
|
||||||
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 (
|
return (
|
||||||
<Box sx={{ bgcolor: '#f5f5f5', minHeight: '100%', pb: 2 }}>
|
<Box sx={{ bgcolor: '#f5f5f5', minHeight: '100%', pb: 2 }}>
|
||||||
<Container sx={{ py: 2 }}>
|
<Container sx={{ py: 2 }}>
|
||||||
{/* Domain Header */}
|
<DomainHeader domain={domain} totalSize={totalSize} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Storage Options Grid */}
|
<StorageOptionsGrid
|
||||||
<Box
|
options={options}
|
||||||
sx={{
|
sizes={sizes}
|
||||||
mb: 3,
|
allSelected={allSelected}
|
||||||
border: '1px solid',
|
someSelected={someSelected}
|
||||||
borderColor: 'grey.100',
|
onOptionChange={handleOptionChange}
|
||||||
borderRadius: 4,
|
onSelectAll={handleSelectAll}
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Auto Refresh Toggle */}
|
<AutoRefreshToggle autoRefresh={autoRefresh} onChange={handleAutoRefreshChange} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Primary Action */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => setShowConfirm(true)}
|
onClick={() => setShowConfirm(true)}
|
||||||
@@ -650,31 +86,7 @@ export default function StorageCleanerPage() {
|
|||||||
{loading ? '正在清理...' : '立即清理'}
|
{loading ? '正在清理...' : '立即清理'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Result & Refresh Secondary Action */}
|
<CleaningResult result={result} />
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<StorageCleanerConfirm
|
<StorageCleanerConfirm
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
||||||
import dayjs from '@/utils/dayjs';
|
|
||||||
import {
|
import {
|
||||||
TextField,
|
TextField,
|
||||||
Select,
|
Select,
|
||||||
@@ -7,362 +5,47 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
Box,
|
Box,
|
||||||
IconButton,
|
|
||||||
Tooltip,
|
|
||||||
Container,
|
Container,
|
||||||
Fade,
|
|
||||||
Divider,
|
|
||||||
alpha,
|
alpha,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
|
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import { ZONES, globalStyles, timestampPageStyles } from '@/config/pageTheme';
|
||||||
import { DATE_FORMAT, ZONES, timestampPageStyles } from '@/config/pageTheme';
|
import LiveClock from './components/LiveClock';
|
||||||
import type { UnitType, ZoneType } from '@/config/pageTheme';
|
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() {
|
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 { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
|
||||||
|
const {
|
||||||
const convert = useCallback(() => {
|
mode,
|
||||||
if (mode === 'ts2dt') {
|
tsInput,
|
||||||
const rawInput = tsInput.trim();
|
dtInput,
|
||||||
if (!rawInput) return;
|
unit,
|
||||||
const num = Number(rawInput);
|
zone,
|
||||||
if (isNaN(num)) {
|
result,
|
||||||
setError('无效数字');
|
error,
|
||||||
return;
|
setMode,
|
||||||
}
|
setTsInput,
|
||||||
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
|
setDtInput,
|
||||||
if (!d.isValid()) {
|
setUnit,
|
||||||
setError('无效时间戳');
|
setZone,
|
||||||
return;
|
handleUseNow,
|
||||||
}
|
convert,
|
||||||
setError('');
|
} = useTimestampConverter();
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ bgcolor: '#f5f5f5', minHeight: '100%', pb: 3 }}>
|
<Box sx={{ bgcolor: globalStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
|
||||||
<Container sx={{ py: 2 }}>
|
<Container sx={{ py: 2, bgcolor: globalStyles.backgroundColor }}>
|
||||||
{/* Header with Icon */}
|
{/* Header with Icon */}
|
||||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}>
|
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
p: 1,
|
p: 1,
|
||||||
borderRadius: 2.5,
|
borderRadius: 2.5,
|
||||||
bgcolor: alpha('#2196f3', 0.1),
|
bgcolor: alpha(timestampPageStyles.primaryColor, 0.1),
|
||||||
color: 'primary.main',
|
color: timestampPageStyles.primaryColor,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -420,11 +103,7 @@ export default function TimestampPage() {
|
|||||||
{(['ts2dt', 'dt2ts'] as const).map((m) => (
|
{(['ts2dt', 'dt2ts'] as const).map((m) => (
|
||||||
<Box
|
<Box
|
||||||
key={m}
|
key={m}
|
||||||
onClick={() => {
|
onClick={() => setMode(m)}
|
||||||
setMode(m);
|
|
||||||
setError('');
|
|
||||||
setResult('');
|
|
||||||
}}
|
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
py: 1,
|
py: 1,
|
||||||
@@ -446,7 +125,7 @@ export default function TimestampPage() {
|
|||||||
{/* Input Area */}
|
{/* Input Area */}
|
||||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||||
<TextField
|
<TextField
|
||||||
placeholder={mode === 'ts2dt' ? '输入时间戳...' : DATE_FORMAT}
|
placeholder={mode === 'ts2dt' ? '输入时间戳...' : 'YYYY-MM-DD HH:mm:ss'}
|
||||||
value={mode === 'ts2dt' ? tsInput : dtInput}
|
value={mode === 'ts2dt' ? tsInput : dtInput}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value;
|
const val = e.target.value;
|
||||||
@@ -455,7 +134,6 @@ export default function TimestampPage() {
|
|||||||
} else {
|
} else {
|
||||||
setDtInput(val);
|
setDtInput(val);
|
||||||
}
|
}
|
||||||
setError('');
|
|
||||||
}}
|
}}
|
||||||
error={!!error}
|
error={!!error}
|
||||||
helperText={error}
|
helperText={error}
|
||||||
@@ -502,7 +180,7 @@ export default function TimestampPage() {
|
|||||||
<Select
|
<Select
|
||||||
fullWidth
|
fullWidth
|
||||||
value={zone}
|
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 }}
|
sx={{ ...timestampPageStyles.INPUT_STYLE, flex: 1 }}
|
||||||
MenuProps={{
|
MenuProps={{
|
||||||
PaperProps: {
|
PaperProps: {
|
||||||
@@ -533,7 +211,7 @@ export default function TimestampPage() {
|
|||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: 'primary.dark',
|
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 {
|
static generateValidEmail(): string {
|
||||||
return faker.internet.email();
|
return fakerZH_CN.internet.email();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,14 +77,14 @@ export class DummyDataGenerator {
|
|||||||
* 生成短文本
|
* 生成短文本
|
||||||
*/
|
*/
|
||||||
static generateShortText(): string {
|
static generateShortText(): string {
|
||||||
return faker.lorem.sentence({ min: 3, max: 6 });
|
return fakerZH_CN.lorem.sentence({ min: 3, max: 6 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成长文本
|
* 生成长文本
|
||||||
*/
|
*/
|
||||||
static generateLongText(): string {
|
static generateLongText(): string {
|
||||||
return faker.lorem.paragraphs(5);
|
return fakerZH_CN.lorem.paragraphs(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,42 +106,42 @@ export class DummyDataGenerator {
|
|||||||
* 生成随机数字
|
* 生成随机数字
|
||||||
*/
|
*/
|
||||||
static generateNumber(): number {
|
static generateNumber(): number {
|
||||||
return faker.number.int(10000);
|
return fakerZH_CN.number.int(10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成随机浮点数
|
* 生成随机浮点数
|
||||||
*/
|
*/
|
||||||
static generateFloat(): number {
|
static generateFloat(): number {
|
||||||
return faker.number.float({ max: 10000 });
|
return fakerZH_CN.number.float({ max: 10000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成随机负数
|
* 生成随机负数
|
||||||
*/
|
*/
|
||||||
static generateNegativeNumber(): number {
|
static generateNegativeNumber(): number {
|
||||||
return -faker.number.int(10000);
|
return -fakerZH_CN.number.int(10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成随机日期
|
* 生成随机日期
|
||||||
*/
|
*/
|
||||||
static generateDate(): string {
|
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 {
|
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 {
|
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 {
|
export enum MessageAction {
|
||||||
|
// 标签页操作
|
||||||
|
RELOAD_TAB = 'reloadTab',
|
||||||
|
|
||||||
// 表单相关操作
|
// 表单相关操作
|
||||||
SCAN_FORM_FIELDS = 'scanFormFields',
|
SCAN_FORM_FIELDS = 'scanFormFields',
|
||||||
FILL_VALID_DATA = 'fillValidData',
|
FILL_VALID_DATA = 'fillValidData',
|
||||||
@@ -22,7 +25,9 @@ export enum MessageAction {
|
|||||||
* 消息载荷接口
|
* 消息载荷接口
|
||||||
*/
|
*/
|
||||||
export interface MessagePayload {
|
export interface MessagePayload {
|
||||||
action: MessageAction;
|
action: MessageAction | string;
|
||||||
|
tabId?: number;
|
||||||
|
delay?: number;
|
||||||
fields?: Omit<FormFieldInfo, 'element'>[];
|
fields?: Omit<FormFieldInfo, 'element'>[];
|
||||||
mode?: FillMode;
|
mode?: FillMode;
|
||||||
includeHidden?: boolean;
|
includeHidden?: boolean;
|
||||||
|
|||||||
+47
-23
@@ -11,26 +11,26 @@ const RESTRICTED_PROTOCOLS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export async function getCurrentTab() {
|
export async function getCurrentTab() {
|
||||||
// First, try the active tab in the last focused window (works for standard popups and side panels)
|
// For popup pages, we need to get the active tab from the browser window that triggered the popup.
|
||||||
const [lastFocusedTab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
|
// 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
|
const [tab] = await chrome.tabs.query({
|
||||||
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({
|
|
||||||
active: true,
|
active: true,
|
||||||
windowType: 'normal',
|
|
||||||
lastFocusedWindow: true,
|
lastFocusedWindow: true,
|
||||||
});
|
});
|
||||||
if (normalTab) return normalTab;
|
|
||||||
|
|
||||||
// Final fallback: any active normal tab (if multiple windows exist, it returns all active tabs)
|
if (tab) {
|
||||||
const normalTabs = await chrome.tabs.query({ active: true, windowType: 'normal' });
|
return tab;
|
||||||
return normalTabs[0];
|
}
|
||||||
|
|
||||||
|
// 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 {
|
export function isRestrictedUrl(url?: string): boolean {
|
||||||
@@ -42,7 +42,11 @@ export async function getCookieSize(url: string): Promise<number> {
|
|||||||
try {
|
try {
|
||||||
const cookies = await chrome.cookies.getAll({ url });
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to get cookie size:', error);
|
console.error('Failed to get cookie size:', error);
|
||||||
return 0;
|
return 0;
|
||||||
@@ -74,7 +78,10 @@ export async function getSessionStorageSize(tabId: number): Promise<number> {
|
|||||||
target: { tabId },
|
target: { tabId },
|
||||||
func: () => {
|
func: () => {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -172,8 +179,10 @@ export async function clearCookies(url: string): Promise<StorageCleanResult> {
|
|||||||
try {
|
try {
|
||||||
const cookies = await chrome.cookies.getAll({ url });
|
const cookies = await chrome.cookies.getAll({ url });
|
||||||
for (const cookie of cookies) {
|
for (const cookie of cookies) {
|
||||||
|
const protocol = cookie.secure ? 'https:' : 'http:';
|
||||||
|
const cookieUrl = `${protocol}//${cookie.domain}${cookie.path}`;
|
||||||
await chrome.cookies.remove({
|
await chrome.cookies.remove({
|
||||||
url,
|
url: cookieUrl,
|
||||||
name: cookie.name,
|
name: cookie.name,
|
||||||
storeId: cookie.storeId,
|
storeId: cookie.storeId,
|
||||||
});
|
});
|
||||||
@@ -233,15 +242,32 @@ export async function injectClearIndexedDB(tabId: number): Promise<StorageCleanR
|
|||||||
for (const db of databases) {
|
for (const db of databases) {
|
||||||
if (db.name) {
|
if (db.name) {
|
||||||
const dbName = db.name as string;
|
const dbName = db.name as string;
|
||||||
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const deleteReq = indexedDB.deleteDatabase(dbName);
|
const deleteReq = indexedDB.deleteDatabase(dbName);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
console.warn('IndexedDB delete timeout:', dbName);
|
||||||
|
resolve(); // Timeout, move to next
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
deleteReq.onblocked = () => {
|
deleteReq.onblocked = () => {
|
||||||
console.warn('IndexedDB delete blocked:', dbName);
|
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}`));
|
||||||
};
|
};
|
||||||
deleteReq.onsuccess = () => resolve();
|
|
||||||
deleteReq.onerror = () => reject();
|
|
||||||
});
|
});
|
||||||
count++;
|
count++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Delete DB error:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { count };
|
return { count };
|
||||||
@@ -287,9 +313,7 @@ export async function injectClearCacheStorage(tabId: number): Promise<StorageCle
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function injectUnregisterServiceWorkers(
|
export async function injectUnregisterServiceWorkers(tabId: number): Promise<StorageCleanResult> {
|
||||||
tabId: number,
|
|
||||||
): Promise<StorageCleanResult> {
|
|
||||||
try {
|
try {
|
||||||
const [result] = await chrome.scripting.executeScript({
|
const [result] = await chrome.scripting.executeScript({
|
||||||
target: { tabId },
|
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