经历部分填写逻辑优化
This commit is contained in:
+194
-36
@@ -5,15 +5,15 @@
|
||||
*/
|
||||
|
||||
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 { matchFormFields } from "~lib/formMatcher"
|
||||
import { matchFormFields, matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher"
|
||||
import { detectPickerField } from "~lib/pickerDetector"
|
||||
import { detectAndUploadResume } from "~lib/resumeUpload"
|
||||
import { getMockResumeData } from "~lib/constants"
|
||||
import { getResumeFieldValue } from "~lib/resumeDataHelper"
|
||||
import { locateExperienceSections, expandExperienceSections } from "~lib/experienceSection"
|
||||
import type { MatchedFormField, ResumeData } from "~lib/types"
|
||||
import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer } from "~lib/experienceSection"
|
||||
import type { MatchedFormField, ResumeData, ExperienceSection } from "~lib/types"
|
||||
import "./SidebarPanel.scss"
|
||||
|
||||
/** 侧边栏面板的 Props */
|
||||
@@ -74,50 +74,163 @@ export function SidebarPanel({ onClose }: SidebarPanelProps) {
|
||||
const sectionResults = locateExperienceSections(document.body, lang)
|
||||
// 4.6 对比简历数据段数,点击添加按钮补足不够的段数
|
||||
const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang)
|
||||
// TODO: 后续实现步骤:
|
||||
// 5. 对5种经历的标签关系数据做特殊标记(后续完善)
|
||||
// 6. 所有经历段数添加完毕后,再进入统一的字段匹配和填写流程
|
||||
|
||||
// 5. 匹配表单字段
|
||||
const fields = matchFormFields(document.body, lang)
|
||||
console.log(`===== OfferPie: 匹配到 ${fields.length} 个表单字段 =====`)
|
||||
|
||||
// 6. 逐个:检测选择器 → 填充数据 → 立即填写(一个个来,避免多个选择器同时打开)
|
||||
// 5. 经历数据按时间排序 + 经历区域字段匹配与填写(阶段A)
|
||||
console.log("===== OfferPie: 阶段A - 经历区域填写 =====")
|
||||
const usedInputs = new Set<Element>() // 全局已使用的 input 集合
|
||||
const excludeRanges: { start: Element; end: Element | null }[] = [] // 经历区域范围(用于阶段B排除)
|
||||
let success = 0, failed = 0, skipped = 0
|
||||
// 记录上一个非 picker 的输入框,用于每次填完后点击它来关闭残留弹窗
|
||||
let lastTextInput: HTMLInputElement | HTMLTextAreaElement | null = null
|
||||
let lastTextInputIsPicker = false
|
||||
|
||||
for (const f of fields) {
|
||||
// 从简历数据中获取填写值(根据 section + sectionIndex + resumeField 定位)
|
||||
if (currentResumeData) {
|
||||
const value = getResumeFieldValue(currentResumeData, f.section, f.sectionIndex, f.resumeField)
|
||||
for (const result of expandedResults) {
|
||||
if (!result.titleElement || result.expandedCount === 0) continue
|
||||
const section = result.section as ExperienceSection
|
||||
const sectionData = currentResumeData[section] as { startDate?: string; endDate?: string }[]
|
||||
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 (!f.fillValue) { skipped++; continue }
|
||||
|
||||
// 检测选择器类型(会点击展开再关闭)
|
||||
await detectPickerField(f, lang)
|
||||
|
||||
console.log(
|
||||
` [${f.key}] "${f.labelText}" → input: ${f.inputSelector || "未找到"}` +
|
||||
` | type: ${f.inputType}` +
|
||||
` | isPicker: ${f.isPicker}` +
|
||||
` | pickerSelector: ${f.pickerDropdownSelector || "无"}` +
|
||||
` | fillValue: "${f.fillValue}"`
|
||||
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++ }
|
||||
}
|
||||
|
||||
// 每个字段填完后,点击上一个非 picker 输入框来关闭残留弹窗
|
||||
if (lastTextInput) {
|
||||
// 关闭残留弹窗(只点击纯文本输入框,不点击选择器类型的 input)
|
||||
if (lastTextInput && !lastTextInputIsPicker) {
|
||||
;(lastTextInput as HTMLElement).click()
|
||||
lastTextInput.focus()
|
||||
await delay(100)
|
||||
lastTextInput.blur()
|
||||
} else {
|
||||
// 兜底:只按 Escape 关闭弹窗,不点击任何元素(避免误触链接导致页面跳转)
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true })
|
||||
@@ -128,14 +241,59 @@ export function SidebarPanel({ onClose }: SidebarPanelProps) {
|
||||
}
|
||||
await delay(300)
|
||||
|
||||
// 更新上一个非 picker 输入框的记录
|
||||
if (!f.isPicker && f.inputElement) {
|
||||
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 }
|
||||
|
||||
await detectPickerField(f, lang)
|
||||
|
||||
console.log(
|
||||
` [${f.key}] "${f.labelText}" → type: ${f.inputType}` +
|
||||
` | isPicker: ${f.isPicker}` +
|
||||
` | fillValue: "${f.fillValue}"`
|
||||
)
|
||||
|
||||
const ok = await fillMatchedField(f)
|
||||
if (ok) { success++ } else { failed++ }
|
||||
|
||||
if (lastTextInput) {
|
||||
;(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) lastTextInput = f.inputElement
|
||||
}
|
||||
|
||||
setFormFields(fields)
|
||||
console.log(`===== OfferPie: 填写结果 成功${success} 失败${failed} 跳过${skipped} =====`)
|
||||
setFormFields([...mainFields])
|
||||
console.log(`===== OfferPie: 阶段B完成 总计成功${success} 失败${failed} 跳过${skipped} =====`)
|
||||
} else {
|
||||
setFormFields([])
|
||||
console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====")
|
||||
@@ -151,7 +309,7 @@ export function SidebarPanel({ onClose }: SidebarPanelProps) {
|
||||
<div className="op-container">
|
||||
{/* 顶部操作栏:反馈、设置、关闭按钮 */}
|
||||
<div className="op-header">
|
||||
<span className="op-header-link">反馈5</span>
|
||||
<span className="op-header-link">反馈123456789</span>
|
||||
<span className="op-header-link">设置</span>
|
||||
{/* 关闭按钮:点击后隐藏侧边栏 */}
|
||||
<button className="op-close-btn" onClick={onClose}>
|
||||
|
||||
+457
-3
@@ -17,15 +17,33 @@ export function delay(ms: number) {
|
||||
/**
|
||||
* 通用的值写入方法(适配各种前端框架的受控组件)
|
||||
* 优先用 execCommand 模拟真实输入,失败则 fallback 到原生 setter
|
||||
* 【改进】确保元素在视口内且真正获得焦点后再写入
|
||||
*/
|
||||
export function forceSetValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
|
||||
if (el instanceof HTMLInputElement && el.type === "file") {
|
||||
console.warn("OfferPie: 跳过 file 类型 input")
|
||||
return
|
||||
}
|
||||
// 确保元素在视口内(scrollIntoView)
|
||||
if (typeof el.scrollIntoView === "function") {
|
||||
el.scrollIntoView({ block: "center", behavior: "instant" })
|
||||
}
|
||||
el.focus()
|
||||
// 确保 focus 生效
|
||||
el.dispatchEvent(new FocusEvent("focus", { bubbles: true }))
|
||||
el.dispatchEvent(new FocusEvent("focusin", { bubbles: true }))
|
||||
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)
|
||||
|
||||
// 调试:记录 execCommand 结果
|
||||
console.log(`OfferPie: [forceSetValue] execCommand结果: inserted=${inserted} | 写入后value="${el.value}" | 目标value="${value}"`)
|
||||
|
||||
if (!inserted || el.value !== value) {
|
||||
const isTextarea = el instanceof HTMLTextAreaElement
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
@@ -34,8 +52,8 @@ export function forceSetValue(el: HTMLInputElement | HTMLTextAreaElement, value:
|
||||
setter?.call(el, value)
|
||||
el.dispatchEvent(new InputEvent("input", { bubbles: true, cancelable: true, inputType: "insertText", data: value }))
|
||||
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 }
|
||||
try {
|
||||
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 (isPicker) return await fillPickerField(field)
|
||||
inputElement.focus()
|
||||
@@ -109,7 +139,7 @@ function fillRadioField(container: Element, labelText: string, fillValue: string
|
||||
async function fillPickerField(field: MatchedFormField): Promise<boolean> {
|
||||
const { labelText, inputElement, fillValue } = field
|
||||
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 快照 ----
|
||||
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} =====`)
|
||||
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: "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: "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: "eduEndDate", zh: ["结束时间", "毕业时间"], en: ["End Date", "To", "Until"], section: "education", resumeField: "endDate" },
|
||||
{ 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: "workCompanyScale", zh: ["公司规模"], en: ["Company Size", "Company Scale"], section: "work", resumeField: "" },
|
||||
{ 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: "workEndDate", zh: ["结束时间", "离职时间"], en: ["End Date", "To"], section: "work", resumeField: "endDate" },
|
||||
{ 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: "internPosition", zh: ["实习职位", "实习岗位"], en: ["Intern Position", "Internship Role"], section: "internship", resumeField: "position" },
|
||||
{ key: "internStartDate", zh: ["实习开始时间"], en: ["Internship Start Date"], section: "internship", resumeField: "startDate" },
|
||||
{ key: "internEndDate", zh: ["实习结束时间"], en: ["Internship End Date"], section: "internship", resumeField: "endDate" },
|
||||
{ key: "internDescription", zh: ["实习描述", "实习内容"], en: ["Internship Description", "Intern Duties"], section: "internship", resumeField: "description" },
|
||||
{ key: "internPeriod", zh: ["起止时间", "实习时间", "就职时间"], en: ["Period", "Duration", "Employment Period"], section: "internship", resumeField: "startDate,endDate" },
|
||||
{ key: "internStartDate", zh: ["实习开始时间", "开始时间"], en: ["Internship Start Date", "Start Date", "From"], section: "internship", resumeField: "startDate" },
|
||||
{ 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: "projectCompany", zh: ["所属公司", "项目所属公司", "项目公司"], en: ["Project Company", "Company"], section: "project", resumeField: "companyName" },
|
||||
{ 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: "projectEndDate", zh: ["项目结束时间"], en: ["Project End Date"], section: "project", resumeField: "endDate" },
|
||||
{ key: "projectPeriod", zh: ["起止时间", "项目时间"], en: ["Period", "Duration", "Project Period"], section: "project", resumeField: "startDate,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: "competitionName", zh: ["竞赛名称", "比赛名称", "竞赛"], en: ["Competition Name", "Contest Name", "Competition"], section: "competition", resumeField: "competitionName" },
|
||||
@@ -316,8 +320,8 @@ export function getMockResumeData(): ResumeData {
|
||||
major: "人工智能",
|
||||
degree: "本科",
|
||||
studyType: "统招",
|
||||
startDate: "2021.09",
|
||||
endDate: "2025.07",
|
||||
startDate: "2017.09",
|
||||
endDate: "2021.07",
|
||||
description: [
|
||||
{ type: "text", content: "大一:高等数学、Python程序设计、面向对象程序设计、计算机体系结构、线性代数、离散数学等,培养严谨的逻辑思维能力" },
|
||||
{ type: "text", content: "大二:Java、软件工程导论、概率论与数理统计、数据结构与算法、Web开发、数据库原理、操作系统等,具备数据管理与分析基础" },
|
||||
@@ -330,48 +334,48 @@ export function getMockResumeData(): ResumeData {
|
||||
internship: [
|
||||
{
|
||||
id: "EoNkjAF1",
|
||||
companyName: "深圳乐动机器人科技有限公司",
|
||||
companyName: "深圳乐动机器人科技有限公司4",
|
||||
position: "机器人感知算法工程师",
|
||||
startDate: "2024.05",
|
||||
endDate: "2024.08",
|
||||
endDate: "2025.08",
|
||||
description: [
|
||||
{ type: "text", content: "负责数据收集、分析、模型训练与测试、算法部署等全流程工作,注重数据准确性与细节管理;" },
|
||||
{ type: "text", content: "4负责数据收集、分析、模型训练与测试、算法部署等全流程工作,注重数据准确性与细节管理;" },
|
||||
{ type: "text", content: "参与割草机算法研发工作,包括分类、检测、分割、跟踪等模块,具备快速学习新技术领域的能力;" },
|
||||
{ type: "text", content: "撰写技术文档与测试报告,进行跨部门沟通协作,确保项目按时交付并符合质量标准。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cX5EGRDD",
|
||||
companyName: "江西卓云深圳研发中心",
|
||||
companyName: "江西卓云深圳研发中心2",
|
||||
position: "图像算法工程师",
|
||||
startDate: "2023.12",
|
||||
endDate: "2024.02",
|
||||
startDate: "2020.12",
|
||||
endDate: "2021.02",
|
||||
description: [
|
||||
{ type: "text", content: "运用Python进行数据处理与分析,开发部署应用程序,具备较强的逻辑分析和数据整理能力;" },
|
||||
{ type: "text", content: "2运用Python进行数据处理与分析,开发部署应用程序,具备较强的逻辑分析和数据整理能力;" },
|
||||
{ type: "text", content: "独立完成项目全生命周期管理,包括数据收集、标注、模型训练、部署及性能调优,能应对重复性工作并保证准确性;" },
|
||||
{ type: "text", content: "熟练掌握Office办公软件及数据库工具,具备文献检索与信息整理经验,工作态度认真负责。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "EoNkjAF2",
|
||||
companyName: "深圳乐动机器人科技有限公司2",
|
||||
companyName: "深圳乐动机器人科技有限公司3",
|
||||
position: "机器人感知算法工程师",
|
||||
startDate: "2024.05",
|
||||
endDate: "2024.08",
|
||||
startDate: "2022.05",
|
||||
endDate: "2023.08",
|
||||
description: [
|
||||
{ type: "text", content: "负责数据收集、分析、模型训练与测试、算法部署等全流程工作,注重数据准确性与细节管理;" },
|
||||
{ type: "text", content: "3负责数据收集、分析、模型训练与测试、算法部署等全流程工作,注重数据准确性与细节管理;" },
|
||||
{ type: "text", content: "参与割草机算法研发工作,包括分类、检测、分割、跟踪等模块,具备快速学习新技术领域的能力;" },
|
||||
{ type: "text", content: "撰写技术文档与测试报告,进行跨部门沟通协作,确保项目按时交付并符合质量标准。" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cX5EGRDA",
|
||||
companyName: "江西卓云深圳研发中心2",
|
||||
id: "mX5EGRDA",
|
||||
companyName: "江西卓云深圳研发中心1",
|
||||
position: "图像算法工程师",
|
||||
startDate: "2023.12",
|
||||
endDate: "2024.02",
|
||||
startDate: "2018.12",
|
||||
endDate: "2019.02",
|
||||
description: [
|
||||
{ type: "text", content: "运用Python进行数据处理与分析,开发部署应用程序,具备较强的逻辑分析和数据整理能力;" },
|
||||
{ type: "text", content: "1运用Python进行数据处理与分析,开发部署应用程序,具备较强的逻辑分析和数据整理能力;" },
|
||||
{ type: "text", content: "独立完成项目全生命周期管理,包括数据收集、标注、模型训练、部署及性能调优,能应对重复性工作并保证准确性;" },
|
||||
{ type: "text", content: "熟练掌握Office办公软件及数据库工具,具备文献检索与信息整理经验,工作态度认真负责。" },
|
||||
],
|
||||
@@ -382,9 +386,9 @@ export function getMockResumeData(): ResumeData {
|
||||
id: "q966EOx2",
|
||||
companyName: "",
|
||||
projectName: "微信小程序开发与测试",
|
||||
role: "",
|
||||
role: "小程序工程师",
|
||||
startDate: "2022.01",
|
||||
endDate: "",
|
||||
endDate: "2023.01",
|
||||
description: [
|
||||
{ type: "text", content: "负责小程序功能模块的开发与测试,严格遵循开发规范和质量标准,确保系统稳定运行" },
|
||||
{ type: "text", content: "参与需求分析与文档撰写工作,具备良好的书面表达能力和跨部门沟通协调能力" },
|
||||
@@ -395,9 +399,9 @@ export function getMockResumeData(): ResumeData {
|
||||
id: "KVSEaywu",
|
||||
companyName: "",
|
||||
projectName: "多人游戏聊天网页开发",
|
||||
role: "",
|
||||
role: "前端工程师",
|
||||
startDate: "2022.08",
|
||||
endDate: "",
|
||||
endDate: "2023.03",
|
||||
description: [
|
||||
{ type: "text", content: "负责网页功能开发与用户交互模块设计,具备快速学习新技术和解决问题的能力" },
|
||||
{ type: "text", content: "熟练使用Office等办公软件进行项目文档管理和数据整理工作" },
|
||||
@@ -408,9 +412,9 @@ export function getMockResumeData(): ResumeData {
|
||||
id: "KQtH64TE",
|
||||
companyName: "",
|
||||
projectName: "基于SEIR模型的疫情大数据追踪预测应用研究",
|
||||
role: "",
|
||||
role: "工程师",
|
||||
startDate: "2022.12",
|
||||
endDate: "",
|
||||
endDate: "2023.08",
|
||||
description: [
|
||||
{ type: "text", content: "参与疫情数据监测与分析项目,运用数据模型进行风险评估和趋势预测" },
|
||||
{ type: "text", content: "负责大量数据的收集、整理与分析工作,具备较强的逻辑分析能力和数据处理能力" },
|
||||
|
||||
+256
-3
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 日期选择器填写模块
|
||||
* 日期选择器填写模块(支持年月日或年月的时间选择)
|
||||
* 负责:通用日期选择器的日历面板操作
|
||||
*
|
||||
* 核心思路:
|
||||
@@ -785,6 +785,231 @@ async function navigateToYearMonth(
|
||||
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) }
|
||||
}
|
||||
|
||||
// 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日
|
||||
const zhMatch = fillValue.match(/(\d{4})年(\d{1,2})月(\d{1,2})日?/)
|
||||
if (zhMatch) {
|
||||
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
|
||||
const compactMatch = fillValue.match(/^(\d{4})(\d{2})(\d{2})$/)
|
||||
if (compactMatch) {
|
||||
@@ -929,12 +1166,19 @@ export async function fillDatePicker(field: MatchedFormField): Promise<boolean>
|
||||
}
|
||||
|
||||
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:写入日期字符串(触发面板联动) ----
|
||||
forceSetValue(inputElement, fillValue)
|
||||
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:分析日历面板结构 ----
|
||||
const panel = analyzeCalendarPanel()
|
||||
if (!panel) {
|
||||
@@ -960,7 +1204,16 @@ export async function fillDatePicker(field: MatchedFormField): Promise<boolean>
|
||||
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)
|
||||
const clicked = await clickDayCell(panel, targetDay, labelText, fillValue, inputElement as HTMLElement)
|
||||
if (clicked) return true
|
||||
|
||||
+770
-296
File diff suppressed because it is too large
Load Diff
@@ -222,3 +222,244 @@ export function matchFormFields(
|
||||
|
||||
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