diff --git a/components/RouterContainer.tsx b/components/RouterContainer.tsx index 0859fc9..2cd0e94 100644 --- a/components/RouterContainer.tsx +++ b/components/RouterContainer.tsx @@ -1,5 +1,5 @@ import { Box } from '@mui/material'; -import { ROUTES } from '@/config/routes'; +import { ROUTES, getEntryPointType } from '@/config/routes'; import { useRouter } from '@/providers/RouterProvider'; import { useMemo } from 'react'; @@ -10,11 +10,16 @@ export default function RouterContainer() { return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter'; }, [currentPage]); + const entryPointType = useMemo(() => { + return getEntryPointType(); + }, []); + if (!isLoaded) { return
Loading...
; } - const currentRoute = ROUTES.find(route => route.key === currentPage); + const currentRoute = ROUTES.find((route) => route.key === currentPage); + const Component = currentRoute ? currentRoute.components[entryPointType] : null; return ( - {currentRoute && } + {Component && } ); } diff --git a/config/routes.ts b/config/routes.ts index f578e38..aa63a47 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -5,12 +5,18 @@ import StorageCleanerPage from '@/entrypoints/popup/pages/StorageCleanerPage'; import OpenUrlPage from '@/entrypoints/popup/pages/OpenUrlPage'; import OpenUrlViewerPage from '@/entrypoints/popup/pages/OpenUrlViewerPage'; import QrCodePage from '@/entrypoints/popup/pages/QrCodePage'; +import FormRecognizerPage from '@/entrypoints/popup/pages/FormRecognizerPage'; +import FormFillSidePanel from '@/entrypoints/sidepanel/pages/FormFillSidePanel'; export interface RouteConfig { key: PageType; label: string; defaultVisible: boolean; - component: React.ComponentType; + components: { + popup: React.ComponentType; + sidepanel: React.ComponentType; + detached: React.ComponentType; + }; } export const ROUTES: RouteConfig[] = [ @@ -18,52 +24,99 @@ export const ROUTES: RouteConfig[] = [ key: 'dashboard', label: 'Dashboard', defaultVisible: true, - component: DashboardPage, + components: { + popup: DashboardPage, + sidepanel: DashboardPage, + detached: DashboardPage, + }, }, { key: 'timestamp', label: '时间戳', defaultVisible: true, - component: TimestampPage, + components: { + popup: TimestampPage, + sidepanel: TimestampPage, + detached: TimestampPage, + }, }, { key: 'storageCleaner', label: '存储清理', defaultVisible: true, - component: StorageCleanerPage, + components: { + popup: StorageCleanerPage, + sidepanel: StorageCleanerPage, + detached: StorageCleanerPage, + }, }, { key: 'openUrl', label: 'Open Url', defaultVisible: true, - component: OpenUrlPage, + components: { + popup: OpenUrlPage, + sidepanel: OpenUrlPage, + detached: OpenUrlPage, + }, }, { key: 'qrCode', label: '二维码工具', defaultVisible: true, - component: QrCodePage, + components: { + popup: QrCodePage, + sidepanel: QrCodePage, + detached: QrCodePage, + }, + }, + { + key: 'formRecognizer', + label: '表单识别', + defaultVisible: true, + components: { + popup: FormRecognizerPage, + sidepanel: FormFillSidePanel, + detached: FormRecognizerPage, + }, }, { key: 'openUrlViewer', label: '查看', defaultVisible: false, - component: OpenUrlViewerPage, + components: { + popup: OpenUrlViewerPage, + sidepanel: OpenUrlViewerPage, + detached: OpenUrlViewerPage, + }, }, ]; export function getRouteByKey(key: PageType): RouteConfig | undefined { - return ROUTES.find(route => route.key === key); + return ROUTES.find((route) => route.key === key); } export function getDefaultVisibleRoutes(): PageType[] { - return ROUTES.filter(route => route.defaultVisible).map(route => route.key); + return ROUTES.filter((route) => route.defaultVisible).map((route) => route.key); } export function getAllRouteKeys(): PageType[] { - return ROUTES.map(route => route.key); + return ROUTES.map((route) => route.key); } export function getDefaultPageOrder(): PageType[] { - return ROUTES.filter(route => route.key !== 'dashboard' && route.key !== 'openUrlViewer').map(route => route.key); + return ROUTES.filter((route) => route.key !== 'dashboard' && route.key !== 'openUrlViewer').map( + (route) => route.key, + ); +} + +export function getEntryPointType(): 'popup' | 'sidepanel' | 'detached' { + const pathname = window.location.pathname; + if (pathname.includes('sidepanel')) { + return 'sidepanel'; + } + if (new URLSearchParams(window.location.search).get('mode') === 'detached') { + return 'detached'; + } + return 'popup'; } diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 916b555..dc78b04 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -1,5 +1,6 @@ import '../.wxt/types/imports.d.ts'; import { browser } from 'wxt/browser'; +import { type MessagePayload, type MessageResponse } from '@/utils/messages'; export default defineBackground(() => { // 监听扩展图标点击事件,打开侧边栏 @@ -20,40 +21,6 @@ export default defineBackground(() => { } else if (reason === 'update') { console.log('Extension updated to a new version'); } - - // 获取所有标签页 - const tabs = await browser.tabs.query({}); - - // 过滤不合法或受限制的 URL - const targetTabs = tabs.filter((tab) => { - if (!tab.id || !tab.url) return false; - const restrictedProtocols = [ - 'chrome:', - 'chrome-extension:', - 'about:', - 'edge:', - 'view-source:', - ]; - return !restrictedProtocols.some((protocol) => tab.url!.startsWith(protocol)); - }); - - const results = await Promise.allSettled( - targetTabs.map((tab) => - browser.scripting - .executeScript({ - target: { tabId: tab.id! }, - files: ['/content-scripts/content.js'], - }) - .catch((err) => { - console.warn(`Failed to inject script into tab ${tab.id}:`, err.message); - }), - ), - ); - - const successCount = results.filter((r) => r.status === 'fulfilled').length; - console.log( - `Successfully injected content script into ${successCount}/${targetTabs.length} tabs.`, - ); }); chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { @@ -61,4 +28,25 @@ export default defineBackground(() => { console.log('加载完成的 Tab ID:', tabId); } }); + + // 监听来自 popup/sidepanel 的消息 + chrome.runtime.onMessage.addListener( + (message: MessagePayload, sender, sendResponse: (response: MessageResponse) => void) => { + try { + // 处理跨标签页的消息转发 + if (sender.tab) { + // 从内容脚本或弹出页面发送的消息 + console.log('收到来自标签页的消息:', message.action); + sendResponse({ success: true, message: '消息已收到' }); + } else { + // 从扩展其他部分发送的消息 + console.log('收到来自扩展的消息:', message.action); + sendResponse({ success: true, message: '消息已收到' }); + } + } catch (error) { + console.error('处理消息失败:', error); + sendResponse({ success: false, message: '处理消息失败' }); + } + }, + ); }); diff --git a/entrypoints/content.ts b/entrypoints/content.ts index c354ff5..872609b 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -1,9 +1,117 @@ import '../.wxt/types/imports.d.ts'; +import { + fillAllFields, + clearAllFields, + fillSelectedFields, + scanFormFields, + highlightField, + unhighlightField, + FillMode, + type FormFieldInfo, +} from '@/utils/dummyDataGenerator'; +import { MessageAction, type MessagePayload, type MessageResponse } from '@/utils/messages'; + +// 存储当前扫描到的字段列表,用于高亮联动 +let currentFields: FormFieldInfo[] = []; export default defineContentScript({ matches: [''], - runAt: 'document_start', + runAt: 'document_end', main() { - // Content script placeholder + // 监听来自 popup/sidepanel 的消息 + chrome.runtime.onMessage.addListener( + (message: MessagePayload, _sender, sendResponse: (response: MessageResponse) => void) => { + try { + switch (message.action) { + case MessageAction.SCAN_FORM_FIELDS: { + const result = scanFormFields(); + currentFields = result.fields; + sendResponse({ + success: true, + fields: result.fields.map((f) => ({ + id: f.id, + fieldType: f.fieldType, + label: f.label, + placeholder: f.placeholder, + name: f.name, + value: f.value, + isSelected: f.isSelected, + generatedValue: f.generatedValue, + })), + totalCount: result.totalCount, + validCount: result.validCount, + hasModal: !!result.modalContainer, + }); + break; + } + case MessageAction.FILL_VALID_DATA: + fillAllFields(FillMode.VALID, message.includeHidden || false); + sendResponse({ success: true, message: '已填充有效数据' }); + break; + case MessageAction.FILL_INVALID_DATA: + fillAllFields(FillMode.INVALID, message.includeHidden || false); + sendResponse({ success: true, message: '已填充无效数据' }); + break; + case MessageAction.FILL_SELECTED_FIELDS: { + // 使用之前扫描时存储的字段,因为它们包含element属性 + const fieldIds = (message.fields || []).filter((f) => f.isSelected).map((f) => f.id); + const fieldsToFill = currentFields.filter((f) => fieldIds.includes(f.id)); + fieldsToFill.forEach((f) => (f.isSelected = true)); + const count = fillSelectedFields(fieldsToFill, message.mode || FillMode.VALID); + sendResponse({ success: true, message: `已填充 ${count} 个字段` }); + break; + } + case MessageAction.CLEAR_ALL_FIELDS: + clearAllFields(); + sendResponse({ success: true, message: '已清空所有字段' }); + break; + case MessageAction.HIGHLIGHT_FIELD: { + const fieldId = message.fieldId; + const field = currentFields.find((f) => f.id === fieldId); + if (field) { + highlightField(field.element); + sendResponse({ success: true }); + } else { + sendResponse({ success: false, message: '未找到字段' }); + } + break; + } + case MessageAction.UNHIGHLIGHT_FIELD: { + const fieldId = message.fieldId; + const field = currentFields.find((f) => f.id === fieldId); + if (field) { + unhighlightField(field.element); + sendResponse({ success: true }); + } else { + sendResponse({ success: false, message: '未找到字段' }); + } + break; + } + case MessageAction.HIGHLIGHT_ALL_FIELDS: { + const fieldIds = message.fieldIds || []; + fieldIds.forEach((id) => { + const field = currentFields.find((f) => f.id === id); + if (field) { + highlightField(field.element); + } + }); + sendResponse({ success: true }); + break; + } + case MessageAction.UNHIGHLIGHT_ALL_FIELDS: + currentFields.forEach((field) => { + unhighlightField(field.element); + }); + sendResponse({ success: true }); + break; + default: + sendResponse({ success: false, message: '未知操作' }); + } + } catch (error) { + console.error('执行操作失败:', error); + sendResponse({ success: false, message: '执行操作失败' }); + } + }, + ); }, }); diff --git a/entrypoints/popup/pages/DashboardPage.tsx b/entrypoints/popup/pages/DashboardPage.tsx index 85fec59..dfc6820 100644 --- a/entrypoints/popup/pages/DashboardPage.tsx +++ b/entrypoints/popup/pages/DashboardPage.tsx @@ -5,6 +5,7 @@ import AccessTimeIcon from '@mui/icons-material/AccessTime'; 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 type { PageType } from '@/types/storage'; import { useEffect, useState } from 'react'; import dayjs from '@/utils/dayjs'; @@ -88,6 +89,18 @@ export default function DashboardPage() { cardBackgroundColor={dashboardPageStyles.cardBackgroundColor} /> ); + case 'formRecognizer': + return ( + } + onClick={() => navigateTo('formRecognizer')} + cardBackgroundColor={dashboardPageStyles.cardBackgroundColor} + /> + ); default: return null; } diff --git a/entrypoints/popup/pages/FormRecognizerPage.tsx b/entrypoints/popup/pages/FormRecognizerPage.tsx new file mode 100644 index 0000000..0c08ea6 --- /dev/null +++ b/entrypoints/popup/pages/FormRecognizerPage.tsx @@ -0,0 +1,193 @@ +import { useState } from 'react'; +import { + Box, + Typography, + Container, + Button, + Paper, + CircularProgress, + Stack, + Switch, + FormControlLabel, +} 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 GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar'; +import { dashboardPageStyles } from '@/config/pageTheme'; + +const FormRecognizerPage = () => { + const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 }); + const [loading, setLoading] = useState(false); + const [includeHidden, setIncludeHidden] = useState(false); + + interface MessagePayload { + includeHidden?: boolean; + } + + const sendMessageToContent = async (action: string, payload?: MessagePayload) => { + setLoading(true); + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab.id) { + showMessage('无法获取当前标签页', { severity: 'error' }); + return; + } + + const response = await chrome.tabs.sendMessage(tab.id, { action, ...payload }); + 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 handleFillValidData = () => { + sendMessageToContent('fillValidData', { includeHidden }); + }; + + const handleFillInvalidData = () => { + sendMessageToContent('fillInvalidData', { includeHidden }); + }; + + const handleClearAllFields = () => { + sendMessageToContent('clearAllFields'); + }; + + return ( + + + + + Dummy Data Generator + + + 一键填充表单测试数据,提升开发和测试效率 + + + + {/* 主要操作按钮 */} + + + + + + + + + {/* 选项设置 */} + + + + 填充选项 + + + + setIncludeHidden(e.target.checked)} + color="primary" + /> + } + label="包含隐藏字段" + sx={{ width: '100%' }} + /> + + + + {/* 功能说明 */} + + + + 功能说明 + + + + + 有效数据模式:生成符合格式要求的测试数据,适用于正常功能测试。 + + + 异常数据模式:生成边界值或格式错误的数据,适用于异常场景测试。 + + + 一键清空:快速清空当前页面所有表单字段的值。 + + + 支持的字段类型: + 文本、邮箱、手机号、数字、日期、文本域、密码、身份证号等。 + + + + + + + + ); +}; + +export default FormRecognizerPage; diff --git a/entrypoints/sidepanel/pages/FormFillSidePanel.tsx b/entrypoints/sidepanel/pages/FormFillSidePanel.tsx new file mode 100644 index 0000000..ccf00e1 --- /dev/null +++ b/entrypoints/sidepanel/pages/FormFillSidePanel.tsx @@ -0,0 +1,477 @@ +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([]); + const [mode, setMode] = useState<'valid' | 'invalid'>('valid'); + const [fillEmptyOnly, setFillEmptyOnly] = useState(false); + const [includeHidden] = useState(false); + const [hoveredFieldId, setHoveredFieldId] = useState(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.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 ( + + + + Dummy Data Pro + + + 智能表单填充助手 + + + + + + + + + + + + + + + setFillEmptyOnly(e.target.checked)} + size="small" + /> + } + label="仅填充空字段" + sx={{ mb: 1 }} + /> + + + + + + + + {fields.length > 0 && ( + + + + 字段列表 ({fields.length}) + + + + + + {fields.map((field) => ( + handleHoverField(field.id)} + onMouseLeave={() => handleHoverField(null)} + > + + handleToggleSelect(field.id)} + sx={{ p: 0, mt: 0.5 }} + /> + + + + {field.label || field.placeholder || field.name || '未命名字段'} + + + + {getFieldTypeLabel(field.fieldType)} + + + + { + e.stopPropagation(); + handleHoverField(field.id); + setTimeout(() => handleHoverField(null), 2000); + }} + sx={{ p: 0.5 }} + > + + + + + + handleEditValue(field.id, e.target.value)} + placeholder="生成的数据..." + sx={{ + '& .MuiInputBase-input': { + fontSize: '0.8rem', + fontFamily: 'monospace', + }, + }} + /> + + { + e.stopPropagation(); + handleRefreshField(field.id); + }} + sx={{ p: 0.5 }} + > + + + + + {field.value && ( + + 当前值: {field.value} + + )} + + + + ))} + + + )} + + {fields.length === 0 && !scanning && ( + + + 点击「扫描表单」按钮开始扫描当前页面的表单字段 + + + )} + + + + ); +}; + +export default FormFillSidePanel; diff --git a/package-lock.json b/package-lock.json index f90a274..a54cdb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,11 @@ "name": "testing-tools", "version": "1.0.0", "hasInstallScript": true, + "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@faker-js/faker": "^10.4.0", "@mui/icons-material": "^7.3.8", "@mui/material": "^7.3.8", "@webext-core/messaging": "^2.3.0", @@ -1447,6 +1449,22 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@faker-js/faker": { + "version": "10.4.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/@faker-js/faker/-/faker-10.4.0.tgz", + "integrity": "sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz", @@ -2065,9 +2083,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2082,9 +2097,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2099,9 +2111,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2116,9 +2125,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2133,9 +2139,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2150,9 +2153,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2167,9 +2167,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2184,9 +2181,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2201,9 +2195,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2218,9 +2209,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2235,9 +2223,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2252,9 +2237,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2269,9 +2251,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index ba392ca..eb4d527 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@faker-js/faker": "^10.4.0", "@mui/icons-material": "^7.3.8", "@mui/material": "^7.3.8", "@webext-core/messaging": "^2.3.0", diff --git a/providers/RouterProvider.tsx b/providers/RouterProvider.tsx index 6edb2ff..77f5824 100644 --- a/providers/RouterProvider.tsx +++ b/providers/RouterProvider.tsx @@ -29,7 +29,7 @@ export function RouterProvider({ children, defaultRoute = 'dashboard', syncRoute = true, - syncKey = 'app/currentRoute' + syncKey = 'app/currentRoute', }: RouterProviderProps) { const [currentPage, setCurrentPage] = useState(defaultRoute); const [visiblePages, setVisiblePages] = useState(getDefaultVisibleRoutes()); @@ -124,7 +124,7 @@ export function RouterProvider({ syncNavigation, goBack, setVisiblePages, - setPageOrder + setPageOrder, }} > {children} diff --git a/types/storage.d.ts b/types/storage.d.ts index b3bb29d..816eca0 100644 --- a/types/storage.d.ts +++ b/types/storage.d.ts @@ -4,6 +4,7 @@ export type PageType = | 'storageCleaner' | 'openUrl' | 'qrCode' + | 'formRecognizer' | 'openUrlViewer'; export interface StorageSchema { diff --git a/utils/dummyDataGenerator.ts b/utils/dummyDataGenerator.ts new file mode 100644 index 0000000..d33d3b9 --- /dev/null +++ b/utils/dummyDataGenerator.ts @@ -0,0 +1,984 @@ +import { faker, fakerZH_CN } from '@faker-js/faker'; + +/** + * 表单字段信息接口 + */ +export interface FormFieldInfo { + id: string; + element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; + fieldType: FieldType; + label: string | null; + placeholder: string; + name: string; + value: string; + isSelected: boolean; + generatedValue: string; +} + +/** + * 扫描结果接口 + */ +export interface ScanResult { + fields: FormFieldInfo[]; + totalCount: number; + validCount: number; + modalContainer: HTMLElement | null; +} + +/** + * 数据生成器工具类 + * 用于生成各种类型的测试数据 + */ +export class DummyDataGenerator { + /** + * 生成随机中文姓名 + */ + static generateChineseName(): string { + return fakerZH_CN.person.fullName(); + } + + /** + * 生成随机英文姓名 + */ + static generateEnglishName(): string { + return faker.person.fullName(); + } + + /** + * 生成随机手机号 + */ + static generatePhoneNumber(): string { + return fakerZH_CN.phone.number(); + } + + /** + * 生成有效邮箱 + */ + static generateValidEmail(): string { + return faker.internet.email(); + } + + /** + * 生成无效邮箱 + */ + static generateInvalidEmail(): string { + const invalidEmails = [ + 'testexample.com', // 缺失 @ + 'test@@example.com', // 多个 @ + 'test@', // 缺失域名 + 'test@.com', // 域名为空 + 'test@example', // 缺失顶级域名 + ]; + + return invalidEmails[Math.floor(Math.random() * invalidEmails.length)]; + } + + /** + * 生成短文本 + */ + static generateShortText(): string { + return faker.lorem.sentence({ min: 3, max: 6 }); + } + + /** + * 生成长文本 + */ + static generateLongText(): string { + return faker.lorem.paragraphs(5); + } + + /** + * 生成边界测试文本 + */ + static generateBoundaryText(): string { + const specialChars = '!@#$%^&*()_+[]{}|;:,.<>?'; + const emoji = '😀😃😄😁😆😅😂🤣'; + let text = ''; + + for (let i = 0; i < 100; i++) { + text += specialChars + emoji + '测试文本'; + } + + return text; + } + + /** + * 生成随机数字 + */ + static generateNumber(): number { + return faker.number.int(10000); + } + + /** + * 生成随机浮点数 + */ + static generateFloat(): number { + return faker.number.float({ max: 10000 }); + } + + /** + * 生成随机负数 + */ + static generateNegativeNumber(): number { + return -faker.number.int(10000); + } + + /** + * 生成随机日期 + */ + static generateDate(): string { + return faker.date.recent({ days: 365 }).toISOString().split('T')[0]; + } + + /** + * 生成过去的日期 + */ + static generatePastDate(): string { + return faker.date.past({ years: 1 }).toISOString().split('T')[0]; + } + + /** + * 生成未来的日期 + */ + static generateFutureDate(): string { + return faker.date.future({ years: 1 }).toISOString().split('T')[0]; + } + + /** + * 生成随机身份证号 + */ + static generateIdCard(): string { + const areaCodes = [ + '110101', + '110102', + '110103', + '110104', + '110105', + '310101', + '310102', + '310103', + '310104', + '310105', + '440101', + '440102', + '440103', + '440104', + '440105', + ]; + const areaCode = areaCodes[Math.floor(Math.random() * areaCodes.length)]; + const year = (1950 + Math.floor(Math.random() * 50)).toString(); + 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; + } +} + +/** + * 表单字段类型 + */ +export enum FieldType { + TEXT = 'text', + EMAIL = 'email', + PHONE = 'phone', + NUMBER = 'number', + DATE = 'date', + TEXTarea = 'textarea', + RADIO = 'radio', + CHECKBOX = 'checkbox', + SELECT = 'select', + PASSWORD = 'password', + NAME = 'name', + ID_CARD = 'id_card', + UNKNOWN = 'unknown', +} + +/** + * 填充模式 + */ +export enum FillMode { + VALID = 'valid', + INVALID = 'invalid', +} + +/** + * 识别表单字段类型 + */ +export function recognizeFieldType( + element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, +): FieldType { + // 基于 type 属性识别 + if (element instanceof HTMLInputElement) { + switch (element.type) { + case 'email': + return FieldType.EMAIL; + case 'tel': + return FieldType.PHONE; + case 'number': + return FieldType.NUMBER; + case 'date': + return FieldType.DATE; + case 'password': + return FieldType.PASSWORD; + case 'radio': + return FieldType.RADIO; + case 'checkbox': + return FieldType.CHECKBOX; + case 'text': + break; + } + } + + // 基于 name 或 id 识别 + const name = (element.name || element.id || '').toLowerCase(); + if (name.includes('email') || name.includes('mail')) { + return FieldType.EMAIL; + } + if (name.includes('phone') || name.includes('tel') || name.includes('mobile')) { + return FieldType.PHONE; + } + if (name.includes('name') || name.includes('user') || name.includes('username')) { + return FieldType.NAME; + } + if (name.includes('id') || name.includes('card') || name.includes('identity')) { + return FieldType.ID_CARD; + } + if (name.includes('password') || name.includes('pass')) { + return FieldType.PASSWORD; + } + if (name.includes('number') || name.includes('num')) { + return FieldType.NUMBER; + } + if (name.includes('date') || name.includes('time')) { + return FieldType.DATE; + } + + // 基于 placeholder 识别 + if ('placeholder' in element) { + const placeholder = (element.placeholder || '').toLowerCase(); + if (placeholder.includes('email') || placeholder.includes('mail')) { + return FieldType.EMAIL; + } + if ( + placeholder.includes('phone') || + placeholder.includes('tel') || + placeholder.includes('mobile') + ) { + return FieldType.PHONE; + } + if ( + placeholder.includes('name') || + placeholder.includes('user') || + placeholder.includes('username') + ) { + return FieldType.NAME; + } + if ( + placeholder.includes('id') || + placeholder.includes('card') || + placeholder.includes('identity') + ) { + return FieldType.ID_CARD; + } + if (placeholder.includes('password') || placeholder.includes('pass')) { + return FieldType.PASSWORD; + } + if (placeholder.includes('number') || placeholder.includes('num')) { + return FieldType.NUMBER; + } + if (placeholder.includes('date') || placeholder.includes('time')) { + return FieldType.DATE; + } + } + + // 基于标签识别 + const label = getFieldLabel(element); + if (label) { + const labelText = label.toLowerCase(); + if (labelText.includes('邮箱') || labelText.includes('email') || labelText.includes('mail')) { + return FieldType.EMAIL; + } + if ( + labelText.includes('手机') || + labelText.includes('电话') || + labelText.includes('tel') || + labelText.includes('mobile') + ) { + return FieldType.PHONE; + } + if ( + labelText.includes('姓名') || + labelText.includes('名字') || + labelText.includes('name') || + labelText.includes('user') || + labelText.includes('username') + ) { + return FieldType.NAME; + } + if ( + labelText.includes('身份证') || + labelText.includes('id') || + labelText.includes('card') || + labelText.includes('identity') + ) { + return FieldType.ID_CARD; + } + if (labelText.includes('密码') || labelText.includes('pass')) { + return FieldType.PASSWORD; + } + if (labelText.includes('数字') || labelText.includes('number') || labelText.includes('num')) { + return FieldType.NUMBER; + } + if ( + labelText.includes('日期') || + labelText.includes('时间') || + labelText.includes('date') || + labelText.includes('time') + ) { + return FieldType.DATE; + } + } + + // 基于元素类型识别 + if (element instanceof HTMLTextAreaElement) { + return FieldType.TEXTarea; + } + if (element instanceof HTMLSelectElement) { + return FieldType.SELECT; + } + + return FieldType.TEXT; +} + +/** + * 获取字段的标签 + */ +function getFieldLabel(element: HTMLElement): string | null { + // 查找相邻的 label 元素 + const labels = document.querySelectorAll('label'); + for (const label of labels) { + const forAttr = label.getAttribute('for'); + if (forAttr === element.id) { + return label.textContent || null; + } + } + + // 查找父元素中的 label + let parent = element.parentElement; + while (parent) { + if (parent.tagName === 'LABEL') { + return parent.textContent || null; + } + parent = parent.parentElement; + } + + return null; +} + +/** + * 填充表单字段 + */ +export function fillField( + element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, + mode: FillMode, +): void { + const fieldType = recognizeFieldType(element); + let value: string | number | boolean = ''; + + switch (fieldType) { + case FieldType.NAME: + value = + Math.random() > 0.5 + ? DummyDataGenerator.generateChineseName() + : DummyDataGenerator.generateEnglishName(); + break; + case FieldType.EMAIL: + value = + mode === FillMode.VALID + ? DummyDataGenerator.generateValidEmail() + : DummyDataGenerator.generateInvalidEmail(); + break; + case FieldType.PHONE: + value = DummyDataGenerator.generatePhoneNumber(); + break; + case FieldType.NUMBER: + value = + mode === FillMode.VALID + ? DummyDataGenerator.generateNumber() + : DummyDataGenerator.generateNegativeNumber(); + break; + case FieldType.DATE: + value = DummyDataGenerator.generateDate(); + break; + case FieldType.TEXTarea: + value = + mode === FillMode.VALID + ? DummyDataGenerator.generateLongText() + : DummyDataGenerator.generateBoundaryText(); + break; + case FieldType.PASSWORD: + value = 'password123'; + break; + case FieldType.ID_CARD: + value = DummyDataGenerator.generateIdCard(); + break; + case FieldType.TEXT: + default: + value = + mode === FillMode.VALID + ? DummyDataGenerator.generateShortText() + : DummyDataGenerator.generateBoundaryText(); + break; + } + + // 填充值 + if (element instanceof HTMLInputElement) { + if (element.type === 'checkbox' || element.type === 'radio') { + element.checked = Math.random() > 0.5; + } else { + element.value = String(value); + } + } else if (element instanceof HTMLTextAreaElement) { + element.value = String(value); + } else if (element instanceof HTMLSelectElement) { + // 随机选择一个非禁用的选项 + const options = Array.from(element.options).filter((option) => !option.disabled); + if (options.length > 0) { + const randomIndex = Math.floor(Math.random() * options.length); + element.selectedIndex = randomIndex; + } + } + + // 触发事件,确保前端框架能够监听到变化 + triggerEvents(element); +} + +/** + * 触发事件 + */ +function triggerEvents(element: HTMLElement): void { + // 触发 input 事件 + const inputEvent = new Event('input', { + bubbles: true, + cancelable: true, + }); + element.dispatchEvent(inputEvent); + + // 触发 change 事件 + const changeEvent = new Event('change', { + bubbles: true, + cancelable: true, + }); + element.dispatchEvent(changeEvent); + + // 触发 blur 事件 + const blurEvent = new Event('blur', { + bubbles: true, + cancelable: true, + }); + element.dispatchEvent(blurEvent); +} + +/** + * 判断元素是否真正可见且允许输入 + */ +function isElementVisible(element: HTMLElement): boolean { + // 1. 排除隐藏域、禁用和只读状态 + if (element instanceof HTMLInputElement) { + if (element.type === 'hidden' || element.disabled || element.readOnly) { + return false; + } + } else if (element instanceof HTMLTextAreaElement) { + if (element.disabled || element.readOnly) { + return false; + } + } else if (element instanceof HTMLSelectElement) { + if (element.disabled) { + return false; + } + } + + // 2. 检查空间尺寸 (能有效过滤大部分 display: none 或未渲染完毕的组件) + const rect = element.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + return false; + } + + // 3. 检查计算样式 (兜底检查 css 隐藏手段) + const style = window.getComputedStyle(element); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.opacity === '0' || + style.visibility === 'collapse' + ) { + return false; + } + + return true; +} + +/** + * Z轴穿透验证 (Raycasting) + * 通过 document.elementFromPoint(x, y) 向元素中心点发射坐标射线 + * 如果获取到的顶层元素不是输入框本身或其子元素,则判定为"视觉遮挡" + */ +function isElementNotObscured(element: HTMLElement): boolean { + const rect = element.getBoundingClientRect(); + + // 计算元素中心点坐标 + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + // 向元素中心点发射坐标射线,获取最顶层的元素 + const topElement = document.elementFromPoint(centerX, centerY); + + if (!topElement) { + return false; + } + + // 检查获取到的顶层元素是否是输入框本身或其子元素 + return element.contains(topElement); +} + +/** + * 判断元素是否在视口范围内 + */ +function isElementInViewport(element: HTMLElement): boolean { + const rect = element.getBoundingClientRect(); + + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + ); +} + +/** + * 判断元素是否真正可见且允许输入(漏斗式检测) + */ +function isElementValidForFill(element: HTMLElement): boolean { + // 1. 基础过滤:排除隐藏域、禁用和只读状态 + if (element instanceof HTMLInputElement) { + if (element.type === 'hidden' || element.disabled || element.readOnly) { + return false; + } + } else if (element instanceof HTMLTextAreaElement) { + if (element.disabled || element.readOnly) { + return false; + } + } else if (element instanceof HTMLSelectElement) { + if (element.disabled) { + return false; + } + } else { + return false; + } + + // 2. 空间尺寸检测:排除宽高为0的元素 + const rect = element.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + return false; + } + + // 3. CSS样式检测:排除通过CSS隐藏的元素 + const style = window.getComputedStyle(element); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.opacity === '0' || + style.visibility === 'collapse' + ) { + return false; + } + + // 4. 视口检测:只处理当前屏幕滚动范围内的元素 + if (!isElementInViewport(element)) { + return false; + } + + // 5. Z轴穿透验证(最后一步,最耗时,放最后) + if (!isElementNotObscured(element)) { + return false; + } + + return true; +} + +/** + * 查找最上层的弹窗容器 + */ +function findActiveModalContainer(): HTMLElement | null { + const modalSelectors = [ + '.ant-modal-content', + '.el-dialog', + '[role="dialog"]', + '.MuiDialog-content', + '.modal-content', + '.dialog-content', + '.popup-content', + ]; + + let topModal: HTMLElement | null = null; + let highestZIndex = 0; + + modalSelectors.forEach((selector) => { + const modals = document.querySelectorAll(selector); + modals.forEach((modal) => { + if (modal instanceof HTMLElement) { + const style = window.getComputedStyle(modal); + const zIndex = parseInt(style.zIndex, 10) || 0; + if (zIndex > highestZIndex && isElementVisible(modal)) { + highestZIndex = zIndex; + topModal = modal; + } + } + }); + }); + + return topModal; +} + +/** + * 扫描页面中所有可见的表单字段 + */ +export function scanFormFields(): ScanResult { + const inputs = document.querySelectorAll('input, textarea, select'); + const fields: FormFieldInfo[] = []; + const modalContainer = findActiveModalContainer(); + + inputs.forEach((input) => { + if ( + input instanceof HTMLInputElement || + input instanceof HTMLTextAreaElement || + input instanceof HTMLSelectElement + ) { + if (isElementValidForFill(input)) { + const fieldType = recognizeFieldType(input); + const label = getFieldLabel(input); + const placeholder = 'placeholder' in input ? input.placeholder : ''; + const name = input.name || input.id || ''; + + fields.push({ + id: `field-${Math.random().toString(36).substr(2, 9)}`, + element: input, + fieldType, + label, + placeholder, + name, + value: input.value, + isSelected: true, + generatedValue: generateValueByFieldType(fieldType, FillMode.VALID), + }); + } + } + }); + + return { + fields, + totalCount: fields.length, + validCount: fields.filter((f) => f.isSelected).length, + modalContainer, + }; +} + +/** + * 根据字段类型生成对应的值 + */ +export function generateValueByFieldType(fieldType: FieldType, mode: FillMode): string { + switch (fieldType) { + case FieldType.NAME: + return Math.random() > 0.5 + ? DummyDataGenerator.generateChineseName() + : DummyDataGenerator.generateEnglishName(); + case FieldType.EMAIL: + return mode === FillMode.VALID + ? DummyDataGenerator.generateValidEmail() + : DummyDataGenerator.generateInvalidEmail(); + case FieldType.PHONE: + return DummyDataGenerator.generatePhoneNumber(); + case FieldType.NUMBER: + return String( + mode === FillMode.VALID + ? DummyDataGenerator.generateNumber() + : DummyDataGenerator.generateNegativeNumber(), + ); + case FieldType.DATE: + return DummyDataGenerator.generateDate(); + case FieldType.TEXTarea: + return mode === FillMode.VALID + ? DummyDataGenerator.generateLongText() + : DummyDataGenerator.generateBoundaryText(); + case FieldType.PASSWORD: + return 'password123'; + case FieldType.ID_CARD: + return DummyDataGenerator.generateIdCard(); + case FieldType.TEXT: + default: + return mode === FillMode.VALID + ? DummyDataGenerator.generateShortText() + : DummyDataGenerator.generateBoundaryText(); + } +} + +/** + * 框架级数据注入器 + * 破解 React/Vue 的 input setter 劫持 + */ +function setInputValue( + element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, + value: string, +): void { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + element instanceof HTMLInputElement + ? window.HTMLInputElement.prototype + : element instanceof HTMLTextAreaElement + ? window.HTMLTextAreaElement.prototype + : window.HTMLSelectElement.prototype, + 'value', + )?.set; + + if (nativeInputValueSetter) { + nativeInputValueSetter.call(element, value); + } else { + element.value = value; + } +} + +/** + * 填充指定字段(使用框架级注入) + */ +export function fillFieldWithInjector( + element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, + value: string, +): void { + if (element instanceof HTMLInputElement) { + if (element.type === 'checkbox' || element.type === 'radio') { + element.checked = value === 'true' || value === '1'; + } else { + setInputValue(element, value); + } + } else if (element instanceof HTMLTextAreaElement) { + setInputValue(element, value); + } else if (element instanceof HTMLSelectElement) { + // 查找匹配的选项 + const options = Array.from(element.options); + const matchingOption = options.find((opt) => opt.value === value || opt.text === value); + if (matchingOption) { + element.value = matchingOption.value; + } else if (options.length > 0) { + element.selectedIndex = 0; + } + } + + triggerEvents(element); +} + +/** + * 批量填充选中的字段 + */ +export function fillSelectedFields(fields: FormFieldInfo[], mode: FillMode): number { + let filledCount = 0; + + fields.forEach((field) => { + if (field.isSelected) { + const value = field.generatedValue || generateValueByFieldType(field.fieldType, mode); + fillFieldWithInjector(field.element, value); + filledCount++; + } + }); + + return filledCount; +} + +/** + * 高亮指定字段 + */ +export function highlightField(element: HTMLElement): void { + const originalStyle = + element.getAttribute('data-original-style') || element.getAttribute('style') || ''; + element.setAttribute('data-original-style', originalStyle); + + element.style.outline = '3px solid #2196f3'; + element.style.outlineOffset = '2px'; + element.style.transition = 'outline 0.2s ease-in-out'; +} + +/** + * 取消高亮指定字段 + */ +export function unhighlightField(element: HTMLElement): void { + const originalStyle = element.getAttribute('data-original-style') || ''; + if (originalStyle) { + element.setAttribute('style', originalStyle); + element.removeAttribute('data-original-style'); + } else { + element.style.outline = ''; + element.style.outlineOffset = ''; + } +} + +/** + * 高亮所有指定字段 + */ +export function highlightAllFields(fieldIds: string[], fields: FormFieldInfo[]): void { + fieldIds.forEach((id) => { + const field = fields.find((f) => f.id === id); + if (field) { + highlightField(field.element); + } + }); +} + +/** + * 取消高亮所有字段 + */ +export function unhighlightAllFields(fields: FormFieldInfo[]): void { + fields.forEach((field) => { + unhighlightField(field.element); + }); +} + +/** + * 填充所有表单字段 + */ +export function fillAllFields(mode: FillMode, includeHidden: boolean = false): void { + const inputs = document.querySelectorAll('input, textarea, select'); + + inputs.forEach((element) => { + if ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLSelectElement + ) { + if (!isElementValidForFill(element)) { + return; + } + + if (!includeHidden) { + if (element instanceof HTMLInputElement && element.type === 'hidden') { + return; + } + } + + const fieldType = recognizeFieldType(element); + const value = generateValueByFieldType(fieldType, mode); + fillFieldWithInjector(element, value); + } + }); +} + +/** + * 填充指定容器内的表单字段 + */ +export function fillFieldsInContainer( + mode: FillMode, + container: HTMLElement, + includeHidden: boolean = false, +): void { + const inputs = container.querySelectorAll('input, textarea, select'); + + inputs.forEach((element) => { + if ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLSelectElement + ) { + if (!isElementValidForFill(element)) { + return; + } + + if (!includeHidden) { + if (element instanceof HTMLInputElement && element.type === 'hidden') { + return; + } + } + + const fieldType = recognizeFieldType(element); + const value = generateValueByFieldType(fieldType, mode); + fillFieldWithInjector(element, value); + } + }); +} + +/** + * 填充弹窗内的表单字段 + */ +export function fillFieldsInActiveModal(mode: FillMode, includeHidden: boolean = false): boolean { + const modal = findActiveModalContainer(); + if (modal) { + fillFieldsInContainer(mode, modal, includeHidden); + return true; + } + return false; +} + +/** + * 清空所有表单字段 + */ +export function clearAllFields(): void { + const inputs = document.querySelectorAll('input, textarea, select'); + + inputs.forEach((element) => { + if ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLSelectElement + ) { + if (!isElementValidForFill(element)) { + return; + } + + if (element instanceof HTMLInputElement) { + if (element.type === 'checkbox' || element.type === 'radio') { + element.checked = false; + } else { + setInputValue(element, ''); + } + } else if (element instanceof HTMLTextAreaElement) { + setInputValue(element, ''); + } else if (element instanceof HTMLSelectElement) { + element.selectedIndex = 0; + } + + triggerEvents(element); + } + }); +} + +/** + * 清空指定容器内的表单字段 + */ +export function clearFieldsInContainer(container: HTMLElement): void { + const inputs = container.querySelectorAll('input, textarea, select'); + + inputs.forEach((element) => { + if ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLSelectElement + ) { + if (!isElementValidForFill(element)) { + return; + } + + if (element instanceof HTMLInputElement) { + if (element.type === 'checkbox' || element.type === 'radio') { + element.checked = false; + } else { + setInputValue(element, ''); + } + } else if (element instanceof HTMLTextAreaElement) { + setInputValue(element, ''); + } else if (element instanceof HTMLSelectElement) { + element.selectedIndex = 0; + } + + triggerEvents(element); + } + }); +} diff --git a/utils/messages.ts b/utils/messages.ts new file mode 100644 index 0000000..d0cf748 --- /dev/null +++ b/utils/messages.ts @@ -0,0 +1,94 @@ +import { FormFieldInfo, FillMode } from './dummyDataGenerator'; + +/** + * 消息动作类型 + */ +export enum MessageAction { + // 表单相关操作 + SCAN_FORM_FIELDS = 'scanFormFields', + FILL_VALID_DATA = 'fillValidData', + FILL_INVALID_DATA = 'fillInvalidData', + FILL_SELECTED_FIELDS = 'fillSelectedFields', + CLEAR_ALL_FIELDS = 'clearAllFields', + + // 字段高亮操作 + HIGHLIGHT_FIELD = 'highlightField', + UNHIGHLIGHT_FIELD = 'unhighlightField', + HIGHLIGHT_ALL_FIELDS = 'highlightAllFields', + UNHIGHLIGHT_ALL_FIELDS = 'unhighlightAllFields', +} + +/** + * 消息载荷接口 + */ +export interface MessagePayload { + action: MessageAction; + fields?: Omit[]; + mode?: FillMode; + includeHidden?: boolean; + fieldId?: string; + fieldIds?: string[]; +} + +/** + * 消息响应接口 + */ +export interface MessageResponse { + success: boolean; + message?: string; + fields?: Omit[]; + totalCount?: number; + validCount?: number; + hasModal?: boolean; +} + +/** + * 发送消息到内容脚本 + */ +export async function sendMessageToContent( + action: MessageAction, + payload?: Omit, +): Promise { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id) { + return { success: false, message: '无法获取当前标签页' }; + } + + return new Promise((resolve) => { + chrome.tabs.sendMessage(tab.id!, { action, ...payload }, (response) => { + if (chrome.runtime.lastError) { + console.error('消息发送失败:', chrome.runtime.lastError); + resolve({ success: false, message: '无法连接到页面,请确保页面已加载' }); + } else { + resolve((response as MessageResponse) || { success: false, message: '未收到响应' }); + } + }); + }); + } catch (error) { + console.error('获取标签页失败:', error); + return { success: false, message: '无法获取当前标签页' }; + } +} + +/** + * 注入内容脚本 + */ +export async function injectContentScript(): Promise { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id) { + return false; + } + + // 尝试注入内容脚本 + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ['/content-scripts/content.js'], + }); + return true; + } catch (error) { + console.error('注入内容脚本失败:', error); + return false; + } +} diff --git a/utils/messages.tsx b/utils/messages.tsx deleted file mode 100644 index d6884db..0000000 --- a/utils/messages.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { defineExtensionMessaging } from '@webext-core/messaging'; - -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -interface ProtocolMap { - // Placeholder - 扩展消息协议 -} - -export const { sendMessage, onMessage } = defineExtensionMessaging();