Files
testing-tool/utils/formMapping/smartInjector.ts
T
LingandRX 60537f6e2e Develop (#17)
新功能 (Features)
智能表单引擎: 新增智能表单填充功能,内置模糊匹配引擎、Mock数据生成器与视觉反馈渲染器。

表单映射与导出: 实现表单映射页面(包含扫描器和高亮器),并支持将配置导出为 JSON 文件,附带 Snackbar 状态提示。

表单识别增强: 增加按域名保存字段类型偏好的功能;添加字段定位闪烁以辅助查找;优化填充逻辑(支持单字段覆盖默认模式);重构 FieldList 组件以提升操作体验。

♻️ 代码重构 (Refactor)
通用组件提取: 提取并统一应用通用的 PageHeader 组件,移除独立的侧边栏页面及未使用的组件文件。

状态与逻辑优化: 改进 useStorageState 钩子(增加加载状态管理与防抖处理);将二维码解析功能重构为独立模块。

类型与依赖简化: 统一使用 SnackbarOptions 类型;简化假数据生成器中 faker 的导入与使用逻辑。

💄 样式与界面 (Style)
UI 细节打磨: 统一各页面头部图标颜色,调整表单输入框与按钮交互样式;优化时间戳页面、结果视图布局(增加圆角、调整内边距/对齐方式);重构存储选项网格及自动刷新开关样式。

代码格式: 优化项目中导入语句的顺序与格式。

👷 持续集成 (CI)
流程提效: 移除 Firefox 测试步骤以减少资源消耗;收紧工作流触发条件,移除 develop 及其变体分支,仅保留 main 分支触发。

📝 文档 (Docs)
代码维护: 补充组件的文档注释与类型导入。
2026-04-30 19:56:32 +08:00

580 lines
16 KiB
TypeScript

import { FormMapEntry } from '@/types/storage';
/**
* 模糊匹配引擎结果接口
*/
export interface MatchResult {
element: HTMLElement | null;
score: number;
}
/**
* 注入结果接口
*/
export interface InjectResult {
success: boolean;
entry: FormMapEntry;
error?: string;
}
/**
* 模糊匹配引擎
* 根据 JSON 指纹在页面中精准定位目标 DOM 元素
*/
export class FuzzyMatcher {
private static readonly MATCH_THRESHOLD = 75;
private static readonly SCORE_SELECTOR = 50;
private static readonly SCORE_NAME_ATTR = 25;
private static readonly SCORE_PLACEHOLDER = 15;
private static readonly SCORE_NEIGHBOR_TEXT = 10;
/**
* 根据指纹查找目标元素
* @param fingerprint - 表单字段指纹
* @returns 匹配结果(包含元素和得分)
*/
public static findTargetElement(fingerprint: FormMapEntry['fingerprint']): MatchResult {
const candidates: Array<{ element: HTMLElement; score: number }> = [];
// 1. 首先尝试精确选择器匹配
if (fingerprint.selector) {
const exactMatch = document.querySelector<HTMLElement>(fingerprint.selector);
if (exactMatch) {
const score = this.calculateScore(exactMatch, fingerprint);
candidates.push({ element: exactMatch, score });
}
}
// 2. 收集所有可能的候选元素
const potentialElements = this.collectPotentialElements(fingerprint);
for (const element of potentialElements) {
const score = this.calculateScore(element, fingerprint);
if (score > 0) {
candidates.push({ element, score });
}
}
// 3. 找到最高分的候选
if (candidates.length === 0) {
return { element: null, score: 0 };
}
const bestMatch = candidates.reduce((prev, curr) => (curr.score > prev.score ? curr : prev));
return bestMatch.score >= this.MATCH_THRESHOLD
? { element: bestMatch.element, score: bestMatch.score }
: { element: null, score: bestMatch.score };
}
/**
* 计算元素匹配得分
*/
private static calculateScore(
element: HTMLElement,
fingerprint: FormMapEntry['fingerprint'],
): number {
let score = 0;
// 选择器精确匹配
if (fingerprint.selector) {
const matched = document.querySelector(fingerprint.selector);
if (matched === element) {
score += this.SCORE_SELECTOR;
}
}
// name 或 id 属性匹配
if (fingerprint.name_attr) {
const elementName = element.getAttribute('name') || '';
const elementId = element.getAttribute('id') || '';
if (elementName === fingerprint.name_attr || elementId === fingerprint.name_attr) {
score += this.SCORE_NAME_ATTR;
}
}
// placeholder 匹配
if (fingerprint.placeholder) {
const elementPlaceholder =
'placeholder' in element && (element as HTMLInputElement).placeholder;
if (elementPlaceholder === fingerprint.placeholder) {
score += this.SCORE_PLACEHOLDER;
}
}
// 邻近文本(label)匹配
if (fingerprint.name_attr || fingerprint.placeholder) {
const neighborText = this.getNeighborText(element);
const searchText = fingerprint.name_attr || fingerprint.placeholder || '';
if (neighborText.includes(searchText)) {
score += this.SCORE_NEIGHBOR_TEXT;
}
}
return score;
}
/**
* 收集潜在的候选元素
*/
private static collectPotentialElements(
_fingerprint: FormMapEntry['fingerprint'],
): HTMLElement[] {
const elements: HTMLElement[] = [];
// 获取所有表单元素
const selectors = [
'input:not([type="hidden"])',
'textarea',
'select',
'[contenteditable="true"]',
];
for (const selector of selectors) {
const found = document.querySelectorAll<HTMLElement>(selector);
found.forEach((el) => {
if (this.isVisibleElement(el)) {
elements.push(el);
}
});
}
return elements;
}
/**
* 获取元素附近的文本内容
*/
private static getNeighborText(element: HTMLElement): string {
const texts: string[] = [];
// 查找关联的 label
const id = element.getAttribute('id');
if (id) {
const label = document.querySelector(`label[for="${id}"]`);
if (label) {
texts.push(label.textContent || '');
}
}
// 查找父级 label
const parentLabel = element.closest('label');
if (parentLabel) {
texts.push(parentLabel.textContent || '');
}
// 查找相邻元素的文本
const prevSibling = element.previousElementSibling;
const nextSibling = element.nextElementSibling;
if (prevSibling) {
texts.push(prevSibling.textContent || '');
}
if (nextSibling) {
texts.push(nextSibling.textContent || '');
}
// 查找父级内的文本节点
const parent = element.parentElement;
if (parent) {
const textNodes = parent.querySelectorAll('span, div, p');
textNodes.forEach((node) => {
texts.push(node.textContent || '');
});
}
return texts.join(' ').toLowerCase();
}
/**
* 检查元素是否可见
*/
private static isVisibleElement(element: HTMLElement): boolean {
const rect = element.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;
const style = window.getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
}
}
/**
* 智能注入引擎
* 突破 React/Vue 等现代框架的表单状态绑定
*/
export class SmartInjectionEngine {
/**
* 注入数据到目标元素
* @param element - 目标 DOM 元素
* @param entry - 表单映射条目
* @returns 注入结果
*/
public static inject(element: HTMLElement, entry: FormMapEntry, mockValue: string): InjectResult {
try {
const { action_logic } = entry;
switch (action_logic.type) {
case 'text':
this.injectText(element as HTMLInputElement | HTMLTextAreaElement, mockValue);
break;
case 'select':
this.injectSelect(element as HTMLSelectElement, action_logic);
break;
case 'checkbox':
this.injectCheckbox(element as HTMLInputElement, action_logic);
break;
default:
this.injectText(element as HTMLInputElement | HTMLTextAreaElement, mockValue);
}
return { success: true, entry };
} catch (error) {
return {
success: false,
entry,
error: error instanceof Error ? error.message : '注入失败',
};
}
}
/**
* 注入文本类输入框
*/
private static injectText(element: HTMLInputElement | HTMLTextAreaElement, value: string): void {
// 获取原生 setter
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
element instanceof HTMLInputElement
? window.HTMLInputElement.prototype
: window.HTMLTextAreaElement.prototype,
'value',
)?.set;
if (nativeInputValueSetter) {
nativeInputValueSetter.call(element, value);
} else {
element.value = value;
}
// 连续触发事件
element.dispatchEvent(new Event('focus', { bubbles: true }));
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
element.dispatchEvent(new Event('blur', { bubbles: true }));
}
/**
* 注入下拉框
*/
private static injectSelect(
element: HTMLSelectElement,
actionLogic: FormMapEntry['action_logic'],
): void {
if (actionLogic.strategy === 'random') {
// 随机选择
const options = Array.from(element.options).filter((opt) => !opt.disabled);
if (options.length > 0) {
const randomIndex = Math.floor(Math.random() * options.length);
element.selectedIndex = randomIndex;
}
} else {
// 使用固定值
const value = actionLogic.value;
const matchingOption = Array.from(element.options).find(
(opt) => opt.value === value || opt.text === value,
);
if (matchingOption) {
element.value = matchingOption.value;
} else if (element.options.length > 0) {
element.selectedIndex = 0;
}
}
element.dispatchEvent(new Event('change', { bubbles: true }));
}
/**
* 注入复选框/单选框
*/
private static injectCheckbox(
element: HTMLInputElement,
actionLogic: FormMapEntry['action_logic'],
): void {
if (actionLogic.strategy === 'random') {
// 随机选择
const isChecked = Math.random() > 0.5;
if (element.type === 'checkbox') {
element.checked = isChecked;
} else if (element.type === 'radio') {
// 对于单选框,找到同 name 的所有选项并随机选择一个
const radioGroup = document.querySelectorAll<HTMLInputElement>(
`input[type="radio"][name="${element.name}"]`,
);
if (radioGroup.length > 0) {
const randomIndex = Math.floor(Math.random() * radioGroup.length);
radioGroup[randomIndex].click();
}
}
} else {
// 使用固定值
const shouldCheck = actionLogic.value === 'true' || actionLogic.value === '1';
element.checked = shouldCheck;
if (shouldCheck) {
element.click();
}
}
element.dispatchEvent(new Event('change', { bubbles: true }));
}
}
/**
* Mock 数据生成器
*/
export class MockDataGenerator {
/**
* 根据策略生成随机数据
*/
public static generate(actionLogic: FormMapEntry['action_logic'], entry: FormMapEntry): string {
const { strategy, value, type } = actionLogic;
if (strategy === 'fixed') {
return value;
}
// 根据字段类型和策略生成数据
switch (type) {
case 'text':
return this.generateText(entry);
case 'select':
return this.generateSelectValue();
case 'checkbox':
return this.generateBoolean();
default:
return this.generateText(entry);
}
}
/**
* 生成文本数据
*/
private static generateText(entry: FormMapEntry): string {
const { fingerprint, action_logic } = entry;
const { strategy, value: pattern } = action_logic;
// 根据模式生成数据
if (pattern) {
return this.generateByPattern(pattern);
}
// 根据指纹特征推断数据类型
const name = fingerprint.name_attr.toLowerCase();
const placeholder = fingerprint.placeholder.toLowerCase();
if (name.includes('phone') || placeholder.includes('phone')) {
return this.generatePhoneNumber();
}
if (name.includes('email') || placeholder.includes('email')) {
return this.generateEmail();
}
if (name.includes('name') || placeholder.includes('name')) {
return this.generateName();
}
if (name.includes('id') || name.includes('card')) {
return this.generateIdCard();
}
if (name.includes('date') || placeholder.includes('date')) {
return this.generateDate();
}
if (name.includes('number') || placeholder.includes('number')) {
return this.generateNumber();
}
// 默认生成随机文本
return strategy === 'random' ? this.generateRandomText() : '测试数据';
}
/**
* 根据模式生成数据
*/
private static generateByPattern(pattern: string): string {
if (pattern.includes('phone') || pattern.includes('mobile')) {
return this.generatePhoneNumber();
}
if (pattern.includes('email')) {
return this.generateEmail();
}
if (pattern.includes('name')) {
return this.generateName();
}
if (pattern.includes('date')) {
return this.generateDate();
}
if (pattern.includes('idcard') || pattern.includes('身份证')) {
return this.generateIdCard();
}
if (/^\d+$/.test(pattern)) {
return this.generateNumber(pattern.length);
}
return pattern;
}
/**
* 生成手机号
*/
private static generatePhoneNumber(): string {
const prefix = '1' + ['3', '4', '5', '6', '7', '8', '9'][Math.floor(Math.random() * 7)];
const suffix = Math.floor(Math.random() * 1000000000)
.toString()
.padStart(9, '0');
return prefix + suffix;
}
/**
* 生成邮箱
*/
private static generateEmail(): string {
const names = ['test', 'user', 'admin', 'guest', 'demo'];
const domains = ['example.com', 'test.com', 'gmail.com', 'outlook.com'];
const name = names[Math.floor(Math.random() * names.length)];
const domain = domains[Math.floor(Math.random() * domains.length)];
const num = Math.floor(Math.random() * 1000);
return `${name}${num}@${domain}`;
}
/**
* 生成姓名
*/
private static generateName(): string {
const surnames = ['张', '李', '王', '刘', '陈', '杨', '赵', '黄'];
const givenNames = ['伟', '芳', '强', '英', '华', '建', '明', '娜'];
return (
surnames[Math.floor(Math.random() * surnames.length)] +
givenNames[Math.floor(Math.random() * givenNames.length)]
);
}
/**
* 生成身份证号
*/
private static generateIdCard(): string {
const areaCodes = ['110101', '310101', '440101', '120101', '320101'];
const areaCode = areaCodes[Math.floor(Math.random() * areaCodes.length)];
const year = (1980 + Math.floor(Math.random() * 30)).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;
}
/**
* 生成日期
*/
private static generateDate(): string {
const date = new Date();
date.setDate(date.getDate() - Math.floor(Math.random() * 365));
return date.toISOString().split('T')[0];
}
/**
* 生成数字
*/
private static generateNumber(length: number = 6): string {
return Math.floor(Math.random() * Math.pow(10, length))
.toString()
.padStart(length, '0');
}
/**
* 生成随机文本
*/
private static generateRandomText(): string {
const texts = ['测试内容', '示例文本', 'Lorem ipsum', '随机数据', 'Sample Text'];
return texts[Math.floor(Math.random() * texts.length)];
}
/**
* 生成下拉框值
*/
private static generateSelectValue(): string {
return '选项' + (Math.floor(Math.random() * 5) + 1);
}
/**
* 生成布尔值
*/
private static generateBoolean(): string {
return Math.random() > 0.5 ? 'true' : 'false';
}
}
/**
* 视觉反馈渲染器
*/
export class FeedbackRenderer {
private static readonly SUCCESS_COLOR = '#32CD32';
private static readonly ERROR_COLOR = '#FF4444';
private static readonly HIGHLIGHT_DURATION = 3000;
/**
* 渲染成功反馈
*/
public static renderSuccess(element: HTMLElement): void {
this.applyHighlight(element, this.SUCCESS_COLOR);
}
/**
* 渲染失败反馈
*/
public static renderError(element: HTMLElement | null): void {
if (!element) return;
this.applyHighlight(element, this.ERROR_COLOR);
}
/**
* 应用高亮样式
*/
private static applyHighlight(element: HTMLElement, color: string): void {
// 保存原始样式
const originalStyle = element.getAttribute('style') || '';
element.setAttribute('data-original-style', originalStyle);
// 应用高亮
element.style.outline = `3px solid ${color}`;
element.style.outlineOffset = '2px';
element.style.transition = 'outline 0.3s ease';
// 自动移除高亮
setTimeout(() => {
const savedStyle = element.getAttribute('data-original-style');
if (savedStyle) {
element.setAttribute('style', savedStyle);
element.removeAttribute('data-original-style');
} else {
element.style.outline = '';
element.style.outlineOffset = '';
}
}, this.HIGHLIGHT_DURATION);
}
/**
* 清除所有高亮
*/
public static clearAllHighlights(): void {
const highlightedElements = document.querySelectorAll('[data-original-style]');
highlightedElements.forEach((element) => {
const savedStyle = element.getAttribute('data-original-style');
if (savedStyle) {
element.setAttribute('style', savedStyle);
element.removeAttribute('data-original-style');
}
});
}
}