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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user