Develop (#17)
✨新功能 (Features) 智能表单引擎: 新增智能表单填充功能,内置模糊匹配引擎、Mock数据生成器与视觉反馈渲染器。 表单映射与导出: 实现表单映射页面(包含扫描器和高亮器),并支持将配置导出为 JSON 文件,附带 Snackbar 状态提示。 表单识别增强: 增加按域名保存字段类型偏好的功能;添加字段定位闪烁以辅助查找;优化填充逻辑(支持单字段覆盖默认模式);重构 FieldList 组件以提升操作体验。 ♻️ 代码重构 (Refactor) 通用组件提取: 提取并统一应用通用的 PageHeader 组件,移除独立的侧边栏页面及未使用的组件文件。 状态与逻辑优化: 改进 useStorageState 钩子(增加加载状态管理与防抖处理);将二维码解析功能重构为独立模块。 类型与依赖简化: 统一使用 SnackbarOptions 类型;简化假数据生成器中 faker 的导入与使用逻辑。 💄 样式与界面 (Style) UI 细节打磨: 统一各页面头部图标颜色,调整表单输入框与按钮交互样式;优化时间戳页面、结果视图布局(增加圆角、调整内边距/对齐方式);重构存储选项网格及自动刷新开关样式。 代码格式: 优化项目中导入语句的顺序与格式。 👷 持续集成 (CI) 流程提效: 移除 Firefox 测试步骤以减少资源消耗;收紧工作流触发条件,移除 develop 及其变体分支,仅保留 main 分支触发。 📝 文档 (Docs) 代码维护: 补充组件的文档注释与类型导入。
This commit is contained in:
@@ -12,6 +12,16 @@ import {
|
||||
} from '@/utils/dummyDataGenerator';
|
||||
import { MessageAction, type MessagePayload, type MessageResponse } from '@/utils/messages';
|
||||
|
||||
import { SmartDetector } from '@/utils/formMapping/scanner';
|
||||
import { highlighter } from '@/utils/formMapping/highlighter';
|
||||
import {
|
||||
FuzzyMatcher,
|
||||
SmartInjectionEngine,
|
||||
FeedbackRenderer,
|
||||
} from '@/utils/formMapping/smartInjector';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import { FormMapEntry } from '@/types/storage';
|
||||
|
||||
// 存储当前扫描到的字段列表,用于高亮联动
|
||||
let currentFields: FormFieldInfo[] = [];
|
||||
|
||||
@@ -19,6 +29,53 @@ export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
runAt: 'document_end',
|
||||
main() {
|
||||
// === 通用表单映射助手逻辑 ===
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (
|
||||
area === 'local' &&
|
||||
(changes['active_form_map'] || changes['app/formMapping/isPicking'])
|
||||
) {
|
||||
updateMappingUI();
|
||||
}
|
||||
});
|
||||
|
||||
async function updateMappingUI() {
|
||||
const entries = ((await storageUtil.get('active_form_map')) as FormMapEntry[]) || [];
|
||||
const isPicking = ((await storageUtil.get('app/formMapping/isPicking')) as boolean) || false;
|
||||
|
||||
if (entries.length > 0 || isPicking) {
|
||||
highlighter.show();
|
||||
highlighter.draw(entries);
|
||||
|
||||
if (isPicking) {
|
||||
highlighter.enablePicker(async (el) => {
|
||||
const fingerprint = SmartDetector.generateFingerprint(el);
|
||||
const label = SmartDetector.extractSemanticLabel(el);
|
||||
|
||||
const newEntry: FormMapEntry = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
label_display: label,
|
||||
fingerprint,
|
||||
action_logic: { type: 'text', strategy: 'fixed', value: '' },
|
||||
ui_state: { is_selected: true },
|
||||
};
|
||||
|
||||
const currentMap = ((await storageUtil.get('active_form_map')) as FormMapEntry[]) || [];
|
||||
await storageUtil.set('active_form_map', [...currentMap, newEntry]);
|
||||
await storageUtil.set('app/formMapping/isPicking', false);
|
||||
});
|
||||
} else {
|
||||
highlighter.disablePicker();
|
||||
}
|
||||
} else {
|
||||
highlighter.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载映射 UI
|
||||
updateMappingUI();
|
||||
|
||||
// === 原有表单识别逻辑 ===
|
||||
// 监听来自 popup/sidepanel 的消息
|
||||
chrome.runtime.onMessage.addListener(
|
||||
(message: MessagePayload, _sender, sendResponse: (response: MessageResponse) => void) => {
|
||||
@@ -126,6 +183,38 @@ export default defineContentScript({
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'FORM_INJECT': {
|
||||
try {
|
||||
const injectData =
|
||||
(message.data as Array<{ entry: FormMapEntry; mockValue: string }>) || [];
|
||||
const results = injectData.map((item) => {
|
||||
const matchResult = FuzzyMatcher.findTargetElement(item.entry.fingerprint);
|
||||
if (matchResult.element) {
|
||||
const injectResult = SmartInjectionEngine.inject(
|
||||
matchResult.element,
|
||||
item.entry,
|
||||
item.mockValue,
|
||||
);
|
||||
if (injectResult.success) {
|
||||
FeedbackRenderer.renderSuccess(matchResult.element);
|
||||
} else {
|
||||
FeedbackRenderer.renderError(matchResult.element);
|
||||
}
|
||||
return { id: item.entry.id, success: injectResult.success };
|
||||
} else {
|
||||
return { id: item.entry.id, success: false };
|
||||
}
|
||||
});
|
||||
sendResponse({ success: true, results });
|
||||
} catch (error) {
|
||||
console.error('智能注入失败:', error);
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '注入失败',
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
sendResponse({ success: false, message: '未知操作' });
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import StorageIcon from '@mui/icons-material/Storage';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
||||
import DescriptionIcon from '@mui/icons-material/Description';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import { useEffect, useState } from 'react';
|
||||
import dayjs from '@/utils/dayjs';
|
||||
@@ -89,6 +90,18 @@ export default function DashboardPage() {
|
||||
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
|
||||
/>
|
||||
);
|
||||
case 'formMapping':
|
||||
return (
|
||||
<ToolCard
|
||||
key={key}
|
||||
title="通用表单映射助手"
|
||||
description="智能识别表单指纹,自定义填充逻辑"
|
||||
colorCode="#3f51b5"
|
||||
icon={<AutoFixHighIcon sx={{ fontSize: 20 }} />}
|
||||
onClick={() => navigateTo('formMapping')}
|
||||
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
|
||||
/>
|
||||
);
|
||||
case 'formRecognizer':
|
||||
return (
|
||||
<ToolCard
|
||||
@@ -101,6 +114,18 @@ export default function DashboardPage() {
|
||||
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
|
||||
/>
|
||||
);
|
||||
case 'formFill':
|
||||
return (
|
||||
<ToolCard
|
||||
key={key}
|
||||
title="智能填充"
|
||||
description="根据表单指纹智能填充表单内容"
|
||||
colorCode="#2196f3"
|
||||
icon={<AutoFixHighIcon sx={{ fontSize: 20 }} />}
|
||||
onClick={() => navigateTo('formFill')}
|
||||
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Container,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
IconButton,
|
||||
Switch,
|
||||
Divider,
|
||||
Paper,
|
||||
Button,
|
||||
Chip,
|
||||
Snackbar,
|
||||
Alert,
|
||||
alpha,
|
||||
} from '@mui/material';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import { FormMapEntry } from '@/types/storage';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import { globalStyles, formMappingPageStyles } from '@/config/pageTheme.ts';
|
||||
import { MockDataGenerator } from '@/utils/formMapping/smartInjector';
|
||||
|
||||
export default function FormFillPage() {
|
||||
const [entries, setEntries] = useState<FormMapEntry[]>([]);
|
||||
const [previewData, setPreviewData] = useState<Map<string, string>>(new Map());
|
||||
const [injectResults, setInjectResults] = useState<Map<string, boolean>>(new Map());
|
||||
const [isInjecting, setIsInjecting] = useState(false);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [showError, setShowError] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const generatePreviewData = useCallback((items: FormMapEntry[]) => {
|
||||
const preview = new Map<string, string>();
|
||||
items.forEach((entry) => {
|
||||
const value = MockDataGenerator.generate(entry.action_logic, entry);
|
||||
preview.set(entry.id, value);
|
||||
});
|
||||
setPreviewData(preview);
|
||||
setInjectResults(new Map());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadEntries = async () => {
|
||||
const data = (await storageUtil.get('active_form_map')) as FormMapEntry[];
|
||||
setEntries(data || []);
|
||||
generatePreviewData(data || []);
|
||||
};
|
||||
|
||||
loadEntries();
|
||||
const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => {
|
||||
if (area === 'local' && changes['active_form_map']) {
|
||||
loadEntries();
|
||||
}
|
||||
};
|
||||
chrome.storage.onChanged.addListener(listener);
|
||||
return () => chrome.storage.onChanged.removeListener(listener);
|
||||
}, [generatePreviewData]);
|
||||
|
||||
const refreshPreview = () => {
|
||||
generatePreviewData(entries);
|
||||
};
|
||||
|
||||
const injectAllFields = async () => {
|
||||
if (entries.length === 0) {
|
||||
setErrorMessage('没有可填充的字段');
|
||||
setShowError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsInjecting(true);
|
||||
const results = new Map<string, boolean>();
|
||||
|
||||
try {
|
||||
// 发送消息到 content script 执行注入
|
||||
const response = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (response.length === 0) {
|
||||
throw new Error('无法获取当前标签页');
|
||||
}
|
||||
|
||||
const tabId = response[0].id;
|
||||
if (!tabId) {
|
||||
throw new Error('标签页ID无效');
|
||||
}
|
||||
|
||||
// 准备注入数据
|
||||
const injectData = entries.map((entry) => ({
|
||||
entry,
|
||||
mockValue: previewData.get(entry.id) || '',
|
||||
}));
|
||||
|
||||
// 执行注入
|
||||
const result = await chrome.tabs.sendMessage(tabId, {
|
||||
type: 'FORM_INJECT',
|
||||
data: injectData,
|
||||
});
|
||||
|
||||
if (result && result.success) {
|
||||
result.results.forEach((r: { id: string; success: boolean }) => {
|
||||
results.set(r.id, r.success);
|
||||
});
|
||||
setInjectResults(results);
|
||||
setShowSuccess(true);
|
||||
} else {
|
||||
throw new Error(result?.error || '注入失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('注入失败:', error);
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单',
|
||||
);
|
||||
setShowError(true);
|
||||
} finally {
|
||||
setIsInjecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getFieldTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
text: '文本',
|
||||
select: '下拉框',
|
||||
checkbox: '复选框',
|
||||
radio: '单选框',
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
const getStrategyLabel = (strategy: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
fixed: '固定值',
|
||||
random: '随机',
|
||||
sequence: '序列',
|
||||
};
|
||||
return labels[strategy] || strategy;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ bgcolor: globalStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
|
||||
<Container sx={{ py: 2, bgcolor: globalStyles.backgroundColor }}>
|
||||
<PageHeader
|
||||
title="智能表单填充"
|
||||
subtitle="基于指纹识别的精准数据注入"
|
||||
icon={<PlayArrowIcon />}
|
||||
/>
|
||||
<Container maxWidth="sm" sx={{ py: 2, px: 0 }}>
|
||||
{/* 操作区域 */}
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 2.5,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight={800} color="text.primary">
|
||||
填充控制
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={refreshPreview}
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
sx={{ borderRadius: 3 }}
|
||||
>
|
||||
刷新预览
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={injectAllFields}
|
||||
size="small"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
disabled={isInjecting || entries.length === 0}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
bgcolor: formMappingPageStyles.secondaryColor || '#9c27b0',
|
||||
boxShadow: `0 4px 12px ${alpha(formMappingPageStyles.secondaryColor || '#9c27b0', 0.2)}`,
|
||||
}}
|
||||
>
|
||||
{isInjecting ? '注入中...' : '开始填充'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
点击"开始填充"后,将根据映射配置向网页表单注入数据。
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* 字段列表 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 1.5,
|
||||
px: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" fontWeight={800} color="text.secondary">
|
||||
映射字段 ({entries.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<List
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
}}
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="暂无映射字段"
|
||||
secondary="请先在表单映射页面配置字段"
|
||||
slotProps={{
|
||||
primary: {
|
||||
align: 'center',
|
||||
color: 'text.secondary',
|
||||
},
|
||||
secondary: {
|
||||
align: 'center',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
) : (
|
||||
entries.map((entry, index) => (
|
||||
<Box key={entry.id}>
|
||||
{index > 0 && <Divider />}
|
||||
<ListItem
|
||||
secondaryAction={
|
||||
<IconButton edge="end" aria-label="preview">
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
<Switch
|
||||
edge="start"
|
||||
checked={entry.ui_state.is_selected}
|
||||
disabled
|
||||
sx={{ mr: 2 }}
|
||||
/>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<span style={{ fontWeight: 500 }}>{entry.label_display}</span>
|
||||
<Chip
|
||||
size="small"
|
||||
label={getFieldTypeLabel(entry.action_logic.type)}
|
||||
sx={{
|
||||
fontSize: '0.65rem',
|
||||
bgcolor: 'grey.100',
|
||||
color: 'grey.700',
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label={getStrategyLabel(entry.action_logic.strategy)}
|
||||
sx={{
|
||||
fontSize: '0.65rem',
|
||||
bgcolor: formMappingPageStyles.secondaryColor + '20',
|
||||
color: formMappingPageStyles.secondaryColor || '#9c27b0',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
color: 'text.secondary',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{entry.fingerprint.selector}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'primary.main',
|
||||
fontStyle: 'italic',
|
||||
wordBreak: 'break-all',
|
||||
maxWidth: '250px',
|
||||
}}
|
||||
>
|
||||
预览: {previewData.get(entry.id) || '---'}
|
||||
</Typography>
|
||||
{injectResults.has(entry.id) &&
|
||||
(injectResults.get(entry.id) ? (
|
||||
<CheckCircleIcon sx={{ color: '#32CD32', fontSize: '1rem' }} />
|
||||
) : (
|
||||
<CancelIcon sx={{ color: '#FF4444', fontSize: '1rem' }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
|
||||
{/* 统计信息 */}
|
||||
{injectResults.size > 0 && (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: 'grey.50',
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.200',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-around' }}>
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h5" fontWeight={800} color="primary.main">
|
||||
{Array.from(injectResults.values()).filter(Boolean).length}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
成功注入
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h5" fontWeight={800} color="error.main">
|
||||
{Array.from(injectResults.values()).filter((v) => !v).length}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
注入失败
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</Container>
|
||||
</Container>
|
||||
|
||||
{/* 提示消息 */}
|
||||
<Snackbar
|
||||
open={showSuccess}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setShowSuccess(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity="success">填充完成!</Alert>
|
||||
</Snackbar>
|
||||
<Snackbar
|
||||
open={showError}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setShowError(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity="error">{errorMessage}</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Container,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
IconButton,
|
||||
Switch,
|
||||
Divider,
|
||||
Paper,
|
||||
alpha,
|
||||
Snackbar,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import { FormMapEntry } from '@/types/storage';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import Button from '@/components/Button';
|
||||
import { globalStyles, formMappingPageStyles } from '@/config/pageTheme.ts';
|
||||
|
||||
export default function FormMappingPage() {
|
||||
const [entries, setEntries] = useState<FormMapEntry[]>([]);
|
||||
const [isPicking, setIsPicking] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [showExportSuccess, setShowExportSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const data = (await storageUtil.get('active_form_map')) as FormMapEntry[];
|
||||
setEntries(data || []);
|
||||
const picking = (await storageUtil.get('app/formMapping/isPicking')) as boolean;
|
||||
setIsPicking(picking || false);
|
||||
};
|
||||
|
||||
loadData();
|
||||
|
||||
const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => {
|
||||
if (area === 'local') {
|
||||
if (changes['active_form_map']) {
|
||||
setEntries((changes['active_form_map'].newValue as FormMapEntry[]) || []);
|
||||
}
|
||||
if (changes['app/formMapping/isPicking']) {
|
||||
setIsPicking((changes['app/formMapping/isPicking'].newValue as boolean) || false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chrome.storage.onChanged.addListener(listener);
|
||||
return () => chrome.storage.onChanged.removeListener(listener);
|
||||
}, []);
|
||||
|
||||
const togglePicking = async () => {
|
||||
await storageUtil.set('app/formMapping/isPicking', !isPicking);
|
||||
};
|
||||
|
||||
const deleteEntry = async (id: string) => {
|
||||
const newEntries = entries.filter((e) => e.id !== id);
|
||||
await storageUtil.set('active_form_map', newEntries);
|
||||
};
|
||||
|
||||
const toggleSelection = async (id: string) => {
|
||||
const newEntries = entries.map((e) =>
|
||||
e.id === id ? { ...e, ui_state: { ...e.ui_state, is_selected: !e.ui_state.is_selected } } : e,
|
||||
);
|
||||
await storageUtil.set('active_form_map', newEntries);
|
||||
};
|
||||
|
||||
const clearAll = async () => {
|
||||
await storageUtil.set('active_form_map', []);
|
||||
await storageUtil.set('app/formMapping/isPicking', false);
|
||||
};
|
||||
|
||||
const exportConfig = () => {
|
||||
try {
|
||||
if (entries.length === 0) {
|
||||
setExportError('没有可导出的配置数据');
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonStr = JSON.stringify(entries, null, 2);
|
||||
const blob = new Blob([jsonStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const date = new Date();
|
||||
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
const filename = `form-mapping-config-${dateStr}.json`;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setShowExportSuccess(true);
|
||||
} catch (error) {
|
||||
console.error('导出配置失败:', error);
|
||||
setExportError(error instanceof Error ? error.message : '导出失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ bgcolor: globalStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
|
||||
<Container sx={{ py: 2, bgcolor: globalStyles.backgroundColor }}>
|
||||
<PageHeader
|
||||
title="通用表单映射助手"
|
||||
subtitle="智能识别表单指纹,自定义填充逻辑"
|
||||
icon={<AutoFixHighIcon />}
|
||||
/>
|
||||
<Container maxWidth="sm" sx={{ py: 2, px: 0 }}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 2.5,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle1" fontWeight={800} color="text.primary">
|
||||
状态控制
|
||||
</Typography>
|
||||
<Button
|
||||
variant={isPicking ? 'contained' : 'outlined'}
|
||||
onClick={togglePicking}
|
||||
size="small"
|
||||
startIcon={<AddCircleOutlineIcon />}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
px: 2,
|
||||
fontWeight: 800,
|
||||
...(isPicking
|
||||
? {
|
||||
bgcolor: 'secondary.main',
|
||||
boxShadow: `0 4px 12px ${alpha(formMappingPageStyles.secondaryColor || '#9c27b0', 0.2)}`,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{isPicking ? '正在拾取...' : '开始拾取'}
|
||||
</Button>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
点击“开始拾取”后,直接在网页上点击想要映射的表单元素。
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 1.5,
|
||||
px: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" fontWeight={800} color="text.secondary">
|
||||
已拾取字段 ({entries.length})
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={clearAll}
|
||||
sx={{ fontWeight: 700, fontSize: '0.75rem' }}
|
||||
>
|
||||
清空全部
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<List
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
}}
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="暂无数据"
|
||||
secondary="点击上方按钮开始探测网页表单"
|
||||
slotProps={{
|
||||
primary: {
|
||||
align: 'center',
|
||||
color: 'text.secondary',
|
||||
},
|
||||
secondary: {
|
||||
align: 'center',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
) : (
|
||||
entries.map((entry, index) => (
|
||||
<Box key={entry.id}>
|
||||
{index > 0 && <Divider />}
|
||||
<ListItem
|
||||
secondaryAction={
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="delete"
|
||||
onClick={() => deleteEntry(entry.id)}
|
||||
sx={{ color: 'error.light' }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
<Switch
|
||||
edge="start"
|
||||
checked={entry.ui_state.is_selected}
|
||||
onChange={() => toggleSelection(entry.id)}
|
||||
/>
|
||||
<ListItemText
|
||||
primary={entry.label_display}
|
||||
secondary={entry.fingerprint.selector}
|
||||
slotProps={{
|
||||
primary: { fontWeight: 500 },
|
||||
secondary: {
|
||||
sx: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '200px',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
|
||||
{entries.length > 0 && (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 1.5,
|
||||
px: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'text.secondary' }}>
|
||||
映射配置导出 (JSON)
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={exportConfig}
|
||||
startIcon={<FileDownloadIcon />}
|
||||
sx={{ fontWeight: 700, fontSize: '0.75rem', borderRadius: 3 }}
|
||||
>
|
||||
导出配置
|
||||
</Button>
|
||||
</Box>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: 'grey.50',
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.200',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
maxHeight: '180px',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<pre style={{ margin: 0 }}>{JSON.stringify(entries, null, 2)}</pre>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</Container>
|
||||
</Container>
|
||||
</Box>
|
||||
<Snackbar
|
||||
open={!!exportError}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setExportError(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity="error" onClose={() => setExportError(null)}>
|
||||
{exportError}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<Snackbar
|
||||
open={showExportSuccess}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setShowExportSuccess(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity="success" onClose={() => setShowExportSuccess(false)}>
|
||||
配置导出成功!
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -339,6 +339,7 @@ const FormRecognizerPage = () => {
|
||||
title="表单测试数据填充器"
|
||||
subtitle="一键填充表单测试数据,提升开发和测试效率"
|
||||
icon={<InputIcon />}
|
||||
iconColor={formRecognizerPageStyles.primaryColor}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import UrlEntryForm from '@/components/UrlEntryForm';
|
||||
import UrlEntryList from '@/components/UrlEntryList';
|
||||
import { useUrlPreferences } from '@/utils/useUrlPreferences';
|
||||
import type { OpenUrlEntry } from '@/types/storage';
|
||||
import { dashboardPageStyles } from '@/config/pageTheme';
|
||||
import { dashboardPageStyles, openUrlPageStyles } from '@/config/pageTheme';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
|
||||
export default function OpenUrlPage() {
|
||||
@@ -41,6 +41,7 @@ export default function OpenUrlPage() {
|
||||
title="URL 工具"
|
||||
subtitle="快速打开 URL 或复制链接"
|
||||
icon={<LanguageIcon />}
|
||||
iconColor={openUrlPageStyles.primaryColor}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import UrlToQrCodeSection from '@/components/UrlToQrCodeSection';
|
||||
import QrCodeToUrlSection from '@/components/QrCodeToUrlSection';
|
||||
import { useStorageState } from '@/utils/useStorageState';
|
||||
import { dashboardPageStyles } from '@/config/pageTheme';
|
||||
import { dashboardPageStyles, qrCodePageStyles } from '@/config/pageTheme';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
|
||||
const QrCodePage = () => {
|
||||
@@ -33,12 +33,13 @@ const QrCodePage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100%', pb: 3, bgcolor: dashboardPageStyles.backgroundColor }}>
|
||||
<Box sx={{ minHeight: '100%', pb: 3 }}>
|
||||
<Container sx={{ py: 2, maxWidth: 400, bgcolor: dashboardPageStyles.backgroundColor }}>
|
||||
<PageHeader
|
||||
title="二维码工具"
|
||||
subtitle="生成和解析二维码"
|
||||
icon={<QrCodeIcon />}
|
||||
iconColor={qrCodePageStyles.primaryColor}
|
||||
sx={{ mb: 2.5 }}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user