经历部分填写逻辑优化
This commit is contained in:
+190
-32
@@ -5,15 +5,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { fillMatchedField, delay } from "~lib/autofill"
|
import { fillMatchedField, delay, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill"
|
||||||
import { extractDomStructure, detectPageLanguage, isJobApplicationForm } from "~lib/dom"
|
import { extractDomStructure, detectPageLanguage, isJobApplicationForm } from "~lib/dom"
|
||||||
import { matchFormFields } from "~lib/formMatcher"
|
import { matchFormFields, matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher"
|
||||||
import { detectPickerField } from "~lib/pickerDetector"
|
import { detectPickerField } from "~lib/pickerDetector"
|
||||||
import { detectAndUploadResume } from "~lib/resumeUpload"
|
import { detectAndUploadResume } from "~lib/resumeUpload"
|
||||||
import { getMockResumeData } from "~lib/constants"
|
import { getMockResumeData } from "~lib/constants"
|
||||||
import { getResumeFieldValue } from "~lib/resumeDataHelper"
|
import { getResumeFieldValue } from "~lib/resumeDataHelper"
|
||||||
import { locateExperienceSections, expandExperienceSections } from "~lib/experienceSection"
|
import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer } from "~lib/experienceSection"
|
||||||
import type { MatchedFormField, ResumeData } from "~lib/types"
|
import type { MatchedFormField, ResumeData, ExperienceSection } from "~lib/types"
|
||||||
import "./SidebarPanel.scss"
|
import "./SidebarPanel.scss"
|
||||||
|
|
||||||
/** 侧边栏面板的 Props */
|
/** 侧边栏面板的 Props */
|
||||||
@@ -74,50 +74,211 @@ export function SidebarPanel({ onClose }: SidebarPanelProps) {
|
|||||||
const sectionResults = locateExperienceSections(document.body, lang)
|
const sectionResults = locateExperienceSections(document.body, lang)
|
||||||
// 4.6 对比简历数据段数,点击添加按钮补足不够的段数
|
// 4.6 对比简历数据段数,点击添加按钮补足不够的段数
|
||||||
const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang)
|
const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang)
|
||||||
// TODO: 后续实现步骤:
|
|
||||||
// 5. 对5种经历的标签关系数据做特殊标记(后续完善)
|
|
||||||
// 6. 所有经历段数添加完毕后,再进入统一的字段匹配和填写流程
|
|
||||||
|
|
||||||
// 5. 匹配表单字段
|
// 5. 经历数据按时间排序 + 经历区域字段匹配与填写(阶段A)
|
||||||
const fields = matchFormFields(document.body, lang)
|
console.log("===== OfferPie: 阶段A - 经历区域填写 =====")
|
||||||
console.log(`===== OfferPie: 匹配到 ${fields.length} 个表单字段 =====`)
|
const usedInputs = new Set<Element>() // 全局已使用的 input 集合
|
||||||
|
const excludeRanges: { start: Element; end: Element | null }[] = [] // 经历区域范围(用于阶段B排除)
|
||||||
// 6. 逐个:检测选择器 → 填充数据 → 立即填写(一个个来,避免多个选择器同时打开)
|
|
||||||
let success = 0, failed = 0, skipped = 0
|
let success = 0, failed = 0, skipped = 0
|
||||||
// 记录上一个非 picker 的输入框,用于每次填完后点击它来关闭残留弹窗
|
|
||||||
let lastTextInput: HTMLInputElement | HTMLTextAreaElement | null = null
|
let lastTextInput: HTMLInputElement | HTMLTextAreaElement | null = null
|
||||||
|
let lastTextInputIsPicker = false
|
||||||
|
|
||||||
for (const f of fields) {
|
for (const result of expandedResults) {
|
||||||
// 从简历数据中获取填写值(根据 section + sectionIndex + resumeField 定位)
|
if (!result.titleElement || result.expandedCount === 0) continue
|
||||||
if (currentResumeData) {
|
const section = result.section as ExperienceSection
|
||||||
const value = getResumeFieldValue(currentResumeData, f.section, f.sectionIndex, f.resumeField)
|
const sectionData = currentResumeData[section] as { startDate?: string; endDate?: string }[]
|
||||||
if (value) f.fillValue = value
|
if (!sectionData || sectionData.length === 0) continue
|
||||||
|
|
||||||
|
// 5.1 对该经历数据按时间排序(最新的在前面)
|
||||||
|
const sortedIndices = sortExperienceByTime(sectionData)
|
||||||
|
console.log(` [${section}] 排序后索引: [${sortedIndices.join(",")}]`)
|
||||||
|
|
||||||
|
// 5.2 记录经历区域范围(用于阶段B排除)
|
||||||
|
// 范围:从该经历大标题到下一个大标题
|
||||||
|
const titleEl = result.titleElement
|
||||||
|
// 找下一个大标题作为范围结束
|
||||||
|
const allTitles = expandedResults.filter((r) => r.titleElement)
|
||||||
|
const currentIdx = allTitles.findIndex((r) => r.titleElement === titleEl)
|
||||||
|
const nextResult = currentIdx >= 0 && currentIdx < allTitles.length - 1 ? allTitles[currentIdx + 1] : null
|
||||||
|
excludeRanges.push({ start: titleEl, end: nextResult?.titleElement || null })
|
||||||
|
|
||||||
|
// 5.3 逐段匹配并填写
|
||||||
|
const segments = result.segmentRanges
|
||||||
|
const fillCount = Math.min(sortedIndices.length, segments.length)
|
||||||
|
|
||||||
|
for (let segIdx = 0; segIdx < fillCount; segIdx++) {
|
||||||
|
const dataIdx = sortedIndices[segIdx] // 排序后对应的简历数据索引
|
||||||
|
const segment = segments[segIdx]
|
||||||
|
|
||||||
|
// 确定该段经历的 DOM 搜索范围
|
||||||
|
// 起始:该段的 startElement(第一个输入框前的标签)
|
||||||
|
const segStartEl = segment.startElement
|
||||||
|
// 结束:下一段的 startElement,或下一个大标题
|
||||||
|
const nextSegment = segIdx < segments.length - 1 ? segments[segIdx + 1] : null
|
||||||
|
const segEndEl = nextSegment?.startElement || nextResult?.titleElement || null
|
||||||
|
|
||||||
|
// 在该段范围内匹配字段
|
||||||
|
const segFields = matchFormFieldsInRange(lang, section, dataIdx, segStartEl, segEndEl, usedInputs, segment.containerElement)
|
||||||
|
console.log(` [${section}] 第${segIdx + 1}段(数据索引${dataIdx})匹配到 ${segFields.length} 个字段`)
|
||||||
|
|
||||||
|
// 逐个填写
|
||||||
|
for (let fIdx = 0; fIdx < segFields.length; fIdx++) {
|
||||||
|
let f = segFields[fIdx]
|
||||||
|
const value = getResumeFieldValue(currentResumeData, f.section, dataIdx, f.resumeField)
|
||||||
|
if (value) f.fillValue = value
|
||||||
|
|
||||||
|
// 【核心】如果 input 已脱离 DOM(React 重新渲染导致),用 locator 重新定位
|
||||||
|
if (f.inputElement && !f.inputElement.isConnected) {
|
||||||
|
console.log(` [重新定位] "${f.labelText}" input 已脱离DOM (isConnected=false)`)
|
||||||
|
|
||||||
|
// 用 locator 重新获取容器
|
||||||
|
let activeContainer: Element | null = null
|
||||||
|
if (segment.locator) {
|
||||||
|
activeContainer = relocateSegmentContainer(segment.locator)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeContainer) {
|
||||||
|
// 在重新定位的容器内,用 placeholder 找到对应的 input
|
||||||
|
// 【注意】排除已有值的 input(说明已被填过,可能是其他段重新渲染后的残留)
|
||||||
|
const freshInputs = activeContainer.querySelectorAll(
|
||||||
|
"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 ph = f.inputElement.getAttribute("placeholder") || ""
|
||||||
|
let found: HTMLInputElement | HTMLTextAreaElement | null = null
|
||||||
|
for (const inp of Array.from(freshInputs)) {
|
||||||
|
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
|
||||||
|
if (usedInputs.has(inp)) continue
|
||||||
|
// 跳过已有值的 input(已被填过)
|
||||||
|
if (inputEl.value && inputEl.value.length > 0) continue
|
||||||
|
if (inp.getAttribute("placeholder") === ph) {
|
||||||
|
found = inputEl
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) {
|
||||||
|
f.inputElement = found
|
||||||
|
usedInputs.add(found)
|
||||||
|
console.log(` [重新定位] "${f.labelText}" ✅ 已通过 locator 重新定位 (placeholder="${ph}")`)
|
||||||
|
} else {
|
||||||
|
console.log(` [重新定位] "${f.labelText}" ❌ 容器内未找到 placeholder="${ph}" 的空 input`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` [重新定位] "${f.labelText}" ❌ locator 重新定位容器失败`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 【补充】如果 labelElement 也脱离了 DOM(React 重新渲染导致),重新定位
|
||||||
|
// 这对于没有 input 的纯 div 选择器(如 Ant Design Select)尤其重要
|
||||||
|
// 因为 fillMatchedField 的 picker 无 input 逻辑需要点击 labelElement
|
||||||
|
if (f.labelElement && !f.labelElement.isConnected && segment.locator) {
|
||||||
|
const activeContainer = relocateSegmentContainer(segment.locator)
|
||||||
|
if (activeContainer) {
|
||||||
|
// 在容器内用标签文字重新找到对应的标签元素
|
||||||
|
const allLabels = activeContainer.querySelectorAll("label, span, div, td, th, p, legend, dt")
|
||||||
|
for (const el of Array.from(allLabels)) {
|
||||||
|
const directText = Array.from(el.childNodes)
|
||||||
|
.filter((n) => n.nodeType === Node.TEXT_NODE)
|
||||||
|
.map((n) => n.textContent?.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("")
|
||||||
|
if (directText && directText.includes(f.labelText) && directText.length < f.labelText.length + 20) {
|
||||||
|
f.labelElement = el
|
||||||
|
console.log(` [重新定位] "${f.labelText}" labelElement ✅ 已重新定位`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间段字段不需要 fillValue(它直接从简历数据取 startDate 和 endDate)
|
||||||
|
if (isTimePeriodField(f.key)) {
|
||||||
|
const startDateVal = getResumeFieldValue(currentResumeData, f.section, dataIdx, "startDate")
|
||||||
|
const endDateVal = getResumeFieldValue(currentResumeData, f.section, dataIdx, "endDate")
|
||||||
|
let ok = await fillTimePeriodField(f, startDateVal, endDateVal, usedInputs)
|
||||||
|
if (!ok) {
|
||||||
|
// fallback 到集成式时间选择器
|
||||||
|
f.fillValue = startDateVal // 用开始时间作为 fillValue
|
||||||
|
await detectPickerField(f, lang)
|
||||||
|
ok = await fillMatchedField(f)
|
||||||
|
}
|
||||||
|
if (ok) { success++ } else { failed++ }
|
||||||
|
} else if (isTimeSingleField(f.key)) {
|
||||||
|
if (!f.fillValue) { skipped++; continue }
|
||||||
|
let ok = await fillTimeSingleField(f, f.fillValue, usedInputs)
|
||||||
|
if (!ok) {
|
||||||
|
await detectPickerField(f, lang)
|
||||||
|
ok = await fillMatchedField(f)
|
||||||
|
}
|
||||||
|
if (ok) { success++ } else { failed++ }
|
||||||
|
} else if (!f.fillValue) {
|
||||||
|
skipped++; continue
|
||||||
|
} else if (isSearchPickerField(f.key)) {
|
||||||
|
const ok = await fillSearchPickerField(f)
|
||||||
|
if (ok) { success++ } else { failed++ }
|
||||||
|
// 搜索选择器内部已处理关闭弹窗,跳过外部关闭逻辑
|
||||||
|
await delay(200)
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
await detectPickerField(f, lang)
|
||||||
|
const ok = await fillMatchedField(f)
|
||||||
|
if (ok) { success++ } else { failed++ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭残留弹窗(只点击纯文本输入框,不点击选择器类型的 input)
|
||||||
|
if (lastTextInput && !lastTextInputIsPicker) {
|
||||||
|
;(lastTextInput as HTMLElement).click()
|
||||||
|
lastTextInput.focus()
|
||||||
|
await delay(100)
|
||||||
|
lastTextInput.blur()
|
||||||
|
} else {
|
||||||
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
|
document.activeElement.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true })
|
||||||
|
)
|
||||||
|
document.activeElement.blur()
|
||||||
|
}
|
||||||
|
document.body.click()
|
||||||
|
}
|
||||||
|
await delay(300)
|
||||||
|
|
||||||
|
if (!f.isPicker && f.inputElement && !isTimePeriodField(f.key) && !isTimeSingleField(f.key)) {
|
||||||
|
lastTextInput = f.inputElement
|
||||||
|
lastTextInputIsPicker = false
|
||||||
|
} else {
|
||||||
|
lastTextInputIsPicker = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
console.log(`===== OfferPie: 阶段A完成 成功${success} 失败${failed} 跳过${skipped} =====`)
|
||||||
|
|
||||||
|
// 6. 非经历区域字段匹配与填写(阶段B)
|
||||||
|
console.log("===== OfferPie: 阶段B - 非经历区域填写 =====")
|
||||||
|
const mainFields = matchMainFields(document.body, lang, excludeRanges, usedInputs)
|
||||||
|
console.log(` 匹配到 ${mainFields.length} 个非经历字段`)
|
||||||
|
|
||||||
|
for (const f of mainFields) {
|
||||||
|
const value = getResumeFieldValue(currentResumeData, f.section, f.sectionIndex, f.resumeField)
|
||||||
|
if (value) f.fillValue = value
|
||||||
if (!f.fillValue) { skipped++; continue }
|
if (!f.fillValue) { skipped++; continue }
|
||||||
|
|
||||||
// 检测选择器类型(会点击展开再关闭)
|
|
||||||
await detectPickerField(f, lang)
|
await detectPickerField(f, lang)
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
` [${f.key}] "${f.labelText}" → input: ${f.inputSelector || "未找到"}` +
|
` [${f.key}] "${f.labelText}" → type: ${f.inputType}` +
|
||||||
` | type: ${f.inputType}` +
|
|
||||||
` | isPicker: ${f.isPicker}` +
|
` | isPicker: ${f.isPicker}` +
|
||||||
` | pickerSelector: ${f.pickerDropdownSelector || "无"}` +
|
|
||||||
` | fillValue: "${f.fillValue}"`
|
` | fillValue: "${f.fillValue}"`
|
||||||
)
|
)
|
||||||
|
|
||||||
// 立即填写这个字段
|
|
||||||
const ok = await fillMatchedField(f)
|
const ok = await fillMatchedField(f)
|
||||||
if (ok) { success++ } else { failed++ }
|
if (ok) { success++ } else { failed++ }
|
||||||
|
|
||||||
// 每个字段填完后,点击上一个非 picker 输入框来关闭残留弹窗
|
|
||||||
if (lastTextInput) {
|
if (lastTextInput) {
|
||||||
;(lastTextInput as HTMLElement).click()
|
;(lastTextInput as HTMLElement).click()
|
||||||
lastTextInput.focus()
|
lastTextInput.focus()
|
||||||
await delay(100)
|
await delay(100)
|
||||||
lastTextInput.blur()
|
lastTextInput.blur()
|
||||||
} else {
|
} else {
|
||||||
// 兜底:只按 Escape 关闭弹窗,不点击任何元素(避免误触链接导致页面跳转)
|
|
||||||
if (document.activeElement instanceof HTMLElement) {
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
document.activeElement.dispatchEvent(
|
document.activeElement.dispatchEvent(
|
||||||
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true })
|
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true })
|
||||||
@@ -128,14 +289,11 @@ export function SidebarPanel({ onClose }: SidebarPanelProps) {
|
|||||||
}
|
}
|
||||||
await delay(300)
|
await delay(300)
|
||||||
|
|
||||||
// 更新上一个非 picker 输入框的记录
|
if (!f.isPicker && f.inputElement) lastTextInput = f.inputElement
|
||||||
if (!f.isPicker && f.inputElement) {
|
|
||||||
lastTextInput = f.inputElement
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormFields(fields)
|
setFormFields([...mainFields])
|
||||||
console.log(`===== OfferPie: 填写结果 成功${success} 失败${failed} 跳过${skipped} =====`)
|
console.log(`===== OfferPie: 阶段B完成 总计成功${success} 失败${failed} 跳过${skipped} =====`)
|
||||||
} else {
|
} else {
|
||||||
setFormFields([])
|
setFormFields([])
|
||||||
console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====")
|
console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====")
|
||||||
@@ -151,7 +309,7 @@ export function SidebarPanel({ onClose }: SidebarPanelProps) {
|
|||||||
<div className="op-container">
|
<div className="op-container">
|
||||||
{/* 顶部操作栏:反馈、设置、关闭按钮 */}
|
{/* 顶部操作栏:反馈、设置、关闭按钮 */}
|
||||||
<div className="op-header">
|
<div className="op-header">
|
||||||
<span className="op-header-link">反馈5</span>
|
<span className="op-header-link">反馈123456789</span>
|
||||||
<span className="op-header-link">设置</span>
|
<span className="op-header-link">设置</span>
|
||||||
{/* 关闭按钮:点击后隐藏侧边栏 */}
|
{/* 关闭按钮:点击后隐藏侧边栏 */}
|
||||||
<button className="op-close-btn" onClick={onClose}>
|
<button className="op-close-btn" onClick={onClose}>
|
||||||
|
|||||||
+457
-3
@@ -17,15 +17,33 @@ export function delay(ms: number) {
|
|||||||
/**
|
/**
|
||||||
* 通用的值写入方法(适配各种前端框架的受控组件)
|
* 通用的值写入方法(适配各种前端框架的受控组件)
|
||||||
* 优先用 execCommand 模拟真实输入,失败则 fallback 到原生 setter
|
* 优先用 execCommand 模拟真实输入,失败则 fallback 到原生 setter
|
||||||
|
* 【改进】确保元素在视口内且真正获得焦点后再写入
|
||||||
*/
|
*/
|
||||||
export function forceSetValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
export function forceSetValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
||||||
if (el instanceof HTMLInputElement && el.type === "file") {
|
if (el instanceof HTMLInputElement && el.type === "file") {
|
||||||
console.warn("OfferPie: 跳过 file 类型 input")
|
console.warn("OfferPie: 跳过 file 类型 input")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 确保元素在视口内(scrollIntoView)
|
||||||
|
if (typeof el.scrollIntoView === "function") {
|
||||||
|
el.scrollIntoView({ block: "center", behavior: "instant" })
|
||||||
|
}
|
||||||
el.focus()
|
el.focus()
|
||||||
|
// 确保 focus 生效
|
||||||
|
el.dispatchEvent(new FocusEvent("focus", { bubbles: true }))
|
||||||
|
el.dispatchEvent(new FocusEvent("focusin", { bubbles: true }))
|
||||||
if (el.value) el.select()
|
if (el.value) el.select()
|
||||||
|
|
||||||
|
// 调试:记录写入前状态
|
||||||
|
const isConnected = el.isConnected
|
||||||
|
const isActiveEl = document.activeElement === el
|
||||||
|
console.log(`OfferPie: [forceSetValue] 写入前: isConnected=${isConnected} | isActiveElement=${isActiveEl} | placeholder="${el.getAttribute("placeholder")}" | 当前value="${el.value}"`)
|
||||||
|
|
||||||
const inserted = document.execCommand("insertText", false, value)
|
const inserted = document.execCommand("insertText", false, value)
|
||||||
|
|
||||||
|
// 调试:记录 execCommand 结果
|
||||||
|
console.log(`OfferPie: [forceSetValue] execCommand结果: inserted=${inserted} | 写入后value="${el.value}" | 目标value="${value}"`)
|
||||||
|
|
||||||
if (!inserted || el.value !== value) {
|
if (!inserted || el.value !== value) {
|
||||||
const isTextarea = el instanceof HTMLTextAreaElement
|
const isTextarea = el instanceof HTMLTextAreaElement
|
||||||
const setter = Object.getOwnPropertyDescriptor(
|
const setter = Object.getOwnPropertyDescriptor(
|
||||||
@@ -34,8 +52,8 @@ export function forceSetValue(el: HTMLInputElement | HTMLTextAreaElement, value:
|
|||||||
setter?.call(el, value)
|
setter?.call(el, value)
|
||||||
el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, inputType: "insertText", data: value }))
|
el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, inputType: "insertText", data: value }))
|
||||||
el.dispatchEvent(new Event("change", { bubbles: true }))
|
el.dispatchEvent(new Event("change", { bubbles: true }))
|
||||||
|
console.log(`OfferPie: [forceSetValue] fallback setter后value="${el.value}"`)
|
||||||
}
|
}
|
||||||
el.dispatchEvent(new Event("blur", { bubbles: true }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 关闭弹出层 */
|
/** 关闭弹出层 */
|
||||||
@@ -57,7 +75,19 @@ export async function fillMatchedField(field: MatchedFormField): Promise<boolean
|
|||||||
if (!fillValue) { console.warn(`OfferPie: ⏭ "${labelText}" 无填写值,跳过`); return false }
|
if (!fillValue) { console.warn(`OfferPie: ⏭ "${labelText}" 无填写值,跳过`); return false }
|
||||||
try {
|
try {
|
||||||
if (inputType === "radio" && radioContainer) return fillRadioField(radioContainer, labelText, fillValue)
|
if (inputType === "radio" && radioContainer) return fillRadioField(radioContainer, labelText, fillValue)
|
||||||
if (!inputElement) { console.warn(`OfferPie: ❌ "${labelText}" 未找到 input 元素,跳过`); return false }
|
if (!inputElement) {
|
||||||
|
// TODO: 【待完善】对于没有 input 的纯 div 选择器(如 Ant Design Select),
|
||||||
|
// 需要找到正确的点击方式触发下拉弹出层。
|
||||||
|
// 当前问题:Ant Design Select 的 .click() 和 mousedown 事件都无法触发展开,
|
||||||
|
// 可能需要模拟更完整的用户交互序列或找到组件内部的触发入口。
|
||||||
|
// 暂时跳过,后续完善。
|
||||||
|
if (isPicker && field.labelElement) {
|
||||||
|
console.log(`OfferPie: ⏭ [picker无input] "${labelText}" 暂不支持纯 div 选择器,跳过 (TODO)`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
console.warn(`OfferPie: ❌ "${labelText}" 未找到 input 元素,跳过`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (inputElement instanceof HTMLInputElement && inputElement.type === "file") { console.warn(`OfferPie: ⏭ "${labelText}" 是文件上传框,跳过`); return false }
|
if (inputElement instanceof HTMLInputElement && inputElement.type === "file") { console.warn(`OfferPie: ⏭ "${labelText}" 是文件上传框,跳过`); return false }
|
||||||
if (isPicker) return await fillPickerField(field)
|
if (isPicker) return await fillPickerField(field)
|
||||||
inputElement.focus()
|
inputElement.focus()
|
||||||
@@ -109,7 +139,7 @@ function fillRadioField(container: Element, labelText: string, fillValue: string
|
|||||||
async function fillPickerField(field: MatchedFormField): Promise<boolean> {
|
async function fillPickerField(field: MatchedFormField): Promise<boolean> {
|
||||||
const { labelText, inputElement, fillValue } = field
|
const { labelText, inputElement, fillValue } = field
|
||||||
if (!inputElement) return false
|
if (!inputElement) return false
|
||||||
const isDateValue = /^\d{4}-\d{2}-\d{2}$/.test(fillValue)
|
const isDateValue = /^\d{4}[.\-\/]\d{1,2}([.\-\/]\d{1,2})?$/.test(fillValue)
|
||||||
|
|
||||||
// ---- 步骤1:记录点击前的 DOM 快照 ----
|
// ---- 步骤1:记录点击前的 DOM 快照 ----
|
||||||
const POPUP_SELECTORS = [
|
const POPUP_SELECTORS = [
|
||||||
@@ -237,3 +267,427 @@ export async function fillAllFields(fields: MatchedFormField[]): Promise<{ total
|
|||||||
console.log(`===== OfferPie: 自动填写完毕 总计 ${result.total} | 成功 ${result.success} | 失败 ${result.failed} | 跳过 ${result.skipped} =====`)
|
console.log(`===== OfferPie: 自动填写完毕 总计 ${result.total} | 成功 ${result.success} | 失败 ${result.failed} | 跳过 ${result.skipped} =====`)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 学校搜索型选择器特殊处理 ============
|
||||||
|
|
||||||
|
/** 需要走搜索型选择器逻辑的字段 key 列表 */
|
||||||
|
const SEARCH_PICKER_KEYS = ["school", "major"]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断字段是否需要走搜索型选择器逻辑
|
||||||
|
*/
|
||||||
|
export function isSearchPickerField(key: string): boolean {
|
||||||
|
return SEARCH_PICKER_KEYS.includes(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 填写搜索型选择器字段(如学校名称)
|
||||||
|
*
|
||||||
|
* 【改进】先检测是否真的有搜索下拉行为:
|
||||||
|
* 1. 先用 forceSetValue 输入值
|
||||||
|
* 2. 等待短暂时间后检测是否有弹出层出现
|
||||||
|
* 3. 如果有弹出层 → 走搜索选择器逻辑(在弹出层中找匹配选项)
|
||||||
|
* 4. 如果没有弹出层 → 说明是普通输入框,值已经写入,直接返回成功
|
||||||
|
*/
|
||||||
|
export async function fillSearchPickerField(field: MatchedFormField): Promise<boolean> {
|
||||||
|
const { labelText, inputElement, fillValue } = field
|
||||||
|
if (!inputElement || !fillValue) return false
|
||||||
|
|
||||||
|
console.log(`OfferPie: [搜索选择器] "${labelText}" 开始填写 "${fillValue}"`)
|
||||||
|
|
||||||
|
// 1. 记录当前可见弹出层快照
|
||||||
|
const POPUP_SELECTORS = [
|
||||||
|
'[class*="dropdown"]', '[class*="popup"]', '[class*="popper"]',
|
||||||
|
'[class*="overlay"]', '[class*="popover"]', '[class*="select-dropdown"]',
|
||||||
|
'[role="listbox"]', '[role="menu"]',
|
||||||
|
]
|
||||||
|
const beforeBodyChildren = new Set(Array.from(document.body.children))
|
||||||
|
const beforeVisiblePopups = new Set<Element>()
|
||||||
|
for (const sel of POPUP_SELECTORS) {
|
||||||
|
document.querySelectorAll(sel).forEach((el) => {
|
||||||
|
if ((el as HTMLElement).offsetHeight > 0 && (el as HTMLElement).offsetWidth > 0) beforeVisiblePopups.add(el)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 点击 input 获取焦点并输入值
|
||||||
|
inputElement.focus()
|
||||||
|
;(inputElement as HTMLElement).click()
|
||||||
|
await delay(200)
|
||||||
|
forceSetValue(inputElement, fillValue)
|
||||||
|
await delay(800)
|
||||||
|
|
||||||
|
// 3. 检测是否有新的弹出层出现
|
||||||
|
let hasNewPopup = false
|
||||||
|
// 检查 body 下新增的子元素
|
||||||
|
for (const child of Array.from(document.body.children)) {
|
||||||
|
if (!beforeBodyChildren.has(child) && (child as HTMLElement).offsetHeight > 10) {
|
||||||
|
hasNewPopup = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 检查新变为可见的弹出层
|
||||||
|
if (!hasNewPopup) {
|
||||||
|
for (const sel of POPUP_SELECTORS) {
|
||||||
|
for (const el of Array.from(document.querySelectorAll(sel))) {
|
||||||
|
const htmlEl = el as HTMLElement
|
||||||
|
if (htmlEl.offsetHeight > 10 && htmlEl.offsetWidth > 0 && !beforeVisiblePopups.has(el)) {
|
||||||
|
hasNewPopup = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasNewPopup) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 如果没有弹出层 → 普通输入框,值已写入,直接成功
|
||||||
|
if (!hasNewPopup) {
|
||||||
|
// 调试:验证值是否真的写入了
|
||||||
|
console.log(`OfferPie: [搜索选择器→普通输入] "${labelText}" 写入后验证: value="${inputElement.value}" | 期望="${fillValue}"`)
|
||||||
|
console.log(`OfferPie: ✅ [搜索选择器→普通输入] "${labelText}" 无弹出层,已直接填写 "${fillValue}"`)
|
||||||
|
inputElement.dispatchEvent(new Event("blur", { bubbles: true }))
|
||||||
|
// 调试:blur 后再验证
|
||||||
|
await delay(50)
|
||||||
|
console.log(`OfferPie: [搜索选择器→普通输入] "${labelText}" blur后验证: value="${inputElement.value}"`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 有弹出层 → 走搜索选择器逻辑,等待搜索结果
|
||||||
|
await delay(500)
|
||||||
|
|
||||||
|
// 在弹出层中查找匹配选项
|
||||||
|
const { findAndClickOptionInVisiblePopups } = await import("./pickerFill")
|
||||||
|
if (findAndClickOptionInVisiblePopups(fillValue, labelText)) {
|
||||||
|
await delay(300)
|
||||||
|
await closePopup(inputElement)
|
||||||
|
console.log(`OfferPie: ✅ [搜索选择器] 已选择 "${labelText}" = "${fillValue}"`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 再等一会重试(有些网站接口慢)
|
||||||
|
await delay(600)
|
||||||
|
if (findAndClickOptionInVisiblePopups(fillValue, labelText)) {
|
||||||
|
await delay(300)
|
||||||
|
await closePopup(inputElement)
|
||||||
|
console.log(`OfferPie: ✅ [搜索选择器] 已选择 "${labelText}" = "${fillValue}"(重试)`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 兜底:关闭弹出层,保留输入的值
|
||||||
|
await closePopup(inputElement)
|
||||||
|
console.log(`OfferPie: ⚠️ [搜索选择器] "${labelText}" 未找到匹配选项,已兜底保留输入值 "${fillValue}"`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 经历时间选择器特殊处理 ============
|
||||||
|
|
||||||
|
/** 需要走时间选择器特殊逻辑的字段 key 列表(合并式起止时间) */
|
||||||
|
const TIME_PERIOD_KEYS = ["eduPeriod", "internPeriod", "workPeriod", "projectPeriod"]
|
||||||
|
|
||||||
|
/** 需要走时间选择器特殊逻辑的字段 key 列表(单独开始/结束时间) */
|
||||||
|
const TIME_SINGLE_KEYS = ["eduStartDate", "eduEndDate", "internStartDate", "internEndDate", "workStartDate", "workEndDate", "projectStartDate", "projectEndDate"]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断字段是否为时间段字段(合并式起止时间)
|
||||||
|
*/
|
||||||
|
export function isTimePeriodField(key: string): boolean {
|
||||||
|
return TIME_PERIOD_KEYS.includes(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断字段是否为单独时间字段(开始时间或结束时间)
|
||||||
|
*/
|
||||||
|
export function isTimeSingleField(key: string): boolean {
|
||||||
|
return TIME_SINGLE_KEYS.includes(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从日期字符串中提取年和月
|
||||||
|
* 支持格式:2025.09、2025-09、2025-09-01、2025/09
|
||||||
|
*/
|
||||||
|
function extractYearMonth(dateStr: string): { year: string; month: string } {
|
||||||
|
const match = dateStr.match(/(\d{4})[.\-/](\d{1,2})/)
|
||||||
|
if (match) {
|
||||||
|
return { year: match[1], month: String(parseInt(match[2])) }
|
||||||
|
}
|
||||||
|
// 只有年份
|
||||||
|
const yearMatch = dateStr.match(/(\d{4})/)
|
||||||
|
if (yearMatch) {
|
||||||
|
return { year: yearMatch[1], month: "" }
|
||||||
|
}
|
||||||
|
return { year: "", month: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 填写单个年或月的下拉选择器
|
||||||
|
* 逻辑:点击 input 展开下拉 → 等待弹出层出现 → 在弹出层中精确匹配年/月选项 → 点击选中
|
||||||
|
*/
|
||||||
|
async function fillYearMonthPicker(inputEl: HTMLInputElement | HTMLTextAreaElement, value: string): Promise<boolean> {
|
||||||
|
if (!value) return false
|
||||||
|
|
||||||
|
const { clickBestOptionInDropdown } = await import("./pickerFill")
|
||||||
|
|
||||||
|
// 记录点击前的 DOM 快照(用于检测新出现的弹出层)
|
||||||
|
const POPUP_SELECTORS = [
|
||||||
|
'[class*="dropdown"]', '[class*="popup"]', '[class*="popper"]',
|
||||||
|
'[class*="overlay"]', '[class*="popover"]', '[class*="select-dropdown"]',
|
||||||
|
'[role="listbox"]', '[role="menu"]',
|
||||||
|
]
|
||||||
|
const beforeBodyChildren = new Set(Array.from(document.body.children))
|
||||||
|
const beforeVisiblePopups = new Set<Element>()
|
||||||
|
for (const sel of POPUP_SELECTORS) {
|
||||||
|
document.querySelectorAll(sel).forEach((el) => {
|
||||||
|
if ((el as HTMLElement).offsetHeight > 0 && (el as HTMLElement).offsetWidth > 0) beforeVisiblePopups.add(el)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击 input 展开下拉
|
||||||
|
inputEl.focus()
|
||||||
|
;(inputEl as HTMLElement).click()
|
||||||
|
await delay(300)
|
||||||
|
|
||||||
|
// 检测新出现的弹出层
|
||||||
|
let dropdownEl: HTMLElement | null = null
|
||||||
|
|
||||||
|
// 策略A:body 下新增的直接子元素
|
||||||
|
for (const child of Array.from(document.body.children)) {
|
||||||
|
if (!beforeBodyChildren.has(child) && (child as HTMLElement).offsetHeight > 10) {
|
||||||
|
dropdownEl = child as HTMLElement
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 策略B:新变为可见的弹出层
|
||||||
|
if (!dropdownEl) {
|
||||||
|
for (const sel of POPUP_SELECTORS) {
|
||||||
|
for (const el of Array.from(document.querySelectorAll(sel))) {
|
||||||
|
const htmlEl = el as HTMLElement
|
||||||
|
if (htmlEl.offsetHeight > 10 && htmlEl.offsetWidth > 0 && !beforeVisiblePopups.has(el)) {
|
||||||
|
dropdownEl = htmlEl
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dropdownEl) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dropdownEl) {
|
||||||
|
// 在弹出层中精确匹配选项并点击
|
||||||
|
if (clickBestOptionInDropdown(dropdownEl, value)) {
|
||||||
|
await delay(200)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:直接写入值
|
||||||
|
forceSetValue(inputEl, value)
|
||||||
|
await delay(100)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 填写合并式起止时间字段("就读时间"/"起止时间"后面跟4个年月年月输入框)
|
||||||
|
*
|
||||||
|
* 检测逻辑:
|
||||||
|
* 1. 从标签元素往后找所有 placeholder 为"年"或"月"的 input
|
||||||
|
* 2. 如果找到4个(年月年月),按顺序填入开始年、开始月、结束年、结束月
|
||||||
|
* 3. 如果找到2个或更少,走集成式时间选择器逻辑
|
||||||
|
*
|
||||||
|
* @param field - 匹配到的字段(labelElement 是"就读时间"/"起止时间"标签)
|
||||||
|
* @param startDate - 开始时间字符串(如 "2025.09")
|
||||||
|
* @param endDate - 结束时间字符串(如 "2026.12")
|
||||||
|
* @param usedInputs - 已使用的 input 集合
|
||||||
|
*/
|
||||||
|
export async function fillTimePeriodField(
|
||||||
|
field: MatchedFormField,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
usedInputs: Set<Element>
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { labelElement } = field
|
||||||
|
if (!labelElement) return false
|
||||||
|
|
||||||
|
// 从标签元素往后找 placeholder 为"年"或"月"的 input
|
||||||
|
const allInputs = document.body.querySelectorAll("input")
|
||||||
|
const yearMonthInputs: HTMLInputElement[] = []
|
||||||
|
|
||||||
|
for (const input of Array.from(allInputs)) {
|
||||||
|
// 必须在标签元素之后
|
||||||
|
if (!(labelElement.compareDocumentPosition(input) & Node.DOCUMENT_POSITION_FOLLOWING)) continue
|
||||||
|
|
||||||
|
const ph = input.getAttribute("placeholder")?.trim() || ""
|
||||||
|
if (ph === "年" || ph === "月") {
|
||||||
|
yearMonthInputs.push(input)
|
||||||
|
if (yearMonthInputs.length >= 4) break // 最多找4个
|
||||||
|
} else if (yearMonthInputs.length > 0) {
|
||||||
|
// 遇到非年月的 input 就停止(说明已经超出这个时间区域了)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`OfferPie: [时间选择器] "${field.labelText}" 找到 ${yearMonthInputs.length} 个年月输入框`)
|
||||||
|
|
||||||
|
if (yearMonthInputs.length >= 4) {
|
||||||
|
// 情况A1:4个年月年月下拉选择器
|
||||||
|
const start = extractYearMonth(startDate)
|
||||||
|
const end = extractYearMonth(endDate)
|
||||||
|
|
||||||
|
// 依次填入:开始年、开始月、结束年、结束月
|
||||||
|
if (start.year) {
|
||||||
|
await fillYearMonthPicker(yearMonthInputs[0], start.year)
|
||||||
|
usedInputs.add(yearMonthInputs[0])
|
||||||
|
}
|
||||||
|
if (start.month) {
|
||||||
|
await fillYearMonthPicker(yearMonthInputs[1], start.month)
|
||||||
|
usedInputs.add(yearMonthInputs[1])
|
||||||
|
}
|
||||||
|
if (end.year) {
|
||||||
|
await fillYearMonthPicker(yearMonthInputs[2], end.year)
|
||||||
|
usedInputs.add(yearMonthInputs[2])
|
||||||
|
}
|
||||||
|
if (end.month) {
|
||||||
|
await fillYearMonthPicker(yearMonthInputs[3], end.month)
|
||||||
|
usedInputs.add(yearMonthInputs[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
await delay(200)
|
||||||
|
// 点击空白关闭弹窗
|
||||||
|
document.body.click()
|
||||||
|
await delay(100)
|
||||||
|
|
||||||
|
console.log(`OfferPie: ✅ [时间选择器] "${field.labelText}" 已填写 ${start.year}.${start.month} - ${end.year}.${end.month}`)
|
||||||
|
return true
|
||||||
|
} else if (yearMonthInputs.length >= 2) {
|
||||||
|
// 可能是2个年月(只有开始时间的年月),或者其他情况
|
||||||
|
const start = extractYearMonth(startDate)
|
||||||
|
if (start.year) {
|
||||||
|
await fillYearMonthPicker(yearMonthInputs[0], start.year)
|
||||||
|
usedInputs.add(yearMonthInputs[0])
|
||||||
|
}
|
||||||
|
if (start.month) {
|
||||||
|
await fillYearMonthPicker(yearMonthInputs[1], start.month)
|
||||||
|
usedInputs.add(yearMonthInputs[1])
|
||||||
|
}
|
||||||
|
document.body.click()
|
||||||
|
await delay(100)
|
||||||
|
console.log(`OfferPie: ✅ [时间选择器] "${field.labelText}" 已填写 ${start.year}.${start.month}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 情况A2:没有年月输入框,检测是否有两个并排的集成式时间选择器 input
|
||||||
|
// (开始时间 input + 结束时间 input,中间可能有"至"/"到"/"-"等分隔符)
|
||||||
|
// 从已匹配到的 field.inputElement 开始,往后找下一个最近的 input 作为结束时间
|
||||||
|
const startInput = field.inputElement
|
||||||
|
if (startInput && startDate) {
|
||||||
|
console.log(`OfferPie: [时间选择器] "${field.labelText}" 检测到集成式起止时间,尝试填写开始+结束时间`)
|
||||||
|
|
||||||
|
// 填写开始时间(用 field.inputElement)
|
||||||
|
const { fillDatePicker } = await import("./datePicker")
|
||||||
|
const startField: MatchedFormField = {
|
||||||
|
...field,
|
||||||
|
inputElement: startInput,
|
||||||
|
fillValue: startDate,
|
||||||
|
isPicker: true,
|
||||||
|
}
|
||||||
|
startInput.focus()
|
||||||
|
;(startInput as HTMLElement).click()
|
||||||
|
await delay(300)
|
||||||
|
const startOk = await fillDatePicker(startField)
|
||||||
|
if (startOk) {
|
||||||
|
usedInputs.add(startInput)
|
||||||
|
console.log(`OfferPie: [时间选择器] "${field.labelText}" 开始时间填写成功: ${startDate}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await delay(500)
|
||||||
|
|
||||||
|
// 从开始时间 input 往后找最近的下一个 input 作为结束时间
|
||||||
|
if (endDate) {
|
||||||
|
const INPUT_SEL_PERIOD = "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled])"
|
||||||
|
const allPageInputs = document.body.querySelectorAll(INPUT_SEL_PERIOD)
|
||||||
|
let endInput: HTMLInputElement | null = null
|
||||||
|
|
||||||
|
for (const inp of Array.from(allPageInputs)) {
|
||||||
|
// 必须在开始时间 input 之后
|
||||||
|
if (!(startInput.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)) continue
|
||||||
|
if (usedInputs.has(inp)) continue
|
||||||
|
endInput = inp as HTMLInputElement
|
||||||
|
break // 取最近的第一个
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endInput) {
|
||||||
|
const endField: MatchedFormField = {
|
||||||
|
...field,
|
||||||
|
inputElement: endInput,
|
||||||
|
fillValue: endDate,
|
||||||
|
isPicker: true,
|
||||||
|
}
|
||||||
|
endInput.focus()
|
||||||
|
;(endInput as HTMLElement).click()
|
||||||
|
await delay(300)
|
||||||
|
const endOk = await fillDatePicker(endField)
|
||||||
|
if (endOk) {
|
||||||
|
usedInputs.add(endInput)
|
||||||
|
console.log(`OfferPie: [时间选择器] "${field.labelText}" 结束时间填写成功: ${endDate}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`OfferPie: [时间选择器] "${field.labelText}" 未找到结束时间 input`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`OfferPie: ✅ [时间选择器] "${field.labelText}" 已填写 ${startDate} - ${endDate}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 情况A3:没有 inputElement,走集成式时间选择器逻辑(由外部 detectPickerField + fillMatchedField 处理)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 填写单独时间字段("开始时间"或"结束时间"后面跟年月输入框或集成式选择器)
|
||||||
|
*
|
||||||
|
* @param field - 匹配到的字段
|
||||||
|
* @param dateStr - 时间字符串(如 "2025.09")
|
||||||
|
* @param usedInputs - 已使用的 input 集合
|
||||||
|
*/
|
||||||
|
export async function fillTimeSingleField(
|
||||||
|
field: MatchedFormField,
|
||||||
|
dateStr: string,
|
||||||
|
usedInputs: Set<Element>
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { labelElement } = field
|
||||||
|
if (!labelElement || !dateStr) return false
|
||||||
|
|
||||||
|
// 从标签元素往后找 placeholder 为"年"或"月"的 input
|
||||||
|
const allInputs = document.body.querySelectorAll("input")
|
||||||
|
const yearMonthInputs: HTMLInputElement[] = []
|
||||||
|
|
||||||
|
for (const input of Array.from(allInputs)) {
|
||||||
|
if (!(labelElement.compareDocumentPosition(input) & Node.DOCUMENT_POSITION_FOLLOWING)) continue
|
||||||
|
|
||||||
|
const ph = input.getAttribute("placeholder")?.trim() || ""
|
||||||
|
if (ph === "年" || ph === "月") {
|
||||||
|
yearMonthInputs.push(input)
|
||||||
|
if (yearMonthInputs.length >= 2) break
|
||||||
|
} else if (yearMonthInputs.length > 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yearMonthInputs.length >= 2) {
|
||||||
|
// 情况B1:年月分开的下拉选择器
|
||||||
|
const { year, month } = extractYearMonth(dateStr)
|
||||||
|
if (year) {
|
||||||
|
await fillYearMonthPicker(yearMonthInputs[0], year)
|
||||||
|
usedInputs.add(yearMonthInputs[0])
|
||||||
|
}
|
||||||
|
if (month) {
|
||||||
|
await fillYearMonthPicker(yearMonthInputs[1], month)
|
||||||
|
usedInputs.add(yearMonthInputs[1])
|
||||||
|
}
|
||||||
|
document.body.click()
|
||||||
|
await delay(100)
|
||||||
|
console.log(`OfferPie: ✅ [时间选择器] "${field.labelText}" 已填写 ${year}.${month}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 情况B2:没有年月输入框,走集成式时间选择器逻辑(由外部处理)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
+33
-29
@@ -46,6 +46,7 @@ export const JOB_FORM_LABELS: FormLabelItem[] = [
|
|||||||
{ key: "degree", zh: ["学历", "学位", "最高学历", "最高学位"], en: ["Degree", "Education Level", "Qualification", "Education"], section: "education", resumeField: "degree" },
|
{ key: "degree", zh: ["学历", "学位", "最高学历", "最高学位"], en: ["Degree", "Education Level", "Qualification", "Education"], section: "education", resumeField: "degree" },
|
||||||
{ key: "studyType", zh: ["学习形式", "就读方式", "全日制/非全日制", "学习方式"], en: ["Study Type", "Study Mode", "Full-time/Part-time"], section: "education", resumeField: "studyType" },
|
{ key: "studyType", zh: ["学习形式", "就读方式", "全日制/非全日制", "学习方式"], en: ["Study Type", "Study Mode", "Full-time/Part-time"], section: "education", resumeField: "studyType" },
|
||||||
{ key: "graduationDate", zh: ["毕业时间", "毕业日期", "毕业年份"], en: ["Graduation Date", "Graduation Year", "Expected Graduation"], section: "education", resumeField: "endDate" },
|
{ key: "graduationDate", zh: ["毕业时间", "毕业日期", "毕业年份"], en: ["Graduation Date", "Graduation Year", "Expected Graduation"], section: "education", resumeField: "endDate" },
|
||||||
|
{ key: "eduPeriod", zh: ["就读时间", "就读期间", "起止时间"], en: ["Study Period", "Duration", "Period"], section: "education", resumeField: "startDate,endDate" },
|
||||||
{ key: "eduStartDate", zh: ["入学时间", "开始时间"], en: ["Start Date", "Enrollment Date", "From"], section: "education", resumeField: "startDate" },
|
{ key: "eduStartDate", zh: ["入学时间", "开始时间"], en: ["Start Date", "Enrollment Date", "From"], section: "education", resumeField: "startDate" },
|
||||||
{ key: "eduEndDate", zh: ["结束时间", "毕业时间"], en: ["End Date", "To", "Until"], section: "education", resumeField: "endDate" },
|
{ key: "eduEndDate", zh: ["结束时间", "毕业时间"], en: ["End Date", "To", "Until"], section: "education", resumeField: "endDate" },
|
||||||
{ key: "eduDescription", zh: ["在校经历", "教育描述", "在校描述"], en: ["Education Description", "Academic Description"], section: "education", resumeField: "description" },
|
{ key: "eduDescription", zh: ["在校经历", "教育描述", "在校描述"], en: ["Education Description", "Academic Description"], section: "education", resumeField: "description" },
|
||||||
@@ -64,21 +65,24 @@ export const JOB_FORM_LABELS: FormLabelItem[] = [
|
|||||||
{ key: "workCompanyShort", zh: ["公司简称"], en: ["Company Abbreviation", "Short Name"], section: "work", resumeField: "" },
|
{ key: "workCompanyShort", zh: ["公司简称"], en: ["Company Abbreviation", "Short Name"], section: "work", resumeField: "" },
|
||||||
{ key: "workCompanyScale", zh: ["公司规模"], en: ["Company Size", "Company Scale"], section: "work", resumeField: "" },
|
{ key: "workCompanyScale", zh: ["公司规模"], en: ["Company Size", "Company Scale"], section: "work", resumeField: "" },
|
||||||
{ key: "workPosition", zh: ["职位", "职位名称", "岗位", "担任职务", "职位名称"], en: ["Job Title", "Position", "Title", "Role"], section: "work", resumeField: "position" },
|
{ key: "workPosition", zh: ["职位", "职位名称", "岗位", "担任职务", "职位名称"], en: ["Job Title", "Position", "Title", "Role"], section: "work", resumeField: "position" },
|
||||||
|
{ key: "workPeriod", zh: ["起止时间", "在职时间", "就职时间"], en: ["Period", "Duration", "Employment Period"], section: "work", resumeField: "startDate,endDate" },
|
||||||
{ key: "workStartDate", zh: ["开始时间", "入职时间"], en: ["Start Date", "From"], section: "work", resumeField: "startDate" },
|
{ key: "workStartDate", zh: ["开始时间", "入职时间"], en: ["Start Date", "From"], section: "work", resumeField: "startDate" },
|
||||||
{ key: "workEndDate", zh: ["结束时间", "离职时间"], en: ["End Date", "To"], section: "work", resumeField: "endDate" },
|
{ key: "workEndDate", zh: ["结束时间", "离职时间"], en: ["End Date", "To"], section: "work", resumeField: "endDate" },
|
||||||
{ key: "workDescription", zh: ["工作描述", "工作内容", "职责描述", "岗位职责", "职位描述"], en: ["Job Description", "Responsibilities", "Description", "Duties"], section: "work", resumeField: "description" },
|
{ key: "workDescription", zh: ["工作描述", "工作内容", "职责描述", "岗位职责", "职位描述"], en: ["Job Description", "Responsibilities", "Description", "Duties"], section: "work", resumeField: "description" },
|
||||||
// ---- 实习经历(数组) ----
|
// ---- 实习经历(数组) ----
|
||||||
{ key: "internCompany", zh: ["实习公司", "实习单位"], en: ["Intern Company", "Internship Company"], section: "internship", resumeField: "companyName" },
|
{ key: "internCompany", zh: ["实习公司", "实习单位"], en: ["Intern Company", "Internship Company"], section: "internship", resumeField: "companyName" },
|
||||||
{ key: "internPosition", zh: ["实习职位", "实习岗位"], en: ["Intern Position", "Internship Role"], section: "internship", resumeField: "position" },
|
{ key: "internPosition", zh: ["实习职位", "实习岗位"], en: ["Intern Position", "Internship Role"], section: "internship", resumeField: "position" },
|
||||||
{ key: "internStartDate", zh: ["实习开始时间"], en: ["Internship Start Date"], section: "internship", resumeField: "startDate" },
|
{ key: "internPeriod", zh: ["起止时间", "实习时间", "就职时间"], en: ["Period", "Duration", "Employment Period"], section: "internship", resumeField: "startDate,endDate" },
|
||||||
{ key: "internEndDate", zh: ["实习结束时间"], en: ["Internship End Date"], section: "internship", resumeField: "endDate" },
|
{ key: "internStartDate", zh: ["实习开始时间", "开始时间"], en: ["Internship Start Date", "Start Date", "From"], section: "internship", resumeField: "startDate" },
|
||||||
{ key: "internDescription", zh: ["实习描述", "实习内容"], en: ["Internship Description", "Intern Duties"], section: "internship", resumeField: "description" },
|
{ key: "internEndDate", zh: ["实习结束时间", "结束时间"], en: ["Internship End Date", "End Date", "To"], section: "internship", resumeField: "endDate" },
|
||||||
|
{ key: "internDescription", zh: ["实习描述", "实习内容","工作描述", "工作职责", "工作内容", "职责描述", "岗位职责", "职位描述"], en: ["Internship Description", "Intern Duties"], section: "internship", resumeField: "description" },
|
||||||
// ---- 项目经历(数组) ----
|
// ---- 项目经历(数组) ----
|
||||||
{ key: "projectName", zh: ["项目名称", "项目名", "项目", "项目名称"], en: ["Project Name", "Project", "Project Title"], section: "project", resumeField: "projectName" },
|
{ key: "projectName", zh: ["项目名称", "项目名", "项目", "项目名称"], en: ["Project Name", "Project", "Project Title"], section: "project", resumeField: "projectName" },
|
||||||
{ key: "projectCompany", zh: ["所属公司", "项目所属公司", "项目公司"], en: ["Project Company", "Company"], section: "project", resumeField: "companyName" },
|
{ key: "projectCompany", zh: ["所属公司", "项目所属公司", "项目公司"], en: ["Project Company", "Company"], section: "project", resumeField: "companyName" },
|
||||||
{ key: "projectRole", zh: ["担任角色", "项目角色", "角色"], en: ["Role", "Project Role", "Your Role"], section: "project", resumeField: "role" },
|
{ key: "projectRole", zh: ["担任角色", "项目角色", "角色"], en: ["Role", "Project Role", "Your Role"], section: "project", resumeField: "role" },
|
||||||
{ key: "projectStartDate", zh: ["项目开始时间"], en: ["Project Start Date"], section: "project", resumeField: "startDate" },
|
{ key: "projectPeriod", zh: ["起止时间", "项目时间"], en: ["Period", "Duration", "Project Period"], section: "project", resumeField: "startDate,endDate" },
|
||||||
{ key: "projectEndDate", zh: ["项目结束时间"], en: ["Project End Date"], section: "project", resumeField: "endDate" },
|
{ key: "projectStartDate", zh: ["项目开始时间", "开始时间"], en: ["Project Start Date", "Start Date", "From"], section: "project", resumeField: "startDate" },
|
||||||
|
{ key: "projectEndDate", zh: ["项目结束时间", "结束时间"], en: ["Project End Date", "End Date", "To"], section: "project", resumeField: "endDate" },
|
||||||
{ key: "projectDescription", zh: ["项目描述", "项目内容", "项目职责"], en: ["Project Description", "Project Details", "Project Responsibilities"], section: "project", resumeField: "description" },
|
{ key: "projectDescription", zh: ["项目描述", "项目内容", "项目职责"], en: ["Project Description", "Project Details", "Project Responsibilities"], section: "project", resumeField: "description" },
|
||||||
// ---- 竞赛经历(数组) ----
|
// ---- 竞赛经历(数组) ----
|
||||||
{ key: "competitionName", zh: ["竞赛名称", "比赛名称", "竞赛"], en: ["Competition Name", "Contest Name", "Competition"], section: "competition", resumeField: "competitionName" },
|
{ key: "competitionName", zh: ["竞赛名称", "比赛名称", "竞赛"], en: ["Competition Name", "Contest Name", "Competition"], section: "competition", resumeField: "competitionName" },
|
||||||
@@ -316,8 +320,8 @@ export function getMockResumeData(): ResumeData {
|
|||||||
major: "人工智能",
|
major: "人工智能",
|
||||||
degree: "本科",
|
degree: "本科",
|
||||||
studyType: "统招",
|
studyType: "统招",
|
||||||
startDate: "2021.09",
|
startDate: "2017.09",
|
||||||
endDate: "2025.07",
|
endDate: "2021.07",
|
||||||
description: [
|
description: [
|
||||||
{ type: "text", content: "大一:高等数学、Python程序设计、面向对象程序设计、计算机体系结构、线性代数、离散数学等,培养严谨的逻辑思维能力" },
|
{ type: "text", content: "大一:高等数学、Python程序设计、面向对象程序设计、计算机体系结构、线性代数、离散数学等,培养严谨的逻辑思维能力" },
|
||||||
{ type: "text", content: "大二:Java、软件工程导论、概率论与数理统计、数据结构与算法、Web开发、数据库原理、操作系统等,具备数据管理与分析基础" },
|
{ type: "text", content: "大二:Java、软件工程导论、概率论与数理统计、数据结构与算法、Web开发、数据库原理、操作系统等,具备数据管理与分析基础" },
|
||||||
@@ -330,48 +334,48 @@ export function getMockResumeData(): ResumeData {
|
|||||||
internship: [
|
internship: [
|
||||||
{
|
{
|
||||||
id: "EoNkjAF1",
|
id: "EoNkjAF1",
|
||||||
companyName: "深圳乐动机器人科技有限公司",
|
companyName: "深圳乐动机器人科技有限公司4",
|
||||||
position: "机器人感知算法工程师",
|
position: "机器人感知算法工程师",
|
||||||
startDate: "2024.05",
|
startDate: "2024.05",
|
||||||
endDate: "2024.08",
|
endDate: "2025.08",
|
||||||
description: [
|
description: [
|
||||||
{ type: "text", content: "负责数据收集、分析、模型训练与测试、算法部署等全流程工作,注重数据准确性与细节管理;" },
|
{ type: "text", content: "4负责数据收集、分析、模型训练与测试、算法部署等全流程工作,注重数据准确性与细节管理;" },
|
||||||
{ type: "text", content: "参与割草机算法研发工作,包括分类、检测、分割、跟踪等模块,具备快速学习新技术领域的能力;" },
|
{ type: "text", content: "参与割草机算法研发工作,包括分类、检测、分割、跟踪等模块,具备快速学习新技术领域的能力;" },
|
||||||
{ type: "text", content: "撰写技术文档与测试报告,进行跨部门沟通协作,确保项目按时交付并符合质量标准。" },
|
{ type: "text", content: "撰写技术文档与测试报告,进行跨部门沟通协作,确保项目按时交付并符合质量标准。" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cX5EGRDD",
|
id: "cX5EGRDD",
|
||||||
companyName: "江西卓云深圳研发中心",
|
companyName: "江西卓云深圳研发中心2",
|
||||||
position: "图像算法工程师",
|
position: "图像算法工程师",
|
||||||
startDate: "2023.12",
|
startDate: "2020.12",
|
||||||
endDate: "2024.02",
|
endDate: "2021.02",
|
||||||
description: [
|
description: [
|
||||||
{ type: "text", content: "运用Python进行数据处理与分析,开发部署应用程序,具备较强的逻辑分析和数据整理能力;" },
|
{ type: "text", content: "2运用Python进行数据处理与分析,开发部署应用程序,具备较强的逻辑分析和数据整理能力;" },
|
||||||
{ type: "text", content: "独立完成项目全生命周期管理,包括数据收集、标注、模型训练、部署及性能调优,能应对重复性工作并保证准确性;" },
|
{ type: "text", content: "独立完成项目全生命周期管理,包括数据收集、标注、模型训练、部署及性能调优,能应对重复性工作并保证准确性;" },
|
||||||
{ type: "text", content: "熟练掌握Office办公软件及数据库工具,具备文献检索与信息整理经验,工作态度认真负责。" },
|
{ type: "text", content: "熟练掌握Office办公软件及数据库工具,具备文献检索与信息整理经验,工作态度认真负责。" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "EoNkjAF2",
|
id: "EoNkjAF2",
|
||||||
companyName: "深圳乐动机器人科技有限公司2",
|
companyName: "深圳乐动机器人科技有限公司3",
|
||||||
position: "机器人感知算法工程师",
|
position: "机器人感知算法工程师",
|
||||||
startDate: "2024.05",
|
startDate: "2022.05",
|
||||||
endDate: "2024.08",
|
endDate: "2023.08",
|
||||||
description: [
|
description: [
|
||||||
{ type: "text", content: "负责数据收集、分析、模型训练与测试、算法部署等全流程工作,注重数据准确性与细节管理;" },
|
{ type: "text", content: "3负责数据收集、分析、模型训练与测试、算法部署等全流程工作,注重数据准确性与细节管理;" },
|
||||||
{ type: "text", content: "参与割草机算法研发工作,包括分类、检测、分割、跟踪等模块,具备快速学习新技术领域的能力;" },
|
{ type: "text", content: "参与割草机算法研发工作,包括分类、检测、分割、跟踪等模块,具备快速学习新技术领域的能力;" },
|
||||||
{ type: "text", content: "撰写技术文档与测试报告,进行跨部门沟通协作,确保项目按时交付并符合质量标准。" },
|
{ type: "text", content: "撰写技术文档与测试报告,进行跨部门沟通协作,确保项目按时交付并符合质量标准。" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cX5EGRDA",
|
id: "mX5EGRDA",
|
||||||
companyName: "江西卓云深圳研发中心2",
|
companyName: "江西卓云深圳研发中心1",
|
||||||
position: "图像算法工程师",
|
position: "图像算法工程师",
|
||||||
startDate: "2023.12",
|
startDate: "2018.12",
|
||||||
endDate: "2024.02",
|
endDate: "2019.02",
|
||||||
description: [
|
description: [
|
||||||
{ type: "text", content: "运用Python进行数据处理与分析,开发部署应用程序,具备较强的逻辑分析和数据整理能力;" },
|
{ type: "text", content: "1运用Python进行数据处理与分析,开发部署应用程序,具备较强的逻辑分析和数据整理能力;" },
|
||||||
{ type: "text", content: "独立完成项目全生命周期管理,包括数据收集、标注、模型训练、部署及性能调优,能应对重复性工作并保证准确性;" },
|
{ type: "text", content: "独立完成项目全生命周期管理,包括数据收集、标注、模型训练、部署及性能调优,能应对重复性工作并保证准确性;" },
|
||||||
{ type: "text", content: "熟练掌握Office办公软件及数据库工具,具备文献检索与信息整理经验,工作态度认真负责。" },
|
{ type: "text", content: "熟练掌握Office办公软件及数据库工具,具备文献检索与信息整理经验,工作态度认真负责。" },
|
||||||
],
|
],
|
||||||
@@ -382,9 +386,9 @@ export function getMockResumeData(): ResumeData {
|
|||||||
id: "q966EOx2",
|
id: "q966EOx2",
|
||||||
companyName: "",
|
companyName: "",
|
||||||
projectName: "微信小程序开发与测试",
|
projectName: "微信小程序开发与测试",
|
||||||
role: "",
|
role: "小程序工程师",
|
||||||
startDate: "2022.01",
|
startDate: "2022.01",
|
||||||
endDate: "",
|
endDate: "2023.01",
|
||||||
description: [
|
description: [
|
||||||
{ type: "text", content: "负责小程序功能模块的开发与测试,严格遵循开发规范和质量标准,确保系统稳定运行" },
|
{ type: "text", content: "负责小程序功能模块的开发与测试,严格遵循开发规范和质量标准,确保系统稳定运行" },
|
||||||
{ type: "text", content: "参与需求分析与文档撰写工作,具备良好的书面表达能力和跨部门沟通协调能力" },
|
{ type: "text", content: "参与需求分析与文档撰写工作,具备良好的书面表达能力和跨部门沟通协调能力" },
|
||||||
@@ -395,9 +399,9 @@ export function getMockResumeData(): ResumeData {
|
|||||||
id: "KVSEaywu",
|
id: "KVSEaywu",
|
||||||
companyName: "",
|
companyName: "",
|
||||||
projectName: "多人游戏聊天网页开发",
|
projectName: "多人游戏聊天网页开发",
|
||||||
role: "",
|
role: "前端工程师",
|
||||||
startDate: "2022.08",
|
startDate: "2022.08",
|
||||||
endDate: "",
|
endDate: "2023.03",
|
||||||
description: [
|
description: [
|
||||||
{ type: "text", content: "负责网页功能开发与用户交互模块设计,具备快速学习新技术和解决问题的能力" },
|
{ type: "text", content: "负责网页功能开发与用户交互模块设计,具备快速学习新技术和解决问题的能力" },
|
||||||
{ type: "text", content: "熟练使用Office等办公软件进行项目文档管理和数据整理工作" },
|
{ type: "text", content: "熟练使用Office等办公软件进行项目文档管理和数据整理工作" },
|
||||||
@@ -408,9 +412,9 @@ export function getMockResumeData(): ResumeData {
|
|||||||
id: "KQtH64TE",
|
id: "KQtH64TE",
|
||||||
companyName: "",
|
companyName: "",
|
||||||
projectName: "基于SEIR模型的疫情大数据追踪预测应用研究",
|
projectName: "基于SEIR模型的疫情大数据追踪预测应用研究",
|
||||||
role: "",
|
role: "工程师",
|
||||||
startDate: "2022.12",
|
startDate: "2022.12",
|
||||||
endDate: "",
|
endDate: "2023.08",
|
||||||
description: [
|
description: [
|
||||||
{ type: "text", content: "参与疫情数据监测与分析项目,运用数据模型进行风险评估和趋势预测" },
|
{ type: "text", content: "参与疫情数据监测与分析项目,运用数据模型进行风险评估和趋势预测" },
|
||||||
{ type: "text", content: "负责大量数据的收集、整理与分析工作,具备较强的逻辑分析能力和数据处理能力" },
|
{ type: "text", content: "负责大量数据的收集、整理与分析工作,具备较强的逻辑分析能力和数据处理能力" },
|
||||||
|
|||||||
+256
-3
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 日期选择器填写模块
|
* 日期选择器填写模块(支持年月日或年月的时间选择)
|
||||||
* 负责:通用日期选择器的日历面板操作
|
* 负责:通用日期选择器的日历面板操作
|
||||||
*
|
*
|
||||||
* 核心思路:
|
* 核心思路:
|
||||||
@@ -785,6 +785,231 @@ async function navigateToYearMonth(
|
|||||||
return finalYear === targetYear && finalMonth === targetMonth
|
return finalYear === targetYear && finalMonth === targetMonth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 月份面板检测与点击 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测当前弹出的是否为月份选择面板,如果是则导航年份并点击目标月份
|
||||||
|
*
|
||||||
|
* 月份面板特征:
|
||||||
|
* - 包含12个格子,文字为 "一月"~"十二月" 或 "1月"~"12月" 或 "Jan"~"Dec"
|
||||||
|
* - 有年份显示和前后翻页按钮
|
||||||
|
* - 没有星期行和1~31的日期格子
|
||||||
|
*
|
||||||
|
* 【注意】复用 detectNavButtons 的按钮探测逻辑(点击观察年份变化)来找年份加减按钮
|
||||||
|
*/
|
||||||
|
async function tryFillMonthPanel(
|
||||||
|
targetYear: number,
|
||||||
|
targetMonth: number,
|
||||||
|
labelText: string,
|
||||||
|
fillValue: string,
|
||||||
|
inputElement: HTMLElement
|
||||||
|
): Promise<boolean> {
|
||||||
|
// 在页面中查找可见的月份面板
|
||||||
|
const panelSelectors = [
|
||||||
|
'[class*="month-panel"]', '[class*="month-picker"]',
|
||||||
|
'[class*="picker-panel"]', '[class*="calendar-month"]',
|
||||||
|
'[class*="picker-body"]', '[class*="picker-content"]',
|
||||||
|
'[class*="popover"]', '[class*="popper"]', '[class*="popup"]',
|
||||||
|
'[class*="dropdown"]', '[class*="overlay"]',
|
||||||
|
]
|
||||||
|
|
||||||
|
let monthPanel: HTMLElement | null = null
|
||||||
|
let monthCells: HTMLElement[] = []
|
||||||
|
|
||||||
|
for (const sel of panelSelectors) {
|
||||||
|
const panels = document.querySelectorAll(sel)
|
||||||
|
for (const panel of Array.from(panels)) {
|
||||||
|
const htmlPanel = panel as HTMLElement
|
||||||
|
if (!isVisible(htmlPanel)) continue
|
||||||
|
const cells = findMonthCells(htmlPanel)
|
||||||
|
if (cells.length >= 10) {
|
||||||
|
monthPanel = htmlPanel
|
||||||
|
monthCells = cells
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (monthPanel) break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:在 body 直接子元素中找
|
||||||
|
if (!monthPanel) {
|
||||||
|
for (const child of Array.from(document.body.children)) {
|
||||||
|
const htmlChild = child as HTMLElement
|
||||||
|
if (!isVisible(htmlChild) || htmlChild.offsetHeight < 50) continue
|
||||||
|
const cells = findMonthCells(htmlChild)
|
||||||
|
if (cells.length >= 10) {
|
||||||
|
monthPanel = htmlChild
|
||||||
|
monthCells = cells
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!monthPanel || monthCells.length === 0) {
|
||||||
|
console.log(`OfferPie: [datePicker] 未检测到月份面板`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`OfferPie: [datePicker] ✅ 检测到月份面板,共 ${monthCells.length} 个月份格子`)
|
||||||
|
|
||||||
|
// ---- 分析月份面板头部,找到年份显示和候选按钮 ----
|
||||||
|
// 月份格子区域作为边界(头部在月份格子之上)
|
||||||
|
const monthGridArea = monthCells[0].closest("table, tbody, [class*='body'], [class*='content'], div") as HTMLElement || monthCells[0].parentElement as HTMLElement
|
||||||
|
|
||||||
|
// 用 analyzeHeaderArea 分析头部(复用已有逻辑)
|
||||||
|
const headerInfo = analyzeHeaderArea(monthPanel, monthGridArea)
|
||||||
|
const currentYear = headerInfo.currentYear
|
||||||
|
console.log(`OfferPie: [datePicker] 月份面板当前年份: ${currentYear},目标年份: ${targetYear}`)
|
||||||
|
|
||||||
|
// ---- 用按钮探测逻辑找年份加减按钮(点击观察年份变化) ----
|
||||||
|
if (currentYear !== 0 && currentYear !== targetYear && headerInfo.headerButtons.length > 0) {
|
||||||
|
// 构造一个临时的 CalendarPanelInfo 用于 detectNavButtons
|
||||||
|
const tempPanel: CalendarPanelInfo = {
|
||||||
|
panelRoot: monthPanel,
|
||||||
|
weekdayRow: monthGridArea, // 用月份格子区域作为边界
|
||||||
|
dayGridArea: monthGridArea,
|
||||||
|
yearLabel: headerInfo.yearLabel,
|
||||||
|
monthLabel: null, // 月份面板里没有独立的月份显示标签
|
||||||
|
currentYear: currentYear,
|
||||||
|
currentMonth: 0,
|
||||||
|
headerButtons: headerInfo.headerButtons,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 探测导航按钮(只关心年份按钮)
|
||||||
|
const nav = await detectNavButtons(tempPanel)
|
||||||
|
|
||||||
|
// 导航到目标年份
|
||||||
|
if (nav.yearPrev || nav.yearNext) {
|
||||||
|
const yearDiff = targetYear - (readCurrentYear(tempPanel) || currentYear)
|
||||||
|
const yearBtn = yearDiff > 0 ? nav.yearNext : nav.yearPrev
|
||||||
|
if (yearBtn) {
|
||||||
|
const steps = Math.abs(yearDiff)
|
||||||
|
console.log(`OfferPie: [datePicker] 月份面板年份导航: ${yearDiff > 0 ? "+" : ""}${yearDiff}年`)
|
||||||
|
for (let i = 0; i < steps && i < 50; i++) {
|
||||||
|
yearBtn.click()
|
||||||
|
await delay(150)
|
||||||
|
const newYear = readCurrentYear(tempPanel)
|
||||||
|
if (newYear === targetYear) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (nav.monthPrev || nav.monthNext) {
|
||||||
|
// 有些月份面板的按钮被探测为"月份按钮"(因为点击后年份变了12个月=1年)
|
||||||
|
// 这种情况用月份按钮来导航年份
|
||||||
|
const yearDiff = targetYear - (readCurrentYear(tempPanel) || currentYear)
|
||||||
|
const btn = yearDiff > 0 ? nav.monthNext : nav.monthPrev
|
||||||
|
if (btn) {
|
||||||
|
const steps = Math.abs(yearDiff)
|
||||||
|
for (let i = 0; i < steps && i < 50; i++) {
|
||||||
|
btn.click()
|
||||||
|
await delay(150)
|
||||||
|
const newYear = readCurrentYear(tempPanel)
|
||||||
|
if (newYear === targetYear) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导航后重新获取月份格子(DOM 可能更新了)
|
||||||
|
monthCells = findMonthCells(monthPanel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 点击目标月份格子 ----
|
||||||
|
for (const cell of monthCells) {
|
||||||
|
const cellMonth = getMonthFromCell(cell)
|
||||||
|
if (cellMonth === targetMonth) {
|
||||||
|
const cls = ((cell as HTMLElement).className || "") + " " + ((cell.parentElement as HTMLElement)?.className || "")
|
||||||
|
if (cls.includes("disabled")) continue
|
||||||
|
|
||||||
|
console.log(`OfferPie: [datePicker] 📅 点击月份格子: ${targetMonth}月`)
|
||||||
|
// 找最内层叶子节点点击(兼容不同组件库)
|
||||||
|
let deepest: HTMLElement = cell
|
||||||
|
const inner = cell.querySelector("[class*='inner'], [class*='content'], span, a")
|
||||||
|
if (inner && isVisible(inner)) deepest = inner as HTMLElement
|
||||||
|
|
||||||
|
deepest.click()
|
||||||
|
await delay(100)
|
||||||
|
if (deepest !== cell) cell.click()
|
||||||
|
await delay(300)
|
||||||
|
await closePopup(inputElement)
|
||||||
|
console.log(`OfferPie: ✅ [月份选择器] 已选择 "${labelText}" = "${fillValue}" (${targetYear}年${targetMonth}月)`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`OfferPie: [datePicker] ❌ 月份面板中未找到 ${targetMonth}月 的格子`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在容器中查找月份格子元素
|
||||||
|
* 月份格子特征:文字为 "一月"~"十二月" / "1月"~"12月" / "Jan"~"Dec" / 纯数字1~12
|
||||||
|
*/
|
||||||
|
function findMonthCells(container: HTMLElement): HTMLElement[] {
|
||||||
|
const cells: HTMLElement[] = []
|
||||||
|
// 常见月份格子选择器
|
||||||
|
const selectors = [
|
||||||
|
'[class*="month-panel-cell"]', '[class*="month-cell"]',
|
||||||
|
'[class*="picker-cell"]', 'td[class*="cell"]',
|
||||||
|
'[class*="month-table"] td', '[class*="month-body"] td',
|
||||||
|
'[role="gridcell"]',
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const sel of selectors) {
|
||||||
|
const candidates = container.querySelectorAll(sel)
|
||||||
|
const validCells: HTMLElement[] = []
|
||||||
|
for (const el of Array.from(candidates)) {
|
||||||
|
if (!isVisible(el)) continue
|
||||||
|
const month = getMonthFromCell(el as HTMLElement)
|
||||||
|
if (month !== null) validCells.push(el as HTMLElement)
|
||||||
|
}
|
||||||
|
if (validCells.length >= 10) return validCells
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:遍历所有可见的短文本元素
|
||||||
|
const allEls = container.querySelectorAll("td, div, span, a")
|
||||||
|
const fallbackCells: HTMLElement[] = []
|
||||||
|
for (const el of Array.from(allEls)) {
|
||||||
|
if (!isVisible(el)) continue
|
||||||
|
const month = getMonthFromCell(el as HTMLElement)
|
||||||
|
if (month !== null) {
|
||||||
|
// 确保不是年份数字(排除 2020~2030 等)
|
||||||
|
const text = getFullText(el)
|
||||||
|
if (extractYear(text) !== null) continue
|
||||||
|
fallbackCells.push(el as HTMLElement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 去重(如果父子元素都匹配,只保留最内层)
|
||||||
|
const filtered = fallbackCells.filter((el) => {
|
||||||
|
return !fallbackCells.some((other) => other !== el && el.contains(other))
|
||||||
|
})
|
||||||
|
if (filtered.length >= 10) return filtered
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从格子元素中提取月份数字
|
||||||
|
* 支持:一月~十二月、1月~12月、Jan~Dec、纯数字1~12
|
||||||
|
*/
|
||||||
|
function getMonthFromCell(el: HTMLElement): number | null {
|
||||||
|
const text = getFullText(el).trim()
|
||||||
|
// 中文月份:一月~十二月
|
||||||
|
for (const [zh, num] of Object.entries(ZH_MONTH_NUMS)) {
|
||||||
|
if (text === zh + "月" || text === zh) return num
|
||||||
|
}
|
||||||
|
// 数字月份:1月~12月 或 01~12
|
||||||
|
const numMatch = text.match(/^0?(\d{1,2})月?$/)
|
||||||
|
if (numMatch) {
|
||||||
|
const n = parseInt(numMatch[1], 10)
|
||||||
|
if (n >= 1 && n <= 12) return n
|
||||||
|
}
|
||||||
|
// 英文月份
|
||||||
|
const lower = text.toLowerCase()
|
||||||
|
for (const [key, val] of Object.entries(EN_MONTH_MAP)) {
|
||||||
|
if (lower === key || lower === key.slice(0, 3)) return val
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// ============ 点击日期格子 ============
|
// ============ 点击日期格子 ============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -871,12 +1096,24 @@ function parseDateValue(fillValue: string): { year: number; month: number; day:
|
|||||||
return { year: parseInt(sepMatch[1], 10), month: parseInt(sepMatch[2], 10), day: parseInt(sepMatch[3], 10) }
|
return { year: parseInt(sepMatch[1], 10), month: parseInt(sepMatch[2], 10), day: parseInt(sepMatch[3], 10) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YYYY-MM / YYYY.MM / YYYY/MM(只有年月,day=0 表示不需要点日期格子)
|
||||||
|
const yearMonthMatch = fillValue.match(/^(\d{4})[\/\-.](\d{1,2})$/)
|
||||||
|
if (yearMonthMatch) {
|
||||||
|
return { year: parseInt(yearMonthMatch[1], 10), month: parseInt(yearMonthMatch[2], 10), day: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
// YYYY年MM月DD日
|
// YYYY年MM月DD日
|
||||||
const zhMatch = fillValue.match(/(\d{4})年(\d{1,2})月(\d{1,2})日?/)
|
const zhMatch = fillValue.match(/(\d{4})年(\d{1,2})月(\d{1,2})日?/)
|
||||||
if (zhMatch) {
|
if (zhMatch) {
|
||||||
return { year: parseInt(zhMatch[1], 10), month: parseInt(zhMatch[2], 10), day: parseInt(zhMatch[3], 10) }
|
return { year: parseInt(zhMatch[1], 10), month: parseInt(zhMatch[2], 10), day: parseInt(zhMatch[3], 10) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// YYYY年MM月(只有年月)
|
||||||
|
const zhYMMatch = fillValue.match(/(\d{4})年(\d{1,2})月/)
|
||||||
|
if (zhYMMatch) {
|
||||||
|
return { year: parseInt(zhYMMatch[1], 10), month: parseInt(zhYMMatch[2], 10), day: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
// YYYYMMDD
|
// YYYYMMDD
|
||||||
const compactMatch = fillValue.match(/^(\d{4})(\d{2})(\d{2})$/)
|
const compactMatch = fillValue.match(/^(\d{4})(\d{2})(\d{2})$/)
|
||||||
if (compactMatch) {
|
if (compactMatch) {
|
||||||
@@ -929,12 +1166,19 @@ export async function fillDatePicker(field: MatchedFormField): Promise<boolean>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { year: targetYear, month: targetMonth, day: targetDay } = dateInfo
|
const { year: targetYear, month: targetMonth, day: targetDay } = dateInfo
|
||||||
console.log(`OfferPie: [datePicker] 目标日期: ${targetYear}年${targetMonth}月${targetDay}日`)
|
console.log(`OfferPie: [datePicker] 目标日期: ${targetYear}年${targetMonth}月${targetDay === 0 ? "(仅年月)" : targetDay + "日"}`)
|
||||||
|
|
||||||
// ---- 步骤2:写入日期字符串(触发面板联动) ----
|
// ---- 步骤2:写入日期字符串(触发面板联动) ----
|
||||||
forceSetValue(inputElement, fillValue)
|
forceSetValue(inputElement, fillValue)
|
||||||
await delay(500)
|
await delay(500)
|
||||||
|
|
||||||
|
// ---- 步骤2.5:如果只有年月(day=0),检测是否为月份面板 ----
|
||||||
|
if (targetDay === 0) {
|
||||||
|
const monthPanelResult = await tryFillMonthPanel(targetYear, targetMonth, labelText, fillValue, inputElement as HTMLElement)
|
||||||
|
if (monthPanelResult) return true
|
||||||
|
// 如果不是月份面板,继续走日期面板逻辑(day 默认选1号)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 步骤3:分析日历面板结构 ----
|
// ---- 步骤3:分析日历面板结构 ----
|
||||||
const panel = analyzeCalendarPanel()
|
const panel = analyzeCalendarPanel()
|
||||||
if (!panel) {
|
if (!panel) {
|
||||||
@@ -960,7 +1204,16 @@ export async function fillDatePicker(field: MatchedFormField): Promise<boolean>
|
|||||||
console.warn("OfferPie: [datePicker] 导航到目标年月失败,仍尝试点击日期格子")
|
console.warn("OfferPie: [datePicker] 导航到目标年月失败,仍尝试点击日期格子")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 步骤6:点击目标日期格子 ----
|
// ---- 步骤6:点击目标日期格子(如果 day=0 表示只选年月,跳过日期点击) ----
|
||||||
|
if (targetDay === 0) {
|
||||||
|
// 只需要年月,不需要点日期格子。尝试点击月份面板中的月份
|
||||||
|
// 如果导航成功了,可能面板已经关闭(某些组件选完月份自动关闭)
|
||||||
|
await delay(200)
|
||||||
|
await closePopup(inputElement as HTMLElement)
|
||||||
|
console.log(`OfferPie: ✅ [日期] 已导航到 "${labelText}" = "${fillValue}" (${targetYear}年${targetMonth}月)`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
await delay(200)
|
await delay(200)
|
||||||
const clicked = await clickDayCell(panel, targetDay, labelText, fillValue, inputElement as HTMLElement)
|
const clicked = await clickDayCell(panel, targetDay, labelText, fillValue, inputElement as HTMLElement)
|
||||||
if (clicked) return true
|
if (clicked) return true
|
||||||
|
|||||||
+794
-320
File diff suppressed because it is too large
Load Diff
@@ -222,3 +222,244 @@ export function matchFormFields(
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 指定 DOM 范围内的字段匹配 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在指定 DOM 范围内匹配表单字段(用于经历区块内的逐段匹配)
|
||||||
|
* 只匹配指定 section 的标签,并限定搜索范围在 startEl 和 endEl 之间
|
||||||
|
*
|
||||||
|
* 【核心改进】如果提供了 containerEl,则只在容器内搜索,确保不跨段
|
||||||
|
*
|
||||||
|
* @param lang - 页面语言
|
||||||
|
* @param section - 要匹配的经历分区(education/work/internship/project/competition)
|
||||||
|
* @param sectionIndex - 该段经历在排序后数组中的索引
|
||||||
|
* @param startEl - 范围起始元素(该段经历的起始标志)
|
||||||
|
* @param endEl - 范围结束元素(下一段经历的起始标志或下一个大标题,null 表示到页面底部)
|
||||||
|
* @param usedInputs - 已使用的 input 集合(避免重复匹配)
|
||||||
|
* @param containerEl - 该段经历的容器 DOM 元素(优先使用,精确限定范围)
|
||||||
|
* @returns 匹配到的字段列表
|
||||||
|
*/
|
||||||
|
export function matchFormFieldsInRange(
|
||||||
|
lang: "zh" | "en",
|
||||||
|
section: string,
|
||||||
|
sectionIndex: number,
|
||||||
|
startEl: Element,
|
||||||
|
endEl: Element | null,
|
||||||
|
usedInputs: Set<Element> = new Set(),
|
||||||
|
containerEl: Element | null = null
|
||||||
|
): MatchedFormField[] {
|
||||||
|
const results: MatchedFormField[] = []
|
||||||
|
// 取该 section 对应的标签,同时加入通用标签(如实习经历里的"公司名称"属于 work section)
|
||||||
|
let sectionLabels = JOB_FORM_LABELS.filter((item) => item.section === section)
|
||||||
|
|
||||||
|
// 对于实习经历,页面上可能用通用的"公司名称"而不是"实习公司",需要加入 work 的公司相关标签
|
||||||
|
if (section === "internship") {
|
||||||
|
const workCompanyLabels = JOB_FORM_LABELS.filter((item) => item.section === "work" && (item.key === "workCompany" || item.key === "workPosition"))
|
||||||
|
// 映射为 internship 的字段
|
||||||
|
const mappedLabels = workCompanyLabels.map((item) => ({
|
||||||
|
...item,
|
||||||
|
section: "internship" as any,
|
||||||
|
resumeField: item.key === "workCompany" ? "companyName" : "position",
|
||||||
|
}))
|
||||||
|
sectionLabels = [...sectionLabels, ...mappedLabels]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取范围内的候选元素
|
||||||
|
// 【核心】如果有 containerEl,直接在容器内搜索(精确,不跨段)
|
||||||
|
// 否则 fallback 到 DOM 位置比较方式
|
||||||
|
const rangeElements: Element[] = []
|
||||||
|
|
||||||
|
if (containerEl) {
|
||||||
|
// 在容器内搜索所有候选标签元素
|
||||||
|
const containerCandidates = containerEl.querySelectorAll(
|
||||||
|
"label, span, div, td, th, p, legend, dt, h1, h2, h3, h4, h5, h6"
|
||||||
|
)
|
||||||
|
for (const el of Array.from(containerCandidates)) {
|
||||||
|
rangeElements.push(el)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// fallback:用 DOM 位置比较
|
||||||
|
const allCandidates = document.body.querySelectorAll(
|
||||||
|
"label, span, div, td, th, p, legend, dt, h1, h2, h3, h4, h5, h6"
|
||||||
|
)
|
||||||
|
for (const el of Array.from(allCandidates)) {
|
||||||
|
const afterStart = startEl === el || (startEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
|
if (!afterStart) continue
|
||||||
|
if (endEl && !(endEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)) continue
|
||||||
|
rangeElements.push(el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of sectionLabels) {
|
||||||
|
const labels = lang === "zh" ? item.zh : item.en
|
||||||
|
const sortedLabels = [...labels].sort((a, b) => b.length - a.length)
|
||||||
|
|
||||||
|
for (const candidateEl of rangeElements) {
|
||||||
|
// 只用元素自身的直接文本匹配(不用 fullText,避免匹配到包含多个标签文字的大容器)
|
||||||
|
const directText = Array.from(candidateEl.childNodes)
|
||||||
|
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
||||||
|
.map((node) => node.textContent?.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
|
||||||
|
if (!directText) continue
|
||||||
|
|
||||||
|
const matchLabel = sortedLabels.find(
|
||||||
|
(label) => directText.includes(label) && directText.length < label.length + 20
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchLabel) {
|
||||||
|
// 检测单选按钮组
|
||||||
|
let isRadioType = false
|
||||||
|
let radioContainer: Element | null = null
|
||||||
|
const closestFormItem = candidateEl.closest(".form-item, .form-group, .el-form-item, .ant-form-item")
|
||||||
|
if (closestFormItem) {
|
||||||
|
const radioInItem = closestFormItem.querySelector('input[type="radio"]')
|
||||||
|
if (radioInItem) { isRadioType = true; radioContainer = closestFormItem }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非 radio 类型,正常找 input
|
||||||
|
let inputEl: HTMLInputElement | HTMLTextAreaElement | null = null
|
||||||
|
let inputType: "text" | "radio" | "picker" | "textarea" = isRadioType ? "radio" : "text"
|
||||||
|
|
||||||
|
if (!isRadioType) {
|
||||||
|
inputEl = findNearestInput(candidateEl)
|
||||||
|
if (inputEl) inputEl = fixSpecialLabelInput(item.key, inputEl, candidateEl, usedInputs)
|
||||||
|
// 【核心】如果有容器限定,验证找到的 input 必须在容器内
|
||||||
|
if (inputEl && containerEl && !containerEl.contains(inputEl)) inputEl = null
|
||||||
|
if (inputEl && usedInputs.has(inputEl)) continue
|
||||||
|
if (inputEl) {
|
||||||
|
usedInputs.add(inputEl)
|
||||||
|
if (inputEl.tagName === "TEXTAREA") inputType = "textarea"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnEl = inputEl ? findNearbyButton(inputEl) : null
|
||||||
|
console.log(` [matchInRange] 匹配: "${matchLabel}" (${item.key}) → 标签元素: <${candidateEl.tagName.toLowerCase()}> "${candidateEl.textContent?.trim()?.substring(0, 20)}" | input: ${inputEl ? `<${inputEl.tagName.toLowerCase()} placeholder="${inputEl.getAttribute("placeholder") || ""}">` : "null"}`)
|
||||||
|
results.push({
|
||||||
|
key: item.key, section: item.section as any, resumeField: item.resumeField,
|
||||||
|
sectionIndex, labelText: matchLabel,
|
||||||
|
labelElement: candidateEl, labelSelector: buildSelector(candidateEl),
|
||||||
|
inputElement: inputEl, inputSelector: inputEl ? buildSelector(inputEl) : "",
|
||||||
|
buttonElement: btnEl, buttonSelector: btnEl ? buildSelector(btnEl) : "",
|
||||||
|
inputType, radioContainer: isRadioType ? radioContainer : null,
|
||||||
|
isPicker: false, pickerDropdownElement: null, pickerDropdownSelector: "", fillValue: "",
|
||||||
|
})
|
||||||
|
break // 每种标签在一段经历内只匹配一次
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 DOM 顺序排序
|
||||||
|
results.sort((a, b) => {
|
||||||
|
const pos = a.labelElement.compareDocumentPosition(b.labelElement)
|
||||||
|
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1
|
||||||
|
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 匹配非经历区域的表单字段(只匹配 section="main" 的标签)
|
||||||
|
* 排除经历区域内的元素,避免重复匹配
|
||||||
|
*
|
||||||
|
* @param rootEl - 搜索根元素
|
||||||
|
* @param lang - 页面语言
|
||||||
|
* @param excludeRanges - 要排除的 DOM 范围列表(经历区块范围)
|
||||||
|
* @param usedInputs - 已使用的 input 集合
|
||||||
|
* @returns 匹配到的字段列表
|
||||||
|
*/
|
||||||
|
export function matchMainFields(
|
||||||
|
rootEl: Element = document.body,
|
||||||
|
lang: "zh" | "en",
|
||||||
|
excludeRanges: { start: Element; end: Element | null }[] = [],
|
||||||
|
usedInputs: Set<Element> = new Set()
|
||||||
|
): MatchedFormField[] {
|
||||||
|
const results: MatchedFormField[] = []
|
||||||
|
// 只取 main section 的标签
|
||||||
|
const mainLabels = JOB_FORM_LABELS.filter((item) => item.section === "main")
|
||||||
|
|
||||||
|
const candidateEls = rootEl.querySelectorAll(
|
||||||
|
"label, span, div, td, th, p, legend, dt, h1, h2, h3, h4, h5, h6"
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const item of mainLabels) {
|
||||||
|
const labels = lang === "zh" ? item.zh : item.en
|
||||||
|
const sortedLabels = [...labels].sort((a, b) => b.length - a.length)
|
||||||
|
|
||||||
|
for (const candidateEl of Array.from(candidateEls)) {
|
||||||
|
// 检查是否在排除范围内
|
||||||
|
let inExcludeRange = false
|
||||||
|
for (const range of excludeRanges) {
|
||||||
|
const afterStart = range.start === candidateEl || (range.start.compareDocumentPosition(candidateEl) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
|
const beforeEnd = !range.end || (range.end.compareDocumentPosition(candidateEl) & Node.DOCUMENT_POSITION_PRECEDING)
|
||||||
|
if (afterStart && beforeEnd) {
|
||||||
|
inExcludeRange = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inExcludeRange) continue
|
||||||
|
|
||||||
|
const directText = Array.from(candidateEl.childNodes)
|
||||||
|
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
||||||
|
.map((node) => node.textContent?.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
const fullText = candidateEl.textContent?.trim() || ""
|
||||||
|
|
||||||
|
const matchLabel = sortedLabels.find(
|
||||||
|
(label) =>
|
||||||
|
(directText && directText.includes(label) && directText.length < label.length + 20) ||
|
||||||
|
(fullText.includes(label) && fullText.length < label.length + 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchLabel) {
|
||||||
|
let isRadioType = false
|
||||||
|
let radioContainer: Element | null = null
|
||||||
|
const closestFormItem = candidateEl.closest(".form-item, .form-group, .el-form-item, .ant-form-item")
|
||||||
|
if (closestFormItem) {
|
||||||
|
const radioInItem = closestFormItem.querySelector('input[type="radio"]')
|
||||||
|
if (radioInItem) { isRadioType = true; radioContainer = closestFormItem }
|
||||||
|
}
|
||||||
|
|
||||||
|
let inputEl: HTMLInputElement | HTMLTextAreaElement | null = null
|
||||||
|
let inputType: "text" | "radio" | "picker" | "textarea" = isRadioType ? "radio" : "text"
|
||||||
|
|
||||||
|
if (!isRadioType) {
|
||||||
|
inputEl = findNearestInput(candidateEl)
|
||||||
|
if (inputEl) inputEl = fixSpecialLabelInput(item.key, inputEl, candidateEl, usedInputs)
|
||||||
|
if (inputEl && usedInputs.has(inputEl)) continue
|
||||||
|
if (inputEl) {
|
||||||
|
usedInputs.add(inputEl)
|
||||||
|
if (inputEl.tagName === "TEXTAREA") inputType = "textarea"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnEl = inputEl ? findNearbyButton(inputEl) : null
|
||||||
|
results.push({
|
||||||
|
key: item.key, section: item.section, resumeField: item.resumeField,
|
||||||
|
sectionIndex: 0, labelText: matchLabel,
|
||||||
|
labelElement: candidateEl, labelSelector: buildSelector(candidateEl),
|
||||||
|
inputElement: inputEl, inputSelector: inputEl ? buildSelector(inputEl) : "",
|
||||||
|
buttonElement: btnEl, buttonSelector: btnEl ? buildSelector(btnEl) : "",
|
||||||
|
inputType, radioContainer: isRadioType ? radioContainer : null,
|
||||||
|
isPicker: false, pickerDropdownElement: null, pickerDropdownSelector: "", fillValue: "",
|
||||||
|
})
|
||||||
|
break // main 字段每种只匹配一次
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 DOM 顺序排序
|
||||||
|
results.sort((a, b) => {
|
||||||
|
const pos = a.labelElement.compareDocumentPosition(b.labelElement)
|
||||||
|
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1
|
||||||
|
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user