表单颜色标记,用户输入数据提交时保存

This commit is contained in:
2026-06-15 18:24:58 +08:00
parent e3a4ae2381
commit 6be840a416
8 changed files with 3127 additions and 15 deletions
+221 -5
View File
@@ -8,6 +8,7 @@ import { useState, useEffect } from "react"
import { getCookieValue } from "~utils/cookie" import { getCookieValue } from "~utils/cookie"
import { getCustomizeResume } from "~api/aiApi" import { getCustomizeResume } from "~api/aiApi"
import { handleAutoFillCommon } from "~handlers/handleAutoFillCommon" import { handleAutoFillCommon } from "~handlers/handleAutoFillCommon"
import { handleAutoFillBeisen } from "~handlers/handleAutoFillBeisen"
import type { MatchedFormField, ResumeData, JobInfo } from "~lib/types" import type { MatchedFormField, ResumeData, JobInfo } from "~lib/types"
import "./SidebarPanel.scss" import "./SidebarPanel.scss"
@@ -34,6 +35,8 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
const [formFields, setFormFields] = useState<MatchedFormField[]>([]) const [formFields, setFormFields] = useState<MatchedFormField[]>([])
/** 当前使用的简历数据 */ /** 当前使用的简历数据 */
const [resumeData, setResumeData] = useState<ResumeData | null>(null) const [resumeData, setResumeData] = useState<ResumeData | null>(null)
/** 自动填写流程是否已完成(控制模拟提交按钮可用) */
const [fillCompleted, setFillCompleted] = useState(false)
/** 页面加载时检查 Token,有岗位信息则查询定制简历 */ /** 页面加载时检查 Token,有岗位信息则查询定制简历 */
useEffect(() => { useEffect(() => {
@@ -64,24 +67,218 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
/** /**
* 自动填写按钮点击处理 * 自动填写按钮点击处理
* 内部根据条件判断走 handlers/ 下具体哪个处理文件 * 内部根据当前页面域名判断走哪个处理模式
* - handleAutoFillCommon:通用模式(当前默认 * - handleAutoFillBeisen:北森模式(域名包含 zhiye.com,如 avicsz.zhiye.com
* - handleAutoFillCommon:通用模式(其他所有网站)
* - 后续特殊网站会在此处加条件分支(如根据 domain 或 jobInfo 来源判断) * - 后续特殊网站会在此处加条件分支(如根据 domain 或 jobInfo 来源判断)
*/ */
const handleAutoFill = async () => { const handleAutoFill = async () => {
setFilling(true) setFilling(true)
try { try {
const fillResult = await handleAutoFillCommon({ resumeData, jobInfo }) // 检测当前页面域名,判断走哪个处理模式
const currentHost = window.location.hostname
const isBeisen = currentHost.includes("zhiye.com") // 北森招聘平台域名特征
const fillResult = isBeisen
? await handleAutoFillBeisen({ resumeData, jobInfo })
: await handleAutoFillCommon({ resumeData, jobInfo })
setPageLang(fillResult.lang) setPageLang(fillResult.lang)
setIsFormPage(fillResult.isFormPage) setIsFormPage(fillResult.isFormPage)
if (fillResult.resumeData) setResumeData(fillResult.resumeData) if (fillResult.resumeData) setResumeData(fillResult.resumeData)
setFormFields(fillResult.formFields) setFormFields(fillResult.formFields)
setFillCompleted(true)
} catch (e) { } catch (e) {
console.error("OfferPie: 自动填写异常", e) console.error("OfferPie: 自动填写异常", e)
} }
setTimeout(() => setFilling(false), 1000) setTimeout(() => setFilling(false), 1000)
} }
/**
* 模拟提交:重新读取页面上 unfilledFormData 对应字段的当前值,更新到缓存
* 按大标题范围限定搜索,避免跨区域匹配到错误字段
*/
const handleSaveUserInput = () => {
try {
const cached = localStorage.getItem("offerpie_unfilled_form")
if (!cached) { alert("未找到缓存数据,请先执行自动填写"); return }
const cacheData = JSON.parse(cached)
const unfilledFormData = cacheData.unfilledFormData as {
title: string
isExperience: boolean
formItems: { label: string; value: string }[] | { label: string; value: string }[][]
}[]
const INPUT_SEL = "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])"
const FORM_ITEM_SELS = [
".form-item", ".form-group", ".form-field",
".el-form-item", ".ant-form-item", ".ant-row",
".arco-form-item", ".t-form-item", ".n-form-item",
"[class*='form-item']", "[class*='form-group']", "[class*='formItem']",
]
const EXCLUDE_LABELS = ["请输入", "输入", ":", "", "请选择", "选择", "*", "必填"]
/**
* 在页面上按文字找到大标题元素
*/
function findTitleElement(titleText: string): Element | null {
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT)
let node: Node | null = walker.nextNode()
while (node) {
const el = node as Element
const directText = Array.from(el.childNodes)
.filter((n) => n.nodeType === Node.TEXT_NODE)
.map((n) => n.textContent?.trim())
.filter(Boolean)
.join("")
if (directText === titleText && el.children.length === 0) {
return el
}
node = walker.nextNode()
}
return null
}
/**
* 在指定大标题范围内,通过标签文字查找对应 input 的值
*/
function findInputValueByLabelInRange(labelText: string, titleEl: Element, nextTitleEl: Element | null): string {
const allInputs = document.querySelectorAll(INPUT_SEL)
for (const inp of Array.from(allInputs)) {
// 范围检查
const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING)
if (!afterTitle || !beforeNext) continue
// 跳过已被阶段A/B填充的简历格式字段(背景色为绿色高亮)
// greenTwo#b7ffc6)是阶段B2填的unfilledFormData字段,不跳过,可以更新
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
const bgColor = inputEl.style.backgroundColor
if (bgColor === "#b7ffc5" || bgColor === "rgb(183, 255, 197)") continue
let container: Element | null = null
for (const sel of FORM_ITEM_SELS) {
container = inp.closest(sel)
if (container) break
}
if (!container) continue
const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt")
for (const el of Array.from(labelEls)) {
const directText = Array.from(el.childNodes)
.filter((n) => n.nodeType === Node.TEXT_NODE)
.map((n) => n.textContent?.trim())
.filter(Boolean)
.join("")
if (!directText || directText.length > 30) continue
if (EXCLUDE_LABELS.some((ex) => directText === ex)) continue
// 非经历类型标签必须全名严格匹配,不走 includes
if (directText === labelText &&
(el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)) {
return inputEl.value?.trim() || ""
}
}
}
return ""
}
/**
* 北森特有:在指定大标题范围内,通过标签文字查找 phoenix-radio-group 的当前选中值
* DOM 结构:form-item > form-item__title > form-item__text(标签)
* > form-item__control > phoenix-radio-group > phoenix-radio-group__radioItem
* 选中判断:radioItem 子级有 phoenix-radio--checked 类名,取该 radioItem 的文字
*/
function findRadioGroupValueByLabelInRange(labelText: string, titleEl: Element, nextTitleEl: Element | null): string {
const radioGroups = document.querySelectorAll(".phoenix-radio-group")
for (const rg of Array.from(radioGroups)) {
// 范围检查
const afterTitle = !!(titleEl.compareDocumentPosition(rg) & Node.DOCUMENT_POSITION_FOLLOWING)
const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(rg) & Node.DOCUMENT_POSITION_PRECEDING)
if (!afterTitle || !beforeNext) continue
// 找标签文字:form-item__control 同级的 form-item__title 里的 form-item__text
const controlEl = rg.closest(".form-item__control")
if (!controlEl || !controlEl.parentElement) continue
const titleDiv = controlEl.parentElement.querySelector(".form-item__title .form-item__text")
if (!titleDiv) continue
const directText = Array.from(titleDiv.childNodes)
.filter((n) => n.nodeType === Node.TEXT_NODE)
.map((n) => n.textContent?.trim())
.filter(Boolean)
.join("")
// 标签严格全名匹配
if (directText !== labelText) continue
// 找选中的 radioItem
const radioItems = rg.querySelectorAll(".phoenix-radio-group__radioItem")
for (const item of Array.from(radioItems)) {
const checkedEl = item.querySelector(".phoenix-radio--checked")
if (checkedEl) {
return item.textContent?.trim() || ""
}
}
// 找到了 radio group 但没有选中项,返回空
return ""
}
return ""
}
// 收集所有标题元素(按 unfilledFormData 顺序)
const titleElements: (Element | null)[] = unfilledFormData.map((s) => findTitleElement(s.title))
// 遍历 unfilledFormData,按大标题范围限定搜索
let updatedCount = 0
for (let i = 0; i < unfilledFormData.length; i++) {
const section = unfilledFormData[i]
const titleEl = titleElements[i]
if (!titleEl) continue
// 找下一个大标题作为范围终点
let nextTitleEl: Element | null = null
for (let j = i + 1; j < titleElements.length; j++) {
if (titleElements[j]) { nextTitleEl = titleElements[j]; break }
}
if (section.isExperience) {
const segments = section.formItems as { label: string; value: string }[][]
for (const seg of segments) {
for (const field of seg) {
const currentValue = findInputValueByLabelInRange(field.label, titleEl, nextTitleEl)
if (currentValue && currentValue !== field.value) {
field.value = currentValue
updatedCount++
}
}
}
} else {
const fields = section.formItems as { label: string; value: string }[]
for (const field of fields) {
// 先尝试从 input/textarea 读取值
let currentValue = findInputValueByLabelInRange(field.label, titleEl, nextTitleEl)
// 如果没读到,尝试从北森 phoenix-radio-group 单选组读取当前选中值
if (!currentValue) {
currentValue = findRadioGroupValueByLabelInRange(field.label, titleEl, nextTitleEl)
}
if (currentValue && currentValue !== field.value) {
field.value = currentValue
updatedCount++
}
}
}
}
// 更新缓存
cacheData.unfilledFormData = unfilledFormData
cacheData.timestamp = Date.now()
localStorage.setItem("offerpie_unfilled_form", JSON.stringify(cacheData))
alert(`已保存!更新了 ${updatedCount} 个字段的值`)
console.log("[OfferPie] 模拟提交:已更新缓存数据", cacheData)
} catch (e) {
console.error("[OfferPie] 模拟提交失败:", e)
alert("保存失败,请查看控制台")
}
}
return ( return (
<div className="op-container"> <div className="op-container">
{/* 顶部操作栏:关闭按钮始终显示,反馈和设置仅登录后显示 */} {/* 顶部操作栏:关闭按钮始终显示,反馈和设置仅登录后显示 */}
@@ -183,8 +380,27 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
{/* <span className="op-resume-change">更改</span> */} {/* <span className="op-resume-change">更改</span> */}
</div> </div>
{/* 针对性优化简历按钮 */} {/* 按钮 */}
<button className="op-optimize-btn"></button> {/* 模拟提交按钮(自动填写完成后才可点击) */}
<button
className="op-optimize-btn"
disabled={!fillCompleted}
onClick={handleSaveUserInput}
style={{ opacity: fillCompleted ? 1 : 0.5, cursor: fillCompleted ? "pointer" : "not-allowed" }}
>
</button>
<button
className="op-optimize-btn"
onClick={() => {
localStorage.removeItem("offerpie_unfilled_form")
alert("已清空缓存数据")
console.log("[OfferPie] 已清空 offerpie_unfilled_form 缓存")
}}
style={{ marginTop: "6px", opacity: 0.8 }}
>
</button>
{/* 填写进度区域 */} {/* 填写进度区域 */}
<div className="op-progress-row"> <div className="op-progress-row">
+2 -2
View File
@@ -3,7 +3,7 @@
*/ */
/** 当前环境,手动切换 */ /** 当前环境,手动切换 */
const ENV = 'dev' const ENV = 'prod'
/** 各环境配置 */ /** 各环境配置 */
const envConfigs: Record<string, { const envConfigs: Record<string, {
@@ -19,7 +19,7 @@ const envConfigs: Record<string, {
}, },
prod: { prod: {
dataBaseApi: 'https://www.offerpai.com.cn/api', dataBaseApi: 'https://www.offerpai.com.cn/api',
aiBaseApi: 'https://www.offerpai.com.cn/ai', aiBaseApi: 'https://www.offerpai.com.cn/ai-api',
cookieSourceUrl: 'https://www.offerpai.com.cn', cookieSourceUrl: 'https://www.offerpai.com.cn',
}, },
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -362,7 +362,7 @@ export async function fillSearchPickerField(field: MatchedFormField): Promise<bo
if (isAsyncDropdown) { if (isAsyncDropdown) {
// 接口异步下拉:轮询等待弹出层出现,最多等3秒 // 接口异步下拉:轮询等待弹出层出现,最多等3秒
console.log(`OfferPie: [搜索选择器] "${labelText}" 异步下拉字段,等待接口返回...`) console.log(`OfferPie: [搜索选择器] "${labelText}" 异步下拉字段,等待接口返回...`)
for (let i = 0; i < 15; i++) { for (let i = 0; i < 10; i++) {
await delay(200) await delay(200)
// 检查是否有新弹出层 // 检查是否有新弹出层
for (const child of Array.from(document.body.children)) { for (const child of Array.from(document.body.children)) {
+41 -2
View File
@@ -594,7 +594,7 @@ function getDirectText(el: Element): string {
* *
* 【注意】验证 fullText 不能包含其他经历类型的标题关键词,防止跨区域误匹配 * 【注意】验证 fullText 不能包含其他经历类型的标题关键词,防止跨区域误匹配
*/ */
function findAddButton( export function findAddButton(
titleEl: Element, titleEl: Element,
nextTitleEl: Element | null, nextTitleEl: Element | null,
config: ExperienceSectionConfig, config: ExperienceSectionConfig,
@@ -671,7 +671,7 @@ function findAddButton(
* 【注意】防止某些网站子标签和父标签都能触发添加,逐层检测避免重复添加 * 【注意】防止某些网站子标签和父标签都能触发添加,逐层检测避免重复添加
* 返回点击是否成功触发了新段落的添加 * 返回点击是否成功触发了新段落的添加
*/ */
async function clickAddButton( export async function clickAddButton(
addBtnEl: Element, addBtnEl: Element,
titleEl: Element, titleEl: Element,
nextTitleEl: Element | null, nextTitleEl: Element | null,
@@ -822,6 +822,45 @@ export function locateExperienceSections(
return results return results
} }
/**
* 【工具方法】获取页面所有大标题列表(复用 locateExperienceSections 内部的 TitleSignature 匹配逻辑)
* 用于填写结束后按大标题分组统计字段处理结果
*
* @param locateResults - locateExperienceSections 的返回结果(用于提取参考 TitleSignature
* @returns 按 DOM 顺序排列的所有大标题列表 { element, text }
*/
export function getAllPageTitles(
locateResults: ExperienceSectionLocateResult[]
): { element: Element; text: string }[] {
const firstLocated = locateResults.find((r) => r.titleElement)
if (!firstLocated || !firstLocated.titleElement) return []
const refSignature = extractTitleSignature(firstLocated.titleElement)
let allPageTitles = findAllTitlesWithSameSignature(document.body, refSignature, firstLocated.titleElement)
// fallback 检查
const locatedElements = new Set(locateResults.filter((r) => r.titleElement).map((r) => r.titleElement!))
const containsLocated = allPageTitles.some((pt) => locatedElements.has(pt.element))
if (!containsLocated || allPageTitles.length <= 1) {
allPageTitles = locateResults
.filter((r) => r.titleElement)
.map((r) => ({
element: r.titleElement!,
text: r.titleText,
signature: r.titleSignature,
}))
allPageTitles.sort((a, b) => {
const pos = a.element.compareDocumentPosition(b.element)
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1
return 0
})
}
return allPageTitles.map((pt) => ({ element: pt.element, text: pt.text }))
}
// ==================================================================== // ====================================================================
// 六、主流程 - 补足经历段数 // 六、主流程 - 补足经历段数
// ==================================================================== // ====================================================================
+3
View File
@@ -154,6 +154,7 @@ export function matchFormFields(
) )
for (const item of JOB_FORM_LABELS) { for (const item of JOB_FORM_LABELS) {
if (!item.resumeField) continue // 跳过简历数据中无对应字段的标签
const labels = lang === "zh" ? item.zh : item.en const labels = lang === "zh" ? item.zh : item.en
const sortedLabels = [...labels].sort((a, b) => b.length - a.length) const sortedLabels = [...labels].sort((a, b) => b.length - a.length)
let matchCount = 0 let matchCount = 0
@@ -292,6 +293,7 @@ export function matchFormFieldsInRange(
} }
for (const item of sectionLabels) { for (const item of sectionLabels) {
if (!item.resumeField) continue // 跳过简历数据中无对应字段的标签
const labels = lang === "zh" ? item.zh : item.en const labels = lang === "zh" ? item.zh : item.en
const sortedLabels = [...labels].sort((a, b) => b.length - a.length) const sortedLabels = [...labels].sort((a, b) => b.length - a.length)
@@ -387,6 +389,7 @@ export function matchMainFields(
) )
for (const item of mainLabels) { for (const item of mainLabels) {
if (!item.resumeField) continue // 跳过简历数据中无对应字段的标签
const labels = lang === "zh" ? item.zh : item.en const labels = lang === "zh" ? item.zh : item.en
const sortedLabels = [...labels].sort((a, b) => b.length - a.length) const sortedLabels = [...labels].sort((a, b) => b.length - a.length)
+303
View File
@@ -0,0 +1,303 @@
/**
* 表单样式控制模块
* 用于在自动填写过程中,对匹配到的表单字段容器设置背景色高亮反馈
* 颜色含义: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 {
// ---- 策略1input 自身 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)
}
}