8d36f21f1b
* docs: 添加组件文档注释和类型导入 refactor: 统一使用 SnackbarOptions 类型 style: 优化导入语句顺序和格式 * refactor: 简化假数据生成器中的faker导入和使用 Co-authored-by: Copilot <copilot@github.com> * feat(form-recognizer): 增强表单识别功能并优化UI交互 - 新增字段类型偏好设置功能,支持按域名保存字段类型 - 重构FieldList组件,改进字段选择和类型修改体验 - 添加字段定位闪烁功能,便于在页面上快速找到对应字段 - 优化表单填充逻辑,支持单个字段覆盖默认填充模式 - 移除独立的侧边栏页面,统一使用主页面组件 - 改进useStorageState钩子,增加加载状态管理和防抖处理 * refactor: 移除未使用的组件文件 * refactor(页面头部): 提取通用 PageHeader 组件并替换各页面头部实现 重构各页面头部为统一的 PageHeader 组件,提高代码复用性和维护性 * style(组件): 调整自动刷新开关和存储选项网格的样式 优化自动刷新开关的文本内边距,重构存储选项网格的布局结构,调整间距和边框样式 * style(ui): 调整时间戳页面和结果视图的样式 - 为时区选择器添加圆角 - 优化结果视图的布局和对齐方式 - 调整结果项的内边距和文本样式 * ci(workflow): 移除Firefox测试以简化CI流程 仅保留Chrome浏览器的构建步骤,减少CI运行时间和资源消耗 * refactor(qrcode): 重构二维码解析功能并提取为独立模块 * style(页面样式): 统一页面头部图标颜色并优化表单样式 更新各页面头部组件的图标颜色配置,移除冗余的表单标签属性,优化输入框样式和按钮悬停效果 * feat(formMapping): 添加表单映射功能 - 创建 FormMappingPage 页面组件 - 实现表单扫描器 scanner.ts - 实现高亮器 highlighter.ts - 更新路由配置 routes.ts - 更新内容脚本 content.ts - 更新类型定义 storage.d.ts - 添加 formMappingPageStyles 样式配置 * feat(表单映射): 添加配置导出功能及状态提示 添加配置导出为JSON文件的功能,包含导出按钮和错误处理。新增Snackbar组件用于显示导出成功和错误状态。优化页面布局结构,将状态提示移至全局容器外。 * feat(表单填充): 新增智能表单填充功能 添加智能表单填充功能,包括: 1. 新增表单填充页面和路由配置 2. 实现模糊匹配引擎和智能注入引擎 3. 添加Mock数据生成器和视觉反馈渲染器 4. 扩展消息接口支持填充操作 * ci: 精简触发CI的工作流分支 移除对develop分支及其变体的触发,仅保留main分支的触发 * test: 更新路由测试以匹配新增的路由数量 * test: 将测试文件中的描述和断言翻译为中文 * test(PageHeader): 添加组件测试用例验证渲染逻辑和样式 * refactor(theme): 重构主题样式并优化仪表盘卡片组件 将页面样式配置集中管理,移除各页面中硬编码的背景色 新增 DashboardCard 组件封装通用卡片逻辑 添加 dashboardCards 配置文件统一管理卡片数据 * refactor(Button): 统一按钮样式并移除重复样式定义 将按钮样式统一封装到 Button 组件中,移除各页面重复的样式定义 更新组件文档说明,提供更清晰的用法示例 * docs: 更新 README 文件中的目录结构说明 添加新组件和配置文件的说明,保持文档与代码同步 * refactor: 优化代码类型声明和UI布局 修复类型声明从any改为never以提高类型安全性 调整TimestampPage页面布局间距 移除DashboardPage中不必要的实时时钟状态 更新ESLint配置以使用推荐配置 添加选项页面打开的错误处理 * refactor(消息通信): 使用 @webext-core/messaging 重构消息处理逻辑 将原有的 chrome.runtime.onMessage 和 chrome.tabs.sendMessage 替换为类型安全的 @webext-core/messaging 实现 添加 ProtocolMap 类型定义确保消息类型安全 更新相关页面和后台脚本使用新的消息通信方式 * fix: 修复依赖项缺失导致的潜在问题 修复 useStorageState 中缺少 defaultValue 依赖的问题 重构 RouterProvider 的初始化逻辑,使用 useCallback 优化性能 * refactor: 重构表单识别和消息处理逻辑 将表单映射UI逻辑提取到独立文件 将消息处理器提取到独立文件 将表单识别页面逻辑提取到自定义hook 优化代码结构和可维护性 * feat(侧边栏): 添加侧边栏状态变化通知功能 - 在消息类型中新增侧边栏状态变化枚举和字段 - 侧边栏打开和关闭时发送状态通知 - 替换轮询检查方式为消息监听机制 * refactor: 优化高亮组件渲染逻辑并添加防抖处理 重构高亮组件渲染逻辑,使用 requestAnimationFrame 进行节流优化 在 useStorageCleaner 中添加防抖处理以避免频繁加载 * refactor: 统一组件导出方式为默认导出 * refactor: 重构样式系统并迁移至 MUI 主题 删除冗余的 CSS 文件,统一使用 MUI 主题管理样式 新增 useActiveTabDomain 和 useSidePanelState 自定义 Hook 优化各入口点的主题集成和布局处理 * docs: 更新项目文档以反映新增功能和技术栈细节 更新 AGENTS.md 文档,详细描述新增的功能模块(存储管理、URL 管理、二维码生成、表单工具套件等)和更新的技术栈信息 * feat: 添加错误边界组件以捕获子组件错误 在 popup、sidepanel 和 options 入口点添加 ErrorBoundary 组件,用于捕获并处理子组件中的 JavaScript 错误。当错误发生时,显示友好的错误界面并提供刷新功能。 * refactor(ui): 使用全局 snackbar 替换本地通知组件 重构表单页面中的通知系统,移除本地 Snackbar 和 Alert 组件,改用全局 GlobalSnackbar 组件统一管理通知 标准化主题配置中的 CSS 属性命名,移除带前缀的样式属性 * refactor(snackbar): 重构全局消息提示为 SnackbarProvider 组件 将原本分散在各页面的 GlobalSnackbar 组件重构为集中管理的 SnackbarProvider,通过 Context 提供统一的消息提示功能。主要变更包括: 1. 创建新的 SnackbarProvider 组件作为全局消息提示容器 2. 提供 useSnackbar hook 供子组件调用 3. 移除各页面中独立的 GlobalSnackbar 实例 4. 在 App 根组件中统一集成 SnackbarProvider 5. 优化消息提示样式和交互行为 * refactor(Snackbar): 重构消息提示组件并优化样式 重构 Snackbar 组件,将状态管理逻辑提取到 GlobalSnackbar 中复用 优化消息提示样式,调整阴影效果和最小尺寸 统一各页面使用 Snackbar 的方式,移除冗余配置 * refactor: 重构路由和功能配置系统 - 将路由配置和仪表盘卡片配置合并为统一的 features 配置 - 重构 RouterProvider 增加数据校验和本地快照功能 - 优化表单字段识别和填充逻辑,提取公共方法 - 更新相关组件和文档以适配新的配置系统 - 删除废弃的 dashboardCards 和 routes 配置文件
839 lines
21 KiB
TypeScript
839 lines
21 KiB
TypeScript
import { fakerZH_CN as faker } 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 faker.person.fullName();
|
|
}
|
|
|
|
/**
|
|
* 生成随机英文姓名
|
|
*/
|
|
static generateEnglishName(): string {
|
|
return faker.person.fullName();
|
|
}
|
|
|
|
/**
|
|
* 生成随机手机号(中国格式)
|
|
*/
|
|
static generatePhoneNumber(): string {
|
|
const prefix =
|
|
'1' + faker.string.numeric({ length: 1, allowLeadingZeros: false, exclude: ['0', '1', '2'] });
|
|
const suffix = faker.string.numeric({ length: 9, allowLeadingZeros: true });
|
|
return prefix + suffix;
|
|
}
|
|
|
|
/**
|
|
* 生成有效邮箱
|
|
*/
|
|
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',
|
|
}
|
|
|
|
/**
|
|
* 关键词与字段类型映射
|
|
*/
|
|
const FIELD_TYPE_KEYWORDS: Record<
|
|
Exclude<
|
|
FieldType,
|
|
| FieldType.UNKNOWN
|
|
| FieldType.TEXT
|
|
| FieldType.TEXTarea
|
|
| FieldType.SELECT
|
|
| FieldType.RADIO
|
|
| FieldType.CHECKBOX
|
|
>,
|
|
string[]
|
|
> = {
|
|
[FieldType.EMAIL]: ['email', 'mail', '邮箱'],
|
|
[FieldType.PHONE]: ['phone', 'tel', 'mobile', '手机', '电话'],
|
|
[FieldType.NAME]: ['name', 'user', 'username', '姓名', '名字'],
|
|
[FieldType.ID_CARD]: ['id', 'card', 'identity', '身份证'],
|
|
[FieldType.PASSWORD]: ['password', 'pass', '密码'],
|
|
[FieldType.NUMBER]: ['number', 'num', '数字'],
|
|
[FieldType.DATE]: ['date', 'time', '日期', '时间'],
|
|
};
|
|
|
|
/**
|
|
* 根据文本识别字段类型
|
|
*/
|
|
function detectTypeFromText(text: string): FieldType | null {
|
|
const lowerText = text.toLowerCase();
|
|
for (const [type, keywords] of Object.entries(FIELD_TYPE_KEYWORDS)) {
|
|
if (keywords.some((kw) => lowerText.includes(kw))) {
|
|
return type as FieldType;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 识别表单字段类型
|
|
*/
|
|
export function recognizeFieldType(
|
|
element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement,
|
|
): FieldType {
|
|
// 1. 基于 HTML5 type 属性识别
|
|
if (element instanceof HTMLInputElement) {
|
|
const typeMap: Record<string, FieldType> = {
|
|
email: FieldType.EMAIL,
|
|
tel: FieldType.PHONE,
|
|
number: FieldType.NUMBER,
|
|
date: FieldType.DATE,
|
|
password: FieldType.PASSWORD,
|
|
radio: FieldType.RADIO,
|
|
checkbox: FieldType.CHECKBOX,
|
|
};
|
|
if (typeMap[element.type]) return typeMap[element.type];
|
|
}
|
|
|
|
// 2. 基于 name/id, placeholder, label 文本识别
|
|
const name = element.name || element.id || '';
|
|
const placeholder = 'placeholder' in element ? element.placeholder || '' : '';
|
|
const label = getFieldLabel(element) || '';
|
|
|
|
const detected =
|
|
detectTypeFromText(name) || detectTypeFromText(placeholder) || detectTypeFromText(label);
|
|
if (detected) return detected;
|
|
|
|
// 3. 基于元素标签识别
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 批量遍历并过滤表单元素
|
|
*/
|
|
function forEachFormElement(
|
|
container: ParentNode,
|
|
callback: (element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) => void,
|
|
options: { includeHidden?: boolean } = {},
|
|
): void {
|
|
const inputs = container.querySelectorAll('input, textarea, select');
|
|
inputs.forEach((el) => {
|
|
if (
|
|
(el instanceof HTMLInputElement ||
|
|
el instanceof HTMLTextAreaElement ||
|
|
el instanceof HTMLSelectElement) &&
|
|
isElementValidForFill(el)
|
|
) {
|
|
if (!options.includeHidden && el instanceof HTMLInputElement && el.type === 'hidden') {
|
|
return;
|
|
}
|
|
callback(el);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 清空单个字段
|
|
*/
|
|
export function clearField(
|
|
element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement,
|
|
): void {
|
|
if (
|
|
element instanceof HTMLInputElement &&
|
|
(element.type === 'checkbox' || element.type === 'radio')
|
|
) {
|
|
element.checked = false;
|
|
} else if (element instanceof HTMLSelectElement) {
|
|
element.selectedIndex = 0;
|
|
} else {
|
|
setInputValue(element, '');
|
|
}
|
|
triggerEvents(element);
|
|
}
|
|
|
|
/**
|
|
* 填充表单字段
|
|
*/
|
|
export function fillField(
|
|
element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement,
|
|
mode: FillMode,
|
|
): void {
|
|
const fieldType = recognizeFieldType(element);
|
|
const value = generateValueByFieldType(fieldType, mode);
|
|
fillFieldWithInjector(element, value);
|
|
}
|
|
|
|
/**
|
|
* 触发事件
|
|
*/
|
|
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);
|
|
return !(
|
|
style.display === 'none' ||
|
|
style.visibility === 'hidden' ||
|
|
style.opacity === '0' ||
|
|
style.visibility === 'collapse'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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轴穿透验证(最后一步,最耗时,放最后)
|
|
return isElementNotObscured(element);
|
|
}
|
|
|
|
/**
|
|
* 查找最上层的弹窗容器
|
|
*/
|
|
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).substring(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: Array<FormFieldInfo & { useInvalidData?: boolean }>,
|
|
defaultMode: FillMode,
|
|
): number {
|
|
let filledCount = 0;
|
|
|
|
fields.forEach((field) => {
|
|
if (field.isSelected) {
|
|
const mode = field.useInvalidData
|
|
? FillMode.INVALID
|
|
: field.useInvalidData === false
|
|
? FillMode.VALID
|
|
: defaultMode;
|
|
// 始终根据当前 fieldType 重新生成值,确保类型变更生效
|
|
const value = generateValueByFieldType(field.fieldType, mode);
|
|
fillFieldWithInjector(field.element, value);
|
|
filledCount++;
|
|
}
|
|
});
|
|
|
|
return filledCount;
|
|
}
|
|
|
|
/**
|
|
* 闪烁字段(用于定位)
|
|
*/
|
|
export function flashField(element: HTMLElement): void {
|
|
let flashCount = 0;
|
|
const maxFlashes = 4;
|
|
const originalStyle =
|
|
element.getAttribute('data-original-style') || element.getAttribute('style') || '';
|
|
element.setAttribute('data-original-style', originalStyle);
|
|
|
|
const flash = () => {
|
|
if (flashCount >= maxFlashes) {
|
|
unhighlightField(element);
|
|
return;
|
|
}
|
|
if (flashCount % 2 === 0) {
|
|
element.style.outline = '3px solid #4caf50';
|
|
element.style.outlineOffset = '2px';
|
|
element.style.transition = 'outline 0.3s ease-in-out';
|
|
} else {
|
|
element.style.outline = '';
|
|
}
|
|
flashCount++;
|
|
setTimeout(flash, 300);
|
|
};
|
|
|
|
flash();
|
|
}
|
|
|
|
/**
|
|
* 高亮指定字段
|
|
*/
|
|
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 {
|
|
forEachFormElement(
|
|
document,
|
|
(el) => {
|
|
const fieldType = recognizeFieldType(el);
|
|
const value = generateValueByFieldType(fieldType, mode);
|
|
fillFieldWithInjector(el, value);
|
|
},
|
|
{ includeHidden },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 填充指定容器内的表单字段
|
|
*/
|
|
export function fillFieldsInContainer(
|
|
mode: FillMode,
|
|
container: HTMLElement,
|
|
includeHidden: boolean = false,
|
|
): void {
|
|
forEachFormElement(
|
|
container,
|
|
(el) => {
|
|
const fieldType = recognizeFieldType(el);
|
|
const value = generateValueByFieldType(fieldType, mode);
|
|
fillFieldWithInjector(el, value);
|
|
},
|
|
{ includeHidden },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 填充弹窗内的表单字段
|
|
*/
|
|
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 {
|
|
forEachFormElement(document, (el) => clearField(el));
|
|
}
|
|
|
|
/**
|
|
* 清空指定容器内的表单字段
|
|
*/
|
|
export function clearFieldsInContainer(container: HTMLElement): void {
|
|
forEachFormElement(container, (el) => clearField(el));
|
|
}
|