60537f6e2e
✨新功能 (Features) 智能表单引擎: 新增智能表单填充功能,内置模糊匹配引擎、Mock数据生成器与视觉反馈渲染器。 表单映射与导出: 实现表单映射页面(包含扫描器和高亮器),并支持将配置导出为 JSON 文件,附带 Snackbar 状态提示。 表单识别增强: 增加按域名保存字段类型偏好的功能;添加字段定位闪烁以辅助查找;优化填充逻辑(支持单字段覆盖默认模式);重构 FieldList 组件以提升操作体验。 ♻️ 代码重构 (Refactor) 通用组件提取: 提取并统一应用通用的 PageHeader 组件,移除独立的侧边栏页面及未使用的组件文件。 状态与逻辑优化: 改进 useStorageState 钩子(增加加载状态管理与防抖处理);将二维码解析功能重构为独立模块。 类型与依赖简化: 统一使用 SnackbarOptions 类型;简化假数据生成器中 faker 的导入与使用逻辑。 💄 样式与界面 (Style) UI 细节打磨: 统一各页面头部图标颜色,调整表单输入框与按钮交互样式;优化时间戳页面、结果视图布局(增加圆角、调整内边距/对齐方式);重构存储选项网格及自动刷新开关样式。 代码格式: 优化项目中导入语句的顺序与格式。 👷 持续集成 (CI) 流程提效: 移除 Firefox 测试步骤以减少资源消耗;收紧工作流触发条件,移除 develop 及其变体分支,仅保留 main 分支触发。 📝 文档 (Docs) 代码维护: 补充组件的文档注释与类型导入。
580 lines
16 KiB
TypeScript
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');
|
|
}
|
|
});
|
|
}
|
|
}
|