From e3a4ae2381f422681209a0da37363cbb598f5014 Mon Sep 17 00:00:00 2001 From: xuxin <15279969124@163.com> Date: Thu, 11 Jun 2026 18:38:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=80=89=E6=8B=A9=E5=99=A8?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E9=80=BB=E8=BE=91=EF=BC=8C=E9=80=9A=E7=94=A8?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=BB=A3=E7=A0=81=E5=8D=95=E7=8B=AC=E5=B0=81?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .kiro/steering/project-guidelines.md | 54 ++++ src/components/SidebarPanel.tsx | 442 +------------------------- src/handlers/handleAutoFillCommon.ts | 449 +++++++++++++++++++++++++++ src/lib/autofill.ts | 248 ++++++++++----- src/lib/constants.ts | 4 +- src/lib/dom.ts | 50 +++ src/lib/experienceSection.ts | 120 +++++-- src/lib/pickerDetector.ts | 99 ++++-- src/lib/pickerFill.ts | 47 ++- src/lib/types.ts | 2 + tsconfig.tsbuildinfo | 1 + 11 files changed, 931 insertions(+), 585 deletions(-) create mode 100644 src/handlers/handleAutoFillCommon.ts create mode 100644 tsconfig.tsbuildinfo diff --git a/.kiro/steering/project-guidelines.md b/.kiro/steering/project-guidelines.md index 13fc0a1..bc299a6 100644 --- a/.kiro/steering/project-guidelines.md +++ b/.kiro/steering/project-guidelines.md @@ -47,3 +47,57 @@ src/ - fillPickerField中的DOM差异对比弹出层检测是核心机制,不要简化或删除 - detectPickerField的方式3(主动点击检测DOM变化)是自建组件选择器识别的关键,不要删除 - UI_LIB_PICKER_CONFIGS里不要加容易误匹配的配置(如之前Beisen Phoenix被误匹配到新东方自建网站) + +## 填充操作核心规范 + +- **所有字段填充必须走 `fillMatchedField` 统一入口**,禁止在外部自行编写填充逻辑 +- **选择器类型字段(下拉、日期、月份、级联等)绝对不能用 `forceSetValue` 直接写值**,这类 input 通常是受控组件,直接写值无效 +- **选择器类型检测必须走 `detectPickerField` 统一入口**(pickerDetector.ts),禁止在外部自行判断字段是否为选择器 + - detectPickerField 内部已封装三种检测方式:UI组件库类名匹配 → 提示文字检测 → 主动点击DOM差异对比 + - 不要自行通过 readonly、class 等属性简单判断是否为选择器,这样会漏判 +- 时间字段(开始时间/结束时间/起止时间)如果没有找到 `placeholder="年"/"月"` 的下拉输入框,必须标记 `isPicker=true` 走 `fillMatchedField` → `fillPickerField` → `fillDatePicker` → `tryFillMonthPanel` 已封装的完整链路 +- **禁止在新增代码中自行编写选择器展开、月份点击、日期导航等操作逻辑**,必须先查看项目中已封装的方法并直接引用: + - 选择器类型检测 → `pickerDetector.ts`(detectPickerField) + - 日期/月份选择器操作 → `datePicker.ts`(fillDatePicker / tryFillMonthPanel / navigateToYearMonth / clickDayCell) + - 下拉选项匹配与点击 → `pickerFill.ts`(clickBestOptionInDropdown / findAndClickOptionInVisiblePopups) + - 选择器展开与DOM差异检测 → `autofill.ts` 中的 `fillPickerField` + - 单选按钮点击 → `autofill.ts` 中的 `fillRadioField` + - 搜索型选择器 → `autofill.ts` 中的 `fillSearchPickerField` + - 年月下拉选择器 → `autofill.ts` 中的 `fillYearMonthPicker` +- `datePicker.ts` 中的按钮探测逻辑(detectNavButtons:逐个点击观察年月变化)是通用日期选择器适配的核心,不要简化或删除 + +## 经历段落定位核心规范 + +- `experienceSection.ts` 中的段落容器定位逻辑(DOM 差异对比 + 特征重建)是5大经历区域填写的核心,**不可擅自修改或简化** +- 核心原理: + 1. 点击添加按钮前后,用 `snapshotElementsInRange` / `diffSnapshots` / `findTopLevelNewElements`(dom.ts)对比 DOM 快照差异 + 2. 找到新增的顶层容器(一段经历的完整 DOM),记住它的 tag + className 前缀作为段容器特征 + 3. 同时保存新增段容器的 DOM 引用(`lastNewContainerEl`) + 4. 所有段添加完后,从 `lastNewContainerEl.parentElement` 出发,在父级 children 中匹配所有同特征兄弟容器 + 5. 按 DOM 顺序重建 `segmentRanges` 数组,确保每段经历的 containerElement 精确对应 +- **禁止**用 input 差集(`beforeInputs`)来定位段容器——React 重渲染会导致原始段的 input 节点被重建,差集不可靠 +- **禁止**依赖 `titleElement` 存活状态来做重建——React 重渲染可能使 titleElement 脱离 DOM +- 对于初始有1段且不需要添加的经历:走 `detectFirstSegment` 的 fallback 逻辑(无同级兄弟时 containerElement 可能为 null,`matchFormFieldsInRange` 有 fallback 用 startElement/endElement 范围搜索) + +## 下拉选择器弹出层搜索核心规范 + +- **弹出层检测不依赖任何固定类名**,全部走 DOM 差异对比(点击前后全页面快照对比新增/新可见元素) +- **禁止使用 POPUP_SELECTORS 或任何固定 CSS 选择器来检测弹出层**,必须走全页面可见性差异对比(`offsetHeight > 0 && offsetWidth > 0`) +- **pickerDetector.ts 方式3(主动点击检测)的核心约束**: + - 检测到弹出层后 **禁止关闭弹出层**(不调用 dismissPopup) + - 将弹出层 DOM 引用保存到 `field.pickerDropdownElement` + - fillPickerField 步骤0 直接在已有弹出层内搜索,避免二次点击触发 toggle 关闭 + - 弹出层由用户选择后自动关闭,无需手动干预 +- **选项搜索必须限定在检测到的弹出层元素内**,禁止全页面搜: + - `findAndClickOptionInVisiblePopups(fillValue, labelText, popupEl?)` 没有 `popupEl` 时直接返回 false,不做全页面搜 + - `fillPickerField` 步骤0 用 `field.pickerDropdownElement`(pickerDetector 保存的引用)在弹出层内搜索 + - `fillPickerField` 步骤4 在 DOM 差异检测到的 `dropdownEl` 内搜索 + - `fillSearchPickerField` 轮询检测到的 `newPopupEl` 传给 `clickBestOptionInDropdown` 和 `findAndClickOptionInVisiblePopups` +- **fillPickerField 步骤0**:pickerDetector 可能已展开弹出层(toggle 冲突问题),所以在步骤1之前先尝试在当前页面搜索匹配选项,避免二次点击关闭弹出层。**不可删除此步骤** +- **isAsyncDropdown 字段的特殊处理**(FormLabelItem.isAsyncDropdown = true): + - 接口异步下拉字段(如学校、专业)输入值后需轮询等待接口返回数据(最多3秒) + - 弹出层检测阈值为 ≥1 个可见子元素(普通字段为 ≥2) + - 检测到弹出层后优先用 `clickBestOptionInDropdown(newPopupEl)` 在弹出层内直接做文字匹配 + - 禁止对 isAsyncDropdown 字段走 `tryClickDropdownListItem` 全页面搜(选项数可能为1,全页面搜会被其他同规格元素干扰) +- `pickerFill.ts` 中 `groupBySpec` / `findVisibleListGroups` 的同规格标签组阈值为 ≥2(不可改回 ≥3,否则只有2个选项的下拉列表会漏匹配) +- `clickBestOptionInDropdown` 是在指定弹出层 DOM 内递归搜所有可见叶子文字节点做模糊匹配的核心方法,**不可简化其递归逻辑** diff --git a/src/components/SidebarPanel.tsx b/src/components/SidebarPanel.tsx index 7e5882c..31936cf 100644 --- a/src/components/SidebarPanel.tsx +++ b/src/components/SidebarPanel.tsx @@ -7,15 +7,8 @@ import { useState, useEffect } from "react" import { getCookieValue } from "~utils/cookie" import { getCustomizeResume } from "~api/aiApi" -import { fillMatchedField, delay, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill" -import { extractDomStructure, detectPageLanguage, isJobApplicationForm } from "~lib/dom" -import { matchFormFields, matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher" -import { detectPickerField } from "~lib/pickerDetector" -import { detectAndUploadResume } from "~lib/resumeUpload" -import { getMockResumeData2 } from "~lib/constants" -import { getResumeFieldValue } from "~lib/resumeDataHelper" -import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer } from "~lib/experienceSection" -import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, UnmatchedFormField } from "~lib/types" +import { handleAutoFillCommon } from "~handlers/handleAutoFillCommon" +import type { MatchedFormField, ResumeData, JobInfo } from "~lib/types" import "./SidebarPanel.scss" /** 侧边栏面板的 Props */ @@ -71,430 +64,21 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) /** * 自动填写按钮点击处理 - * 流程:提取 DOM → 检测语言 → 判断是否表单页 → 检测简历上传 → 匹配字段 → 识别选择器 → 填充测试数据 + * 内部根据条件判断走 handlers/ 下具体哪个处理文件: + * - handleAutoFillCommon:通用模式(当前默认) + * - 后续特殊网站会在此处加条件分支(如根据 domain 或 jobInfo 来源判断) */ const handleAutoFill = async () => { setFilling(true) try { - // 1. 提取 DOM 结构 - const domStructure = extractDomStructure() - console.log("===== OfferPie: 完整 DOM 树结构 =====") - console.log(domStructure) - console.log(`===== OfferPie: 结构总长度 ${domStructure.length} 字符 =====`) - - // 2. 检测页面语言,更新到页面参数 - const lang = detectPageLanguage(domStructure) - setPageLang(lang) - console.log(`===== OfferPie: 页面语言检测结果 = ${lang} =====`) - - // 3. 判断是否为职位申请表单页面 - const isForm = isJobApplicationForm(document.body, lang) - setIsFormPage(isForm) - console.log(`===== OfferPie: 是否为职位申请表单页面 = ${isForm} =====`) - - if (isForm) { - // 4. 获取简历数据(优先使用接口数据,无接口数据时 fallback 到 mock) - const currentResumeData = resumeData || getMockResumeData2() - if (!resumeData) setResumeData(currentResumeData) - console.log(`===== OfferPie: 已加载简历数据,教育${currentResumeData.education.length}段 工作${currentResumeData.work.length}段 实习${currentResumeData.internship.length}段 项目${currentResumeData.project.length}段 竞赛${currentResumeData.competition.length}段 =====`) - - // 4.1 检测并上传简历文件 - const resumeUrl = "https://offerpie.oss-cn-guangzhou.aliyuncs.com/%E6%B4%AA%E8%B5%AB%E2%80%94%E6%95%B0%E6%8D%AE%E3%80%81AI%E7%AE%97%E6%B3%95%E5%B7%A5%E7%A8%8B%E5%B8%88.pdf" - const uploaded = await detectAndUploadResume(resumeUrl) - console.log(`===== OfferPie: 简历上传 ${uploaded ? "成功" : "跳过(未找到上传按钮或失败)"} =====`) - if (uploaded) await delay(1000) // 等待网站解析简历 - - // 4.5 定位经历区块并统计已展开段数 - const sectionResults = locateExperienceSections(document.body, lang) - // 4.6 对比简历数据段数,点击添加按钮补足不够的段数 - const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang) - - // 4.7 检测第一段经历是否已被网站自动填写(上传简历后网站可能自动解析填入) - let skipPhaseA = false - for (const result of expandedResults) { - if (!result.titleElement || result.expandedCount === 0) continue - const section = result.section as ExperienceSection - const segments = result.segmentRanges - if (segments.length === 0) continue - - // 检查第一段经历的核心字段(学校/公司/项目名称)是否已有值 - const firstSeg = segments[0] - if (firstSeg.containerElement) { - const inputs = firstSeg.containerElement.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])" - ) - for (const inp of Array.from(inputs)) { - const inputEl = inp as HTMLInputElement | HTMLTextAreaElement - if (inputEl.value && inputEl.value.trim().length > 0) { - skipPhaseA = true - console.log(`===== OfferPie: 检测到第一段经历[${section}]已有数据("${inputEl.value.trim().substring(0, 20)}"),跳过阶段A =====`) - break - } - } - } - if (skipPhaseA) break - } - - // 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 - let lastTextInput: HTMLInputElement | HTMLTextAreaElement | null = null - let lastTextInputIsPicker = false - - // 如果网站已自动填写经历,跳过阶段A,只记录排除范围 - if (skipPhaseA) { - console.log("===== OfferPie: 阶段A 已跳过(网站已自动填写经历) =====") - for (const result of expandedResults) { - if (!result.titleElement) continue - 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 }) - } - } else { - - 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} =====`) - - } // end of else (skipPhaseA) - - // 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.inputElement && f.inputElement.value && f.inputElement.value.trim().length > 0) { - console.log(` [${f.key}] "${f.labelText}" 已有值="${f.inputElement.value.trim()}",跳过`) - skipped++ - continue - } - - 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([...mainFields]) - console.log(`===== OfferPie: 阶段B完成 总计成功${success} 失败${failed} 跳过${skipped} =====`) - - // 7. 阶段C - 收集剩余空白输入框,调用AI接口填写 - console.log("===== OfferPie: 阶段C - AI辅助填写剩余空白字段 =====") - - // 需要过滤的标签文字(这些不是有效标签) - const EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", ":", "请选择", "选择", "*", "必填"] - - // 收集非经历区域内所有空白输入框 - const allInputsOnPage = document.body.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 unmatchedFields: UnmatchedFormField[] = [] - - for (const inp of Array.from(allInputsOnPage)) { - const inputEl = inp as HTMLInputElement | HTMLTextAreaElement - // 跳过已使用的 input - if (usedInputs.has(inp)) continue - // 跳过已有值的 input - if (inputEl.value && inputEl.value.trim().length > 0) continue - // 跳过在经历区域范围内的 input - let inExcludeRange = false - for (const range of excludeRanges) { - const afterStart = range.start === inp || (range.start.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) - const beforeEnd = !range.end || (range.end.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING) - if (afterStart && beforeEnd) { inExcludeRange = true; break } - } - if (inExcludeRange) continue - - // 向上查找最近的标签文字 - let labelText = "" - let labelElement: Element | null = null - - // 策略1:查找 input 所在表单项容器内的标签 - const formItemSelectors = [ - ".form-item", ".form-group", ".form-field", - ".el-form-item", ".ant-form-item", ".ant-row", - ".arco-form-item", ".t-form-item", ".n-form-item", - "[class*='form-item']", "[class*='form-group']", "[class*='formItem']", - ] - let container: Element | null = null - for (const sel of formItemSelectors) { - container = inp.closest(sel) - if (container) break - } - if (container) { - const labelEls = container.querySelectorAll("label, span, div, td, th, p, legend, dt") - for (const el of Array.from(labelEls)) { - // 只取直接文本内容 - const directText = Array.from(el.childNodes) - .filter((n) => n.nodeType === Node.TEXT_NODE) - .map((n) => n.textContent?.trim()) - .filter(Boolean) - .join("") - if (!directText || directText.length > 30) continue - // 过滤无效标签文字 - if (EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue - // 确保标签在 input 之前 - if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) { - labelText = directText - labelElement = el - break - } - } - } - - // 策略2:向前查找兄弟/父级中的标签 - if (!labelText) { - let prev: Element | null = inp.previousElementSibling - for (let i = 0; i < 3 && prev; i++) { - const text = prev.textContent?.trim() || "" - if (text && text.length <= 20 && !EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) { - labelText = text - labelElement = prev - break - } - prev = prev.previousElementSibling - } - } - - if (!labelText || !labelElement) continue - - // 确定表单类型 - let formType: UnmatchedFormField["formType"] = "input" - if (inputEl.tagName === "TEXTAREA") { - formType = "textarea" - } else if (inputEl.hasAttribute("readonly") || inputEl.closest("[class*='select']") || inputEl.closest("[class*='picker']")) { - formType = "select" - } - - unmatchedFields.push({ - labelText, - labelElement, - inputElement: inputEl, - radioContainer: null, - formType, - isPicker: formType === "select", - fillValue: "", - alreadyFilled: false, - }) - usedInputs.add(inp) - } - - console.log(` 收集到 ${unmatchedFields.length} 个待填写的空白字段`) - - // 打印收集到的空白字段详情 - for (const uf of unmatchedFields) { - console.log(` [待填写] "${uf.labelText}" | formType: ${uf.formType} | isPicker: ${uf.isPicker}`) - } - - console.log(`===== OfferPie: 阶段C完成 收集到 ${unmatchedFields.length} 个空白字段 =====`) - } else { - setFormFields([]) - console.log("===== OfferPie: 当前页面不是职位申请表单,跳过字段匹配 =====") - } + const fillResult = await handleAutoFillCommon({ resumeData, jobInfo }) + setPageLang(fillResult.lang) + setIsFormPage(fillResult.isFormPage) + if (fillResult.resumeData) setResumeData(fillResult.resumeData) + setFormFields(fillResult.formFields) } catch (e) { - console.error("OfferPie: 获取页面结构失败", e) + console.error("OfferPie: 自动填写异常", e) } - // 1秒后恢复按钮状态 setTimeout(() => setFilling(false), 1000) } @@ -502,8 +86,8 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
{/* 顶部操作栏:关闭按钮始终显示,反馈和设置仅登录后显示 */}
- {isLoggedIn && 反馈12345} - {isLoggedIn && 设置} + {isLoggedIn && 反馈} + {/*{isLoggedIn && 设置}*/}