Enhance form recognition, optimize UI, and unify components (#19)

- **docs**: 完善组件注释、README 目录结构及 AGENTS.md 文档。
- **refactor**:
  - 提取通用 `PageHeader`、`Button`、`DashboardCard` 及 `ErrorBoundary` 组件。
  - 重构消息通信机制,采用 `@webext-core/messaging` 实现类型安全。
  - 将全局通知系统重构为 `SnackbarProvider` (后合并至 `GlobalSnackbar`)。
  - 迁移样式系统至 MUI 主题,移除冗余 CSS。
  - 优化路由配置,支持独立标签页模式及页面懒加载。
  - 移除未使用文件、URL 工具及表单映射相关功能。
- **feat**:
  - 新增配置导出功能(JSON)及状态提示。
  - 新增侧边栏状态变化通知机制。
  - 新增文本统计及 JWT 解析工具。
  - 优化二维码生成与解析逻辑,换用更轻量的 `qrious` 和 `qr-scanner`。
  - 增强高亮器功能,支持闪烁效果及 Shadow DOM 穿透。
- **style**: 优化仪表盘响应式网格布局及 UI 细节。
- **fix**: 修复 `useStorageState` 依赖缺失及路由初始化性能问题。
- **test**: 更新单元测试以覆盖新增的工具函数及功能特性。
This commit is contained in:
LingandRX
2026-05-03 17:06:32 +08:00
committed by GitHub
parent 8d36f21f1b
commit 57ea4d9858
80 changed files with 1909 additions and 15265 deletions
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { parseJwt, decodeBase64Url } from '../jwt';
describe('jwt utils', () => {
describe('decodeBase64Url', () => {
it('should decode standard base64url', () => {
// "test" -> "dGVzdA"
expect(decodeBase64Url('dGVzdA')).toBe('test');
});
it('should handle padding correctly', () => {
// "a" -> "YQ" (needs ==)
expect(decodeBase64Url('YQ')).toBe('a');
// "ab" -> "YWI" (needs =)
expect(decodeBase64Url('YWI')).toBe('ab');
});
it('should handle - and _ correctly', () => {
// Validating base64url specific chars
// standard base64 of binary 0xFF 0xEF is "/+8="
// base64url should be "_-8"
// Wait, let's use a simpler one.
// 0xFB 0xFF -> "+/8=" in base64, "-_8=" in base64url? No.
// + -> -
// / -> _
// let's try to encode something that results in + and /
// binary 0xFB 0xFF 0xBE -> "+/++" in base64 -> "-_--" in base64url
expect(decodeBase64Url('-_--')).toBeDefined();
});
it('should decode UTF-8 characters correctly', () => {
// "你好" -> "5L2g5aW9"
expect(decodeBase64Url('5L2g5aW9')).toBe('你好');
});
});
describe('parseJwt', () => {
it('should return error for invalid format', () => {
const result = parseJwt('invalid-token');
expect(result.error).toContain('格式错误');
});
it('should parse a valid JWT structure', () => {
// Header: {"alg":"HS256","typ":"JWT"}
// Payload: {"sub":"1234567890","name":"John Doe","iat":1516239022}
const token =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const result = parseJwt(token);
expect(result.error).toBeUndefined();
expect(result.header?.alg).toBe('HS256');
expect(result.payload?.name).toBe('John Doe');
expect(result.signature).toBe('SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c');
});
it('should handle malformed json in header/payload', () => {
// Base64 of "{"
const token = 'ew.ew.signature';
const result = parseJwt(token);
expect(result.error).toBeDefined();
});
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { getTextStats, formatByteSize } from '../textStatistics';
describe('textStatistics utils', () => {
describe('getTextStats', () => {
it('should return zeros for empty text', () => {
const stats = getTextStats('');
expect(stats).toEqual({ characters: 0, words: 0, lines: 0, bytes: 0 });
});
it('should count characters correctly', () => {
expect(getTextStats('abc').characters).toBe(3);
expect(getTextStats('a b c').characters).toBe(5);
expect(getTextStats('你好').characters).toBe(2);
});
it('should count English words correctly', () => {
expect(getTextStats('hello world').words).toBe(2);
expect(getTextStats(' hello world ').words).toBe(2);
expect(getTextStats('hello, world!').words).toBe(2);
});
it('should count Chinese words correctly', () => {
// "你好世界" 在 Intl.Segmenter 中通常被识别为 "你好" 和 "世界" 两个词
const stats = getTextStats('你好世界');
expect(stats.words).toBe(2);
});
it('should count mixed language words correctly', () => {
const stats = getTextStats('Hello 你好');
// "Hello" (1) + "你好" (1) = 2
expect(stats.words).toBe(2);
});
it('should count lines correctly', () => {
expect(getTextStats('line1\nline2').lines).toBe(2);
expect(getTextStats('line1\nline2\n').lines).toBe(3);
});
it('should count bytes correctly (UTF-8)', () => {
expect(getTextStats('abc').bytes).toBe(3);
expect(getTextStats('你好').bytes).toBe(6); // UTF-8 中每个常用汉字占 3 字节
});
it('should handle special cases', () => {
expect(getTextStats(' ').words).toBe(0);
expect(getTextStats('\n\n\n').lines).toBe(4);
expect(getTextStats('\n\n\n').words).toBe(0);
});
});
describe('formatByteSize', () => {
it('should format bytes correctly', () => {
expect(formatByteSize(100)).toBe('100 Bytes');
expect(formatByteSize(0)).toBe('0 Bytes');
});
});
});
-164
View File
@@ -1,164 +0,0 @@
/**
* 数据模板管理工具
* 用于创建、编辑、保存和管理自定义测试数据模板
*/
import { FieldType } from './dummyDataGenerator';
/**
* 模板字段接口
*/
export interface TemplateField {
id: string;
name: string;
label: string;
fieldType: FieldType;
defaultValue: string;
rules: TemplateRule[];
}
/**
* 模板规则接口
*/
export interface TemplateRule {
type: 'required' | 'pattern' | 'minLength' | 'maxLength' | 'min' | 'max' | 'custom';
value: string | number;
message?: string;
}
/**
* 数据模板接口
*/
export interface DataTemplate {
id: string;
name: string;
description: string;
fields: TemplateField[];
createdAt: string;
updatedAt: string;
}
/**
* 模板存储键名
*/
const TEMPLATE_STORAGE_KEY = 'dataTemplates';
/**
* 数据模板管理类
*/
export class DataTemplateManager {
/**
* 获取所有模板
*/
static async getAllTemplates(): Promise<DataTemplate[]> {
try {
const stored = await chrome.storage.local.get(TEMPLATE_STORAGE_KEY);
return (stored[TEMPLATE_STORAGE_KEY] as DataTemplate[]) || [];
} catch (error) {
console.error('获取模板失败:', error);
return [];
}
}
/**
* 保存模板
*/
static async saveTemplate(template: DataTemplate): Promise<boolean> {
try {
const templates = await this.getAllTemplates();
const existingIndex = templates.findIndex((t) => t.id === template.id);
if (existingIndex >= 0) {
templates[existingIndex] = {
...template,
updatedAt: new Date().toISOString(),
};
} else {
templates.push({
...template,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
await chrome.storage.local.set({ [TEMPLATE_STORAGE_KEY]: templates });
return true;
} catch (error) {
console.error('保存模板失败:', error);
return false;
}
}
/**
* 删除模板
*/
static async deleteTemplate(templateId: string): Promise<boolean> {
try {
const templates = await this.getAllTemplates();
const filtered = templates.filter((t) => t.id !== templateId);
await chrome.storage.local.set({ [TEMPLATE_STORAGE_KEY]: filtered });
return true;
} catch (error) {
console.error('删除模板失败:', error);
return false;
}
}
/**
* 导出模板
*/
static exportTemplates(templates: DataTemplate[]): string {
return JSON.stringify(templates, null, 2);
}
/**
* 导入模板
*/
static async importTemplates(jsonString: string): Promise<boolean> {
try {
const importedTemplates = JSON.parse(jsonString) as DataTemplate[];
if (!Array.isArray(importedTemplates)) {
return false;
}
const existingTemplates = await this.getAllTemplates();
const mergedTemplates = [...existingTemplates];
for (const template of importedTemplates) {
const existingIndex = mergedTemplates.findIndex((t) => t.id === template.id);
if (existingIndex >= 0) {
mergedTemplates[existingIndex] = template;
} else {
mergedTemplates.push(template);
}
}
await chrome.storage.local.set({ [TEMPLATE_STORAGE_KEY]: mergedTemplates });
return true;
} catch (error) {
console.error('导入模板失败:', error);
return false;
}
}
/**
* 生成唯一ID
*/
static generateId(): string {
return `template_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* 创建空模板
*/
static createEmptyTemplate(name: string, description: string = ''): DataTemplate {
return {
id: this.generateId(),
name,
description,
fields: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
}
-166
View File
@@ -1,166 +0,0 @@
/**
* 数据验证工具
* 用于在数据填充前进行格式验证
*/
import { FieldType } from './dummyDataGenerator';
/**
* 验证结果接口
*/
export interface ValidationResult {
isValid: boolean;
errors: string[];
}
/**
* 数据验证工具类
*/
export class DataValidator {
/**
* 验证邮箱格式
*/
private static validateEmail(value: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value);
}
/**
* 验证手机号格式(中国大陆)
*/
private static validatePhone(value: string): boolean {
const phoneRegex = /^1[3-9]\d{9}$/;
return phoneRegex.test(value);
}
/**
* 验证身份证号格式(中国大陆)
*/
private static validateIdCard(value: string): boolean {
const idCardRegex =
/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/;
return idCardRegex.test(value);
}
/**
* 验证日期格式
*/
private static validateDate(value: string): boolean {
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(value)) return false;
const date = new Date(value);
return !isNaN(date.getTime());
}
/**
* 验证数字格式
*/
private static validateNumber(value: string): boolean {
return !isNaN(Number(value)) && value.trim() !== '';
}
/**
* 验证字段值
*/
static validateField(fieldType: FieldType, value: string): ValidationResult {
const errors: string[] = [];
switch (fieldType) {
case FieldType.EMAIL:
if (!this.validateEmail(value)) {
errors.push('邮箱格式不正确');
}
break;
case FieldType.PHONE:
if (!this.validatePhone(value)) {
errors.push('手机号格式不正确,应为11位数字');
}
break;
case FieldType.ID_CARD:
if (!this.validateIdCard(value)) {
errors.push('身份证号格式不正确');
}
break;
case FieldType.DATE:
if (!this.validateDate(value)) {
errors.push('日期格式不正确,应为YYYY-MM-DD');
}
break;
case FieldType.NUMBER:
if (!this.validateNumber(value)) {
errors.push('数字格式不正确');
}
break;
case FieldType.NAME:
if (value.length < 2 || value.length > 50) {
errors.push('姓名长度应在2-50个字符之间');
}
break;
case FieldType.PASSWORD:
if (value.length < 6) {
errors.push('密码长度不能少于6个字符');
}
break;
case FieldType.TEXT:
case FieldType.TEXTarea:
if (value.length > 10000) {
errors.push('文本长度不能超过10000个字符');
}
break;
default:
// 未知类型不做验证
break;
}
return {
isValid: errors.length === 0,
errors,
};
}
/**
* 批量验证字段
*/
static validateFields(fields: Array<{ fieldType: FieldType; value: string }>): ValidationResult {
const allErrors: string[] = [];
fields.forEach((field, index) => {
const result = this.validateField(field.fieldType, field.value);
if (!result.isValid) {
allErrors.push(`字段 ${index + 1}: ${result.errors.join(', ')}`);
}
});
return {
isValid: allErrors.length === 0,
errors: allErrors,
};
}
/**
* 获取字段类型的验证规则描述
*/
static getValidationRules(fieldType: FieldType): string[] {
switch (fieldType) {
case FieldType.EMAIL:
return ['格式: user@domain.com'];
case FieldType.PHONE:
return ['格式: 11位中国大陆手机号', '以1开头,第二位为3-9'];
case FieldType.ID_CARD:
return ['格式: 18位身份证号', '前6位为地区码', '中间8位为生日', '最后1位为校验码'];
case FieldType.DATE:
return ['格式: YYYY-MM-DD', '例如: 2024-01-01'];
case FieldType.NUMBER:
return ['格式: 整数或浮点数'];
case FieldType.NAME:
return ['长度: 2-50个字符'];
case FieldType.PASSWORD:
return ['长度: 至少6个字符'];
case FieldType.TEXT:
case FieldType.TEXTarea:
return ['长度: 不超过10000个字符'];
default:
return [];
}
}
}
-838
View File
@@ -1,838 +0,0 @@
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));
}
-170
View File
@@ -1,170 +0,0 @@
import { FormMapEntry } from '@/types/storage';
/**
* 可视化交互模块:负责在网页上绘制非破坏性的高亮遮罩
*/
export class VisualHighlighter {
private canvas: HTMLCanvasElement | null = null;
private ctx: CanvasRenderingContext2D | null = null;
private isVisible = false;
private currentEntries: FormMapEntry[] = [];
private animationFrameId: number | null = null;
constructor() {
this.handleResize = this.handleResize.bind(this);
this.render = this.render.bind(this);
}
public init() {
if (this.canvas) return;
this.canvas = document.createElement('canvas');
this.canvas.id = 'form-mapping-highlighter';
Object.assign(this.canvas.style, {
position: 'fixed',
top: '0',
left: '0',
width: '100vw',
height: '100vh',
pointerEvents: 'none',
zIndex: '2147483647',
display: 'none',
});
document.body.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d');
window.addEventListener('resize', this.handleResize);
window.addEventListener('scroll', this.handleResize);
}
public show() {
if (!this.canvas) this.init();
this.isVisible = true;
this.canvas!.style.display = 'block';
this.requestUpdate();
}
public hide() {
this.isVisible = false;
if (this.canvas) this.canvas.style.display = 'none';
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
}
private handleResize() {
if (!this.isVisible || !this.canvas || !this.ctx) return;
this.requestUpdate();
}
/**
* 核心更新请求,使用 requestAnimationFrame 节流
*/
private requestUpdate() {
if (this.animationFrameId !== null) return;
this.animationFrameId = requestAnimationFrame(this.render);
}
/**
* 核心渲染逻辑
*/
private render() {
this.animationFrameId = null;
if (!this.ctx || !this.isVisible || !this.canvas) return;
// 适配分辨率
if (this.canvas.width !== window.innerWidth || this.canvas.height !== window.innerHeight) {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.currentEntries.forEach((entry) => {
const el = document.querySelector<HTMLElement>(entry.fingerprint.selector);
if (!el) return;
const rect = el.getBoundingClientRect();
// 检查元素是否在视口内
if (
rect.bottom < 0 ||
rect.top > window.innerHeight ||
rect.right < 0 ||
rect.left > window.innerWidth
) {
return;
}
// 设置样式
if (entry.ui_state.is_selected) {
// 选中状态:亮黄色边框
this.ctx!.strokeStyle = '#FFD700';
this.ctx!.lineWidth = 3;
this.ctx!.fillStyle = 'rgba(255, 215, 0, 0.2)';
} else {
// 未选中状态:浅蓝色半透明
this.ctx!.strokeStyle = 'rgba(173, 216, 230, 0.8)';
this.ctx!.lineWidth = 1;
this.ctx!.fillStyle = 'rgba(173, 216, 230, 0.4)';
}
// 绘制矩形
this.ctx!.beginPath();
this.ctx!.rect(rect.left, rect.top, rect.width, rect.height);
this.ctx!.fill();
this.ctx!.stroke();
// 如果被选中,绘制一个小标签
if (entry.ui_state.is_selected) {
this.ctx!.fillStyle = '#FFD700';
this.ctx!.font = '12px sans-serif';
this.ctx!.fillText(entry.label_display, rect.left, rect.top - 5);
}
});
}
/**
* 公共 draw 方法,仅更新数据并触发渲染请求
*/
public draw(entries: FormMapEntry[] = []) {
this.currentEntries = entries;
if (this.isVisible) {
this.requestUpdate();
}
}
/**
* 开启拾取模式:拦截点击事件
*/
public enablePicker(onPick: (element: HTMLElement) => void) {
if (!this.canvas) this.init();
this.canvas!.style.pointerEvents = 'auto';
this.canvas!.style.cursor = 'crosshair';
const handleClick = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// 暂时禁用 canvas pointer-events 以便探测下方的真实元素
this.canvas!.style.pointerEvents = 'none';
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement;
this.canvas!.style.pointerEvents = 'auto';
if (el) {
onPick(el);
}
};
this.canvas!.addEventListener('click', handleClick, { capture: true, once: true });
}
public disablePicker() {
if (this.canvas) {
this.canvas.style.pointerEvents = 'none';
this.canvas.style.cursor = 'default';
}
}
}
export const highlighter = new VisualHighlighter();
-100
View File
@@ -1,100 +0,0 @@
import { FormMapEntry } from '@/types/storage';
/**
* 智能探测引擎:负责扫描 DOM 并生成唯一指纹
*/
export class SmartDetector {
/**
* 扫描页面中符合条件的表单元素
*/
public static scanFormElements(): HTMLElement[] {
const selector =
'input:not([type="hidden"]):not([type="submit"]):not([type="button"]), textarea, select, [contenteditable="true"]';
const elements = Array.from(document.querySelectorAll<HTMLElement>(selector));
return elements.filter((el) => {
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && window.getComputedStyle(el).display !== 'none';
});
}
/**
* 生成元素的唯一性指纹
*/
public static generateFingerprint(element: HTMLElement): FormMapEntry['fingerprint'] {
return {
selector: this.getUniqueSelector(element),
name_attr: element.getAttribute('name') || element.getAttribute('id') || '',
placeholder: element.getAttribute('placeholder') || '',
};
}
/**
* 提取元素的语义标签 (核心算法)
* 优先查找 label[for],其次在物理位置上方或左侧 50px 范围内寻找文本
*/
public static extractSemanticLabel(element: HTMLElement): string {
// 1. 尝试查找关联的 label 元素
if (element.id) {
const label = document.querySelector(`label[for="${element.id}"]`);
if (label?.textContent) return label.textContent.trim();
}
// 2. 尝试向上查找父级中的 label
const parentLabel = element.closest('label');
if (parentLabel?.textContent) return parentLabel.textContent.trim();
// 3. 物理位置探测算法 (getBoundingClientRect)
const rect = element.getBoundingClientRect();
// 探测左侧 50px
const leftText = this.getTextNearby(rect.left - 25, rect.top + rect.height / 2);
if (leftText) return leftText;
// 探测上方 50px
const topText = this.getTextNearby(rect.left + rect.width / 2, rect.top - 25);
if (topText) return topText;
// 4. 降级:使用 placeholder 或 name
return element.getAttribute('placeholder') || element.getAttribute('name') || '未知字段';
}
/**
* 在指定坐标附近寻找最可能的文本节点
*/
private static getTextNearby(x: number, y: number): string | null {
if (x < 0 || y < 0) return null;
const el = document.elementFromPoint(x, y);
if (!el) return null;
// 如果命中了文本容器
const text = el.textContent?.trim();
if (text && text.length < 30) return text; // 避免抓到太长的段落
return null;
}
/**
* 计算元素的相对短且唯一的 CSS 选择器
*/
private static getUniqueSelector(el: HTMLElement): string {
if (el.id) return `#${el.id}`;
let path = el.tagName.toLowerCase();
// 尝试添加类名以增加唯一性
if (el.classList.length > 0) {
path += `.${Array.from(el.classList).join('.')}`;
}
// 如果当前路径在文档中不是唯一的,则增加 nth-child
if (document.querySelectorAll(path).length > 1) {
const parent = el.parentElement;
if (parent) {
const index = Array.from(parent.children).indexOf(el) + 1;
path = `${this.getUniqueSelector(parent as HTMLElement)} > ${el.tagName.toLowerCase()}:nth-child(${index})`;
}
}
return path;
}
}
-579
View File
@@ -1,579 +0,0 @@
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 - 表单映射条目
* @param mockValue - mock数据
* @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) {
element.selectedIndex = Math.floor(Math.random() * options.length);
}
} 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');
}
});
}
}
-54
View File
@@ -1,54 +0,0 @@
import { SmartDetector } from './scanner';
import { highlighter } from './highlighter';
import { storageUtil } from '@/utils/chromeStorage';
import { FormMapEntry } from '@/types/storage';
/**
* 更新表单映射辅助 UI
*/
export async function updateMappingUI() {
const entries = ((await storageUtil.get('active_form_map')) as FormMapEntry[]) || [];
const isPicking = ((await storageUtil.get('app/formMapping/isPicking')) as boolean) || false;
if (entries.length > 0 || isPicking) {
highlighter.show();
highlighter.draw(entries);
if (isPicking) {
highlighter.enablePicker(async (el) => {
const fingerprint = SmartDetector.generateFingerprint(el);
const label = SmartDetector.extractSemanticLabel(el);
const newEntry: FormMapEntry = {
id: Math.random().toString(36).substring(2, 9),
label_display: label,
fingerprint,
action_logic: { type: 'text', strategy: 'fixed', value: '' },
ui_state: { is_selected: true },
};
const currentMap = ((await storageUtil.get('active_form_map')) as FormMapEntry[]) || [];
await storageUtil.set('active_form_map', [...currentMap, newEntry]);
await storageUtil.set('app/formMapping/isPicking', false);
});
} else {
highlighter.disablePicker();
}
} else {
highlighter.hide();
}
}
/**
* 初始化表单映射助手
*/
export function initFormMappingHelper() {
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && (changes['active_form_map'] || changes['app/formMapping/isPicking'])) {
updateMappingUI().catch(console.error);
}
});
// 初始加载
updateMappingUI().catch(console.error);
}
+125
View File
@@ -0,0 +1,125 @@
/**
* JWT 解析工具
*/
export interface JwtHeader {
alg: string;
typ?: string;
[key: string]: unknown;
}
export interface JwtPayload {
iss?: string;
sub?: string;
aud?: string | string[];
exp?: number;
nbf?: number;
iat?: number;
jti?: string;
[key: string]: unknown;
}
export interface JwtResult {
header: JwtHeader | null;
payload: JwtPayload | null;
signature: string;
raw: {
header: string;
payload: string;
signature: string;
};
error?: string;
}
/**
* Base64URL 解码
* @param str Base64URL 编码字符串
*/
export function decodeBase64Url(str: string): string {
// 将 Base64URL 转换为 标准 Base64
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
// 添加填充
const pad = base64.length % 4;
if (pad) {
if (pad === 1) {
throw new Error('Invalid base64url string');
}
base64 += new Array(5 - pad).join('=');
}
try {
// 使用 TextDecoder 处理 UTF-8 字符
const binStr = atob(base64);
const binLen = binStr.length;
const bytes = new Uint8Array(binLen);
for (let i = 0; i < binLen; i++) {
bytes[i] = binStr.charCodeAt(i);
}
const decoder = new TextDecoder('utf-8');
return decoder.decode(bytes);
} catch (e) {
throw new Error('Failed to decode base64url: ' + (e instanceof Error ? e.message : String(e)));
}
}
/**
* 解析 JWT 字符串
* @param token JWT 字符串
*/
export function parseJwt(token: string): JwtResult {
const parts = token.trim().split('.');
if (parts.length !== 3) {
return {
header: null,
payload: null,
signature: '',
raw: { header: '', payload: '', signature: '' },
error: 'JWT 格式错误:必须包含三个由 "." 分隔的部分',
};
}
const [headerB64, payloadB64, signatureB64] = parts;
const result: JwtResult = {
header: null,
payload: null,
signature: signatureB64,
raw: {
header: headerB64,
payload: payloadB64,
signature: signatureB64,
},
};
try {
const headerJson = decodeBase64Url(headerB64);
result.header = JSON.parse(headerJson);
} catch (e) {
result.error = '解析 Header 失败:' + (e instanceof Error ? e.message : String(e));
return result;
}
try {
const payloadJson = decodeBase64Url(payloadB64);
result.payload = JSON.parse(payloadJson);
} catch (e) {
result.error = '解析 Payload 失败:' + (e instanceof Error ? e.message : String(e));
return result;
}
return result;
}
/**
* 格式化 JSON
* @param obj 对象
*/
export function formatJson(obj: unknown): string {
try {
return JSON.stringify(obj, null, 2);
} catch (e) {
console.error('格式化 JSON 失败:', e);
return String(obj);
}
}
+27 -121
View File
@@ -1,154 +1,60 @@
import { FormFieldInfo, FillMode } from './dummyDataGenerator';
import { defineExtensionMessaging } from '@webext-core/messaging';
/**
* 字段数据接口(用于消息传递)
*/
export interface MessageFieldData extends Omit<FormFieldInfo, 'element'> {
useInvalidData?: boolean;
}
/**
* 消息动作类型
*/
export enum MessageAction {
// 标签页操作
RELOAD_TAB = 'reloadTab',
// 表单相关操作
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',
// 字段定位/闪烁
FLASH_FIELD = 'flashField',
// 智能表单注入
FORM_INJECT = 'FORM_INJECT',
// 侧边栏状态变化
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
}
/**
* 消息载荷接口 (保留兼容性)
*/
export interface MessagePayload {
action: MessageAction | string;
tabId?: number;
delay?: number;
fields?: MessageFieldData[];
mode?: FillMode;
includeHidden?: boolean;
fieldId?: string;
fieldIds?: string[];
data?: unknown;
isOpen?: boolean;
}
/**
* 消息响应接口
*/
export interface FormInjectItem {
entry: import('@/types/storage').FormMapEntry;
mockValue: string;
}
export interface FormInjectResult {
id: string;
success: boolean;
}
export interface MessageResponse {
success: boolean;
message?: string;
fields?: Omit<FormFieldInfo, 'element'>[];
totalCount?: number;
validCount?: number;
hasModal?: boolean;
results?: FormInjectResult[];
error?: string;
}
/**
* 协议映射定义(用于类型安全的消息通信)
*/
export interface ProtocolMap {
// 基础消息格式,用于逐步迁移
[MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): MessageResponse;
[MessageAction.SCAN_FORM_FIELDS](): MessageResponse;
[MessageAction.FILL_VALID_DATA](data: { includeHidden?: boolean }): MessageResponse;
[MessageAction.FILL_INVALID_DATA](data: { includeHidden?: boolean }): MessageResponse;
[MessageAction.FILL_SELECTED_FIELDS](data: {
fields: MessageFieldData[];
mode?: FillMode;
includeHidden?: boolean;
}): MessageResponse;
[MessageAction.CLEAR_ALL_FIELDS](): MessageResponse;
[MessageAction.HIGHLIGHT_FIELD](data: { fieldId: string }): MessageResponse;
[MessageAction.UNHIGHLIGHT_FIELD](data: { fieldId: string }): MessageResponse;
[MessageAction.HIGHLIGHT_ALL_FIELDS](data: { fieldIds: string[] }): MessageResponse;
[MessageAction.UNHIGHLIGHT_ALL_FIELDS](): MessageResponse;
[MessageAction.FLASH_FIELD](data: { fieldId: string }): MessageResponse;
[MessageAction.FORM_INJECT](data: { data: FormInjectItem[] }): MessageResponse;
[MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void;
}
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
/**
* 发送消息到内容脚本 (旧版包装器,内部使用新机制)
* @deprecated 建议直接使用 sendMessage
*/
type ProtocolData<K extends keyof ProtocolMap> = Parameters<ProtocolMap[K]>[0];
type ProtocolReturn<K extends keyof ProtocolMap> = ReturnType<ProtocolMap[K]>;
export async function sendMessageToContent<K extends keyof ProtocolMap>(
action: K,
...args: ProtocolData<K> extends undefined ? [] : [data: ProtocolData<K>]
): Promise<ProtocolReturn<K>> {
...args: Parameters<ProtocolMap[K]>[0] extends undefined
? []
: [data: Parameters<ProtocolMap[K]>[0]]
): Promise<ReturnType<ProtocolMap[K]>> {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) {
return { success: false, message: '无法获取当前标签页' } as ProtocolReturn<K>;
console.warn(`[Messaging] 无法获取当前标签页,无法发送动作: ${action}`);
return { success: false, message: '无法获取当前标签页' } as ReturnType<ProtocolMap[K]>;
}
const data = args.length > 0 ? args[0] : undefined;
return await (
sendMessage as (type: K, data: ProtocolData<K>, arg?: number) => Promise<ProtocolReturn<K>>
)(action, data as ProtocolData<K>, tab.id);
} catch (error) {
console.error('发送消息失败:', error);
return { success: false, message: '发送消息失败' } as ProtocolReturn<K>;
}
}
/**
* 注入内容脚本
*/
export async function injectContentScript(): Promise<boolean> {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) {
return false;
const response = await (
sendMessage as (
type: K,
data: Parameters<ProtocolMap[K]>[0],
arg?: number,
) => Promise<ReturnType<ProtocolMap[K]>>
)(action, data as Parameters<ProtocolMap[K]>[0], tab.id);
return response;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Messaging] 向内容脚本发送消息失败 [Action: ${action}]:`, errorMsg);
if (errorMsg.includes('Could not establish connection')) {
return { success: false, message: '无法连接到网页,请刷新页面后再试' } as ReturnType<
ProtocolMap[K]
>;
}
if (errorMsg.includes('No response')) {
return { success: false, message: '网页响应超时,请重试' } as ReturnType<ProtocolMap[K]>;
}
// 尝试注入内容脚本
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['/content-scripts/content.js'],
});
return true;
} catch (error) {
console.error('注入内容脚本失败:', error);
return false;
return { success: false, message: `通信失败: ${errorMsg}` } as ReturnType<ProtocolMap[K]>;
}
}
+21 -38
View File
@@ -1,4 +1,4 @@
import jsQR from 'jsqr';
import QrScanner from 'qr-scanner';
export interface QrCodeParseResult {
success: boolean;
@@ -6,49 +6,32 @@ export interface QrCodeParseResult {
error?: string;
}
export async function parseQrCodeFromFile(
file: File,
timeout: number = 10000,
): Promise<QrCodeParseResult> {
/**
* 从文件中解析二维码
* 使用 qr-scanner 替代 jsqr 以减小体积并提高性能
*/
export async function parseQrCodeFromFile(file: File): Promise<QrCodeParseResult> {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
return { success: false, error: '无法创建 canvas 上下文' };
}
const image = new Image();
image.src = URL.createObjectURL(file);
await new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('图片加载超时'));
}, timeout);
image.onload = () => {
clearTimeout(timeoutId);
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0);
resolve();
};
image.onerror = () => {
clearTimeout(timeoutId);
reject(new Error('图片加载失败'));
};
// qr-scanner 的 scanImage 方法支持直接传入 File 对象
// 它会自动处理图片加载、Canvas 绘制和解析过程
// 并且在支持的浏览器中会优先使用原生的 BarcodeDetector API
const result = await QrScanner.scanImage(file, {
returnDetailedScanResult: true,
});
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (code) {
return { success: true, data: code.data };
if (result && result.data) {
return { success: true, data: result.data };
} else {
return { success: false, error: '未检测到二维码' };
}
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : '解析失败' };
// qr-scanner 在未发现二维码时会抛出 "No QR code found"
const errorMsg =
err === 'No QR code found'
? '未检测到二维码'
: err instanceof Error
? err.message
: String(err);
return { success: false, error: errorMsg };
}
}
+67
View File
@@ -0,0 +1,67 @@
/**
* 文本统计信息接口
*/
export interface TextStats {
/** 字符数(包含空格和特殊字符) */
characters: number;
/** 单词数 */
words: number;
/** 行数 */
lines: number;
/** 字节大小 */
bytes: number;
}
/**
* 计算文本统计信息
*
* @param text 输入的文本内容
* @returns 统计结果对象
*/
export function getTextStats(text: string): TextStats {
if (!text) {
return { characters: 0, words: 0, lines: 0, bytes: 0 };
}
// 1. 字符数:统计总字符数量
const characters = text.length;
// 2. 单词数:使用 Intl.Segmenter 识别单词边界
// 这能很好地处理中英文混合文本。中文会按词组切分,英文按单词切分。
let words = 0;
try {
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
const segments = segmenter.segment(text);
for (const segment of segments) {
// isWordLike 为 true 表示该片段是“类词”的(非空格、非标点)
if (segment.isWordLike) {
words++;
}
}
} catch {
// 降级方案:如果不支持 Intl.Segmenter,使用正则匹配英文单词
// 但对中文支持较差
const englishWords = text.match(/\b\w+\b/g) || [];
const chineseChars = text.match(/[\u4e00-\u9fa5]/g) || [];
words = englishWords.length + chineseChars.length;
}
// 3. 行数:统计换行符数量
// 空字符串已在上方处理。非空文本至少有一行。
const lines = text.split('\n').length;
// 4. 字节大小:计算文本内容的字节数 (UTF-8)
const bytes = new TextEncoder().encode(text).length;
return { characters, words, lines, bytes };
}
/**
* 格式化字节大小显示
*
* @param bytes 字节数
* @returns 格式化后的字符串,例如 "100 Bytes"
*/
export function formatByteSize(bytes: number): string {
return `${bytes} Bytes`;
}
-45
View File
@@ -1,45 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { storageUtil } from '@/utils/chromeStorage';
import type { OpenUrlPreferences, OpenUrlEntry } from '@/types/storage';
const DEFAULT_PREFERENCES: OpenUrlPreferences = {
entries: [],
};
export const useUrlPreferences = () => {
const [entries, setEntries] = useState<OpenUrlEntry[]>(DEFAULT_PREFERENCES.entries);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const loadPreferences = async () => {
try {
const saved = await storageUtil.get('openUrl/preferences', DEFAULT_PREFERENCES);
if (saved && saved.entries) {
setEntries(saved.entries);
}
} catch (error) {
console.error('Failed to load Open Url preferences:', error);
} finally {
setIsLoaded(true);
}
};
loadPreferences().catch(console.error);
}, []);
const savePreferences = useCallback(() => {
const preferences: OpenUrlPreferences = { entries };
storageUtil.set('openUrl/preferences', preferences).catch((error) => {
console.error('Failed to save Open Url preferences:', error);
});
}, [entries]);
useEffect(() => {
if (!isLoaded) return;
const timer = setTimeout(() => {
savePreferences();
}, 500);
return () => clearTimeout(timer);
}, [entries, isLoaded, savePreferences]);
return { entries, setEntries, isLoaded };
};