Files
testing-tool/entrypoints/popup/pages/FormRecognizerPage.tsx
T
LingandRX c039475119 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运行时间和资源消耗
2026-04-28 08:56:47 +08:00

484 lines
16 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import {
Box,
Container,
Button,
CircularProgress,
FormControlLabel,
Switch,
alpha,
} from '@mui/material';
import InputIcon from '@mui/icons-material/Input';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import { dashboardPageStyles, formRecognizerPageStyles } from '@/config/pageTheme';
import { MessageAction, sendMessageToContent, injectContentScript } from '@/utils/messages';
import { FillMode } from '@/utils/dummyDataGenerator';
import FieldList from '@/components/FieldList';
import { useStorageState } from '@/utils/useStorageState';
import { FieldTypePreferences } from '@/types/storage';
import PageHeader from '@/components/PageHeader';
// 字段数据接口
interface FieldData {
id: string;
fieldType: string;
label: string | null;
placeholder: string;
name: string;
value: string;
isSelected: boolean;
generatedValue: string;
useInvalidData?: boolean;
}
const DEFAULT_FIELD_TYPE_PREFERENCES: FieldTypePreferences = {};
const FormRecognizerPage = () => {
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
const [fillLoading, setFillLoading] = useState(false);
const [clearLoading, setClearLoading] = useState(false);
const [includeHidden, setIncludeHidden] = useState(false);
const isProcessingRef = useRef(false);
const [fields, setFields] = useState<FieldData[]>([]);
const [scanning, setScanning] = useState(false);
const [showFields, setShowFields] = useState(false);
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
const [currentDomain, setCurrentDomain] = useState<string>('');
const [sidePanelOpen, setSidePanelOpen] = useState(false);
const [fieldTypePreferences, setFieldTypePreferences] = useStorageState(
'formRecognizer/fieldTypePreferences',
DEFAULT_FIELD_TYPE_PREFERENCES,
);
// 获取当前域名
useEffect(() => {
const getActiveTab = async () => {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab?.url) {
const url = new URL(tab.url);
setCurrentDomain(url.hostname);
}
} catch (error) {
console.error('获取标签页信息失败:', error);
}
};
getActiveTab();
}, []);
// 检测侧边栏状态
useEffect(() => {
console.log('开始检测侧边栏状态');
const checkSidePanelState = async () => {
try {
// 检查 chrome.runtime.getContexts 是否可用
if (typeof chrome.runtime.getContexts === 'function') {
const contexts = await chrome.runtime.getContexts({
contextTypes: ['SIDE_PANEL'],
});
console.log('侧边栏上下文:', contexts);
setSidePanelOpen(contexts.length > 0);
} else {
console.log('chrome.runtime.getContexts 不可用');
setSidePanelOpen(false);
}
} catch (error) {
console.error('检测侧边栏状态失败:', error);
setSidePanelOpen(false);
}
};
// 初始检查
checkSidePanelState();
// 定期检查侧边栏状态(每 500ms)
const interval = setInterval(checkSidePanelState, 500);
console.log('侧边栏状态检测已启动');
return () => {
console.log('清理侧边栏状态检测');
clearInterval(interval);
};
}, []);
// 生成字段标识符
const getFieldIdentifier = (field: Pick<FieldData, 'label' | 'name' | 'placeholder'>): string => {
return field.label || field.name || field.placeholder || 'unknown';
};
// 应用保存的类型偏好
const applySavedPreferences = (fields: FieldData[]): FieldData[] => {
if (!currentDomain || !(fieldTypePreferences as FieldTypePreferences)[currentDomain]) {
return fields;
}
const prefs = (fieldTypePreferences as FieldTypePreferences)[currentDomain];
return fields.map((field) => {
const identifier = getFieldIdentifier(field);
if (prefs && prefs[identifier]) {
return { ...field, fieldType: prefs[identifier] };
}
return field;
});
};
// 保存类型偏好
const saveTypePreference = (field: FieldData, newType: string) => {
if (!currentDomain) return;
const identifier = getFieldIdentifier(field);
setFieldTypePreferences((prev) => {
const prevPrefs = prev as FieldTypePreferences;
const currentDomainPrefs = prevPrefs[currentDomain] || {};
return {
...prevPrefs,
[currentDomain]: {
...currentDomainPrefs,
[identifier]: newType,
},
} as FieldTypePreferences;
});
};
// 扫描表单字段
const handleScanFields = async () => {
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 && response.fields) {
const fieldsWithPreferences = applySavedPreferences(response.fields as FieldData[]);
setFields(fieldsWithPreferences);
setShowFields(true);
showMessage(`扫描完成,发现 ${response.totalCount} 个可填充字段`, { severity: 'success' });
} else {
showMessage(response.message || '扫描失败', { severity: 'error' });
}
} catch (error) {
console.error('扫描失败:', error);
showMessage('扫描失败,请确保页面已加载', { severity: 'error' });
} finally {
setScanning(false);
}
};
// 更新字段类型
const handleFieldTypeChange = (fieldId: string, newType: string) => {
setFields((prev) =>
prev.map((field) => {
if (field.id === fieldId) {
const updatedField = {
...field,
fieldType: newType,
// 清空旧的 generatedValue,保持界面一致性
generatedValue: '',
};
saveTypePreference(field, newType);
return updatedField;
}
return field;
}),
);
};
// 切换单个字段的选中状态
const handleToggleFieldSelection = (fieldId: string) => {
setFields((prev) =>
prev.map((field) =>
field.id === fieldId ? { ...field, isSelected: !field.isSelected } : field,
),
);
};
// 全选/取消全选
const handleToggleAllFields = () => {
setFields((prev) => {
const allSelected = prev.every((f) => f.isSelected);
return prev.map((f) => ({ ...f, isSelected: !allSelected }));
});
};
// 定位字段(闪烁)
const handleLocateField = async (fieldId: string) => {
try {
const response = await sendMessageToContent(MessageAction.FLASH_FIELD, { fieldId });
if (!response.success) {
showMessage(response.message || '定位字段失败', { severity: 'error' });
}
} catch (error) {
console.error('定位字段失败:', error);
}
};
// 悬停高亮
const handleHoverField = async (fieldId: string | null) => {
setHoveredFieldId(fieldId);
try {
if (fieldId) {
await sendMessageToContent(MessageAction.HIGHLIGHT_FIELD, { fieldId });
} else {
await sendMessageToContent(MessageAction.UNHIGHLIGHT_ALL_FIELDS);
}
} catch (error) {
console.error('高亮字段失败:', error);
}
};
// 填充选中字段
const handleFillSelectedFields = async () => {
if (isProcessingRef.current) {
showMessage('操作进行中,请稍候...', { severity: 'warning' });
return;
}
const selectedCount = fields.filter((f) => f.isSelected).length;
if (selectedCount === 0) {
showMessage('请先选择要填充的字段', { severity: 'warning' });
return;
}
setFillLoading(true);
isProcessingRef.current = true;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messageFields = fields as any;
let response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
fields: messageFields,
mode: FillMode.VALID,
includeHidden,
});
if (!response.success && response.message && response.message.includes('无法连接')) {
showMessage('正在注入内容脚本...', { severity: 'info' });
const injected = await injectContentScript();
if (injected) {
response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
fields: messageFields,
mode: FillMode.VALID,
includeHidden,
});
}
}
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 {
setFillLoading(false);
isProcessingRef.current = false;
}
};
// 清空所有字段
const handleClearAllFields = async () => {
if (isProcessingRef.current) {
showMessage('操作进行中,请稍候...', { severity: 'warning' });
return;
}
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;
}
};
// 打开侧边栏
const handleOpenSidePanel = async () => {
try {
await chrome.sidePanel.open({ windowId: chrome.windows.WINDOW_ID_CURRENT });
showMessage('侧边栏已打开', { severity: 'success' });
} catch (error) {
console.error('打开侧边栏失败:', error);
showMessage('打开侧边栏失败', { severity: 'error' });
}
};
const selectedCount = fields.filter((f) => f.isSelected).length;
return (
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 4 }}>
<Container maxWidth="sm" sx={{ py: 3, px: 2, bgcolor: '#f5f5f5' }}>
{/* Header */}
<PageHeader
title="表单测试数据填充器"
subtitle="一键填充表单测试数据,提升开发和测试效率"
icon={<InputIcon />}
sx={{ mb: 2.5 }}
/>
<Button
size="small"
startIcon={<OpenInNewIcon />}
onClick={handleOpenSidePanel}
sx={{
textTransform: 'none',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
opacity: sidePanelOpen ? 0 : 1,
transform: sidePanelOpen ? 'scale(0.8)' : 'scale(1)',
pointerEvents: sidePanelOpen ? 'none' : 'auto',
visibility: sidePanelOpen ? 'hidden' : 'visible',
position: 'relative',
}}
>
侧边栏
</Button>
{/* 扫描按钮 */}
<Button
variant="outlined"
onClick={handleScanFields}
disabled={scanning}
fullWidth
sx={{
...formRecognizerPageStyles.buttonStyle,
mb: 2,
borderColor: formRecognizerPageStyles.validColor,
color: formRecognizerPageStyles.validColor,
'&:hover': {
borderColor: formRecognizerPageStyles.validDark,
bgcolor: 'rgba(76, 175, 80, 0.05)',
},
}}
startIcon={scanning ? <CircularProgress size={16} color="inherit" /> : <InputIcon />}
>
{scanning ? '扫描中...' : '扫描表单字段'}
</Button>
<FieldList
fields={fields}
showFields={showFields}
onToggleShowFields={() => setShowFields(!showFields)}
onFieldTypeChange={handleFieldTypeChange}
onLocateField={handleLocateField}
onHoverField={handleHoverField}
onToggleFieldSelection={handleToggleFieldSelection}
onToggleAllFields={handleToggleAllFields}
hoveredFieldId={hoveredFieldId}
/>
{/* 操作按钮 */}
{fields.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Button
disableElevation
disableRipple
variant="contained"
onClick={handleFillSelectedFields}
disabled={fillLoading || selectedCount === 0}
fullWidth
sx={{
py: 1.6,
borderRadius: 4,
bgcolor: formRecognizerPageStyles.validColor,
fontWeight: 700,
fontSize: '1rem',
textTransform: 'none',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
bgcolor: formRecognizerPageStyles.validDark,
boxShadow: `0 8px 24px ${alpha(formRecognizerPageStyles.validColor, 0.3)}`,
transform: 'translateY(-1px)',
},
'&:disabled': {
bgcolor: '#e0e0e0',
color: '#9e9e9e',
},
'&:active': {
transform: 'translateY(0)',
},
}}
>
填充选中字段
</Button>
<Button
disableElevation
disableRipple
variant="outlined"
onClick={handleClearAllFields}
disabled={clearLoading}
fullWidth
sx={{
py: 1.6,
borderRadius: 4,
borderColor: formRecognizerPageStyles.clearColor,
color: formRecognizerPageStyles.clearColor,
fontWeight: 600,
fontSize: '1rem',
textTransform: 'none',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
borderColor: formRecognizerPageStyles.clearDark,
bgcolor: formRecognizerPageStyles.clearBg,
transform: 'translateY(-1px)',
},
'&:disabled': {
borderColor: '#e0e0e0',
color: '#9e9e9e',
},
'&:active': {
transform: 'translateY(0)',
},
}}
>
清空所有字段
</Button>
</Box>
)}
<Box sx={{ mt: 3 }}>
<FormControlLabel
control={
<Switch
checked={includeHidden}
onChange={(e) => setIncludeHidden(e.target.checked)}
/>
}
label="包含隐藏字段"
/>
</Box>
<GlobalSnackbar {...snackbarProps} />
</Container>
</Box>
);
};
export default FormRecognizerPage;