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]
|
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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
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}
|
||||||
>
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
已识别字段 ({fields.length})
|
已识别字段 ({fields.length})
|
||||||
</Typography>
|
</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 />}
|
{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"
|
||||||
|
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}`}
|
{field.label || field.name || field.placeholder || `字段 ${index + 1}`}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip
|
|
||||||
label={FIELD_TYPE_NAMES[field.fieldType] || '未知'}
|
|
||||||
size="small"
|
|
||||||
color={FIELD_TYPE_COLORS[field.fieldType] || 'default'}
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</Box>
|
</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>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</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 {
|
import {
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Paper,
|
|
||||||
CircularProgress,
|
|
||||||
Alert,
|
Alert,
|
||||||
|
Box,
|
||||||
|
CircularProgress,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
useTheme,
|
useTheme,
|
||||||
} from '@mui/material';
|
} 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 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
@@ -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: '未知操作' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
// 清空旧的 generatedValue,保持界面一致性
|
||||||
|
generatedValue: '',
|
||||||
};
|
};
|
||||||
setOperationHistory((prev) => [newEntry, ...prev].slice(0, 50)); // 最多保留50条记录
|
saveTypePreference(field, newType);
|
||||||
|
return updatedField;
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载模板列表
|
// 切换单个字段的选中状态
|
||||||
const loadTemplates = async () => {
|
const handleToggleFieldSelection = (fieldId: string) => {
|
||||||
setTemplateLoading(true);
|
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 {
|
try {
|
||||||
const allTemplates = await DataTemplateManager.getAllTemplates();
|
const response = await sendMessageToContent(MessageAction.FLASH_FIELD, { fieldId });
|
||||||
setTemplates(allTemplates);
|
if (!response.success) {
|
||||||
|
showMessage(response.message || '定位字段失败', { severity: 'error' });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载模板失败:', error);
|
console.error('定位字段失败:', error);
|
||||||
showMessage('加载模板失败', { severity: 'error' });
|
|
||||||
} finally {
|
|
||||||
setTemplateLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 导出模板
|
// 悬停高亮
|
||||||
const handleExportTemplates = async () => {
|
const handleHoverField = async (fieldId: string | null) => {
|
||||||
const allTemplates = await DataTemplateManager.getAllTemplates();
|
setHoveredFieldId(fieldId);
|
||||||
if (allTemplates.length === 0) {
|
try {
|
||||||
showMessage('没有可导出的模板', { severity: 'warning' });
|
if (fieldId) {
|
||||||
return;
|
await sendMessageToContent(MessageAction.HIGHLIGHT_FIELD, { fieldId });
|
||||||
}
|
|
||||||
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 {
|
} 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,
|
const handleFillSelectedFields = async () => {
|
||||||
payload?: { includeHidden?: boolean },
|
|
||||||
) => {
|
|
||||||
// 防抖处理:防止快速点击导致多次请求
|
|
||||||
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>
|
||||||
|
|
||||||
|
<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} />
|
label="包含隐藏字段"
|
||||||
|
|
||||||
<OperationHistory
|
|
||||||
history={operationHistory}
|
|
||||||
showHistory={showHistory}
|
|
||||||
onToggleShowHistory={() => setShowHistory(!showHistory)}
|
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
<TemplateManager
|
|
||||||
templates={templates}
|
|
||||||
showTemplates={showTemplates}
|
|
||||||
templateLoading={templateLoading}
|
|
||||||
onToggleShowTemplates={() => setShowTemplates(!showTemplates)}
|
|
||||||
onLoadTemplates={loadTemplates}
|
|
||||||
onExportTemplates={handleExportTemplates}
|
|
||||||
onImportTemplates={handleImportTemplates}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FeatureDescription />
|
|
||||||
|
|
||||||
<GlobalSnackbar {...snackbarProps} />
|
<GlobalSnackbar {...snackbarProps} />
|
||||||
</Container>
|
</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 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} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,48 +1,41 @@
|
|||||||
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',
|
|
||||||
boxShadow: '0 2px 8px rgba(255, 152, 0, 0.15)',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'rgba(255, 152, 0, 0.15)',
|
|
||||||
transform: 'scale(1.05)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StorageIcon sx={{ fontSize: 22 }} />
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ flex: 1 }}>
|
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
fontWeight={900}
|
|
||||||
sx={{
|
|
||||||
letterSpacing: '-0.5px',
|
|
||||||
lineHeight: 1.2,
|
|
||||||
fontSize: '1rem',
|
|
||||||
color: 'text.primary',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
存储清理
|
|
||||||
</Typography>
|
|
||||||
{totalSize > 0 && (
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: 'rgba(255, 152, 0, 0.15)',
|
bgcolor: 'rgba(255, 152, 0, 0.15)',
|
||||||
@@ -61,13 +54,22 @@ export default function DomainHeader({ domain, totalSize }: DomainHeaderProps) {
|
|||||||
>
|
>
|
||||||
已占用 {formatSize(totalSize)}
|
已占用 {formatSize(totalSize)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
) : null
|
||||||
</Stack>
|
}
|
||||||
<Typography
|
iconSx={{
|
||||||
variant="body2"
|
p: 1.2,
|
||||||
color="text.secondary"
|
borderRadius: 3,
|
||||||
sx={{
|
boxShadow: '0 2px 8px rgba(255, 152, 0, 0.15)',
|
||||||
fontWeight: 600,
|
transition: 'all 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'rgba(255, 152, 0, 0.15)',
|
||||||
|
transform: 'scale(1.05)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
titleSx={{
|
||||||
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
subtitleSx={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
maxWidth: 240,
|
maxWidth: 240,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -76,10 +78,7 @@ export default function DomainHeader({ domain, totalSize }: DomainHeaderProps) {
|
|||||||
mt: 0.3,
|
mt: 0.3,
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem',
|
||||||
}}
|
}}
|
||||||
>
|
sx={{ mb: 3 }}
|
||||||
{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,15 +26,16 @@ 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)',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Box sx={{ p: 1.2 }}>
|
||||||
<Grid container spacing={1.5}>
|
<Grid container spacing={1.5}>
|
||||||
<Grid size={6}>
|
<Grid size={6}>
|
||||||
<OptionItem
|
<OptionItem
|
||||||
@@ -87,16 +88,17 @@ export default function StorageOptionsGrid({
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Divider sx={{ my: 1.2, borderColor: 'grey.100' }} />
|
</Box>
|
||||||
|
<Divider sx={{ mx: 0, 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',
|
||||||
|
|||||||
@@ -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;
|
|
||||||
Vendored
+7
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user