表单颜色标记,用户输入数据提交时保存
This commit is contained in:
@@ -8,6 +8,7 @@ import { useState, useEffect } from "react"
|
||||
import { getCookieValue } from "~utils/cookie"
|
||||
import { getCustomizeResume } from "~api/aiApi"
|
||||
import { handleAutoFillCommon } from "~handlers/handleAutoFillCommon"
|
||||
import { handleAutoFillBeisen } from "~handlers/handleAutoFillBeisen"
|
||||
import type { MatchedFormField, ResumeData, JobInfo } from "~lib/types"
|
||||
import "./SidebarPanel.scss"
|
||||
|
||||
@@ -34,6 +35,8 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
const [formFields, setFormFields] = useState<MatchedFormField[]>([])
|
||||
/** 当前使用的简历数据 */
|
||||
const [resumeData, setResumeData] = useState<ResumeData | null>(null)
|
||||
/** 自动填写流程是否已完成(控制模拟提交按钮可用) */
|
||||
const [fillCompleted, setFillCompleted] = useState(false)
|
||||
|
||||
/** 页面加载时检查 Token,有岗位信息则查询定制简历 */
|
||||
useEffect(() => {
|
||||
@@ -64,24 +67,218 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
|
||||
/**
|
||||
* 自动填写按钮点击处理
|
||||
* 内部根据条件判断走 handlers/ 下具体哪个处理文件:
|
||||
* - handleAutoFillCommon:通用模式(当前默认)
|
||||
* 内部根据当前页面域名判断走哪个处理模式:
|
||||
* - handleAutoFillBeisen:北森模式(域名包含 zhiye.com,如 avicsz.zhiye.com)
|
||||
* - handleAutoFillCommon:通用模式(其他所有网站)
|
||||
* - 后续特殊网站会在此处加条件分支(如根据 domain 或 jobInfo 来源判断)
|
||||
*/
|
||||
const handleAutoFill = async () => {
|
||||
setFilling(true)
|
||||
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)
|
||||
setIsFormPage(fillResult.isFormPage)
|
||||
if (fillResult.resumeData) setResumeData(fillResult.resumeData)
|
||||
setFormFields(fillResult.formFields)
|
||||
setFillCompleted(true)
|
||||
} catch (e) {
|
||||
console.error("OfferPie: 自动填写异常", e)
|
||||
}
|
||||
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 (
|
||||
<div className="op-container">
|
||||
{/* 顶部操作栏:关闭按钮始终显示,反馈和设置仅登录后显示 */}
|
||||
@@ -183,8 +380,27 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
|
||||
{/* <span className="op-resume-change">更改</span> */}
|
||||
</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">
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
/** 当前环境,手动切换 */
|
||||
const ENV = 'dev'
|
||||
const ENV = 'prod'
|
||||
|
||||
/** 各环境配置 */
|
||||
const envConfigs: Record<string, {
|
||||
@@ -19,7 +19,7 @@ const envConfigs: Record<string, {
|
||||
},
|
||||
prod: {
|
||||
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',
|
||||
},
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -362,7 +362,7 @@ export async function fillSearchPickerField(field: MatchedFormField): Promise<bo
|
||||
if (isAsyncDropdown) {
|
||||
// 接口异步下拉:轮询等待弹出层出现,最多等3秒
|
||||
console.log(`OfferPie: [搜索选择器] "${labelText}" 异步下拉字段,等待接口返回...`)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await delay(200)
|
||||
// 检查是否有新弹出层
|
||||
for (const child of Array.from(document.body.children)) {
|
||||
|
||||
@@ -594,7 +594,7 @@ function getDirectText(el: Element): string {
|
||||
*
|
||||
* 【注意】验证 fullText 不能包含其他经历类型的标题关键词,防止跨区域误匹配
|
||||
*/
|
||||
function findAddButton(
|
||||
export function findAddButton(
|
||||
titleEl: Element,
|
||||
nextTitleEl: Element | null,
|
||||
config: ExperienceSectionConfig,
|
||||
@@ -671,7 +671,7 @@ function findAddButton(
|
||||
* 【注意】防止某些网站子标签和父标签都能触发添加,逐层检测避免重复添加
|
||||
* 返回点击是否成功触发了新段落的添加
|
||||
*/
|
||||
async function clickAddButton(
|
||||
export async function clickAddButton(
|
||||
addBtnEl: Element,
|
||||
titleEl: Element,
|
||||
nextTitleEl: Element | null,
|
||||
@@ -822,6 +822,45 @@ export function locateExperienceSections(
|
||||
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 }))
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 六、主流程 - 补足经历段数
|
||||
// ====================================================================
|
||||
|
||||
@@ -154,6 +154,7 @@ export function matchFormFields(
|
||||
)
|
||||
|
||||
for (const item of JOB_FORM_LABELS) {
|
||||
if (!item.resumeField) continue // 跳过简历数据中无对应字段的标签
|
||||
const labels = lang === "zh" ? item.zh : item.en
|
||||
const sortedLabels = [...labels].sort((a, b) => b.length - a.length)
|
||||
let matchCount = 0
|
||||
@@ -292,6 +293,7 @@ export function matchFormFieldsInRange(
|
||||
}
|
||||
|
||||
for (const item of sectionLabels) {
|
||||
if (!item.resumeField) continue // 跳过简历数据中无对应字段的标签
|
||||
const labels = lang === "zh" ? item.zh : item.en
|
||||
const sortedLabels = [...labels].sort((a, b) => b.length - a.length)
|
||||
|
||||
@@ -387,6 +389,7 @@ export function matchMainFields(
|
||||
)
|
||||
|
||||
for (const item of mainLabels) {
|
||||
if (!item.resumeField) continue // 跳过简历数据中无对应字段的标签
|
||||
const labels = lang === "zh" ? item.zh : item.en
|
||||
const sortedLabels = [...labels].sort((a, b) => b.length - a.length)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
// ---- 策略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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user