From 84a2a3993a747d40ba69aab7e844ff8c3bb0ee63 Mon Sep 17 00:00:00 2001 From: xuxin <15279969124@163.com> Date: Tue, 12 May 2026 21:21:59 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=8F=E5=8E=86=E9=83=A8=E5=88=86=E5=A1=AB?= =?UTF-8?q?=E5=86=99=E9=80=BB=E8=BE=91=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SidebarPanel.tsx | 222 +++++- src/lib/autofill.ts | 460 ++++++++++++- src/lib/constants.ts | 62 +- src/lib/datePicker.ts | 259 ++++++- src/lib/experienceSection.ts | 1114 ++++++++++++++++++++++--------- src/lib/formMatcher.ts | 241 +++++++ 6 files changed, 1971 insertions(+), 387 deletions(-) diff --git a/src/components/SidebarPanel.tsx b/src/components/SidebarPanel.tsx index c415df2..bc831db 100644 --- a/src/components/SidebarPanel.tsx +++ b/src/components/SidebarPanel.tsx @@ -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,211 @@ 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() // 全局已使用的 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) - if (value) f.fillValue = value + 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 (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 } - // 检测选择器类型(会点击展开再关闭) await detectPickerField(f, lang) console.log( - ` [${f.key}] "${f.labelText}" → input: ${f.inputSelector || "未找到"}` + - ` | type: ${f.inputType}` + + ` [${f.key}] "${f.labelText}" → type: ${f.inputType}` + ` | isPicker: ${f.isPicker}` + - ` | pickerSelector: ${f.pickerDropdownSelector || "无"}` + ` | fillValue: "${f.fillValue}"` ) - // 立即填写这个字段 const ok = await fillMatchedField(f) if (ok) { success++ } else { failed++ } - // 每个字段填完后,点击上一个非 picker 输入框来关闭残留弹窗 if (lastTextInput) { ;(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 +289,11 @@ export function SidebarPanel({ onClose }: SidebarPanelProps) { } await delay(300) - // 更新上一个非 picker 输入框的记录 - if (!f.isPicker && f.inputElement) { - lastTextInput = f.inputElement - } + 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) {
{/* 顶部操作栏:反馈、设置、关闭按钮 */}
- 反馈5 + 反馈123456789 设置 {/* 关闭按钮:点击后隐藏侧边栏 */}