* docs: 添加组件文档注释和类型导入

refactor: 统一使用 SnackbarOptions 类型
style: 优化导入语句顺序和格式

* refactor: 简化假数据生成器中的faker导入和使用

Co-authored-by: Copilot <copilot@github.com>

* feat(form-recognizer): 增强表单识别功能并优化UI交互

- 新增字段类型偏好设置功能,支持按域名保存字段类型
- 重构FieldList组件,改进字段选择和类型修改体验
- 添加字段定位闪烁功能,便于在页面上快速找到对应字段
- 优化表单填充逻辑,支持单个字段覆盖默认填充模式
- 移除独立的侧边栏页面,统一使用主页面组件
- 改进useStorageState钩子,增加加载状态管理和防抖处理

* refactor: 移除未使用的组件文件

* refactor(页面头部): 提取通用 PageHeader 组件并替换各页面头部实现

重构各页面头部为统一的 PageHeader 组件,提高代码复用性和维护性

* style(组件): 调整自动刷新开关和存储选项网格的样式

优化自动刷新开关的文本内边距,重构存储选项网格的布局结构,调整间距和边框样式

* style(ui): 调整时间戳页面和结果视图的样式

- 为时区选择器添加圆角
- 优化结果视图的布局和对齐方式
- 调整结果项的内边距和文本样式

* ci(workflow): 移除Firefox测试以简化CI流程

仅保留Chrome浏览器的构建步骤,减少CI运行时间和资源消耗
This commit is contained in:
LingandRX
2026-04-28 08:56:47 +08:00
committed by GitHub
parent fb5f98dd3b
commit c039475119
29 changed files with 908 additions and 1317 deletions
+1 -5
View File
@@ -79,7 +79,7 @@ jobs:
needs: [lint, typecheck, test] needs: [lint, typecheck, test]
strategy: strategy:
matrix: matrix:
browser: [chrome, firefox] browser: [chrome]
fail-fast: false fail-fast: false
steps: steps:
- name: Checkout - name: Checkout
@@ -97,7 +97,3 @@ jobs:
- name: Build (Chrome) - name: Build (Chrome)
if: matrix.browser == 'chrome' if: matrix.browser == 'chrome'
run: npm run build run: npm run build
- name: Build (Firefox)
if: matrix.browser == 'firefox'
run: npm run build:firefox
+6
View File
@@ -2,6 +2,12 @@ import { Button as MuiButton, ButtonProps as MuiButtonProps } from '@mui/materia
export type ButtonProps = MuiButtonProps; export type ButtonProps = MuiButtonProps;
/**
* 按钮组件
* @param sx 自定义样式
* @param props 其他按钮属性
* @returns 按钮组件
*/
export function Button({ sx = [], ...props }: ButtonProps) { export function Button({ sx = [], ...props }: ButtonProps) {
return ( return (
<MuiButton <MuiButton
+14 -3
View File
@@ -3,14 +3,24 @@ import { IconButton, Tooltip } from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CheckIcon from '@mui/icons-material/Check'; import CheckIcon from '@mui/icons-material/Check';
import { copyToClipboard } from '@/utils/clipboard'; import { copyToClipboard } from '@/utils/clipboard';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
/**
* 复制按钮组件属性
* @param text 要复制的文本
* @param tooltip 提示信息
* @param size 按钮大小
* @param color 按钮颜色
* @param style 自定义样式
* @param showMessage 消息提示函数,用于显示复制成功或失败的消息
*/
interface CopyButtonProps { interface CopyButtonProps {
text: string; text: string;
tooltip?: string; tooltip?: string;
size?: 'small' | 'medium' | 'large'; size?: 'small' | 'medium' | 'large';
color?: 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning' | string; color?: 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning' | string;
style?: React.CSSProperties; style?: React.CSSProperties;
showMessage?: (message: string, options?: { severity: 'success' | 'error' }) => void; showMessage?: (message: string, options?: SnackbarOptions) => void;
} }
/** /**
@@ -19,6 +29,8 @@ interface CopyButtonProps {
* @param tooltip 提示信息 * @param tooltip 提示信息
* @param size 按钮大小 * @param size 按钮大小
* @param color 按钮颜色 * @param color 按钮颜色
* @param style 自定义样式
* @param showMessage 消息提示函数,用于显示复制成功或失败的消息
* @returns 复制按钮组件 * @returns 复制按钮组件
*/ */
export const CopyButton: React.FC<CopyButtonProps> = ({ export const CopyButton: React.FC<CopyButtonProps> = ({
@@ -56,8 +68,7 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
'&:hover': { '&:hover': {
bgcolor: copied bgcolor: copied
? 'success.main' ? 'success.main'
: typeof color === 'string' && : !['primary', 'secondary', 'success', 'error', 'info', 'warning'].includes(color)
!['primary', 'secondary', 'success', 'error', 'info', 'warning'].includes(color)
? color ? color
: `${color}.main`, : `${color}.main`,
color: '#fff', color: '#fff',
-31
View File
@@ -1,31 +0,0 @@
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;
+135 -58
View File
@@ -5,14 +5,19 @@ import {
Paper, Paper,
List, List,
ListItem, ListItem,
ListItemText,
ListItemIcon, ListItemIcon,
Collapse, Collapse,
Chip, FormControl,
InputLabel,
Select,
MenuItem,
SelectChangeEvent,
Checkbox,
Button,
} from '@mui/material'; } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import InputIcon from '@mui/icons-material/Input'; import { FieldType } from '@/utils/dummyDataGenerator';
// 字段数据接口 // 字段数据接口
interface FieldData { interface FieldData {
@@ -24,51 +29,57 @@ interface FieldData {
value: string; value: string;
isSelected: boolean; isSelected: boolean;
generatedValue: string; generatedValue: string;
useInvalidData?: boolean;
} }
// 字段类型显示名称映射 // 字段类型显示名称映射
const FIELD_TYPE_NAMES: Record<string, string> = { const FIELD_TYPE_NAMES: Record<string, string> = {
text: '文本', [FieldType.TEXT]: '文本',
email: '邮箱', [FieldType.EMAIL]: '邮箱',
phone: '手机号', [FieldType.PHONE]: '手机号',
number: '数字', [FieldType.NUMBER]: '数字',
date: '日期', [FieldType.DATE]: '日期',
textarea: '文本域', [FieldType.TEXTarea]: '文本域',
radio: '单选框', [FieldType.RADIO]: '单选框',
checkbox: '复选框', [FieldType.CHECKBOX]: '复选框',
select: '下拉框', [FieldType.SELECT]: '下拉框',
password: '密码', [FieldType.PASSWORD]: '密码',
name: '姓名', [FieldType.NAME]: '姓名',
id_card: '身份证号', [FieldType.ID_CARD]: '身份证号',
unknown: '未知', [FieldType.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 { interface FieldListProps {
fields: FieldData[]; fields: FieldData[];
showFields: boolean; showFields: boolean;
onToggleShowFields: () => void; onToggleShowFields: () => void;
onFieldTypeChange: (fieldId: string, newType: string) => void;
onLocateField: (fieldId: string) => void;
onHoverField: (fieldId: string | null) => void;
onToggleFieldSelection: (fieldId: string) => void;
onToggleAllFields: () => void;
hoveredFieldId: string | null;
} }
const FieldList: React.FC<FieldListProps> = ({ fields, showFields, onToggleShowFields }) => { const FieldList: React.FC<FieldListProps> = ({
fields,
showFields,
onToggleShowFields,
onFieldTypeChange,
onHoverField,
onToggleFieldSelection,
onToggleAllFields,
hoveredFieldId,
}) => {
if (fields.length === 0) return null; if (fields.length === 0) return null;
const handleTypeChange = (fieldId: string, event: SelectChangeEvent<string>) => {
onFieldTypeChange(fieldId, event.target.value);
};
const allSelected = fields.every((f) => f.isSelected);
const selectedCount = fields.filter((f) => f.isSelected).length;
return ( return (
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 2 }}> <Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 2 }}>
<Box <Box
@@ -84,34 +95,100 @@ const FieldList: React.FC<FieldListProps> = ({ fields, showFields, onToggleShowF
}} }}
onClick={onToggleShowFields} onClick={onToggleShowFields}
> >
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
({fields.length}) <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
</Typography> ({fields.length})
{showFields ? <ExpandLessIcon /> : <ExpandMoreIcon />} </Typography>
<Typography
variant="caption"
sx={{
bgcolor: selectedCount > 0 ? 'primary.main' : 'grey.300',
color: selectedCount > 0 ? 'white' : 'text.secondary',
px: 1,
py: 0.25,
borderRadius: 1,
}}
>
{selectedCount}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
size="small"
onClick={(e) => {
e.stopPropagation();
onToggleAllFields();
}}
>
{allSelected ? '取消全选' : '全选'}
</Button>
{showFields ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</Box>
</Box> </Box>
<Collapse in={showFields}> <Collapse in={showFields}>
<List dense sx={{ maxHeight: 300, overflow: 'auto' }}> <List dense sx={{ maxHeight: 400, overflow: 'auto' }}>
{fields.map((field, index) => ( {fields.map((field, index) => (
<ListItem key={field.id} sx={{ py: 0.5 }}> <ListItem
<ListItemIcon sx={{ minWidth: 36 }}> key={field.id}
<InputIcon fontSize="small" color="action" /> sx={{
py: 1,
px: 2,
bgcolor: hoveredFieldId === field.id ? '#e3f2fd' : 'transparent',
transition: 'background-color 0.2s ease',
}}
onMouseEnter={() => onHoverField(field.id)}
onMouseLeave={() => onHoverField(null)}
>
<ListItemIcon sx={{ minWidth: 40 }}>
<Checkbox
size="small"
checked={field.isSelected}
onChange={(e) => {
e.stopPropagation();
onToggleFieldSelection(field.id);
}}
/>
</ListItemIcon> </ListItemIcon>
<ListItemText <Box sx={{ flex: 1, minWidth: 0 }}>
primary={ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Typography
<Typography variant="body2" sx={{ fontWeight: 600 }}> variant="body2"
{field.label || field.name || field.placeholder || `字段 ${index + 1}`} sx={{
</Typography> fontWeight: 600,
<Chip overflow: 'hidden',
label={FIELD_TYPE_NAMES[field.fieldType] || '未知'} textOverflow: 'ellipsis',
size="small" whiteSpace: 'nowrap',
color={FIELD_TYPE_COLORS[field.fieldType] || 'default'} flex: 1,
variant="outlined" opacity: field.isSelected ? 1 : 0.5,
/> }}
</Box> >
} {field.label || field.name || field.placeholder || `字段 ${index + 1}`}
secondary={field.placeholder || field.name} </Typography>
/> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FormControl size="small" sx={{ flex: 1, minWidth: 120 }}>
<InputLabel></InputLabel>
<Select
value={field.fieldType}
label="类型"
onChange={(e) => handleTypeChange(field.id, e)}
>
{Object.values(FieldType).map((type) => (
<MenuItem key={type} value={type}>
{FIELD_TYPE_NAMES[type] || type}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
{field.placeholder && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
: {field.placeholder}
</Typography>
)}
</Box>
</ListItem> </ListItem>
))} ))}
</List> </List>
-79
View File
@@ -1,79 +0,0 @@
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;
-80
View File
@@ -1,80 +0,0 @@
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;
-34
View File
@@ -1,34 +0,0 @@
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;
+106
View File
@@ -0,0 +1,106 @@
import { Stack, Typography, Box, alpha, SxProps, Theme } from '@mui/material';
import { ReactNode } from 'react';
/**
* PageHeader 组件属性接口
*/
export interface PageHeaderProps {
/** 要显示的图标组件 */
icon: ReactNode;
/** 图标的颜色,默认为 '#1976d2'(蓝色) */
iconColor?: string;
/** 主标题文本 */
title: string;
/** 副标题文本(可选) */
subtitle?: string;
/** 在标题右侧显示的徽章/标签组件(可选) */
badge?: ReactNode;
/** 图标容器的自定义样式 */
iconSx?: SxProps<Theme>;
/** 标题文本的自定义样式 */
titleSx?: SxProps<Theme>;
/** 副标题文本的自定义样式 */
subtitleSx?: SxProps<Theme>;
/** 整个组件的自定义样式 */
sx?: SxProps<Theme>;
}
/**
* PageHeader - 通用页面标题栏组件
*
* 用于显示带图标的页面标题,支持自定义颜色、副标题、徽章等功能
*
* @example
* ```tsx
* <PageHeader
* icon={<AccessTimeIcon />}
* iconColor="#1976d2"
* title="时间戳转换"
* subtitle="Unix 毫秒数转换与格式化"
* />
* ```
*
* @example
* ```tsx
* <PageHeader
* icon={<StorageIcon />}
* iconColor={storageCleanerPageStyles.warningColor}
* title="存储清理"
* subtitle={domain}
* badge={<Badge>已占用 {size}</Badge>}
* />
* ```
*/
export default function PageHeader({
icon,
iconColor = '#1976d2',
title,
subtitle,
badge,
iconSx,
titleSx,
subtitleSx,
sx,
}: PageHeaderProps) {
return (
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5, ...sx }}>
{/* 图标容器 */}
<Box
sx={{
p: 1,
borderRadius: 2.5,
bgcolor: alpha(iconColor, 0.1),
color: iconColor,
display: 'flex',
...iconSx,
}}
>
{icon}
</Box>
{/* 标题区域 */}
<Box sx={{ flex: 1 }}>
{/* 标题行(含徽章) */}
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography
variant="subtitle1"
fontWeight={900}
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2, ...titleSx }}
>
{title}
</Typography>
{badge}
</Stack>
{/* 副标题 */}
{subtitle && (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontWeight: 600, ...subtitleSx }}
>
{subtitle}
</Typography>
)}
</Box>
</Stack>
);
}
+5 -5
View File
@@ -1,11 +1,11 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { import {
Box,
Typography,
Paper,
CircularProgress,
Alert, Alert,
Box,
CircularProgress,
IconButton, IconButton,
Paper,
Typography,
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from '@mui/material'; } from '@mui/material';
-117
View File
@@ -1,117 +0,0 @@
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;
+1 -2
View File
@@ -6,7 +6,6 @@ import OpenUrlPage from '@/entrypoints/popup/pages/OpenUrlPage';
import OpenUrlViewerPage from '@/entrypoints/popup/pages/OpenUrlViewerPage'; import OpenUrlViewerPage from '@/entrypoints/popup/pages/OpenUrlViewerPage';
import QrCodePage from '@/entrypoints/popup/pages/QrCodePage'; import QrCodePage from '@/entrypoints/popup/pages/QrCodePage';
import FormRecognizerPage from '@/entrypoints/popup/pages/FormRecognizerPage'; import FormRecognizerPage from '@/entrypoints/popup/pages/FormRecognizerPage';
import FormFillSidePanel from '@/entrypoints/sidepanel/pages/FormFillSidePanel';
export interface RouteConfig { export interface RouteConfig {
key: PageType; key: PageType;
@@ -76,7 +75,7 @@ export const ROUTES: RouteConfig[] = [
defaultVisible: true, defaultVisible: true,
components: { components: {
popup: FormRecognizerPage, popup: FormRecognizerPage,
sidepanel: FormFillSidePanel, sidepanel: FormRecognizerPage,
detached: FormRecognizerPage, detached: FormRecognizerPage,
}, },
}, },
+25 -3
View File
@@ -6,6 +6,7 @@ import {
scanFormFields, scanFormFields,
highlightField, highlightField,
unhighlightField, unhighlightField,
flashField,
FillMode, FillMode,
type FormFieldInfo, type FormFieldInfo,
} from '@/utils/dummyDataGenerator'; } from '@/utils/dummyDataGenerator';
@@ -54,9 +55,19 @@ export default defineContentScript({
break; break;
case MessageAction.FILL_SELECTED_FIELDS: { case MessageAction.FILL_SELECTED_FIELDS: {
// 使用之前扫描时存储的字段,因为它们包含element属性 // 使用之前扫描时存储的字段,因为它们包含element属性
const fieldIds = (message.fields || []).filter((f) => f.isSelected).map((f) => f.id); const incomingFields = message.fields || [];
const fieldsToFill = currentFields.filter((f) => fieldIds.includes(f.id)); const fieldsToFill = currentFields.map((field) => {
fieldsToFill.forEach((f) => (f.isSelected = true)); const incomingField = incomingFields.find((f) => f.id === field.id);
if (incomingField) {
return {
...field,
fieldType: incomingField.fieldType,
isSelected: incomingField.isSelected,
useInvalidData: (incomingField as { useInvalidData?: boolean }).useInvalidData,
};
}
return field;
});
const count = fillSelectedFields(fieldsToFill, message.mode || FillMode.VALID); const count = fillSelectedFields(fieldsToFill, message.mode || FillMode.VALID);
sendResponse({ success: true, message: `已填充 ${count} 个字段` }); sendResponse({ success: true, message: `已填充 ${count} 个字段` });
break; break;
@@ -104,6 +115,17 @@ export default defineContentScript({
}); });
sendResponse({ success: true }); sendResponse({ success: true });
break; break;
case MessageAction.FLASH_FIELD: {
const fieldId = message.fieldId;
const field = currentFields.find((f) => f.id === fieldId);
if (field) {
flashField(field.element);
sendResponse({ success: true });
} else {
sendResponse({ success: false, message: '未找到字段' });
}
break;
}
default: default:
sendResponse({ success: false, message: '未知操作' }); sendResponse({ success: false, message: '未知操作' });
} }
+354 -161
View File
@@ -1,16 +1,23 @@
import { useState, useRef } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Box, Typography, Container, Button, CircularProgress } from '@mui/material'; import {
Box,
Container,
Button,
CircularProgress,
FormControlLabel,
Switch,
alpha,
} from '@mui/material';
import InputIcon from '@mui/icons-material/Input'; import InputIcon from '@mui/icons-material/Input';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar'; import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import { dashboardPageStyles, formRecognizerPageStyles } from '@/config/pageTheme'; import { dashboardPageStyles, formRecognizerPageStyles } from '@/config/pageTheme';
import { MessageAction, sendMessageToContent, injectContentScript } from '@/utils/messages'; import { MessageAction, sendMessageToContent, injectContentScript } from '@/utils/messages';
import { DataTemplateManager, type DataTemplate } from '@/utils/dataTemplate'; import { FillMode } from '@/utils/dummyDataGenerator';
import FieldList from '@/components/FieldList'; import FieldList from '@/components/FieldList';
import OperationHistory from '@/components/OperationHistory'; import { useStorageState } from '@/utils/useStorageState';
import TemplateManager from '@/components/TemplateManager'; import { FieldTypePreferences } from '@/types/storage';
import MainActions from '@/components/MainActions'; import PageHeader from '@/components/PageHeader';
import OptionsPanel from '@/components/OptionsPanel';
import FeatureDescription from '@/components/FeatureDescription';
// 字段数据接口 // 字段数据接口
interface FieldData { interface FieldData {
@@ -22,28 +29,116 @@ interface FieldData {
value: string; value: string;
isSelected: boolean; isSelected: boolean;
generatedValue: string; generatedValue: string;
useInvalidData?: boolean;
} }
const DEFAULT_FIELD_TYPE_PREFERENCES: FieldTypePreferences = {};
const FormRecognizerPage = () => { const FormRecognizerPage = () => {
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 }); const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
const [loading, setLoading] = useState(false); const [fillLoading, setFillLoading] = useState(false);
const [clearLoading, setClearLoading] = useState(false);
const [includeHidden, setIncludeHidden] = useState(false); const [includeHidden, setIncludeHidden] = useState(false);
const isProcessingRef = useRef(false); const isProcessingRef = useRef(false);
const [fields, setFields] = useState<FieldData[]>([]); const [fields, setFields] = useState<FieldData[]>([]);
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [showFields, setShowFields] = useState(false); const [showFields, setShowFields] = useState(false);
const [operationHistory, setOperationHistory] = useState< const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
Array<{ const [currentDomain, setCurrentDomain] = useState<string>('');
time: string; const [sidePanelOpen, setSidePanelOpen] = useState(false);
type: string;
content: string; const [fieldTypePreferences, setFieldTypePreferences] = useStorageState(
result: string; 'formRecognizer/fieldTypePreferences',
}> DEFAULT_FIELD_TYPE_PREFERENCES,
>([]); );
const [showHistory, setShowHistory] = useState(false);
const [templates, setTemplates] = useState<DataTemplate[]>([]); // 获取当前域名
const [showTemplates, setShowTemplates] = useState(false); useEffect(() => {
const [templateLoading, setTemplateLoading] = useState(false); const getActiveTab = async () => {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab?.url) {
const url = new URL(tab.url);
setCurrentDomain(url.hostname);
}
} catch (error) {
console.error('获取标签页信息失败:', error);
}
};
getActiveTab();
}, []);
// 检测侧边栏状态
useEffect(() => {
console.log('开始检测侧边栏状态');
const checkSidePanelState = async () => {
try {
// 检查 chrome.runtime.getContexts 是否可用
if (typeof chrome.runtime.getContexts === 'function') {
const contexts = await chrome.runtime.getContexts({
contextTypes: ['SIDE_PANEL'],
});
console.log('侧边栏上下文:', contexts);
setSidePanelOpen(contexts.length > 0);
} else {
console.log('chrome.runtime.getContexts 不可用');
setSidePanelOpen(false);
}
} catch (error) {
console.error('检测侧边栏状态失败:', error);
setSidePanelOpen(false);
}
};
// 初始检查
checkSidePanelState();
// 定期检查侧边栏状态(每 500ms)
const interval = setInterval(checkSidePanelState, 500);
console.log('侧边栏状态检测已启动');
return () => {
console.log('清理侧边栏状态检测');
clearInterval(interval);
};
}, []);
// 生成字段标识符
const getFieldIdentifier = (field: Pick<FieldData, 'label' | 'name' | 'placeholder'>): string => {
return field.label || field.name || field.placeholder || 'unknown';
};
// 应用保存的类型偏好
const applySavedPreferences = (fields: FieldData[]): FieldData[] => {
if (!currentDomain || !(fieldTypePreferences as FieldTypePreferences)[currentDomain]) {
return fields;
}
const prefs = (fieldTypePreferences as FieldTypePreferences)[currentDomain];
return fields.map((field) => {
const identifier = getFieldIdentifier(field);
if (prefs && prefs[identifier]) {
return { ...field, fieldType: prefs[identifier] };
}
return field;
});
};
// 保存类型偏好
const saveTypePreference = (field: FieldData, newType: string) => {
if (!currentDomain) return;
const identifier = getFieldIdentifier(field);
setFieldTypePreferences((prev) => {
const prevPrefs = prev as FieldTypePreferences;
const currentDomainPrefs = prevPrefs[currentDomain] || {};
return {
...prevPrefs,
[currentDomain]: {
...currentDomainPrefs,
[identifier]: newType,
},
} as FieldTypePreferences;
});
};
// 扫描表单字段 // 扫描表单字段
const handleScanFields = async () => { const handleScanFields = async () => {
@@ -60,9 +155,10 @@ const FormRecognizerPage = () => {
} }
if (response.success && response.fields) { if (response.success && response.fields) {
setFields(response.fields as FieldData[]); const fieldsWithPreferences = applySavedPreferences(response.fields as FieldData[]);
setFields(fieldsWithPreferences);
setShowFields(true);
showMessage(`扫描完成,发现 ${response.totalCount} 个可填充字段`, { severity: 'success' }); showMessage(`扫描完成,发现 ${response.totalCount} 个可填充字段`, { severity: 'success' });
addOperationHistory('扫描', `扫描表单字段,发现 ${response.totalCount} 个字段`, '成功');
} else { } else {
showMessage(response.message || '扫描失败', { severity: 'error' }); showMessage(response.message || '扫描失败', { severity: 'error' });
} }
@@ -74,158 +170,194 @@ const FormRecognizerPage = () => {
} }
}; };
// 添加操作历史记录 // 更新字段类型
const addOperationHistory = (type: string, content: string, result: string) => { const handleFieldTypeChange = (fieldId: string, newType: string) => {
const newEntry = { setFields((prev) =>
time: new Date().toLocaleString('zh-CN'), prev.map((field) => {
type, if (field.id === fieldId) {
content, const updatedField = {
result, ...field,
}; fieldType: newType,
setOperationHistory((prev) => [newEntry, ...prev].slice(0, 50)); // 最多保留50条记录 // 清空旧的 generatedValue,保持界面一致性
}; generatedValue: '',
};
// 加载模板列表 saveTypePreference(field, newType);
const loadTemplates = async () => { return updatedField;
setTemplateLoading(true);
try {
const allTemplates = await DataTemplateManager.getAllTemplates();
setTemplates(allTemplates);
} catch (error) {
console.error('加载模板失败:', error);
showMessage('加载模板失败', { severity: 'error' });
} finally {
setTemplateLoading(false);
}
};
// 导出模板
const handleExportTemplates = async () => {
const allTemplates = await DataTemplateManager.getAllTemplates();
if (allTemplates.length === 0) {
showMessage('没有可导出的模板', { severity: 'warning' });
return;
}
const jsonStr = DataTemplateManager.exportTemplates(allTemplates);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `templates_${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
showMessage(`已导出 ${allTemplates.length} 个模板`, { severity: 'success' });
addOperationHistory('导出', `导出 ${allTemplates.length} 个模板`, '成功');
};
// 导入模板
const handleImportTemplates = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
const content = event.target?.result as string;
const success = await DataTemplateManager.importTemplates(content);
if (success) {
showMessage('模板导入成功', { severity: 'success' });
addOperationHistory('导入', '导入模板', '成功');
loadTemplates();
} else {
showMessage('模板导入失败,请检查文件格式', { severity: 'error' });
} }
}; return field;
reader.readAsText(file); }),
}; );
input.click();
}; };
const sendMessageWithHandler = async ( // 切换单个字段的选中状态
action: MessageAction, const handleToggleFieldSelection = (fieldId: string) => {
payload?: { includeHidden?: boolean }, setFields((prev) =>
) => { prev.map((field) =>
// 防抖处理:防止快速点击导致多次请求 field.id === fieldId ? { ...field, isSelected: !field.isSelected } : field,
),
);
};
// 全选/取消全选
const handleToggleAllFields = () => {
setFields((prev) => {
const allSelected = prev.every((f) => f.isSelected);
return prev.map((f) => ({ ...f, isSelected: !allSelected }));
});
};
// 定位字段(闪烁)
const handleLocateField = async (fieldId: string) => {
try {
const response = await sendMessageToContent(MessageAction.FLASH_FIELD, { fieldId });
if (!response.success) {
showMessage(response.message || '定位字段失败', { severity: 'error' });
}
} catch (error) {
console.error('定位字段失败:', error);
}
};
// 悬停高亮
const handleHoverField = async (fieldId: string | null) => {
setHoveredFieldId(fieldId);
try {
if (fieldId) {
await sendMessageToContent(MessageAction.HIGHLIGHT_FIELD, { fieldId });
} else {
await sendMessageToContent(MessageAction.UNHIGHLIGHT_ALL_FIELDS);
}
} catch (error) {
console.error('高亮字段失败:', error);
}
};
// 填充选中字段
const handleFillSelectedFields = async () => {
if (isProcessingRef.current) { if (isProcessingRef.current) {
showMessage('操作进行中,请稍候...', { severity: 'warning' }); showMessage('操作进行中,请稍候...', { severity: 'warning' });
return; return;
} }
setLoading(true); const selectedCount = fields.filter((f) => f.isSelected).length;
if (selectedCount === 0) {
showMessage('请先选择要填充的字段', { severity: 'warning' });
return;
}
setFillLoading(true);
isProcessingRef.current = true; isProcessingRef.current = true;
try { try {
let response = await sendMessageToContent(action, payload); // eslint-disable-next-line @typescript-eslint/no-explicit-any
const messageFields = fields as any;
let response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
fields: messageFields,
mode: FillMode.VALID,
includeHidden,
});
// 如果连接失败,尝试注入内容脚本
if (!response.success && response.message && response.message.includes('无法连接')) { if (!response.success && response.message && response.message.includes('无法连接')) {
showMessage('正在注入内容脚本...', { severity: 'info' }); showMessage('正在注入内容脚本...', { severity: 'info' });
const injected = await injectContentScript(); const injected = await injectContentScript();
if (injected) { if (injected) {
// 注入成功后再次尝试 response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
response = await sendMessageToContent(action, payload); fields: messageFields,
} else { mode: FillMode.VALID,
showMessage('内容脚本注入失败,请刷新页面后重试', { severity: 'error' }); includeHidden,
return; });
} }
} }
if (response.success) { if (response.success) {
showMessage(response.message || '操作成功', { severity: 'success' }); showMessage(response.message || '填充成功', { severity: 'success' });
} else { } else {
// 增强错误提示信息 showMessage(response.message || '填充失败', { severity: 'error' });
const errorMsg = response.message || '操作失败';
const errorDetails = getErrorDetails(errorMsg);
showMessage(errorDetails, { severity: 'error' });
} }
} catch (error) { } catch (error) {
console.error('发送消息失败:', error); console.error('填充失败:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误'; const errorMessage = error instanceof Error ? error.message : '未知错误';
showMessage(`操作失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' }); showMessage(`填充失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
} finally { } finally {
setLoading(false); setFillLoading(false);
isProcessingRef.current = false; isProcessingRef.current = false;
} }
}; };
// 获取详细的错误信息 // 清空所有字段
const getErrorDetails = (baseMsg: string): string => { const handleClearAllFields = async () => {
if (baseMsg.includes('标签页')) { if (isProcessingRef.current) {
return `${baseMsg},请确保已打开网页页面`; showMessage('操作进行中,请稍候...', { severity: 'warning' });
return;
} }
if (baseMsg.includes('注入')) {
return `${baseMsg},请检查页面是否支持内容脚本`; setClearLoading(true);
isProcessingRef.current = true;
try {
let response = await sendMessageToContent(MessageAction.CLEAR_ALL_FIELDS);
if (!response.success && response.message && response.message.includes('无法连接')) {
showMessage('正在注入内容脚本...', { severity: 'info' });
const injected = await injectContentScript();
if (injected) {
response = await sendMessageToContent(MessageAction.CLEAR_ALL_FIELDS);
}
}
if (response.success) {
showMessage(response.message || '清空成功', { severity: 'success' });
} else {
showMessage(response.message || '清空失败', { severity: 'error' });
}
} catch (error) {
console.error('清空失败:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
showMessage(`清空失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
} finally {
setClearLoading(false);
isProcessingRef.current = false;
} }
return baseMsg;
}; };
const handleFillValidData = () => { // 打开侧边栏
sendMessageWithHandler(MessageAction.FILL_VALID_DATA, { includeHidden }); const handleOpenSidePanel = async () => {
addOperationHistory('填充', '填充有效数据', '成功'); try {
await chrome.sidePanel.open({ windowId: chrome.windows.WINDOW_ID_CURRENT });
showMessage('侧边栏已打开', { severity: 'success' });
} catch (error) {
console.error('打开侧边栏失败:', error);
showMessage('打开侧边栏失败', { severity: 'error' });
}
}; };
const handleFillInvalidData = () => { const selectedCount = fields.filter((f) => f.isSelected).length;
sendMessageWithHandler(MessageAction.FILL_INVALID_DATA, { includeHidden });
addOperationHistory('填充', '填充异常数据', '成功');
};
const handleClearAllFields = () => {
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, bgcolor: '#f5f5f5' }}> <Container maxWidth="sm" sx={{ py: 3, px: 2, bgcolor: '#f5f5f5' }}>
<Box sx={{ mb: 4 }}> {/* Header */}
<Typography variant="h6" component="h1" sx={{ fontWeight: 700, mb: 1 }}> <PageHeader
Dummy Data Generator title="表单测试数据填充器"
</Typography> subtitle="一键填充表单测试数据,提升开发和测试效率"
<Typography variant="body2" color="text.secondary"> icon={<InputIcon />}
sx={{ mb: 2.5 }}
</Typography> />
</Box>
<Button
size="small"
startIcon={<OpenInNewIcon />}
onClick={handleOpenSidePanel}
sx={{
textTransform: 'none',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
opacity: sidePanelOpen ? 0 : 1,
transform: sidePanelOpen ? 'scale(0.8)' : 'scale(1)',
pointerEvents: sidePanelOpen ? 'none' : 'auto',
visibility: sidePanelOpen ? 'hidden' : 'visible',
position: 'relative',
}}
>
</Button>
{/* 扫描按钮 */} {/* 扫描按钮 */}
<Button <Button
@@ -252,34 +384,95 @@ const FormRecognizerPage = () => {
fields={fields} fields={fields}
showFields={showFields} showFields={showFields}
onToggleShowFields={() => setShowFields(!showFields)} onToggleShowFields={() => setShowFields(!showFields)}
onFieldTypeChange={handleFieldTypeChange}
onLocateField={handleLocateField}
onHoverField={handleHoverField}
onToggleFieldSelection={handleToggleFieldSelection}
onToggleAllFields={handleToggleAllFields}
hoveredFieldId={hoveredFieldId}
/> />
<MainActions {/* 操作按钮 */}
loading={loading} {fields.length > 0 && (
onFillValidData={handleFillValidData} <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
onFillInvalidData={handleFillInvalidData} <Button
onClearAllFields={handleClearAllFields} disableElevation
/> disableRipple
variant="contained"
onClick={handleFillSelectedFields}
disabled={fillLoading || selectedCount === 0}
fullWidth
sx={{
py: 1.6,
borderRadius: 4,
bgcolor: formRecognizerPageStyles.validColor,
fontWeight: 700,
fontSize: '1rem',
textTransform: 'none',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
bgcolor: formRecognizerPageStyles.validDark,
boxShadow: `0 8px 24px ${alpha(formRecognizerPageStyles.validColor, 0.3)}`,
transform: 'translateY(-1px)',
},
'&:disabled': {
bgcolor: '#e0e0e0',
color: '#9e9e9e',
},
'&:active': {
transform: 'translateY(0)',
},
}}
>
</Button>
<OptionsPanel includeHidden={includeHidden} onIncludeHiddenChange={setIncludeHidden} /> <Button
disableElevation
disableRipple
variant="outlined"
onClick={handleClearAllFields}
disabled={clearLoading}
fullWidth
sx={{
py: 1.6,
borderRadius: 4,
borderColor: formRecognizerPageStyles.clearColor,
color: formRecognizerPageStyles.clearColor,
fontWeight: 600,
fontSize: '1rem',
textTransform: 'none',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
borderColor: formRecognizerPageStyles.clearDark,
bgcolor: formRecognizerPageStyles.clearBg,
transform: 'translateY(-1px)',
},
'&:disabled': {
borderColor: '#e0e0e0',
color: '#9e9e9e',
},
'&:active': {
transform: 'translateY(0)',
},
}}
>
</Button>
</Box>
)}
<OperationHistory <Box sx={{ mt: 3 }}>
history={operationHistory} <FormControlLabel
showHistory={showHistory} control={
onToggleShowHistory={() => setShowHistory(!showHistory)} <Switch
/> checked={includeHidden}
onChange={(e) => setIncludeHidden(e.target.checked)}
<TemplateManager />
templates={templates} }
showTemplates={showTemplates} label="包含隐藏字段"
templateLoading={templateLoading} />
onToggleShowTemplates={() => setShowTemplates(!showTemplates)} </Box>
onLoadTemplates={loadTemplates}
onExportTemplates={handleExportTemplates}
onImportTemplates={handleImportTemplates}
/>
<FeatureDescription />
<GlobalSnackbar {...snackbarProps} /> <GlobalSnackbar {...snackbarProps} />
</Container> </Container>
+9 -29
View File
@@ -1,13 +1,12 @@
import { Box, Typography, Container, Stack, alpha } from '@mui/material'; import { Box, Typography, Container } from '@mui/material';
import LanguageIcon from '@mui/icons-material/Language'; import LanguageIcon from '@mui/icons-material/Language';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar'; import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import UrlEntryForm from '@/components/UrlEntryForm'; import UrlEntryForm from '@/components/UrlEntryForm';
import UrlEntryList from '@/components/UrlEntryList'; import UrlEntryList from '@/components/UrlEntryList';
import { useUrlPreferences } from '@/utils/useUrlPreferences'; import { useUrlPreferences } from '@/utils/useUrlPreferences';
import type { OpenUrlEntry } from '@/types/storage'; import type { OpenUrlEntry } from '@/types/storage';
import { openUrlPageStyles, dashboardPageStyles } from '@/config/pageTheme'; import { dashboardPageStyles } from '@/config/pageTheme';
import PageHeader from '@/components/PageHeader';
const THEME_COLOR = openUrlPageStyles.themeColor;
export default function OpenUrlPage() { export default function OpenUrlPage() {
const { entries, setEntries, isLoaded } = useUrlPreferences(); const { entries, setEntries, isLoaded } = useUrlPreferences();
@@ -38,31 +37,12 @@ export default function OpenUrlPage() {
<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 }}>
{/* Header */} {/* Header */}
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}> <PageHeader
<Box title="URL 工具"
sx={{ subtitle="快速打开 URL 或复制链接"
p: 1, icon={<LanguageIcon />}
borderRadius: 2.5, sx={{ mb: 2.5 }}
bgcolor: alpha(THEME_COLOR, 0.1), />
color: THEME_COLOR,
display: 'flex',
}}
>
<LanguageIcon sx={{ fontSize: 20 }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography
variant="subtitle1"
fontWeight={900}
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2 }}
>
URL
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
URL
</Typography>
</Box>
</Stack>
{/* Form Section */} {/* Form Section */}
<UrlEntryForm onAddEntry={handleAddEntry} showMessage={showMessage} /> <UrlEntryForm onAddEntry={handleAddEntry} showMessage={showMessage} />
+9 -29
View File
@@ -1,11 +1,11 @@
import { Box, Typography, Stack, Container, CircularProgress } from '@mui/material'; import { Box, Stack, Container, CircularProgress } from '@mui/material';
import { alpha } from '@mui/system';
import QrCodeIcon from '@mui/icons-material/QrCode'; import QrCodeIcon from '@mui/icons-material/QrCode';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar'; import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import UrlToQrCodeSection from '@/components/UrlToQrCodeSection'; import UrlToQrCodeSection from '@/components/UrlToQrCodeSection';
import QrCodeToUrlSection from '@/components/QrCodeToUrlSection'; import QrCodeToUrlSection from '@/components/QrCodeToUrlSection';
import { useStorageState } from '@/utils/useStorageState'; import { useStorageState } from '@/utils/useStorageState';
import { qrCodePageStyles, dashboardPageStyles } from '@/config/pageTheme'; import { dashboardPageStyles } from '@/config/pageTheme';
import PageHeader from '@/components/PageHeader';
const QrCodePage = () => { const QrCodePage = () => {
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 }); const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
@@ -35,32 +35,12 @@ const QrCodePage = () => {
return ( return (
<Box sx={{ minHeight: '100%', pb: 3, bgcolor: dashboardPageStyles.backgroundColor }}> <Box sx={{ minHeight: '100%', pb: 3, bgcolor: dashboardPageStyles.backgroundColor }}>
<Container sx={{ py: 2, maxWidth: 400, bgcolor: dashboardPageStyles.backgroundColor }}> <Container sx={{ py: 2, maxWidth: 400, bgcolor: dashboardPageStyles.backgroundColor }}>
{/* Header */} <PageHeader
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}> title="二维码工具"
<Box subtitle="生成和解析二维码"
sx={{ icon={<QrCodeIcon />}
p: 1, sx={{ mb: 2.5 }}
borderRadius: 2.5, />
bgcolor: alpha(qrCodePageStyles.successColor, 0.1),
color: qrCodePageStyles.successColor,
display: 'flex',
}}
>
<QrCodeIcon sx={{ fontSize: 20 }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography
variant="subtitle1"
fontWeight={900}
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2 }}
>
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
</Stack>
<Stack spacing={3}> <Stack spacing={3}>
<UrlToQrCodeSection <UrlToQrCodeSection
+10 -37
View File
@@ -1,16 +1,8 @@
import { import { TextField, Select, MenuItem, Stack, Box, Container, alpha } from '@mui/material';
TextField,
Select,
MenuItem,
Stack,
Typography,
Box,
Container,
alpha,
} 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 PageHeader from '@/components/PageHeader';
import { ZONES, globalStyles, timestampPageStyles } from '@/config/pageTheme'; import { ZONES, globalStyles, timestampPageStyles } from '@/config/pageTheme';
import LiveClock from './components/LiveClock'; import LiveClock from './components/LiveClock';
import ResultView from './components/ResultView'; import ResultView from './components/ResultView';
@@ -38,32 +30,13 @@ export default function TimestampPage() {
return ( return (
<Box sx={{ bgcolor: globalStyles.backgroundColor, minHeight: '100%', pb: 3 }}> <Box sx={{ bgcolor: globalStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
<Container sx={{ py: 2, bgcolor: globalStyles.backgroundColor }}> <Container sx={{ py: 2, bgcolor: globalStyles.backgroundColor }}>
{/* Header with Icon */} {/* Header */}
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}> <PageHeader
<Box title="时间戳转换"
sx={{ subtitle="Unix 毫秒数转换与格式化"
p: 1, icon={<AccessTimeIcon />}
borderRadius: 2.5, sx={{ mb: 2.5 }}
bgcolor: alpha(timestampPageStyles.primaryColor, 0.1), />
color: timestampPageStyles.primaryColor,
display: 'flex',
}}
>
<AccessTimeIcon sx={{ fontSize: 20 }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography
variant="subtitle1"
fontWeight={900}
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2 }}
>
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
Unix
</Typography>
</Box>
</Stack>
{/* Live Clock Card */} {/* Live Clock Card */}
<LiveClock <LiveClock
@@ -181,7 +154,7 @@ export default function TimestampPage() {
fullWidth fullWidth
value={zone} value={zone}
onChange={(e) => setZone(e.target.value as typeof zone)} onChange={(e) => setZone(e.target.value as typeof zone)}
sx={{ ...timestampPageStyles.INPUT_STYLE, flex: 1 }} sx={{ ...timestampPageStyles.INPUT_STYLE, flex: 1, borderRadius: 4 }}
MenuProps={{ MenuProps={{
PaperProps: { PaperProps: {
sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 32px rgba(0,0,0,0.1)' }, sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 32px rgba(0,0,0,0.1)' },
@@ -25,7 +25,7 @@ export default function AutoRefreshToggle({ autoRefresh, onChange }: AutoRefresh
}, },
}} }}
> >
<Typography variant="body2" fontWeight={700} sx={{ fontSize: '0.8rem' }}> <Typography variant="body2" fontWeight={700} sx={{ fontSize: '0.8rem', px: 1.2 }}>
</Typography> </Typography>
<Switch <Switch
@@ -1,85 +1,84 @@
import { Box, Stack, Typography } from '@mui/material'; import { Box } from '@mui/material';
import StorageIcon from '@mui/icons-material/Storage'; import StorageIcon from '@mui/icons-material/Storage';
import PageHeader from '@/components/PageHeader';
import { formatSize } from '@/utils/storageCleaner'; import { formatSize } from '@/utils/storageCleaner';
import { storageCleanerPageStyles } from '@/config/pageTheme'; import { storageCleanerPageStyles } from '@/config/pageTheme';
/**
* DomainHeader 组件属性接口
*/
interface DomainHeaderProps { interface DomainHeaderProps {
/** 当前域名 */
domain: string; domain: string;
/** 已占用的存储大小(字节) */
totalSize: number; totalSize: number;
} }
/**
* DomainHeader - 存储清理页面标题栏组件
*
* 使用 PageHeader 组件构建,显示域名和已占用存储空间大小
*
* @example
* ```tsx
* <DomainHeader
* domain="example.com"
* totalSize={1048576}
* />
* ```
*/
export default function DomainHeader({ domain, totalSize }: DomainHeaderProps) { export default function DomainHeader({ domain, totalSize }: DomainHeaderProps) {
return ( return (
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 3 }}> <PageHeader
<Box icon={<StorageIcon sx={{ fontSize: 22 }} />}
sx={{ iconColor={storageCleanerPageStyles.warningColor}
p: 1.2, title="存储清理"
borderRadius: 3, subtitle={domain || '加载中...'}
bgcolor: 'rgba(255, 152, 0, 0.1)', badge={
color: storageCleanerPageStyles.warningColor, totalSize > 0 ? (
display: 'flex', <Box
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={{ sx={{
letterSpacing: '-0.5px', bgcolor: 'rgba(255, 152, 0, 0.15)',
lineHeight: 1.2, color: storageCleanerPageStyles.warningColor,
fontSize: '1rem', px: 1.5,
color: 'text.primary', 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)}
</Typography> </Box>
{totalSize > 0 && ( ) : null
<Box }
sx={{ iconSx={{
bgcolor: 'rgba(255, 152, 0, 0.15)', p: 1.2,
color: storageCleanerPageStyles.warningColor, borderRadius: 3,
px: 1.5, boxShadow: '0 2px 8px rgba(255, 152, 0, 0.15)',
py: 0.3, transition: 'all 0.2s',
borderRadius: 2, '&:hover': {
fontWeight: 800, bgcolor: 'rgba(255, 152, 0, 0.15)',
fontSize: '0.7rem', transform: 'scale(1.05)',
boxShadow: '0 2px 4px rgba(255, 152, 0, 0.2)', },
transition: 'all 0.2s', }}
'&:hover': { titleSx={{
bgcolor: 'rgba(255, 152, 0, 0.25)', fontSize: '1rem',
}, }}
}} subtitleSx={{
> display: 'block',
{formatSize(totalSize)} maxWidth: 240,
</Box> overflow: 'hidden',
)} textOverflow: 'ellipsis',
</Stack> whiteSpace: 'nowrap',
<Typography mt: 0.3,
variant="body2" fontSize: '0.75rem',
color="text.secondary" }}
sx={{ sx={{ mb: 3 }}
fontWeight: 600, />
display: 'block',
maxWidth: 240,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
mt: 0.3,
fontSize: '0.75rem',
}}
>
{domain || '加载中...'}
</Typography>
</Box>
</Stack>
); );
} }
@@ -4,12 +4,13 @@ import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import { timestampPageStyles } from '@/config/pageTheme'; import { timestampPageStyles } from '@/config/pageTheme';
import type { UnitType } from '@/config/pageTheme'; import type { UnitType } from '@/config/pageTheme';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
interface LiveClockProps { interface LiveClockProps {
unit: UnitType; unit: UnitType;
onUseNow: (val: number) => void; onUseNow: (val: number) => void;
onUnitChange: (u: UnitType) => void; onUnitChange: (u: UnitType) => void;
showMessage?: (message: string, options?: { severity: 'success' | 'error' }) => void; showMessage?: (message: string, options?: SnackbarOptions) => void;
} }
const LiveClock = React.memo(({ unit, onUseNow, onUnitChange, showMessage }: LiveClockProps) => { const LiveClock = React.memo(({ unit, onUseNow, onUnitChange, showMessage }: LiveClockProps) => {
@@ -4,13 +4,14 @@ import dayjs from '@/utils/dayjs';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import { DATE_FORMAT, timestampPageStyles } from '@/config/pageTheme'; import { DATE_FORMAT, timestampPageStyles } from '@/config/pageTheme';
import type { UnitType } from '@/config/pageTheme'; import type { UnitType } from '@/config/pageTheme';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
interface ResultViewProps { interface ResultViewProps {
result: string; result: string;
mode: 'ts2dt' | 'dt2ts'; mode: 'ts2dt' | 'dt2ts';
unit: UnitType; unit: UnitType;
zone: string; zone: string;
showMessage?: (message: string, options?: { severity: 'success' | 'error' }) => void; showMessage?: (message: string, options?: SnackbarOptions) => void;
} }
const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: ResultViewProps) => { const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: ResultViewProps) => {
@@ -57,6 +58,9 @@ const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: Result
mb: 2.5, mb: 2.5,
border: '1px solid', border: '1px solid',
borderColor: alpha(timestampPageStyles.primaryColor, 0.1), borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}} }}
> >
<Typography <Typography
@@ -77,12 +81,6 @@ const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: Result
tooltip="复制结果" tooltip="复制结果"
size="small" size="small"
color={timestampPageStyles.primaryColor} color={timestampPageStyles.primaryColor}
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
}}
showMessage={showMessage} showMessage={showMessage}
/> />
</Box> </Box>
@@ -95,7 +93,6 @@ const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: Result
borderRadius: 4, borderRadius: 4,
border: '1px solid', border: '1px solid',
borderColor: alpha(timestampPageStyles.primaryColor, 0.1), borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
mt: 2,
}} }}
> >
{[ {[
@@ -105,11 +102,11 @@ const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: Result
].map((item) => ( ].map((item) => (
<Box <Box
key={item.label} key={item.label}
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: 1 }} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
> >
<Typography <Typography
variant="caption" variant="caption"
sx={{ color: 'text.disabled', fontWeight: 700, fontSize: '0.65rem' }} sx={{ color: 'text.disabled', fontWeight: 700, fontSize: '0.65rem', pr: 4 }}
> >
{item.label} {item.label}
</Typography> </Typography>
@@ -26,77 +26,79 @@ export default function StorageOptionsGrid({
border: '1px solid', border: '1px solid',
borderColor: 'grey.100', borderColor: 'grey.100',
borderRadius: 4, borderRadius: 4,
p: 1.2,
bgcolor: 'background.paper', bgcolor: 'background.paper',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
transition: 'all 0.2s', transition: 'all 0.2s',
overflow: 'hidden',
'&:hover': { '&:hover': {
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.08)', boxShadow: '0 6px 16px rgba(0, 0, 0, 0.08)',
}, },
}} }}
> >
<Grid container spacing={1.5}> <Box sx={{ p: 1.2 }}>
<Grid size={6}> <Grid container spacing={1.5}>
<OptionItem <Grid size={6}>
label="LocalStorage" <OptionItem
checked={options.localStorage} label="LocalStorage"
size={sizes.localStorage} checked={options.localStorage}
onChange={() => onOptionChange('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> </Grid>
<Grid size={6}> </Box>
<OptionItem <Divider sx={{ mx: 0, borderColor: 'grey.100' }} />
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 <Box
sx={{ sx={{
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
px: 1.5, px: 2.7,
py: 0.6, py: 0.8,
bgcolor: 'rgba(0, 0, 0, 0.02)', borderBottomLeftRadius: 4,
borderRadius: 2, borderBottomRightRadius: 4,
transition: 'all 0.2s', transition: 'all 0.2s',
'&:hover': { '&:hover': {
bgcolor: 'rgba(0, 0, 0, 0.04)', bgcolor: 'rgba(0, 0, 0, 0.04)',
@@ -106,7 +108,7 @@ export default function StorageOptionsGrid({
<Typography <Typography
variant="body2" variant="body2"
fontWeight={700} fontWeight={700}
sx={{ color: 'text.secondary', fontSize: '0.7rem' }} sx={{ color: 'text.secondary', fontSize: '0.7rem', px: 0 }}
> >
</Typography> </Typography>
@@ -118,6 +120,7 @@ export default function StorageOptionsGrid({
color="warning" color="warning"
sx={{ sx={{
p: 0.6, p: 0.6,
mr: 0,
'& .MuiSvgIcon-root': { '& .MuiSvgIcon-root': {
fontSize: 18, fontSize: 18,
transition: 'transform 0.2s', transition: 'transform 0.2s',
+2 -4
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
import type { import type {
StorageCleanerOptions, StorageCleanerOptions,
CleaningResult, CleaningResult,
@@ -57,10 +58,7 @@ export interface UseStorageCleanerReturn {
} }
export interface UseStorageCleanerOptions { export interface UseStorageCleanerOptions {
showMessage: ( showMessage: (message: string, options?: SnackbarOptions) => void;
message: string,
options?: { severity?: 'success' | 'warning' | 'error' | 'info' },
) => void;
} }
export function useStorageCleaner({ export function useStorageCleaner({
@@ -1,477 +0,0 @@
import { useState } from 'react';
import {
Box,
Typography,
Paper,
Button,
Checkbox,
TextField,
IconButton,
CircularProgress,
Switch,
FormControlLabel,
Tooltip,
Alert,
} from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import HighlightAltIcon from '@mui/icons-material/HighlightAlt';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import { FieldType, FillMode } from '@/utils/dummyDataGenerator';
import { MessageAction, sendMessageToContent, injectContentScript } from '@/utils/messages';
interface FieldData {
id: string;
fieldType: FieldType;
label: string | null;
placeholder: string;
name: string;
value: string;
isSelected: boolean;
generatedValue: string;
}
const FormFillSidePanel = () => {
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
const [loading, setLoading] = useState(false);
const [scanning, setScanning] = useState(false);
const [fields, setFields] = useState<FieldData[]>([]);
const [mode, setMode] = useState<'valid' | 'invalid'>('valid');
const [fillEmptyOnly, setFillEmptyOnly] = useState(false);
const [includeHidden] = useState(false);
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
const handleScan = async () => {
setScanning(true);
try {
let response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
// 如果连接失败,尝试注入内容脚本
if (!response.success && response.message && response.message.includes('无法连接')) {
showMessage('正在注入内容脚本...', { severity: 'info' });
const injected = await injectContentScript();
if (injected) {
// 注入成功后再次尝试
response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
}
}
if (response.success) {
setFields(response.fields || []);
showMessage(`扫描完成,发现 ${response.totalCount} 个可填充字段`, { severity: 'success' });
} else {
showMessage(response.message || '扫描失败', { severity: 'error' });
}
} catch (error) {
console.error('扫描失败:', error);
showMessage('扫描失败,请确保页面已加载', { severity: 'error' });
} finally {
setScanning(false);
}
};
const handleRefreshAll = async () => {
const updatedFields = fields.map((field) => ({
...field,
generatedValue: generateRandomValue(field.fieldType, mode),
}));
setFields(updatedFields);
showMessage('已刷新所有数据', { severity: 'success' });
};
const handleRefreshField = (fieldId: string) => {
setFields((prevFields) =>
prevFields.map((field) =>
field.id === fieldId
? { ...field, generatedValue: generateRandomValue(field.fieldType, mode) }
: field,
),
);
};
const handleToggleSelect = (fieldId: string) => {
setFields((prevFields) =>
prevFields.map((field) =>
field.id === fieldId ? { ...field, isSelected: !field.isSelected } : field,
),
);
};
const handleSelectAll = () => {
const allSelected = fields.every((f) => f.isSelected);
setFields((prevFields) => prevFields.map((field) => ({ ...field, isSelected: !allSelected })));
};
const handleEditValue = (fieldId: string, newValue: string) => {
setFields((prevFields) =>
prevFields.map((field) =>
field.id === fieldId ? { ...field, generatedValue: newValue } : field,
),
);
};
const handleFill = async () => {
setLoading(true);
try {
const selectedFields = fields.filter((f) => f.isSelected);
const response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
fields: selectedFields,
mode: mode === 'valid' ? FillMode.VALID : FillMode.INVALID,
includeHidden,
});
if (response.success) {
showMessage(response.message || '填充成功', { severity: 'success' });
} else {
showMessage(response.message || '填充失败', { severity: 'error' });
}
} catch (error) {
console.error('填充失败:', error);
showMessage('填充失败', { severity: 'error' });
} finally {
setLoading(false);
}
};
const handleClear = async () => {
setLoading(true);
try {
const response = await sendMessageToContent(MessageAction.CLEAR_ALL_FIELDS);
if (response.success) {
showMessage(response.message || '已清空', { severity: 'success' });
} else {
showMessage(response.message || '清空失败', { severity: 'error' });
}
} catch (error) {
console.error('清空失败:', error);
showMessage('清空失败', { severity: 'error' });
} finally {
setLoading(false);
}
};
const handleHoverField = async (fieldId: string | null) => {
setHoveredFieldId(fieldId);
if (fieldId) {
await sendMessageToContent(MessageAction.HIGHLIGHT_FIELD, { fieldId });
} else {
await sendMessageToContent(MessageAction.UNHIGHLIGHT_ALL_FIELDS);
}
};
const generateRandomValue = (fieldType: FieldType, fillMode: 'valid' | 'invalid'): string => {
const chineseNames = ['张三', '李四', '王五', '赵六', '钱七', '孙八', '周九', '吴十'];
const englishNames = ['John Doe', 'Jane Smith', 'Bob Wilson', 'Alice Brown'];
const domains = ['example.com', 'test.com', 'demo.com'];
const specialChars = '!@#$%^&*()';
switch (fieldType) {
case FieldType.NAME:
return Math.random() > 0.5
? chineseNames[Math.floor(Math.random() * chineseNames.length)]
: englishNames[Math.floor(Math.random() * englishNames.length)];
case FieldType.EMAIL: {
const emailPrefix = Math.random().toString(36).substr(2, 8);
const emailDomain = domains[Math.floor(Math.random() * domains.length)];
return fillMode === 'valid' ? `${emailPrefix}@${emailDomain}` : `${emailPrefix}example.com`;
}
case FieldType.PHONE: {
const phonePrefixes = ['130', '131', '132', '135', '136', '137', '138', '139'];
const phonePrefix = phonePrefixes[Math.floor(Math.random() * phonePrefixes.length)];
const phoneSuffix = Math.floor(Math.random() * 100000000)
.toString()
.padStart(8, '0');
return fillMode === 'valid'
? phonePrefix + phoneSuffix
: phonePrefix + Math.floor(Math.random() * 10000000).toString();
}
case FieldType.NUMBER:
return fillMode === 'valid'
? String(Math.floor(Math.random() * 10000))
: String(-Math.floor(Math.random() * 10000));
case FieldType.DATE: {
const days = Math.floor(Math.random() * 365);
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString().split('T')[0];
}
case FieldType.TEXTarea:
return fillMode === 'valid'
? '这是一段测试文本,用于填充表单输入区域。'.repeat(3)
: specialChars.repeat(100);
case FieldType.PASSWORD:
return 'Test@123';
case FieldType.ID_CARD: {
const areaCode = '110101';
const year = 1990 + Math.floor(Math.random() * 30);
const month = String(1 + Math.floor(Math.random() * 12)).padStart(2, '0');
const day = String(1 + Math.floor(Math.random() * 28)).padStart(2, '0');
const random = Math.floor(Math.random() * 10000)
.toString()
.padStart(4, '0');
return areaCode + year + month + day + random;
}
default:
return fillMode === 'valid' ? '测试数据' : specialChars.repeat(50);
}
};
const getFieldTypeLabel = (fieldType: FieldType): string => {
const labels: Record<FieldType, string> = {
[FieldType.TEXT]: '文本',
[FieldType.EMAIL]: '邮箱',
[FieldType.PHONE]: '手机',
[FieldType.NUMBER]: '数字',
[FieldType.DATE]: '日期',
[FieldType.TEXTarea]: '文本域',
[FieldType.RADIO]: '单选',
[FieldType.CHECKBOX]: '多选',
[FieldType.SELECT]: '下拉',
[FieldType.PASSWORD]: '密码',
[FieldType.NAME]: '姓名',
[FieldType.ID_CARD]: '身份证',
[FieldType.UNKNOWN]: '未知',
};
return labels[fieldType] || '未知';
};
const selectedCount = fields.filter((f) => f.isSelected).length;
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', bgcolor: '#f5f5f5' }}>
<Box sx={{ p: 2, bgcolor: 'white', borderBottom: '1px solid #e0e0e0' }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
Dummy Data Pro
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
<Box sx={{ p: 2, bgcolor: 'white', mb: 1 }}>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Button
variant="contained"
size="small"
startIcon={scanning ? <CircularProgress size={16} color="inherit" /> : <RefreshIcon />}
onClick={handleScan}
disabled={scanning}
sx={{ flex: 1 }}
>
{scanning ? '扫描中...' : '扫描表单'}
</Button>
<Button
variant="outlined"
size="small"
startIcon={<AutoFixHighIcon />}
onClick={handleRefreshAll}
disabled={fields.length === 0}
>
</Button>
</Box>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Button
variant="contained"
color="success"
size="small"
onClick={() => setMode('valid')}
sx={{
flex: 1,
bgcolor: mode === 'valid' ? '#4caf50' : '#e0e0e0',
color: mode === 'valid' ? 'white' : 'text.primary',
}}
>
</Button>
<Button
variant="contained"
color="warning"
size="small"
onClick={() => setMode('invalid')}
sx={{
flex: 1,
bgcolor: mode === 'invalid' ? '#ff9800' : '#e0e0e0',
color: mode === 'invalid' ? 'white' : 'text.primary',
}}
>
</Button>
</Box>
<FormControlLabel
control={
<Switch
checked={fillEmptyOnly}
onChange={(e) => setFillEmptyOnly(e.target.checked)}
size="small"
/>
}
label="仅填充空字段"
sx={{ mb: 1 }}
/>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
variant="contained"
color="primary"
size="small"
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : null}
onClick={handleFill}
disabled={loading || selectedCount === 0}
sx={{ flex: 1 }}
>
{loading ? '填充中...' : `确认填充 (${selectedCount})`}
</Button>
<Button
variant="outlined"
color="error"
size="small"
startIcon={<DeleteOutlineIcon />}
onClick={handleClear}
disabled={loading}
>
</Button>
</Box>
</Box>
{fields.length > 0 && (
<Box sx={{ flex: 1, overflow: 'auto', px: 2, pb: 2 }}>
<Box
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
({fields.length})
</Typography>
<Button size="small" onClick={handleSelectAll}>
{fields.every((f) => f.isSelected) ? '取消全选' : '全选'}
</Button>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{fields.map((field) => (
<Paper
key={field.id}
elevation={0}
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: hoveredFieldId === field.id ? '#e3f2fd' : 'white',
border: '1px solid',
borderColor: hoveredFieldId === field.id ? '#2196f3' : '#e0e0e0',
transition: 'all 0.2s ease',
cursor: 'pointer',
}}
onMouseEnter={() => handleHoverField(field.id)}
onMouseLeave={() => handleHoverField(null)}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<Checkbox
size="small"
checked={field.isSelected}
onChange={() => handleToggleSelect(field.id)}
sx={{ p: 0, mt: 0.5 }}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '60%',
}}
>
{field.label || field.placeholder || field.name || '未命名字段'}
</Typography>
<Tooltip title="字段类型">
<Box
sx={{
px: 0.5,
py: 0.25,
bgcolor: '#e0e0e0',
borderRadius: 1,
fontSize: '0.7rem',
}}
>
{getFieldTypeLabel(field.fieldType)}
</Box>
</Tooltip>
<Tooltip title="高亮显示">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleHoverField(field.id);
setTimeout(() => handleHoverField(null), 2000);
}}
sx={{ p: 0.5 }}
>
<HighlightAltIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<TextField
size="small"
fullWidth
value={field.generatedValue}
onChange={(e) => handleEditValue(field.id, e.target.value)}
placeholder="生成的数据..."
sx={{
'& .MuiInputBase-input': {
fontSize: '0.8rem',
fontFamily: 'monospace',
},
}}
/>
<Tooltip title="刷新此项">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleRefreshField(field.id);
}}
sx={{ p: 0.5 }}
>
<RefreshIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
</Box>
{field.value && (
<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 0.5, display: 'block' }}
>
: {field.value}
</Typography>
)}
</Box>
</Box>
</Paper>
))}
</Box>
</Box>
)}
{fields.length === 0 && !scanning && (
<Box
sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', p: 3 }}
>
<Alert severity="info" sx={{ width: '100%' }}>
</Alert>
</Box>
)}
<GlobalSnackbar {...snackbarProps} />
</Box>
);
};
export default FormFillSidePanel;
+7
View File
@@ -20,6 +20,13 @@ export interface StorageSchema {
'openUrl/currentUrl': string; 'openUrl/currentUrl': string;
'qrCode/qrExpanded': boolean; 'qrCode/qrExpanded': boolean;
'qrCode/urlExpanded': boolean; 'qrCode/urlExpanded': boolean;
'formRecognizer/fieldTypePreferences': FieldTypePreferences;
}
export interface FieldTypePreferences {
[domain: string]: {
[fieldIdentifier: string]: string;
};
} }
export interface StorageCleanerPreferences { export interface StorageCleanerPreferences {
+2 -1
View File
@@ -1,3 +1,4 @@
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
import { useSnackbar } from '@/components/GlobalSnackbar'; import { useSnackbar } from '@/components/GlobalSnackbar';
/** /**
@@ -8,7 +9,7 @@ import { useSnackbar } from '@/components/GlobalSnackbar';
*/ */
export const copyToClipboard = async ( export const copyToClipboard = async (
text: string, text: string,
showMessage?: (message: string, options?: { severity: 'success' | 'error' }) => void, showMessage?: (message: string, options?: SnackbarOptions) => void,
): Promise<boolean> => { ): Promise<boolean> => {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
+57 -16
View File
@@ -1,4 +1,4 @@
import { faker, fakerZH_CN } from '@faker-js/faker'; import { fakerZH_CN as faker } from '@faker-js/faker';
/** /**
* 表单字段信息接口 * 表单字段信息接口
@@ -34,7 +34,7 @@ export class DummyDataGenerator {
* 生成随机中文姓名 * 生成随机中文姓名
*/ */
static generateChineseName(): string { static generateChineseName(): string {
return fakerZH_CN.person.fullName(); return faker.person.fullName();
} }
/** /**
@@ -45,17 +45,20 @@ export class DummyDataGenerator {
} }
/** /**
* 生成随机手机号 * 生成随机手机号(中国格式)
*/ */
static generatePhoneNumber(): string { static generatePhoneNumber(): string {
return fakerZH_CN.phone.number(); const prefix =
'1' + faker.string.numeric({ length: 1, allowLeadingZeros: false, exclude: ['0', '1', '2'] });
const suffix = faker.string.numeric({ length: 9, allowLeadingZeros: true });
return prefix + suffix;
} }
/** /**
* 生成有效邮箱 * 生成有效邮箱
*/ */
static generateValidEmail(): string { static generateValidEmail(): string {
return fakerZH_CN.internet.email(); return faker.internet.email();
} }
/** /**
@@ -77,14 +80,14 @@ export class DummyDataGenerator {
* 生成短文本 * 生成短文本
*/ */
static generateShortText(): string { static generateShortText(): string {
return fakerZH_CN.lorem.sentence({ min: 3, max: 6 }); return faker.lorem.sentence({ min: 3, max: 6 });
} }
/** /**
* 生成长文本 * 生成长文本
*/ */
static generateLongText(): string { static generateLongText(): string {
return fakerZH_CN.lorem.paragraphs(5); return faker.lorem.paragraphs(5);
} }
/** /**
@@ -106,42 +109,42 @@ export class DummyDataGenerator {
* 生成随机数字 * 生成随机数字
*/ */
static generateNumber(): number { static generateNumber(): number {
return fakerZH_CN.number.int(10000); return faker.number.int(10000);
} }
/** /**
* 生成随机浮点数 * 生成随机浮点数
*/ */
static generateFloat(): number { static generateFloat(): number {
return fakerZH_CN.number.float({ max: 10000 }); return faker.number.float({ max: 10000 });
} }
/** /**
* 生成随机负数 * 生成随机负数
*/ */
static generateNegativeNumber(): number { static generateNegativeNumber(): number {
return -fakerZH_CN.number.int(10000); return -faker.number.int(10000);
} }
/** /**
* 生成随机日期 * 生成随机日期
*/ */
static generateDate(): string { static generateDate(): string {
return fakerZH_CN.date.recent({ days: 365 }).toISOString().split('T')[0]; return faker.date.recent({ days: 365 }).toISOString().split('T')[0];
} }
/** /**
* 生成过去的日期 * 生成过去的日期
*/ */
static generatePastDate(): string { static generatePastDate(): string {
return fakerZH_CN.date.past({ years: 1 }).toISOString().split('T')[0]; return faker.date.past({ years: 1 }).toISOString().split('T')[0];
} }
/** /**
* 生成未来的日期 * 生成未来的日期
*/ */
static generateFutureDate(): string { static generateFutureDate(): string {
return fakerZH_CN.date.future({ years: 1 }).toISOString().split('T')[0]; return faker.date.future({ years: 1 }).toISOString().split('T')[0];
} }
/** /**
@@ -779,14 +782,23 @@ export function fillFieldWithInjector(
} }
/** /**
* 批量填充选中的字段 * 批量填充选中的字段(支持单字段模式覆盖)
*/ */
export function fillSelectedFields(fields: FormFieldInfo[], mode: FillMode): number { export function fillSelectedFields(
fields: Array<FormFieldInfo & { useInvalidData?: boolean }>,
defaultMode: FillMode,
): number {
let filledCount = 0; let filledCount = 0;
fields.forEach((field) => { fields.forEach((field) => {
if (field.isSelected) { if (field.isSelected) {
const value = field.generatedValue || generateValueByFieldType(field.fieldType, mode); const mode = field.useInvalidData
? FillMode.INVALID
: field.useInvalidData === false
? FillMode.VALID
: defaultMode;
// 始终根据当前 fieldType 重新生成值,确保类型变更生效
const value = generateValueByFieldType(field.fieldType, mode);
fillFieldWithInjector(field.element, value); fillFieldWithInjector(field.element, value);
filledCount++; filledCount++;
} }
@@ -795,6 +807,35 @@ export function fillSelectedFields(fields: FormFieldInfo[], mode: FillMode): num
return filledCount; return filledCount;
} }
/**
* 闪烁字段(用于定位)
*/
export function flashField(element: HTMLElement): void {
let flashCount = 0;
const maxFlashes = 4;
const originalStyle =
element.getAttribute('data-original-style') || element.getAttribute('style') || '';
element.setAttribute('data-original-style', originalStyle);
const flash = () => {
if (flashCount >= maxFlashes) {
unhighlightField(element);
return;
}
if (flashCount % 2 === 0) {
element.style.outline = '3px solid #4caf50';
element.style.outlineOffset = '2px';
element.style.transition = 'outline 0.3s ease-in-out';
} else {
element.style.outline = '';
}
flashCount++;
setTimeout(flash, 300);
};
flash();
}
/** /**
* 高亮指定字段 * 高亮指定字段
*/ */
+11 -1
View File
@@ -1,5 +1,12 @@
import { FormFieldInfo, FillMode } from './dummyDataGenerator'; import { FormFieldInfo, FillMode } from './dummyDataGenerator';
/**
* 字段数据接口(用于消息传递)
*/
interface MessageFieldData extends Omit<FormFieldInfo, 'element'> {
useInvalidData?: boolean;
}
/** /**
* 消息动作类型 * 消息动作类型
*/ */
@@ -19,6 +26,9 @@ export enum MessageAction {
UNHIGHLIGHT_FIELD = 'unhighlightField', UNHIGHLIGHT_FIELD = 'unhighlightField',
HIGHLIGHT_ALL_FIELDS = 'highlightAllFields', HIGHLIGHT_ALL_FIELDS = 'highlightAllFields',
UNHIGHLIGHT_ALL_FIELDS = 'unhighlightAllFields', UNHIGHLIGHT_ALL_FIELDS = 'unhighlightAllFields',
// 字段定位/闪烁
FLASH_FIELD = 'flashField',
} }
/** /**
@@ -28,7 +38,7 @@ export interface MessagePayload {
action: MessageAction | string; action: MessageAction | string;
tabId?: number; tabId?: number;
delay?: number; delay?: number;
fields?: Omit<FormFieldInfo, 'element'>[]; fields?: MessageFieldData[];
mode?: FillMode; mode?: FillMode;
includeHidden?: boolean; includeHidden?: boolean;
fieldId?: string; fieldId?: string;
+15 -6
View File
@@ -1,28 +1,37 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import type { StorageSchema } from '@/types/storage';
export const useStorageState = ( export const useStorageState = <K extends keyof StorageSchema>(
key: 'qrCode/urlExpanded' | 'qrCode/qrExpanded', key: K,
defaultValue: boolean, defaultValue: StorageSchema[K],
) => { ) => {
const [value, setValue] = useState(defaultValue); const [value, setValue] = useState(defaultValue);
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const hasLoadedFromStorage = useRef(false);
// Only load from storage once on mount
useEffect(() => { useEffect(() => {
if (hasLoadedFromStorage.current) return;
const loadState = async () => { const loadState = async () => {
try { try {
const savedValue = await storageUtil.get(key, defaultValue); const savedValue = await storageUtil.get(key, defaultValue);
setValue(savedValue ?? defaultValue); if (savedValue !== undefined) {
setValue(savedValue);
}
} catch (error) { } catch (error) {
console.error(`加载状态失败 (${key}):`, error); console.error(`加载状态失败 (${key}):`, error);
} finally { } finally {
setIsInitialized(true); setIsInitialized(true);
hasLoadedFromStorage.current = true;
} }
}; };
loadState(); loadState();
}, [key, defaultValue]); }, [key]); // eslint-disable-line react-hooks/exhaustive-deps -- defaultValue intentionally excluded to prevent infinite loops
// Save to storage when value changes (after initial load)
useEffect(() => { useEffect(() => {
if (!isInitialized) return; if (!isInitialized) return;