Develop (#17)
✨新功能 (Features) 智能表单引擎: 新增智能表单填充功能,内置模糊匹配引擎、Mock数据生成器与视觉反馈渲染器。 表单映射与导出: 实现表单映射页面(包含扫描器和高亮器),并支持将配置导出为 JSON 文件,附带 Snackbar 状态提示。 表单识别增强: 增加按域名保存字段类型偏好的功能;添加字段定位闪烁以辅助查找;优化填充逻辑(支持单字段覆盖默认模式);重构 FieldList 组件以提升操作体验。 ♻️ 代码重构 (Refactor) 通用组件提取: 提取并统一应用通用的 PageHeader 组件,移除独立的侧边栏页面及未使用的组件文件。 状态与逻辑优化: 改进 useStorageState 钩子(增加加载状态管理与防抖处理);将二维码解析功能重构为独立模块。 类型与依赖简化: 统一使用 SnackbarOptions 类型;简化假数据生成器中 faker 的导入与使用逻辑。 💄 样式与界面 (Style) UI 细节打磨: 统一各页面头部图标颜色,调整表单输入框与按钮交互样式;优化时间戳页面、结果视图布局(增加圆角、调整内边距/对齐方式);重构存储选项网格及自动刷新开关样式。 代码格式: 优化项目中导入语句的顺序与格式。 👷 持续集成 (CI) 流程提效: 移除 Firefox 测试步骤以减少资源消耗;收紧工作流触发条件,移除 develop 及其变体分支,仅保留 main 分支触发。 📝 文档 (Docs) 代码维护: 补充组件的文档注释与类型导入。
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { FormMapEntry } from '@/types/storage';
|
||||
|
||||
/**
|
||||
* 可视化交互模块:负责在网页上绘制非破坏性的高亮遮罩
|
||||
*/
|
||||
export class VisualHighlighter {
|
||||
private canvas: HTMLCanvasElement | null = null;
|
||||
private ctx: CanvasRenderingContext2D | null = null;
|
||||
private isVisible = false;
|
||||
|
||||
constructor() {
|
||||
this.handleResize = this.handleResize.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.handleResize();
|
||||
}
|
||||
|
||||
public hide() {
|
||||
this.isVisible = false;
|
||||
if (this.canvas) this.canvas.style.display = 'none';
|
||||
}
|
||||
|
||||
private handleResize() {
|
||||
if (!this.isVisible || !this.canvas || !this.ctx) return;
|
||||
this.canvas.width = window.innerWidth;
|
||||
this.canvas.height = window.innerHeight;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心渲染循环
|
||||
*/
|
||||
public draw(entries: FormMapEntry[] = []) {
|
||||
if (!this.ctx || !this.isVisible) return;
|
||||
this.ctx.clearRect(0, 0, this.canvas!.width, this.canvas!.height);
|
||||
|
||||
entries.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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启拾取模式:拦截点击事件
|
||||
*/
|
||||
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();
|
||||
@@ -0,0 +1,100 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
import { FormMapEntry } from '@/types/storage';
|
||||
|
||||
/**
|
||||
* 模糊匹配引擎结果接口
|
||||
*/
|
||||
export interface MatchResult {
|
||||
element: HTMLElement | null;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入结果接口
|
||||
*/
|
||||
export interface InjectResult {
|
||||
success: boolean;
|
||||
entry: FormMapEntry;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模糊匹配引擎
|
||||
* 根据 JSON 指纹在页面中精准定位目标 DOM 元素
|
||||
*/
|
||||
export class FuzzyMatcher {
|
||||
private static readonly MATCH_THRESHOLD = 75;
|
||||
private static readonly SCORE_SELECTOR = 50;
|
||||
private static readonly SCORE_NAME_ATTR = 25;
|
||||
private static readonly SCORE_PLACEHOLDER = 15;
|
||||
private static readonly SCORE_NEIGHBOR_TEXT = 10;
|
||||
|
||||
/**
|
||||
* 根据指纹查找目标元素
|
||||
* @param fingerprint - 表单字段指纹
|
||||
* @returns 匹配结果(包含元素和得分)
|
||||
*/
|
||||
public static findTargetElement(fingerprint: FormMapEntry['fingerprint']): MatchResult {
|
||||
const candidates: Array<{ element: HTMLElement; score: number }> = [];
|
||||
|
||||
// 1. 首先尝试精确选择器匹配
|
||||
if (fingerprint.selector) {
|
||||
const exactMatch = document.querySelector<HTMLElement>(fingerprint.selector);
|
||||
if (exactMatch) {
|
||||
const score = this.calculateScore(exactMatch, fingerprint);
|
||||
candidates.push({ element: exactMatch, score });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 收集所有可能的候选元素
|
||||
const potentialElements = this.collectPotentialElements(fingerprint);
|
||||
for (const element of potentialElements) {
|
||||
const score = this.calculateScore(element, fingerprint);
|
||||
if (score > 0) {
|
||||
candidates.push({ element, score });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 找到最高分的候选
|
||||
if (candidates.length === 0) {
|
||||
return { element: null, score: 0 };
|
||||
}
|
||||
|
||||
const bestMatch = candidates.reduce((prev, curr) => (curr.score > prev.score ? curr : prev));
|
||||
|
||||
return bestMatch.score >= this.MATCH_THRESHOLD
|
||||
? { element: bestMatch.element, score: bestMatch.score }
|
||||
: { element: null, score: bestMatch.score };
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算元素匹配得分
|
||||
*/
|
||||
private static calculateScore(
|
||||
element: HTMLElement,
|
||||
fingerprint: FormMapEntry['fingerprint'],
|
||||
): number {
|
||||
let score = 0;
|
||||
|
||||
// 选择器精确匹配
|
||||
if (fingerprint.selector) {
|
||||
const matched = document.querySelector(fingerprint.selector);
|
||||
if (matched === element) {
|
||||
score += this.SCORE_SELECTOR;
|
||||
}
|
||||
}
|
||||
|
||||
// name 或 id 属性匹配
|
||||
if (fingerprint.name_attr) {
|
||||
const elementName = element.getAttribute('name') || '';
|
||||
const elementId = element.getAttribute('id') || '';
|
||||
if (elementName === fingerprint.name_attr || elementId === fingerprint.name_attr) {
|
||||
score += this.SCORE_NAME_ATTR;
|
||||
}
|
||||
}
|
||||
|
||||
// placeholder 匹配
|
||||
if (fingerprint.placeholder) {
|
||||
const elementPlaceholder =
|
||||
'placeholder' in element && (element as HTMLInputElement).placeholder;
|
||||
if (elementPlaceholder === fingerprint.placeholder) {
|
||||
score += this.SCORE_PLACEHOLDER;
|
||||
}
|
||||
}
|
||||
|
||||
// 邻近文本(label)匹配
|
||||
if (fingerprint.name_attr || fingerprint.placeholder) {
|
||||
const neighborText = this.getNeighborText(element);
|
||||
const searchText = fingerprint.name_attr || fingerprint.placeholder || '';
|
||||
if (neighborText.includes(searchText)) {
|
||||
score += this.SCORE_NEIGHBOR_TEXT;
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集潜在的候选元素
|
||||
*/
|
||||
private static collectPotentialElements(
|
||||
_fingerprint: FormMapEntry['fingerprint'],
|
||||
): HTMLElement[] {
|
||||
const elements: HTMLElement[] = [];
|
||||
|
||||
// 获取所有表单元素
|
||||
const selectors = [
|
||||
'input:not([type="hidden"])',
|
||||
'textarea',
|
||||
'select',
|
||||
'[contenteditable="true"]',
|
||||
];
|
||||
|
||||
for (const selector of selectors) {
|
||||
const found = document.querySelectorAll<HTMLElement>(selector);
|
||||
found.forEach((el) => {
|
||||
if (this.isVisibleElement(el)) {
|
||||
elements.push(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元素附近的文本内容
|
||||
*/
|
||||
private static getNeighborText(element: HTMLElement): string {
|
||||
const texts: string[] = [];
|
||||
|
||||
// 查找关联的 label
|
||||
const id = element.getAttribute('id');
|
||||
if (id) {
|
||||
const label = document.querySelector(`label[for="${id}"]`);
|
||||
if (label) {
|
||||
texts.push(label.textContent || '');
|
||||
}
|
||||
}
|
||||
|
||||
// 查找父级 label
|
||||
const parentLabel = element.closest('label');
|
||||
if (parentLabel) {
|
||||
texts.push(parentLabel.textContent || '');
|
||||
}
|
||||
|
||||
// 查找相邻元素的文本
|
||||
const prevSibling = element.previousElementSibling;
|
||||
const nextSibling = element.nextElementSibling;
|
||||
if (prevSibling) {
|
||||
texts.push(prevSibling.textContent || '');
|
||||
}
|
||||
if (nextSibling) {
|
||||
texts.push(nextSibling.textContent || '');
|
||||
}
|
||||
|
||||
// 查找父级内的文本节点
|
||||
const parent = element.parentElement;
|
||||
if (parent) {
|
||||
const textNodes = parent.querySelectorAll('span, div, p');
|
||||
textNodes.forEach((node) => {
|
||||
texts.push(node.textContent || '');
|
||||
});
|
||||
}
|
||||
|
||||
return texts.join(' ').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查元素是否可见
|
||||
*/
|
||||
private static isVisibleElement(element: HTMLElement): boolean {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return false;
|
||||
|
||||
const style = window.getComputedStyle(element);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能注入引擎
|
||||
* 突破 React/Vue 等现代框架的表单状态绑定
|
||||
*/
|
||||
export class SmartInjectionEngine {
|
||||
/**
|
||||
* 注入数据到目标元素
|
||||
* @param element - 目标 DOM 元素
|
||||
* @param entry - 表单映射条目
|
||||
* @returns 注入结果
|
||||
*/
|
||||
public static inject(element: HTMLElement, entry: FormMapEntry, mockValue: string): InjectResult {
|
||||
try {
|
||||
const { action_logic } = entry;
|
||||
|
||||
switch (action_logic.type) {
|
||||
case 'text':
|
||||
this.injectText(element as HTMLInputElement | HTMLTextAreaElement, mockValue);
|
||||
break;
|
||||
|
||||
case 'select':
|
||||
this.injectSelect(element as HTMLSelectElement, action_logic);
|
||||
break;
|
||||
|
||||
case 'checkbox':
|
||||
this.injectCheckbox(element as HTMLInputElement, action_logic);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.injectText(element as HTMLInputElement | HTMLTextAreaElement, mockValue);
|
||||
}
|
||||
|
||||
return { success: true, entry };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
entry,
|
||||
error: error instanceof Error ? error.message : '注入失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入文本类输入框
|
||||
*/
|
||||
private static injectText(element: HTMLInputElement | HTMLTextAreaElement, value: string): void {
|
||||
// 获取原生 setter
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
element instanceof HTMLInputElement
|
||||
? window.HTMLInputElement.prototype
|
||||
: window.HTMLTextAreaElement.prototype,
|
||||
'value',
|
||||
)?.set;
|
||||
|
||||
if (nativeInputValueSetter) {
|
||||
nativeInputValueSetter.call(element, value);
|
||||
} else {
|
||||
element.value = value;
|
||||
}
|
||||
|
||||
// 连续触发事件
|
||||
element.dispatchEvent(new Event('focus', { bubbles: true }));
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
element.dispatchEvent(new Event('blur', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入下拉框
|
||||
*/
|
||||
private static injectSelect(
|
||||
element: HTMLSelectElement,
|
||||
actionLogic: FormMapEntry['action_logic'],
|
||||
): void {
|
||||
if (actionLogic.strategy === 'random') {
|
||||
// 随机选择
|
||||
const options = Array.from(element.options).filter((opt) => !opt.disabled);
|
||||
if (options.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * options.length);
|
||||
element.selectedIndex = randomIndex;
|
||||
}
|
||||
} else {
|
||||
// 使用固定值
|
||||
const value = actionLogic.value;
|
||||
const matchingOption = Array.from(element.options).find(
|
||||
(opt) => opt.value === value || opt.text === value,
|
||||
);
|
||||
if (matchingOption) {
|
||||
element.value = matchingOption.value;
|
||||
} else if (element.options.length > 0) {
|
||||
element.selectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入复选框/单选框
|
||||
*/
|
||||
private static injectCheckbox(
|
||||
element: HTMLInputElement,
|
||||
actionLogic: FormMapEntry['action_logic'],
|
||||
): void {
|
||||
if (actionLogic.strategy === 'random') {
|
||||
// 随机选择
|
||||
const isChecked = Math.random() > 0.5;
|
||||
if (element.type === 'checkbox') {
|
||||
element.checked = isChecked;
|
||||
} else if (element.type === 'radio') {
|
||||
// 对于单选框,找到同 name 的所有选项并随机选择一个
|
||||
const radioGroup = document.querySelectorAll<HTMLInputElement>(
|
||||
`input[type="radio"][name="${element.name}"]`,
|
||||
);
|
||||
if (radioGroup.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * radioGroup.length);
|
||||
radioGroup[randomIndex].click();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 使用固定值
|
||||
const shouldCheck = actionLogic.value === 'true' || actionLogic.value === '1';
|
||||
element.checked = shouldCheck;
|
||||
if (shouldCheck) {
|
||||
element.click();
|
||||
}
|
||||
}
|
||||
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock 数据生成器
|
||||
*/
|
||||
export class MockDataGenerator {
|
||||
/**
|
||||
* 根据策略生成随机数据
|
||||
*/
|
||||
public static generate(actionLogic: FormMapEntry['action_logic'], entry: FormMapEntry): string {
|
||||
const { strategy, value, type } = actionLogic;
|
||||
|
||||
if (strategy === 'fixed') {
|
||||
return value;
|
||||
}
|
||||
|
||||
// 根据字段类型和策略生成数据
|
||||
switch (type) {
|
||||
case 'text':
|
||||
return this.generateText(entry);
|
||||
|
||||
case 'select':
|
||||
return this.generateSelectValue();
|
||||
|
||||
case 'checkbox':
|
||||
return this.generateBoolean();
|
||||
|
||||
default:
|
||||
return this.generateText(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文本数据
|
||||
*/
|
||||
private static generateText(entry: FormMapEntry): string {
|
||||
const { fingerprint, action_logic } = entry;
|
||||
const { strategy, value: pattern } = action_logic;
|
||||
|
||||
// 根据模式生成数据
|
||||
if (pattern) {
|
||||
return this.generateByPattern(pattern);
|
||||
}
|
||||
|
||||
// 根据指纹特征推断数据类型
|
||||
const name = fingerprint.name_attr.toLowerCase();
|
||||
const placeholder = fingerprint.placeholder.toLowerCase();
|
||||
|
||||
if (name.includes('phone') || placeholder.includes('phone')) {
|
||||
return this.generatePhoneNumber();
|
||||
}
|
||||
if (name.includes('email') || placeholder.includes('email')) {
|
||||
return this.generateEmail();
|
||||
}
|
||||
if (name.includes('name') || placeholder.includes('name')) {
|
||||
return this.generateName();
|
||||
}
|
||||
if (name.includes('id') || name.includes('card')) {
|
||||
return this.generateIdCard();
|
||||
}
|
||||
if (name.includes('date') || placeholder.includes('date')) {
|
||||
return this.generateDate();
|
||||
}
|
||||
if (name.includes('number') || placeholder.includes('number')) {
|
||||
return this.generateNumber();
|
||||
}
|
||||
|
||||
// 默认生成随机文本
|
||||
return strategy === 'random' ? this.generateRandomText() : '测试数据';
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模式生成数据
|
||||
*/
|
||||
private static generateByPattern(pattern: string): string {
|
||||
if (pattern.includes('phone') || pattern.includes('mobile')) {
|
||||
return this.generatePhoneNumber();
|
||||
}
|
||||
if (pattern.includes('email')) {
|
||||
return this.generateEmail();
|
||||
}
|
||||
if (pattern.includes('name')) {
|
||||
return this.generateName();
|
||||
}
|
||||
if (pattern.includes('date')) {
|
||||
return this.generateDate();
|
||||
}
|
||||
if (pattern.includes('idcard') || pattern.includes('身份证')) {
|
||||
return this.generateIdCard();
|
||||
}
|
||||
if (/^\d+$/.test(pattern)) {
|
||||
return this.generateNumber(pattern.length);
|
||||
}
|
||||
|
||||
return pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成手机号
|
||||
*/
|
||||
private static generatePhoneNumber(): string {
|
||||
const prefix = '1' + ['3', '4', '5', '6', '7', '8', '9'][Math.floor(Math.random() * 7)];
|
||||
const suffix = Math.floor(Math.random() * 1000000000)
|
||||
.toString()
|
||||
.padStart(9, '0');
|
||||
return prefix + suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成邮箱
|
||||
*/
|
||||
private static generateEmail(): string {
|
||||
const names = ['test', 'user', 'admin', 'guest', 'demo'];
|
||||
const domains = ['example.com', 'test.com', 'gmail.com', 'outlook.com'];
|
||||
const name = names[Math.floor(Math.random() * names.length)];
|
||||
const domain = domains[Math.floor(Math.random() * domains.length)];
|
||||
const num = Math.floor(Math.random() * 1000);
|
||||
return `${name}${num}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成姓名
|
||||
*/
|
||||
private static generateName(): string {
|
||||
const surnames = ['张', '李', '王', '刘', '陈', '杨', '赵', '黄'];
|
||||
const givenNames = ['伟', '芳', '强', '英', '华', '建', '明', '娜'];
|
||||
return (
|
||||
surnames[Math.floor(Math.random() * surnames.length)] +
|
||||
givenNames[Math.floor(Math.random() * givenNames.length)]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成身份证号
|
||||
*/
|
||||
private static generateIdCard(): string {
|
||||
const areaCodes = ['110101', '310101', '440101', '120101', '320101'];
|
||||
const areaCode = areaCodes[Math.floor(Math.random() * areaCodes.length)];
|
||||
const year = (1980 + Math.floor(Math.random() * 30)).toString();
|
||||
const month = String(1 + Math.floor(Math.random() * 12)).padStart(2, '0');
|
||||
const day = String(1 + Math.floor(Math.random() * 28)).padStart(2, '0');
|
||||
const random = Math.floor(Math.random() * 10000)
|
||||
.toString()
|
||||
.padStart(4, '0');
|
||||
return areaCode + year + month + day + random;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成日期
|
||||
*/
|
||||
private static generateDate(): string {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - Math.floor(Math.random() * 365));
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成数字
|
||||
*/
|
||||
private static generateNumber(length: number = 6): string {
|
||||
return Math.floor(Math.random() * Math.pow(10, length))
|
||||
.toString()
|
||||
.padStart(length, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机文本
|
||||
*/
|
||||
private static generateRandomText(): string {
|
||||
const texts = ['测试内容', '示例文本', 'Lorem ipsum', '随机数据', 'Sample Text'];
|
||||
return texts[Math.floor(Math.random() * texts.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成下拉框值
|
||||
*/
|
||||
private static generateSelectValue(): string {
|
||||
return '选项' + (Math.floor(Math.random() * 5) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成布尔值
|
||||
*/
|
||||
private static generateBoolean(): string {
|
||||
return Math.random() > 0.5 ? 'true' : 'false';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 视觉反馈渲染器
|
||||
*/
|
||||
export class FeedbackRenderer {
|
||||
private static readonly SUCCESS_COLOR = '#32CD32';
|
||||
private static readonly ERROR_COLOR = '#FF4444';
|
||||
private static readonly HIGHLIGHT_DURATION = 3000;
|
||||
|
||||
/**
|
||||
* 渲染成功反馈
|
||||
*/
|
||||
public static renderSuccess(element: HTMLElement): void {
|
||||
this.applyHighlight(element, this.SUCCESS_COLOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染失败反馈
|
||||
*/
|
||||
public static renderError(element: HTMLElement | null): void {
|
||||
if (!element) return;
|
||||
this.applyHighlight(element, this.ERROR_COLOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用高亮样式
|
||||
*/
|
||||
private static applyHighlight(element: HTMLElement, color: string): void {
|
||||
// 保存原始样式
|
||||
const originalStyle = element.getAttribute('style') || '';
|
||||
element.setAttribute('data-original-style', originalStyle);
|
||||
|
||||
// 应用高亮
|
||||
element.style.outline = `3px solid ${color}`;
|
||||
element.style.outlineOffset = '2px';
|
||||
element.style.transition = 'outline 0.3s ease';
|
||||
|
||||
// 自动移除高亮
|
||||
setTimeout(() => {
|
||||
const savedStyle = element.getAttribute('data-original-style');
|
||||
if (savedStyle) {
|
||||
element.setAttribute('style', savedStyle);
|
||||
element.removeAttribute('data-original-style');
|
||||
} else {
|
||||
element.style.outline = '';
|
||||
element.style.outlineOffset = '';
|
||||
}
|
||||
}, this.HIGHLIGHT_DURATION);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有高亮
|
||||
*/
|
||||
public static clearAllHighlights(): void {
|
||||
const highlightedElements = document.querySelectorAll('[data-original-style]');
|
||||
highlightedElements.forEach((element) => {
|
||||
const savedStyle = element.getAttribute('data-original-style');
|
||||
if (savedStyle) {
|
||||
element.setAttribute('style', savedStyle);
|
||||
element.removeAttribute('data-original-style');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export interface MessagePayload {
|
||||
includeHidden?: boolean;
|
||||
fieldId?: string;
|
||||
fieldIds?: string[];
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +56,8 @@ export interface MessageResponse {
|
||||
totalCount?: number;
|
||||
validCount?: number;
|
||||
hasModal?: boolean;
|
||||
results?: unknown[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import jsQR from 'jsqr';
|
||||
|
||||
export interface QrCodeParseResult {
|
||||
success: boolean;
|
||||
data?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function parseQrCodeFromFile(
|
||||
file: File,
|
||||
timeout: number = 10000,
|
||||
): 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('图片加载失败'));
|
||||
};
|
||||
});
|
||||
|
||||
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 };
|
||||
} else {
|
||||
return { success: false, error: '未检测到二维码' };
|
||||
}
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : '解析失败' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user