表单颜色标记,用户输入数据提交时保存
This commit is contained in:
@@ -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
@@ -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
@@ -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)) {
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
}
|
||||||
|
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
// 六、主流程 - 补足经历段数
|
// 六、主流程 - 补足经历段数
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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