Develop (#15)
* 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:
@@ -79,7 +79,7 @@ jobs:
|
||||
needs: [lint, typecheck, test]
|
||||
strategy:
|
||||
matrix:
|
||||
browser: [chrome, firefox]
|
||||
browser: [chrome]
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -97,7 +97,3 @@ jobs:
|
||||
- name: Build (Chrome)
|
||||
if: matrix.browser == 'chrome'
|
||||
run: npm run build
|
||||
|
||||
- name: Build (Firefox)
|
||||
if: matrix.browser == 'firefox'
|
||||
run: npm run build:firefox
|
||||
|
||||
@@ -2,6 +2,12 @@ import { Button as MuiButton, ButtonProps as MuiButtonProps } from '@mui/materia
|
||||
|
||||
export type ButtonProps = MuiButtonProps;
|
||||
|
||||
/**
|
||||
* 按钮组件
|
||||
* @param sx 自定义样式
|
||||
* @param props 其他按钮属性
|
||||
* @returns 按钮组件
|
||||
*/
|
||||
export function Button({ sx = [], ...props }: ButtonProps) {
|
||||
return (
|
||||
<MuiButton
|
||||
|
||||
@@ -3,14 +3,24 @@ import { IconButton, Tooltip } from '@mui/material';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
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 {
|
||||
text: string;
|
||||
tooltip?: string;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
color?: 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning' | string;
|
||||
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 size 按钮大小
|
||||
* @param color 按钮颜色
|
||||
* @param style 自定义样式
|
||||
* @param showMessage 消息提示函数,用于显示复制成功或失败的消息
|
||||
* @returns 复制按钮组件
|
||||
*/
|
||||
export const CopyButton: React.FC<CopyButtonProps> = ({
|
||||
@@ -56,8 +68,7 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
|
||||
'&:hover': {
|
||||
bgcolor: copied
|
||||
? 'success.main'
|
||||
: typeof color === 'string' &&
|
||||
!['primary', 'secondary', 'success', 'error', 'info', 'warning'].includes(color)
|
||||
: !['primary', 'secondary', 'success', 'error', 'info', 'warning'].includes(color)
|
||||
? color
|
||||
: `${color}.main`,
|
||||
color: '#fff',
|
||||
|
||||
@@ -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;
|
||||
+128
-51
@@ -5,14 +5,19 @@ import {
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Collapse,
|
||||
Chip,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
SelectChangeEvent,
|
||||
Checkbox,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import InputIcon from '@mui/icons-material/Input';
|
||||
import { FieldType } from '@/utils/dummyDataGenerator';
|
||||
|
||||
// 字段数据接口
|
||||
interface FieldData {
|
||||
@@ -24,51 +29,57 @@ interface FieldData {
|
||||
value: string;
|
||||
isSelected: boolean;
|
||||
generatedValue: string;
|
||||
useInvalidData?: boolean;
|
||||
}
|
||||
|
||||
// 字段类型显示名称映射
|
||||
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',
|
||||
[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]: '未知',
|
||||
};
|
||||
|
||||
interface FieldListProps {
|
||||
fields: FieldData[];
|
||||
showFields: boolean;
|
||||
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;
|
||||
|
||||
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 (
|
||||
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 2 }}>
|
||||
<Box
|
||||
@@ -84,34 +95,100 @@ const FieldList: React.FC<FieldListProps> = ({ fields, showFields, onToggleShowF
|
||||
}}
|
||||
onClick={onToggleShowFields}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
已识别字段 ({fields.length})
|
||||
</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>
|
||||
<Collapse in={showFields}>
|
||||
<List dense sx={{ maxHeight: 300, overflow: 'auto' }}>
|
||||
<List dense sx={{ maxHeight: 400, overflow: 'auto' }}>
|
||||
{fields.map((field, index) => (
|
||||
<ListItem key={field.id} sx={{ py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<InputIcon fontSize="small" color="action" />
|
||||
<ListItem
|
||||
key={field.id}
|
||||
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>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
opacity: field.isSelected ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
{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}
|
||||
/>
|
||||
|
||||
<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>
|
||||
))}
|
||||
</List>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Box,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Paper,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
|
||||
@@ -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
@@ -6,7 +6,6 @@ import OpenUrlPage from '@/entrypoints/popup/pages/OpenUrlPage';
|
||||
import OpenUrlViewerPage from '@/entrypoints/popup/pages/OpenUrlViewerPage';
|
||||
import QrCodePage from '@/entrypoints/popup/pages/QrCodePage';
|
||||
import FormRecognizerPage from '@/entrypoints/popup/pages/FormRecognizerPage';
|
||||
import FormFillSidePanel from '@/entrypoints/sidepanel/pages/FormFillSidePanel';
|
||||
|
||||
export interface RouteConfig {
|
||||
key: PageType;
|
||||
@@ -76,7 +75,7 @@ export const ROUTES: RouteConfig[] = [
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: FormRecognizerPage,
|
||||
sidepanel: FormFillSidePanel,
|
||||
sidepanel: FormRecognizerPage,
|
||||
detached: FormRecognizerPage,
|
||||
},
|
||||
},
|
||||
|
||||
+25
-3
@@ -6,6 +6,7 @@ import {
|
||||
scanFormFields,
|
||||
highlightField,
|
||||
unhighlightField,
|
||||
flashField,
|
||||
FillMode,
|
||||
type FormFieldInfo,
|
||||
} from '@/utils/dummyDataGenerator';
|
||||
@@ -54,9 +55,19 @@ export default defineContentScript({
|
||||
break;
|
||||
case MessageAction.FILL_SELECTED_FIELDS: {
|
||||
// 使用之前扫描时存储的字段,因为它们包含element属性
|
||||
const fieldIds = (message.fields || []).filter((f) => f.isSelected).map((f) => f.id);
|
||||
const fieldsToFill = currentFields.filter((f) => fieldIds.includes(f.id));
|
||||
fieldsToFill.forEach((f) => (f.isSelected = true));
|
||||
const incomingFields = message.fields || [];
|
||||
const fieldsToFill = currentFields.map((field) => {
|
||||
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);
|
||||
sendResponse({ success: true, message: `已填充 ${count} 个字段` });
|
||||
break;
|
||||
@@ -104,6 +115,17 @@ export default defineContentScript({
|
||||
});
|
||||
sendResponse({ success: true });
|
||||
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:
|
||||
sendResponse({ success: false, message: '未知操作' });
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Box, Typography, Container, Button, CircularProgress } from '@mui/material';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Button,
|
||||
CircularProgress,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
alpha,
|
||||
} from '@mui/material';
|
||||
import InputIcon from '@mui/icons-material/Input';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import { dashboardPageStyles, formRecognizerPageStyles } from '@/config/pageTheme';
|
||||
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 OperationHistory from '@/components/OperationHistory';
|
||||
import TemplateManager from '@/components/TemplateManager';
|
||||
import MainActions from '@/components/MainActions';
|
||||
import OptionsPanel from '@/components/OptionsPanel';
|
||||
import FeatureDescription from '@/components/FeatureDescription';
|
||||
import { useStorageState } from '@/utils/useStorageState';
|
||||
import { FieldTypePreferences } from '@/types/storage';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
|
||||
// 字段数据接口
|
||||
interface FieldData {
|
||||
@@ -22,28 +29,116 @@ interface FieldData {
|
||||
value: string;
|
||||
isSelected: boolean;
|
||||
generatedValue: string;
|
||||
useInvalidData?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_FIELD_TYPE_PREFERENCES: FieldTypePreferences = {};
|
||||
|
||||
const FormRecognizerPage = () => {
|
||||
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 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);
|
||||
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
|
||||
const [currentDomain, setCurrentDomain] = useState<string>('');
|
||||
const [sidePanelOpen, setSidePanelOpen] = useState(false);
|
||||
|
||||
const [fieldTypePreferences, setFieldTypePreferences] = useStorageState(
|
||||
'formRecognizer/fieldTypePreferences',
|
||||
DEFAULT_FIELD_TYPE_PREFERENCES,
|
||||
);
|
||||
|
||||
// 获取当前域名
|
||||
useEffect(() => {
|
||||
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 () => {
|
||||
@@ -60,9 +155,10 @@ const FormRecognizerPage = () => {
|
||||
}
|
||||
|
||||
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' });
|
||||
addOperationHistory('扫描', `扫描表单字段,发现 ${response.totalCount} 个字段`, '成功');
|
||||
} else {
|
||||
showMessage(response.message || '扫描失败', { severity: 'error' });
|
||||
}
|
||||
@@ -74,158 +170,194 @@ const FormRecognizerPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 添加操作历史记录
|
||||
const addOperationHistory = (type: string, content: string, result: string) => {
|
||||
const newEntry = {
|
||||
time: new Date().toLocaleString('zh-CN'),
|
||||
type,
|
||||
content,
|
||||
result,
|
||||
// 更新字段类型
|
||||
const handleFieldTypeChange = (fieldId: string, newType: string) => {
|
||||
setFields((prev) =>
|
||||
prev.map((field) => {
|
||||
if (field.id === fieldId) {
|
||||
const updatedField = {
|
||||
...field,
|
||||
fieldType: newType,
|
||||
// 清空旧的 generatedValue,保持界面一致性
|
||||
generatedValue: '',
|
||||
};
|
||||
setOperationHistory((prev) => [newEntry, ...prev].slice(0, 50)); // 最多保留50条记录
|
||||
saveTypePreference(field, newType);
|
||||
return updatedField;
|
||||
}
|
||||
return field;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// 加载模板列表
|
||||
const loadTemplates = async () => {
|
||||
setTemplateLoading(true);
|
||||
// 切换单个字段的选中状态
|
||||
const handleToggleFieldSelection = (fieldId: string) => {
|
||||
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 allTemplates = await DataTemplateManager.getAllTemplates();
|
||||
setTemplates(allTemplates);
|
||||
const response = await sendMessageToContent(MessageAction.FLASH_FIELD, { fieldId });
|
||||
if (!response.success) {
|
||||
showMessage(response.message || '定位字段失败', { severity: 'error' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载模板失败:', error);
|
||||
showMessage('加载模板失败', { severity: 'error' });
|
||||
} finally {
|
||||
setTemplateLoading(false);
|
||||
console.error('定位字段失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出模板
|
||||
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();
|
||||
// 悬停高亮
|
||||
const handleHoverField = async (fieldId: string | null) => {
|
||||
setHoveredFieldId(fieldId);
|
||||
try {
|
||||
if (fieldId) {
|
||||
await sendMessageToContent(MessageAction.HIGHLIGHT_FIELD, { fieldId });
|
||||
} else {
|
||||
showMessage('模板导入失败,请检查文件格式', { severity: 'error' });
|
||||
await sendMessageToContent(MessageAction.UNHIGHLIGHT_ALL_FIELDS);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('高亮字段失败:', error);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const sendMessageWithHandler = async (
|
||||
action: MessageAction,
|
||||
payload?: { includeHidden?: boolean },
|
||||
) => {
|
||||
// 防抖处理:防止快速点击导致多次请求
|
||||
// 填充选中字段
|
||||
const handleFillSelectedFields = async () => {
|
||||
if (isProcessingRef.current) {
|
||||
showMessage('操作进行中,请稍候...', { severity: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const selectedCount = fields.filter((f) => f.isSelected).length;
|
||||
if (selectedCount === 0) {
|
||||
showMessage('请先选择要填充的字段', { severity: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setFillLoading(true);
|
||||
isProcessingRef.current = true;
|
||||
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('无法连接')) {
|
||||
showMessage('正在注入内容脚本...', { severity: 'info' });
|
||||
const injected = await injectContentScript();
|
||||
if (injected) {
|
||||
// 注入成功后再次尝试
|
||||
response = await sendMessageToContent(action, payload);
|
||||
} else {
|
||||
showMessage('内容脚本注入失败,请刷新页面后重试', { severity: 'error' });
|
||||
return;
|
||||
response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
|
||||
fields: messageFields,
|
||||
mode: FillMode.VALID,
|
||||
includeHidden,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
showMessage(response.message || '操作成功', { severity: 'success' });
|
||||
showMessage(response.message || '填充成功', { severity: 'success' });
|
||||
} else {
|
||||
// 增强错误提示信息
|
||||
const errorMsg = response.message || '操作失败';
|
||||
const errorDetails = getErrorDetails(errorMsg);
|
||||
showMessage(errorDetails, { severity: 'error' });
|
||||
showMessage(response.message || '填充失败', { severity: 'error' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
console.error('填充失败:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||
showMessage(`操作失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
|
||||
showMessage(`填充失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setFillLoading(false);
|
||||
isProcessingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取详细的错误信息
|
||||
const getErrorDetails = (baseMsg: string): string => {
|
||||
if (baseMsg.includes('标签页')) {
|
||||
return `${baseMsg},请确保已打开网页页面`;
|
||||
// 清空所有字段
|
||||
const handleClearAllFields = async () => {
|
||||
if (isProcessingRef.current) {
|
||||
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 });
|
||||
addOperationHistory('填充', '填充有效数据', '成功');
|
||||
// 打开侧边栏
|
||||
const handleOpenSidePanel = async () => {
|
||||
try {
|
||||
await chrome.sidePanel.open({ windowId: chrome.windows.WINDOW_ID_CURRENT });
|
||||
showMessage('侧边栏已打开', { severity: 'success' });
|
||||
} catch (error) {
|
||||
console.error('打开侧边栏失败:', error);
|
||||
showMessage('打开侧边栏失败', { severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFillInvalidData = () => {
|
||||
sendMessageWithHandler(MessageAction.FILL_INVALID_DATA, { includeHidden });
|
||||
addOperationHistory('填充', '填充异常数据', '成功');
|
||||
};
|
||||
|
||||
const handleClearAllFields = () => {
|
||||
sendMessageWithHandler(MessageAction.CLEAR_ALL_FIELDS);
|
||||
addOperationHistory('清空', '清空所有表单字段', '成功');
|
||||
};
|
||||
const selectedCount = fields.filter((f) => f.isSelected).length;
|
||||
|
||||
return (
|
||||
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 4 }}>
|
||||
<Container maxWidth="sm" sx={{ py: 3, px: 2, bgcolor: '#f5f5f5' }}>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" component="h1" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
Dummy Data Generator
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
一键填充表单测试数据,提升开发和测试效率
|
||||
</Typography>
|
||||
</Box>
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="表单测试数据填充器"
|
||||
subtitle="一键填充表单测试数据,提升开发和测试效率"
|
||||
icon={<InputIcon />}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
|
||||
<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
|
||||
@@ -252,34 +384,95 @@ const FormRecognizerPage = () => {
|
||||
fields={fields}
|
||||
showFields={showFields}
|
||||
onToggleShowFields={() => setShowFields(!showFields)}
|
||||
onFieldTypeChange={handleFieldTypeChange}
|
||||
onLocateField={handleLocateField}
|
||||
onHoverField={handleHoverField}
|
||||
onToggleFieldSelection={handleToggleFieldSelection}
|
||||
onToggleAllFields={handleToggleAllFields}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
/>
|
||||
|
||||
<MainActions
|
||||
loading={loading}
|
||||
onFillValidData={handleFillValidData}
|
||||
onFillInvalidData={handleFillInvalidData}
|
||||
onClearAllFields={handleClearAllFields}
|
||||
{/* 操作按钮 */}
|
||||
{fields.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Button
|
||||
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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={includeHidden}
|
||||
onChange={(e) => setIncludeHidden(e.target.checked)}
|
||||
/>
|
||||
|
||||
<OptionsPanel includeHidden={includeHidden} onIncludeHiddenChange={setIncludeHidden} />
|
||||
|
||||
<OperationHistory
|
||||
history={operationHistory}
|
||||
showHistory={showHistory}
|
||||
onToggleShowHistory={() => setShowHistory(!showHistory)}
|
||||
}
|
||||
label="包含隐藏字段"
|
||||
/>
|
||||
|
||||
<TemplateManager
|
||||
templates={templates}
|
||||
showTemplates={showTemplates}
|
||||
templateLoading={templateLoading}
|
||||
onToggleShowTemplates={() => setShowTemplates(!showTemplates)}
|
||||
onLoadTemplates={loadTemplates}
|
||||
onExportTemplates={handleExportTemplates}
|
||||
onImportTemplates={handleImportTemplates}
|
||||
/>
|
||||
|
||||
<FeatureDescription />
|
||||
</Box>
|
||||
|
||||
<GlobalSnackbar {...snackbarProps} />
|
||||
</Container>
|
||||
|
||||
@@ -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 GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import UrlEntryForm from '@/components/UrlEntryForm';
|
||||
import UrlEntryList from '@/components/UrlEntryList';
|
||||
import { useUrlPreferences } from '@/utils/useUrlPreferences';
|
||||
import type { OpenUrlEntry } from '@/types/storage';
|
||||
import { openUrlPageStyles, dashboardPageStyles } from '@/config/pageTheme';
|
||||
|
||||
const THEME_COLOR = openUrlPageStyles.themeColor;
|
||||
import { dashboardPageStyles } from '@/config/pageTheme';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
|
||||
export default function OpenUrlPage() {
|
||||
const { entries, setEntries, isLoaded } = useUrlPreferences();
|
||||
@@ -38,31 +37,12 @@ export default function OpenUrlPage() {
|
||||
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
|
||||
<Container sx={{ py: 2 }}>
|
||||
{/* Header */}
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
borderRadius: 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>
|
||||
<PageHeader
|
||||
title="URL 工具"
|
||||
subtitle="快速打开 URL 或复制链接"
|
||||
icon={<LanguageIcon />}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
|
||||
{/* Form Section */}
|
||||
<UrlEntryForm onAddEntry={handleAddEntry} showMessage={showMessage} />
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Box, Typography, Stack, Container, CircularProgress } from '@mui/material';
|
||||
import { alpha } from '@mui/system';
|
||||
import { Box, Stack, Container, CircularProgress } from '@mui/material';
|
||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
||||
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import UrlToQrCodeSection from '@/components/UrlToQrCodeSection';
|
||||
import QrCodeToUrlSection from '@/components/QrCodeToUrlSection';
|
||||
import { useStorageState } from '@/utils/useStorageState';
|
||||
import { qrCodePageStyles, dashboardPageStyles } from '@/config/pageTheme';
|
||||
import { dashboardPageStyles } from '@/config/pageTheme';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
|
||||
const QrCodePage = () => {
|
||||
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
|
||||
@@ -35,32 +35,12 @@ const QrCodePage = () => {
|
||||
return (
|
||||
<Box sx={{ minHeight: '100%', pb: 3, bgcolor: dashboardPageStyles.backgroundColor }}>
|
||||
<Container sx={{ py: 2, maxWidth: 400, bgcolor: dashboardPageStyles.backgroundColor }}>
|
||||
{/* Header */}
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
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>
|
||||
<PageHeader
|
||||
title="二维码工具"
|
||||
subtitle="生成和解析二维码"
|
||||
icon={<QrCodeIcon />}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<UrlToQrCodeSection
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import {
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
Stack,
|
||||
Typography,
|
||||
Box,
|
||||
Container,
|
||||
alpha,
|
||||
} from '@mui/material';
|
||||
import { TextField, Select, MenuItem, Stack, Box, Container, alpha } from '@mui/material';
|
||||
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import Button from '@/components/Button';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import { ZONES, globalStyles, timestampPageStyles } from '@/config/pageTheme';
|
||||
import LiveClock from './components/LiveClock';
|
||||
import ResultView from './components/ResultView';
|
||||
@@ -38,32 +30,13 @@ export default function TimestampPage() {
|
||||
return (
|
||||
<Box sx={{ bgcolor: globalStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
|
||||
<Container sx={{ py: 2, bgcolor: globalStyles.backgroundColor }}>
|
||||
{/* Header with Icon */}
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
borderRadius: 2.5,
|
||||
bgcolor: alpha(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>
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="时间戳转换"
|
||||
subtitle="Unix 毫秒数转换与格式化"
|
||||
icon={<AccessTimeIcon />}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
|
||||
{/* Live Clock Card */}
|
||||
<LiveClock
|
||||
@@ -181,7 +154,7 @@ export default function TimestampPage() {
|
||||
fullWidth
|
||||
value={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={{
|
||||
PaperProps: {
|
||||
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>
|
||||
<Switch
|
||||
|
||||
@@ -1,48 +1,41 @@
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
import { Box } from '@mui/material';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import { formatSize } from '@/utils/storageCleaner';
|
||||
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
||||
|
||||
/**
|
||||
* DomainHeader 组件属性接口
|
||||
*/
|
||||
interface DomainHeaderProps {
|
||||
/** 当前域名 */
|
||||
domain: string;
|
||||
/** 已占用的存储大小(字节) */
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DomainHeader - 存储清理页面标题栏组件
|
||||
*
|
||||
* 使用 PageHeader 组件构建,显示域名和已占用存储空间大小
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <DomainHeader
|
||||
* domain="example.com"
|
||||
* totalSize={1048576}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
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 && (
|
||||
<PageHeader
|
||||
icon={<StorageIcon sx={{ fontSize: 22 }} />}
|
||||
iconColor={storageCleanerPageStyles.warningColor}
|
||||
title="存储清理"
|
||||
subtitle={domain || '加载中...'}
|
||||
badge={
|
||||
totalSize > 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'rgba(255, 152, 0, 0.15)',
|
||||
@@ -61,13 +54,22 @@ export default function DomainHeader({ domain, totalSize }: DomainHeaderProps) {
|
||||
>
|
||||
已占用 {formatSize(totalSize)}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
) : null
|
||||
}
|
||||
iconSx={{
|
||||
p: 1.2,
|
||||
borderRadius: 3,
|
||||
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)',
|
||||
},
|
||||
}}
|
||||
titleSx={{
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
subtitleSx={{
|
||||
display: 'block',
|
||||
maxWidth: 240,
|
||||
overflow: 'hidden',
|
||||
@@ -76,10 +78,7 @@ export default function DomainHeader({ domain, totalSize }: DomainHeaderProps) {
|
||||
mt: 0.3,
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{domain || '加载中...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { timestampPageStyles } from '@/config/pageTheme';
|
||||
import type { UnitType } from '@/config/pageTheme';
|
||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||
|
||||
interface LiveClockProps {
|
||||
unit: UnitType;
|
||||
onUseNow: (val: number) => 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) => {
|
||||
|
||||
@@ -4,13 +4,14 @@ import dayjs from '@/utils/dayjs';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { DATE_FORMAT, timestampPageStyles } from '@/config/pageTheme';
|
||||
import type { UnitType } from '@/config/pageTheme';
|
||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||
|
||||
interface ResultViewProps {
|
||||
result: string;
|
||||
mode: 'ts2dt' | 'dt2ts';
|
||||
unit: UnitType;
|
||||
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) => {
|
||||
@@ -57,6 +58,9 @@ const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: Result
|
||||
mb: 2.5,
|
||||
border: '1px solid',
|
||||
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
@@ -77,12 +81,6 @@ const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: Result
|
||||
tooltip="复制结果"
|
||||
size="small"
|
||||
color={timestampPageStyles.primaryColor}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
showMessage={showMessage}
|
||||
/>
|
||||
</Box>
|
||||
@@ -95,7 +93,6 @@ const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: Result
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
@@ -105,11 +102,11 @@ const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: Result
|
||||
].map((item) => (
|
||||
<Box
|
||||
key={item.label}
|
||||
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: 1 }}
|
||||
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: 'text.disabled', fontWeight: 700, fontSize: '0.65rem' }}
|
||||
sx={{ color: 'text.disabled', fontWeight: 700, fontSize: '0.65rem', pr: 4 }}
|
||||
>
|
||||
{item.label}
|
||||
</Typography>
|
||||
|
||||
@@ -26,15 +26,16 @@ export default function StorageOptionsGrid({
|
||||
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',
|
||||
overflow: 'hidden',
|
||||
'&:hover': {
|
||||
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 1.2 }}>
|
||||
<Grid container spacing={1.5}>
|
||||
<Grid size={6}>
|
||||
<OptionItem
|
||||
@@ -87,16 +88,17 @@ export default function StorageOptionsGrid({
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Divider sx={{ my: 1.2, borderColor: 'grey.100' }} />
|
||||
</Box>
|
||||
<Divider sx={{ mx: 0, 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,
|
||||
px: 2.7,
|
||||
py: 0.8,
|
||||
borderBottomLeftRadius: 4,
|
||||
borderBottomRightRadius: 4,
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(0, 0, 0, 0.04)',
|
||||
@@ -106,7 +108,7 @@ export default function StorageOptionsGrid({
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={700}
|
||||
sx={{ color: 'text.secondary', fontSize: '0.7rem' }}
|
||||
sx={{ color: 'text.secondary', fontSize: '0.7rem', px: 0 }}
|
||||
>
|
||||
全选所有项
|
||||
</Typography>
|
||||
@@ -118,6 +120,7 @@ export default function StorageOptionsGrid({
|
||||
color="warning"
|
||||
sx={{
|
||||
p: 0.6,
|
||||
mr: 0,
|
||||
'& .MuiSvgIcon-root': {
|
||||
fontSize: 18,
|
||||
transition: 'transform 0.2s',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||
import type {
|
||||
StorageCleanerOptions,
|
||||
CleaningResult,
|
||||
@@ -57,10 +58,7 @@ export interface UseStorageCleanerReturn {
|
||||
}
|
||||
|
||||
export interface UseStorageCleanerOptions {
|
||||
showMessage: (
|
||||
message: string,
|
||||
options?: { severity?: 'success' | 'warning' | 'error' | 'info' },
|
||||
) => void;
|
||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||
}
|
||||
|
||||
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;
|
||||
Vendored
+7
@@ -20,6 +20,13 @@ export interface StorageSchema {
|
||||
'openUrl/currentUrl': string;
|
||||
'qrCode/qrExpanded': boolean;
|
||||
'qrCode/urlExpanded': boolean;
|
||||
'formRecognizer/fieldTypePreferences': FieldTypePreferences;
|
||||
}
|
||||
|
||||
export interface FieldTypePreferences {
|
||||
[domain: string]: {
|
||||
[fieldIdentifier: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StorageCleanerPreferences {
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
|
||||
/**
|
||||
@@ -8,7 +9,7 @@ import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
*/
|
||||
export const copyToClipboard = async (
|
||||
text: string,
|
||||
showMessage?: (message: string, options?: { severity: 'success' | 'error' }) => void,
|
||||
showMessage?: (message: string, options?: SnackbarOptions) => void,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
||||
+57
-16
@@ -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 {
|
||||
return fakerZH_CN.person.fullName();
|
||||
return faker.person.fullName();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,17 +45,20 @@ export class DummyDataGenerator {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机手机号
|
||||
* 生成随机手机号(中国格式)
|
||||
*/
|
||||
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 {
|
||||
return fakerZH_CN.internet.email();
|
||||
return faker.internet.email();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,14 +80,14 @@ export class DummyDataGenerator {
|
||||
* 生成短文本
|
||||
*/
|
||||
static generateShortText(): string {
|
||||
return fakerZH_CN.lorem.sentence({ min: 3, max: 6 });
|
||||
return faker.lorem.sentence({ min: 3, max: 6 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成长文本
|
||||
*/
|
||||
static generateLongText(): string {
|
||||
return fakerZH_CN.lorem.paragraphs(5);
|
||||
return faker.lorem.paragraphs(5);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,42 +109,42 @@ export class DummyDataGenerator {
|
||||
* 生成随机数字
|
||||
*/
|
||||
static generateNumber(): number {
|
||||
return fakerZH_CN.number.int(10000);
|
||||
return faker.number.int(10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机浮点数
|
||||
*/
|
||||
static generateFloat(): number {
|
||||
return fakerZH_CN.number.float({ max: 10000 });
|
||||
return faker.number.float({ max: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机负数
|
||||
*/
|
||||
static generateNegativeNumber(): number {
|
||||
return -fakerZH_CN.number.int(10000);
|
||||
return -faker.number.int(10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机日期
|
||||
*/
|
||||
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 {
|
||||
return fakerZH_CN.date.past({ years: 1 }).toISOString().split('T')[0];
|
||||
return faker.date.past({ years: 1 }).toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成未来的日期
|
||||
*/
|
||||
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;
|
||||
|
||||
fields.forEach((field) => {
|
||||
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);
|
||||
filledCount++;
|
||||
}
|
||||
@@ -795,6 +807,35 @@ export function fillSelectedFields(fields: FormFieldInfo[], mode: FillMode): num
|
||||
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
@@ -1,5 +1,12 @@
|
||||
import { FormFieldInfo, FillMode } from './dummyDataGenerator';
|
||||
|
||||
/**
|
||||
* 字段数据接口(用于消息传递)
|
||||
*/
|
||||
interface MessageFieldData extends Omit<FormFieldInfo, 'element'> {
|
||||
useInvalidData?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息动作类型
|
||||
*/
|
||||
@@ -19,6 +26,9 @@ export enum MessageAction {
|
||||
UNHIGHLIGHT_FIELD = 'unhighlightField',
|
||||
HIGHLIGHT_ALL_FIELDS = 'highlightAllFields',
|
||||
UNHIGHLIGHT_ALL_FIELDS = 'unhighlightAllFields',
|
||||
|
||||
// 字段定位/闪烁
|
||||
FLASH_FIELD = 'flashField',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,7 +38,7 @@ export interface MessagePayload {
|
||||
action: MessageAction | string;
|
||||
tabId?: number;
|
||||
delay?: number;
|
||||
fields?: Omit<FormFieldInfo, 'element'>[];
|
||||
fields?: MessageFieldData[];
|
||||
mode?: FillMode;
|
||||
includeHidden?: boolean;
|
||||
fieldId?: string;
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import type { StorageSchema } from '@/types/storage';
|
||||
|
||||
export const useStorageState = (
|
||||
key: 'qrCode/urlExpanded' | 'qrCode/qrExpanded',
|
||||
defaultValue: boolean,
|
||||
export const useStorageState = <K extends keyof StorageSchema>(
|
||||
key: K,
|
||||
defaultValue: StorageSchema[K],
|
||||
) => {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const hasLoadedFromStorage = useRef(false);
|
||||
|
||||
// Only load from storage once on mount
|
||||
useEffect(() => {
|
||||
if (hasLoadedFromStorage.current) return;
|
||||
|
||||
const loadState = async () => {
|
||||
try {
|
||||
const savedValue = await storageUtil.get(key, defaultValue);
|
||||
setValue(savedValue ?? defaultValue);
|
||||
if (savedValue !== undefined) {
|
||||
setValue(savedValue);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`加载状态失败 (${key}):`, error);
|
||||
} finally {
|
||||
setIsInitialized(true);
|
||||
hasLoadedFromStorage.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
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(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user