304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
/**
|
||
* 表单样式控制模块
|
||
* 用于在自动填写过程中,对匹配到的表单字段容器设置背景色高亮反馈
|
||
* 颜色含义:green=填写成功(淡绿色)、red=填写失败/必填未填(淡红色)、yellow=非必填跳过(淡黄色)
|
||
*/
|
||
|
||
/** 高亮颜色类型 */
|
||
export type HighlightColor = "green" |"greenTwo" | "red" | "yellow"
|
||
|
||
/** 颜色映射表(淡色系,不影响文字可读性) */
|
||
const COLOR_MAP: Record<HighlightColor, string> = {
|
||
green: "#b7ffc5", // 淡绿色
|
||
greenTwo: "#b7ffc6", // 淡绿色2
|
||
red: "#ffb7b7", // 淡红色
|
||
yellow: "#fff", // 白色
|
||
}
|
||
|
||
/** 常见的表单项容器选择器(与 formMatcher.ts 保持一致) */
|
||
const FORM_ITEM_SELECTORS = [
|
||
".form-item", ".form-group", ".form-field",
|
||
".el-form-item", ".ant-form-item", ".ant-row",
|
||
".arco-form-item", ".t-form-item", ".n-form-item",
|
||
".ivu-form-item", ".v-input", ".MuiFormControl-root",
|
||
"[class*='form-item']", "[class*='form-group']", "[class*='formItem']",
|
||
]
|
||
|
||
// ============ 必填红*标记检测 ============
|
||
|
||
/**
|
||
* 非必填字段的红*标记样式特征排除配置
|
||
* 用于兼容不同网站:某些网站的红*标记不代表必填,通过此配置排除误判
|
||
*
|
||
* 使用场景:特殊网站中某些 label 带红*但实际非必填,可以把这些特征加进来排除
|
||
*
|
||
* @property containerClassExcludes - 如果表单项容器包含这些 class 片段则跳过必填判定
|
||
* @property labelClassExcludes - 如果标签元素包含这些 class 片段则跳过必填判定
|
||
* @property labelTextExcludes - 如果标签文字包含这些关键词则跳过必填判定
|
||
*/
|
||
export interface RequiredMarkExcludeConfig {
|
||
/** 容器 class 排除特征(className 中包含任一则视为非必填) */
|
||
containerClassExcludes?: string[]
|
||
/** 标签 class 排除特征 */
|
||
labelClassExcludes?: string[]
|
||
/** 标签文字排除关键词 */
|
||
labelTextExcludes?: string[]
|
||
}
|
||
|
||
/**
|
||
* 检测表单字段是否有红*必填标记
|
||
*
|
||
* 检测策略(按优先级):
|
||
* 1. input 元素自身的 required 属性 / aria-required="true"
|
||
* 2. 表单项容器 class 包含 required / is-required 等关键词
|
||
* 3. 标签元素或其子元素中存在纯文字 "*" 的红色节点
|
||
* 4. 标签元素或其相邻元素的 ::before / ::after 伪元素 content 包含 "*"
|
||
*
|
||
* @param labelElement - 标签 DOM 元素(从 MatchedFormField.labelElement 获取)
|
||
* @param inputElement - 输入框 DOM 元素(从 MatchedFormField.inputElement 获取)
|
||
* @param excludeConfig - 排除配置(预留给特殊网站兼容)
|
||
* @returns true=该字段有必填标记,false=未检测到必填标记
|
||
*/
|
||
export function isRequiredField(
|
||
labelElement: Element | null,
|
||
inputElement: HTMLInputElement | HTMLTextAreaElement | null,
|
||
excludeConfig?: RequiredMarkExcludeConfig
|
||
): boolean {
|
||
// ---- 策略1:input 自身 required 属性 ----
|
||
if (inputElement) {
|
||
if (inputElement.hasAttribute("required") || inputElement.getAttribute("aria-required") === "true") {
|
||
return true
|
||
}
|
||
}
|
||
|
||
// ---- 找到表单项容器 ----
|
||
const formItemContainer = findFormItemContainer(labelElement, inputElement)
|
||
|
||
// ---- 排除配置检查 ----
|
||
if (excludeConfig && formItemContainer) {
|
||
const containerClass = formItemContainer.className || ""
|
||
if (excludeConfig.containerClassExcludes?.some((cls) => containerClass.includes(cls))) {
|
||
return false
|
||
}
|
||
}
|
||
if (excludeConfig && labelElement) {
|
||
const labelClass = (labelElement as HTMLElement).className || ""
|
||
if (excludeConfig.labelClassExcludes?.some((cls) => labelClass.includes(cls))) {
|
||
return false
|
||
}
|
||
const labelText = labelElement.textContent?.trim() || ""
|
||
if (excludeConfig.labelTextExcludes?.some((kw) => labelText.includes(kw))) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// ---- 策略2:容器 class 包含 required 关键词 ----
|
||
if (formItemContainer) {
|
||
const containerClass = formItemContainer.className || ""
|
||
if (/\brequired\b|is-required|isRequired|form-item--required/.test(containerClass)) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
// ---- 策略3:标签元素内或附近的红色 "*" 文本节点 ----
|
||
// 搜索范围:标签元素自身 + 表单项容器内的前几个子元素
|
||
const searchElements: Element[] = []
|
||
if (labelElement) searchElements.push(labelElement)
|
||
if (formItemContainer) {
|
||
// 在容器内寻找标签附近的小元素(span/em/i 等可能放红*的标签)
|
||
const smallEls = formItemContainer.querySelectorAll("span, em, i, sup, label")
|
||
for (const el of Array.from(smallEls)) {
|
||
// 只看标签元素前后附近的(DOM 位置接近的)
|
||
if (labelElement && isNearLabel(el, labelElement)) {
|
||
searchElements.push(el)
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const el of searchElements) {
|
||
if (hasRedAsteriskText(el)) return true
|
||
}
|
||
|
||
// ---- 策略4:伪元素 ::before / ::after 检测 ----
|
||
if (labelElement && labelElement instanceof HTMLElement) {
|
||
if (hasPseudoElementAsterisk(labelElement)) return true
|
||
}
|
||
// 也检查容器内的 label 标签(有些框架把伪元素放在 label 上)
|
||
if (formItemContainer) {
|
||
const labels = formItemContainer.querySelectorAll("label, [class*='label']")
|
||
for (const lbl of Array.from(labels)) {
|
||
if (lbl instanceof HTMLElement && hasPseudoElementAsterisk(lbl)) return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* 查找标签/输入框所在的表单项容器
|
||
* 与 formMatcher.ts 中 findNearestInput 使用的容器选择器一致
|
||
*/
|
||
function findFormItemContainer(
|
||
labelElement: Element | null,
|
||
inputElement: Element | null
|
||
): Element | null {
|
||
// 优先从 labelElement 向上找
|
||
if (labelElement) {
|
||
for (const sel of FORM_ITEM_SELECTORS) {
|
||
const container = labelElement.closest(sel)
|
||
if (container) return container
|
||
}
|
||
}
|
||
// fallback:从 inputElement 向上找
|
||
if (inputElement) {
|
||
for (const sel of FORM_ITEM_SELECTORS) {
|
||
const container = inputElement.closest(sel)
|
||
if (container) return container
|
||
}
|
||
}
|
||
// 最后兜底:labelElement 的父级(最多向上3层)
|
||
let parent = labelElement?.parentElement || inputElement?.parentElement || null
|
||
for (let i = 0; i < 3 && parent; i++) {
|
||
if (parent !== document.body) return parent
|
||
parent = parent.parentElement
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 判断元素是否在标签元素附近(DOM 距离近)
|
||
*/
|
||
function isNearLabel(el: Element, labelElement: Element): boolean {
|
||
// 如果是标签的子元素或标签本身
|
||
if (labelElement.contains(el) || el.contains(labelElement)) return true
|
||
// 如果是标签的前后兄弟
|
||
if (el.previousElementSibling === labelElement || el.nextElementSibling === labelElement) return true
|
||
// 如果和标签在同一个父元素内且距离不超过3个节点
|
||
if (el.parentElement === labelElement.parentElement) return true
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* 检测元素内是否有红色的 "*" 文本
|
||
* 遍历元素的子节点,查找包含 "*" 的文本节点,并检查其颜色是否为红色系
|
||
*/
|
||
function hasRedAsteriskText(el: Element): boolean {
|
||
// 检查元素自身的直接文本子节点
|
||
for (const child of Array.from(el.childNodes)) {
|
||
if (child.nodeType === Node.TEXT_NODE) {
|
||
const text = child.textContent || ""
|
||
if (text.includes("*")) {
|
||
// 检查该文本所在元素的颜色
|
||
const color = getComputedColor(el)
|
||
if (isRedColor(color)) return true
|
||
}
|
||
}
|
||
}
|
||
// 检查子元素(如 <span>*</span>)
|
||
for (const child of Array.from(el.children)) {
|
||
const text = child.textContent?.trim() || ""
|
||
if (text === "*" || text === "* " || text === " *") {
|
||
const color = getComputedColor(child)
|
||
if (isRedColor(color)) return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* 检测元素的 ::before 或 ::after 伪元素是否包含 "*"
|
||
* 这是最常见的必填标记实现方式(如 Element UI、Ant Design)
|
||
*/
|
||
function hasPseudoElementAsterisk(el: HTMLElement): boolean {
|
||
for (const pseudo of ["::before", "::after"] as const) {
|
||
const style = window.getComputedStyle(el, pseudo)
|
||
const content = style.getPropertyValue("content")
|
||
// content 格式形如 '"*"' 或 '"* "' 或 '"\uff0a"'(全角星号)
|
||
if (content && (content.includes("*") || content.includes("\\*") || content.includes("\uff0a"))) {
|
||
// 进一步确认颜色是红色系(排除装饰性星号)
|
||
const color = style.getPropertyValue("color")
|
||
if (isRedColor(color)) return true
|
||
// 有些情况不设颜色但 content 确实是 "*",也认为是必填
|
||
if (content.replace(/['"\\s ]/g, "") === "*") return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
/** 获取元素的计算颜色值 */
|
||
function getComputedColor(el: Element): string {
|
||
if (!(el instanceof HTMLElement)) return ""
|
||
return window.getComputedStyle(el).getPropertyValue("color")
|
||
}
|
||
|
||
/**
|
||
* 判断颜色值是否为红色系
|
||
* 支持 rgb / rgba / hex 格式
|
||
*/
|
||
function isRedColor(color: string): boolean {
|
||
if (!color) return false
|
||
// rgb(r, g, b) 或 rgba(r, g, b, a) 格式
|
||
const rgbMatch = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/)
|
||
if (rgbMatch) {
|
||
const r = parseInt(rgbMatch[1])
|
||
const g = parseInt(rgbMatch[2])
|
||
const b = parseInt(rgbMatch[3])
|
||
// 红色系:R 通道较高,G 和 B 通道较低
|
||
return r > 150 && g < 100 && b < 100
|
||
}
|
||
// 常见红色关键词
|
||
if (color === "red" || color.includes("#f5222d") || color.includes("#ff4d4f") || color.includes("#e00")) return true
|
||
return false
|
||
}
|
||
|
||
// ============ 背景高亮设置 ============
|
||
|
||
/**
|
||
* 设置表单字段容器的背景高亮颜色
|
||
* 通过行内样式直接覆盖原网页 class 中的背景色
|
||
*
|
||
* @param element - 要设置背景色的 HTML 元素(input / textarea / div 等)
|
||
* @param color - 高亮颜色:green=成功 red=失败 yellow=跳过
|
||
*/
|
||
export function setFieldHighlight(element: Element | null, color: HighlightColor): void {
|
||
if (!element || !(element instanceof HTMLElement)) return
|
||
if (!element.isConnected) return // 元素已脱离 DOM 则跳过
|
||
|
||
element.style.backgroundColor = COLOR_MAP[color]
|
||
}
|
||
|
||
/**
|
||
* 清除表单字段容器的背景高亮颜色(恢复为透明)
|
||
*
|
||
* @param element - 要清除背景色的 HTML 元素
|
||
*/
|
||
export function clearFieldHighlight(element: Element | null): void {
|
||
if (!element || !(element instanceof HTMLElement)) return
|
||
if (!element.isConnected) return
|
||
|
||
element.style.backgroundColor = ""
|
||
}
|
||
|
||
/**
|
||
* 批量设置多个元素的背景高亮颜色
|
||
*
|
||
* @param elements - 要设置的元素数组
|
||
* @param color - 高亮颜色
|
||
*/
|
||
export function setFieldsHighlight(elements: (Element | null)[], color: HighlightColor): void {
|
||
for (const el of elements) {
|
||
setFieldHighlight(el, color)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 批量清除多个元素的背景高亮颜色
|
||
*
|
||
* @param elements - 要清除的元素数组
|
||
*/
|
||
export function clearFieldsHighlight(elements: (Element | null)[]): void {
|
||
for (const el of elements) {
|
||
clearFieldHighlight(el)
|
||
}
|
||
}
|