处理北森级联选择器操作bug,修改插件面板功能,添加填写表单填写动态跟踪进度区、简历外表单信息自动保存功能和手动修改面板、插件收起、插件面板拖动功能

This commit is contained in:
2026-06-18 16:42:02 +08:00
parent 6be840a416
commit bca1ca3182
9 changed files with 2499 additions and 310 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

+428 -38
View File
@@ -8,46 +8,125 @@ $radius-sm: 12px;
position: fixed; position: fixed;
top: 10px; top: 10px;
right: 10px; right: 10px;
width: 384px; width: 380px;
min-height: 480px; min-height: 470px;
max-height: calc(100vh - 20px); max-height: calc(100vh - 20px);
background: #fff; background: #fff;
border-radius: $radius-lg; border-radius: $radius-lg;
padding: 20px 20px 24px;
font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(0, 0, 0, 0.06); box-shadow: 0 8px 40px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(0, 0, 0, 0.06);
z-index: 2147483647; z-index: 2147483647;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto;
color: $primary; color: $primary;
font-size: 14px; font-size: 14px;
font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif;
user-select: none;
/* 收起状态 */
&--collapsed {
width: auto;
min-height: auto;
max-height: none;
background: transparent;
box-shadow: none;
border-radius: 50%;
}
}
/* 收起状态的 logo 圆圈 */
.op-collapsed-logo {
width: 60px;
height: 60px;
border-radius: 50%;
background: #fff;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 6px 28px rgba(0, 0, 0, 0.18);
}
}
/* 拖拽提示气泡 */
.op-drag-tooltip {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #fff;
color: #333;
font-size: 13px;
padding: 8px 16px;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
white-space: nowrap;
z-index: 10000;
pointer-events: none;
}
/* 内层容器:承载 padding 和滚动 */
.op-inner {
padding: 20px 20px 24px;
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
}
/* 面板填写遮罩:和 op-inner 同级,覆盖整个 op-container */
.op-filling-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.05);
border-radius: $radius-lg;
z-index: 9999;
} }
.op-header { .op-header {
display: flex; display: flex;
justify-content: flex-end; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 4px; margin-bottom: 12px;
gap: 12px; gap: 12px;
&-link { &-left {
display: flex;
align-items: center;
gap: 8px;
}
&-logo {
flex-shrink: 0;
}
&-status {
font-size: 13px; font-size: 13px;
color: #333; color: #555;
cursor: pointer; font-weight: 500;
text-decoration: underline;
text-underline-offset: 3px;
} }
} }
.op-close-btn { .op-collapse-btn {
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
padding: 2px; padding: 4px;
display: flex; display: flex;
align-items: center; align-items: center;
color: #333; color: #666;
border-radius: 4px;
transition: background 0.15s;
&:hover {
background: #f0f0f0;
}
} }
.op-title { .op-title {
@@ -61,8 +140,6 @@ $radius-sm: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 14px 16px;
background: $bg-card;
border-radius: $radius-md; border-radius: $radius-md;
margin-bottom: 24px; margin-bottom: 24px;
} }
@@ -103,15 +180,15 @@ $radius-sm: 12px;
.op-autofill-btn { .op-autofill-btn {
width: 100%; width: 100%;
height: 52px; height: 40px;
background: $primary; background: #52CAD1;
color: #fff; color: #fff;
border: none; border: none;
border-radius: $radius-md; border-radius: 4px;
font-size: 20px; font-size: 16px;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
margin-bottom: 12px; margin-bottom: 6px;
transition: opacity 0.2s; transition: opacity 0.2s;
&:hover { opacity: 0.85; } &:hover { opacity: 0.85; }
@@ -120,11 +197,11 @@ $radius-sm: 12px;
.op-credits { .op-credits {
&-row { &-row {
display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 20px; margin-bottom: 2px;
padding: 0 2px; padding: 0 2px;
text-align: center;
} }
&-text { &-text {
@@ -133,11 +210,12 @@ $radius-sm: 12px;
} }
&-link { &-link {
font-size: 13px; font-size: 12px;
font-weight: 500; font-weight: 500;
text-decoration: underline; text-decoration: underline;
text-underline-offset: 3px; text-underline-offset: 3px;
cursor: pointer; cursor: pointer;
color: #666;
} }
} }
@@ -152,23 +230,21 @@ $radius-sm: 12px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 12px 14px; padding: 2px 0px;
background: $bg-card;
border-radius: $radius-sm; border-radius: $radius-sm;
margin-bottom: 12px; margin-bottom: 12px;
} }
&-avatar { &-avatar {
width: 32px; width: 36px;
height: 32px; height: 16px;
border-radius: 50%; border-radius: 2px;
background: #e8e8e8; background: #E7F8F9;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 14px; font-size: 8px;
font-weight: 600; color: #52CAD1;
color: #555;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -184,6 +260,7 @@ $radius-sm: 12px;
&-name { &-name {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color:#666;
} }
&-tag { &-tag {
@@ -233,21 +310,21 @@ $radius-sm: 12px;
} }
&-value { &-value {
font-size: 14px; font-size: 12px;
font-weight: 700; color:#666;
} }
&-bar-bg { &-bar-bg {
width: 100%; width: 100%;
height: 6px; height: 6px;
background: #f0f0f0; background: #D7F3F4;
border-radius: 3px; border-radius: 3px;
} }
&-bar-fill { &-bar-fill {
width: 50%; width: 50%;
height: 100%; height: 100%;
background: $primary; background: #52CAD1;
border-radius: 3px; border-radius: 3px;
transition: width 0.3s; transition: width 0.3s;
} }
@@ -284,3 +361,316 @@ $radius-sm: 12px;
&:hover { opacity: 0.85; } &:hover { opacity: 0.85; }
} }
/* ============ 字段列表区域(待填写 / 已填写) ============ */
.op-field-section {
margin-top: 16px;
&-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
color: #333;
}
&-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&--red { background: #ff4d4f; }
&--green { background: #34c759; }
}
&-count {
font-size: 11px;
font-weight: 500;
color: #999;
margin-left: 2px;
}
&-required-count {
font-size: 11px;
font-weight: 500;
color: #ff4d4f;
margin-left: 4px;
}
}
.op-field-list {
max-height: 301px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 10px;
padding: 6px 0;
/* 自定义滚动条 */
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 2px;
}
}
.op-field-group {
&-title {
font-size: 12.5px;
font-weight: 600;
color: #222;
padding: 10px 14px 4px;
}
&-segment {
font-size: 11px;
font-weight: 500;
color: #888;
padding: 6px 14px 2px 22px;
border-top: 1px dashed #f0f0f0;
margin-top: 2px;
}
}
.op-field-item {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
cursor: pointer;
transition: background 0.15s;
user-select: none;
&:hover {
background: #f5f8fa;
}
&-check {
flex-shrink: 0;
}
&-icon {
flex-shrink: 0;
}
&-name {
font-size: 12.5px;
color: #333;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-required {
font-size: 10px;
color: #ff4d4f;
font-weight: 500;
flex-shrink: 0;
padding: 1px 5px;
border: 1px solid #ffccc7;
border-radius: 4px;
line-height: 14px;
}
}
/* ============ 助手已记住的表单 弹窗 ============ */
.op-cache-modal-mask {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.4);
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
}
.op-cache-modal {
width: 700px;
height: 500px;
background: #fff;
border-radius: 12px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.18);
display: flex;
flex-direction: column;
overflow: hidden;
}
.op-cache-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
.op-cache-modal-title {
font-size: 16px;
font-weight: 700;
color: #222;
}
.op-cache-modal-close {
background: none;
border: none;
font-size: 18px;
color: #999;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.15s;
&:hover {
background: #f5f5f5;
color: #333;
}
}
.op-cache-modal-body {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 3px;
}
}
.op-cache-modal-empty {
text-align: center;
color: #999;
font-size: 14px;
padding: 60px 0;
}
.op-cache-modal-section {
margin-bottom: 20px;
&-title {
font-size: 15px;
font-weight: 700;
color: #111;
margin-bottom: 12px;
padding-bottom: 6px;
border-bottom: 1px solid #eee;
}
}
.op-cache-modal-segment {
margin-bottom: 14px;
&-title {
font-size: 13px;
font-weight: 600;
color: #555;
margin-bottom: 8px;
padding-left: 4px;
}
}
.op-cache-modal-fields {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.op-cache-modal-field {
display: inline-flex;
align-items: center;
gap: 6px;
/* 长文本 textarea 独占一行 */
&--full {
width: 100%;
flex-direction: column;
align-items: flex-start;
}
}
.op-cache-modal-label {
font-size: 12px;
color: #555;
white-space: nowrap;
flex-shrink: 0;
}
.op-cache-modal-input {
width: 200px;
height: 30px;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0 8px;
font-size: 13px;
color: #333;
outline: none;
transition: border-color 0.2s;
&:focus {
border-color: #52CAD1;
}
}
.op-cache-modal-textarea {
width: 100%;
border: 1px solid #ddd;
border-radius: 4px;
padding: 6px 8px;
font-size: 13px;
color: #333;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color 0.2s;
&:focus {
border-color: #52CAD1;
}
}
.op-cache-modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
flex-shrink: 0;
}
.op-cache-modal-btn {
height: 34px;
padding: 0 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
&:hover { opacity: 0.85; }
&--clear {
background: #fff1f0;
color: #ff4d4f;
border: 1px solid #ffccc7;
}
&--save {
background: #52CAD1;
color: #fff;
}
}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -12,10 +12,10 @@ const envConfigs: Record<string, {
cookieSourceUrl: string cookieSourceUrl: string
}> = { }> = {
dev: { dev: {
dataBaseApi: 'http://localhost:8080/api', dataBaseApi: 'http://127.0.0.1:8080/api',
// aiBaseApi: 'http://192.168.31.133:8000', // aiBaseApi: 'http://192.168.31.133:8000',
aiBaseApi: 'http://localhost:8000', aiBaseApi: 'http://127.0.0.1:8000',
cookieSourceUrl: 'http://192.168.31.135:5173', cookieSourceUrl: 'http://127.0.0.1:5173',
}, },
prod: { prod: {
dataBaseApi: 'https://www.offerpai.com.cn/api', dataBaseApi: 'https://www.offerpai.com.cn/api',
+357 -21
View File
@@ -7,13 +7,14 @@
* 不要在此文件中自行编写选择器操作逻辑,必须引用 lib 中已封装的方法。 * 不要在此文件中自行编写选择器操作逻辑,必须引用 lib 中已封装的方法。
*/ */
import { fillMatchedField, delay, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill" import { fillMatchedField, delay, forceSetValue, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill"
import { extractDomStructure, detectPageLanguage, isJobApplicationForm, buildSelector } from "~lib/dom" import { extractDomStructure, detectPageLanguage, isJobApplicationForm, buildSelector } from "~lib/dom"
import { matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher" import { matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher"
import { detectPickerField } from "~lib/pickerDetector" import { detectPickerField } from "~lib/pickerDetector"
import { detectAndUploadResume } from "~lib/resumeUpload" import { detectAndUploadResume } from "~lib/resumeUpload"
import { getMockResumeData2, JOB_FORM_LABELS } from "~lib/constants" import { getMockResumeData2, JOB_FORM_LABELS } from "~lib/constants"
import { getResumeFieldValue } from "~lib/resumeDataHelper" import { getResumeFieldValue } from "~lib/resumeDataHelper"
import { fillDatePicker } from "~lib/datePicker"
import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer, getAllPageTitles, findAddButton, clickAddButton } from "~lib/experienceSection" import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer, getAllPageTitles, findAddButton, clickAddButton } from "~lib/experienceSection"
import type { ExperienceSectionLocateResult } from "~lib/experienceSection" import type { ExperienceSectionLocateResult } from "~lib/experienceSection"
import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, UnmatchedFormField } from "~lib/types" import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, UnmatchedFormField } from "~lib/types"
@@ -45,6 +46,10 @@ export interface AutoFillBeisenResult {
formFields: MatchedFormField[] formFields: MatchedFormField[]
/** 收集到的待填写空白字段 */ /** 收集到的待填写空白字段 */
unmatchedFields: UnmatchedFormField[] unmatchedFields: UnmatchedFormField[]
/** 经历段落定位结果(用于 fillStats 分段展示) */
sectionResults: ExperienceSectionLocateResult[]
/** 经历段落展开结果(用于 fillStats 分段展示) */
expandedResults: ExperienceSectionLocateResult[]
} }
/** /**
@@ -58,6 +63,8 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
resumeData: params.resumeData, resumeData: params.resumeData,
formFields: [], formFields: [],
unmatchedFields: [], unmatchedFields: [],
sectionResults: [],
expandedResults: [],
} }
// 1. 提取 DOM 结构 // 1. 提取 DOM 结构
@@ -101,6 +108,10 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
// 4.6 对比简历数据段数,点击添加按钮补足不够的段数 // 4.6 对比简历数据段数,点击添加按钮补足不够的段数
const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang) const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang)
// 存入 result 供外部使用(如 fillStats 分段展示)
result.sectionResults = sectionResults
result.expandedResults = expandedResults
// 4.65 【预匹配+标绿】经历段落添加完成后,一次性预匹配所有有简历数据的字段并标绿背景 // 4.65 【预匹配+标绿】经历段落添加完成后,一次性预匹配所有有简历数据的字段并标绿背景
// 使用临时 usedInputs,不影响后续正式流程的匹配 // 使用临时 usedInputs,不影响后续正式流程的匹配
{ {
@@ -318,7 +329,14 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
const ok = await fillSearchPickerField(f) const ok = await fillSearchPickerField(f)
if (ok) { result.success++ } else { result.failed++ } if (ok) { result.success++ } else { result.failed++ }
phaseAFields.push({ labelText: f.labelText, inputElement: f.inputElement, filled: ok, fillValue: f.fillValue, section: f.section, segmentIndex: segIdx }) phaseAFields.push({ labelText: f.labelText, inputElement: f.inputElement, filled: ok, fillValue: f.fillValue, section: f.section, segmentIndex: segIdx })
await delay(200) await delay(100)
continue
} else if (isBeisenCascadeField(f.labelText) && f.inputElement) {
// 北森多级级联选择器字段(地区、籍贯、民族等)
const ok = await fillBeisenCascadePicker(f.inputElement, f.labelText, f.fillValue)
if (ok) { result.success++ } else { result.failed++ }
phaseAFields.push({ labelText: f.labelText, inputElement: f.inputElement, filled: ok, fillValue: f.fillValue, section: f.section, segmentIndex: segIdx })
await delay(150)
continue continue
} else { } else {
await detectPickerField(f, lang) await detectPickerField(f, lang)
@@ -333,7 +351,7 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
if (lastTextInput && !lastTextInputIsPicker) { if (lastTextInput && !lastTextInputIsPicker) {
;(lastTextInput as HTMLElement).click() ;(lastTextInput as HTMLElement).click()
lastTextInput.focus() lastTextInput.focus()
await delay(100) await delay(50)
lastTextInput.blur() lastTextInput.blur()
} else { } else {
if (document.activeElement instanceof HTMLElement) { if (document.activeElement instanceof HTMLElement) {
@@ -344,7 +362,7 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
} }
document.body.click() document.body.click()
} }
await delay(300) await delay(150)
if (!f.isPicker && f.inputElement && !isTimePeriodField(f.key) && !isTimeSingleField(f.key)) { if (!f.isPicker && f.inputElement && !isTimePeriodField(f.key) && !isTimeSingleField(f.key)) {
lastTextInput = f.inputElement lastTextInput = f.inputElement
@@ -377,6 +395,15 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
if (!f.fillValue) { result.skipped++; continue } if (!f.fillValue) { result.skipped++; continue }
// 北森多级级联选择器字段优先处理(地区、籍贯、民族等)
if (isBeisenCascadeField(f.labelText) && f.inputElement) {
console.log(` [${f.key}] "${f.labelText}" → 北森级联选择器 | fillValue: "${f.fillValue}"`)
const ok = await fillBeisenCascadePicker(f.inputElement, f.labelText, f.fillValue)
if (ok) { result.success++ } else { result.failed++ }
await delay(150)
continue
}
await detectPickerField(f, lang) await detectPickerField(f, lang)
console.log( console.log(
@@ -391,7 +418,7 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
if (lastTextInput) { if (lastTextInput) {
;(lastTextInput as HTMLElement).click() ;(lastTextInput as HTMLElement).click()
lastTextInput.focus() lastTextInput.focus()
await delay(100) await delay(50)
lastTextInput.blur() lastTextInput.blur()
} else { } else {
if (document.activeElement instanceof HTMLElement) { if (document.activeElement instanceof HTMLElement) {
@@ -402,7 +429,7 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
} }
document.body.click() document.body.click()
} }
await delay(300) await delay(150)
if (!f.isPicker && f.inputElement) lastTextInput = f.inputElement if (!f.isPicker && f.inputElement) lastTextInput = f.inputElement
} }
@@ -1323,6 +1350,61 @@ async function b2FillSingleField(
return await fillMatchedField(field) return await fillMatchedField(field)
} }
/** 时间/日期相关的标签关键字 */
const B2_DATE_TIME_KEYWORDS = ["时间", "日期", "日期时间", "开始", "结束", "起始", "截止", "入职", "离职", "毕业"]
/**
* 判断标签名是否为时间/日期类字段
* 标签文字中包含时间/日期相关关键字即认为是时间字段
*/
function b2IsDateTimeLabel(label: string): boolean {
return B2_DATE_TIME_KEYWORDS.some((kw) => label.includes(kw))
}
/**
* 阶段B2:填写时间/日期字段
* 走和阶段A同款的流程:直接点击 input → fillDatePicker
* 不经过 detectPickerField(避免方式3主动点击导致 toggle 问题)
* 不经过 fillPickerField(避免步骤1-2再次点击关闭弹出层)
*
* 引用方式和阶段A的 fillTimePeriodField 情况A2 一致:
* input.focus() → input.click() → delay → fillDatePicker(field)
*/
async function b2FillDateTimeField(
inputEl: HTMLInputElement | HTMLTextAreaElement,
labelText: string,
fillValue: string
): Promise<boolean> {
// 构造 MatchedFormField(和阶段A一样,isPicker=true,不设 pickerDropdownElement
const field: MatchedFormField = {
key: "", section: "main", resumeField: "",
sectionIndex: 0, labelText,
labelElement: inputEl.previousElementSibling || inputEl.parentElement || inputEl,
labelSelector: "",
inputElement: inputEl,
inputSelector: buildSelector(inputEl),
buttonElement: null, buttonSelector: "",
inputType: "text",
radioContainer: null,
isPicker: true,
pickerDropdownElement: null, pickerDropdownSelector: "",
fillValue,
}
// 和阶段A同样的方式:点击 input 展开日期面板,然后直接调 fillDatePicker
inputEl.focus()
;(inputEl as HTMLElement).click()
await delay(150)
const ok = await fillDatePicker(field)
if (ok) {
console.log(`OfferPie: ✅ [B2-时间字段] "${labelText}" = "${fillValue}" 填写成功`)
} else {
console.log(`OfferPie: ❌ [B2-时间字段] "${labelText}" = "${fillValue}" 填写失败`)
}
return ok
}
/** /**
* 北森特有:阶段B2 填写 phoenix-radio-group 单选组字段 * 北森特有:阶段B2 填写 phoenix-radio-group 单选组字段
* 在指定大标题范围内,通过标签文字找到对应的 radio group,点击匹配选项 * 在指定大标题范围内,通过标签文字找到对应的 radio group,点击匹配选项
@@ -1378,21 +1460,21 @@ async function b2FillBeisenRadioGroup(
const childEl = item.firstElementChild as HTMLElement | null const childEl = item.firstElementChild as HTMLElement | null
if (childEl) { if (childEl) {
childEl.click() childEl.click()
await delay(100) await delay(50)
// 子子级也点一下确保触发 // 子子级也点一下确保触发
const grandChildEl = childEl.firstElementChild as HTMLElement | null const grandChildEl = childEl.firstElementChild as HTMLElement | null
if (grandChildEl) { if (grandChildEl) {
grandChildEl.click() grandChildEl.click()
await delay(100) await delay(50)
} }
} else { } else {
// fallback:直接点击 radioItem 自身 // fallback:直接点击 radioItem 自身
;(item as HTMLElement).click() ;(item as HTMLElement).click()
await delay(100) await delay(50)
} }
// 4. 验证选中结果:检查子级是否出现 phoenix-radio--checked 类名 // 4. 验证选中结果:检查子级是否出现 phoenix-radio--checked 类名
await delay(200) await delay(100)
const checkedEl = item.querySelector(".phoenix-radio--checked") const checkedEl = item.querySelector(".phoenix-radio--checked")
if (checkedEl) { if (checkedEl) {
// 选中成功,radio group 容器变 greenTwo // 选中成功,radio group 容器变 greenTwo
@@ -1480,9 +1562,19 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
if (!field.value) { result.skipped++; continue } if (!field.value) { result.skipped++; continue }
const inputEl = b2FindInputByLabelInContainer(field.label, seg.containerElement, usedInputs) const inputEl = b2FindInputByLabelInContainer(field.label, seg.containerElement, usedInputs)
if (!inputEl) { result.skipped++; continue } if (!inputEl) { result.skipped++; continue }
const ok = await b2FillSingleField(inputEl, field.label, field.value, lang) // 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } if (b2IsDateTimeLabel(field.label)) {
await delay(200) const ok = await b2FillDateTimeField(inputEl, field.label, field.value)
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
} else if (isBeisenCascadeField(field.label)) {
// 北森多级级联选择器字段(地区、籍贯、民族等)
const ok = await fillBeisenCascadePicker(inputEl, field.label, field.value)
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
} else {
const ok = await b2FillSingleField(inputEl, field.label, field.value, lang)
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
}
await delay(100)
} }
} }
} else { } else {
@@ -1532,7 +1624,7 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
}).length }).length
const clicked = await clickAddButton(addBtn, titleEl, nextTitleEl, beforeCount) const clicked = await clickAddButton(addBtn, titleEl, nextTitleEl, beforeCount)
if (!clicked) { console.log(` [B2] "${sectionData.title}" 点击添加按钮无效`); break } if (!clicked) { console.log(` [B2] "${sectionData.title}" 点击添加按钮无效`); break }
await delay(300) await delay(150)
} }
} }
@@ -1542,9 +1634,19 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
if (!field.value) { result.skipped++; continue } if (!field.value) { result.skipped++; continue }
const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs) const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs)
if (!inputEl) { result.skipped++; continue } if (!inputEl) { result.skipped++; continue }
const ok = await b2FillSingleField(inputEl, field.label, field.value, lang) // 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } if (b2IsDateTimeLabel(field.label)) {
await delay(200) const ok = await b2FillDateTimeField(inputEl, field.label, field.value)
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
} else if (isBeisenCascadeField(field.label)) {
// 北森多级级联选择器字段(地区、籍贯、民族等)
const ok = await fillBeisenCascadePicker(inputEl, field.label, field.value)
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
} else {
const ok = await b2FillSingleField(inputEl, field.label, field.value, lang)
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
}
await delay(100)
} }
} }
} }
@@ -1555,15 +1657,25 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
if (!field.value) { result.skipped++; continue } if (!field.value) { result.skipped++; continue }
const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs) const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs)
if (inputEl) { if (inputEl) {
// 普通 input/textarea 字段 // 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField
const ok = await b2FillSingleField(inputEl, field.label, field.value, lang) if (b2IsDateTimeLabel(field.label)) {
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } const ok = await b2FillDateTimeField(inputEl, field.label, field.value)
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
} else if (isBeisenCascadeField(field.label)) {
// 北森多级级联选择器字段(地区、籍贯、民族等)
const ok = await fillBeisenCascadePicker(inputEl, field.label, field.value)
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
} else {
// 普通 input/textarea 字段
const ok = await b2FillSingleField(inputEl, field.label, field.value, lang)
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
}
} else { } else {
// 找不到 input,尝试北森 phoenix-radio-group 单选组 // 找不到 input,尝试北森 phoenix-radio-group 单选组
const radioOk = await b2FillBeisenRadioGroup(field.label, field.value, titleEl, nextTitleEl) const radioOk = await b2FillBeisenRadioGroup(field.label, field.value, titleEl, nextTitleEl)
if (radioOk) { result.success++ } else { result.skipped++ } if (radioOk) { result.success++ } else { result.skipped++ }
} }
await delay(200) await delay(100)
} }
} }
} }
@@ -1571,3 +1683,227 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
console.log(`===== OfferPie: 阶段B2完成 成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`) console.log(`===== OfferPie: 阶段B2完成 成功${result.success} 失败${result.failed} 跳过${result.skipped} =====`)
return result return result
} }
// ============================================================
// 北森多级级联选择器(cascade picker)检测与填写
// ============================================================
/**
* 北森多级级联选择器关键字
* 字段标签中包含以下关键字的,可能是级联选择器字段
*/
const BEISEN_CASCADE_KEYWORDS = ["地区", "居住地", "籍贯", "户籍", "地点", "所在地", "民族", "地址", "现居"]
/**
* 判断字段标签是否可能是北森多级级联选择器字段
* 通过标签文字中是否含有地区/籍贯/民族等关键字来判断
*/
export function isBeisenCascadeField(labelText: string): boolean {
return BEISEN_CASCADE_KEYWORDS.some((kw) => labelText.includes(kw))
}
/**
* 北森多级级联选择器填写方法
*
* 操作流程:
* 1. 点击字段对应的 input,等待弹出 DOM 面板
* 2. 在页面中找到 common-unmodeled-layer 类名的面板元素
* 3. 在面板内找到 phoenix-input__input 类名的 input 输入框
* 4. 先点击 clear-select-data 清空已选内容
* 5. 在输入框中输入要填写的值,等待 50ms
* 6. 检测选项集合位置(两种情况):
* - 情况1: left-container 下找 area-data-container,其子级 div 就是选项
* - 情况2: left-container 下找 list-data-container,其第1子div的第1子div的第1子div是选项集合
* 7. 在选项集合的第一个 div 里找 icon-container 的 span 并点击选中
* 8. 找 area-footer-button 里的确定按钮(phoenix-button / phoenix-button__wraper / phoenix-button__content 三级都点一下)
*
* @param inputEl - 字段对应的 input 元素(表单里那个输入框,用于点击触发弹出面板)
* @param labelText - 字段标签文字(用于日志)
* @param fillValue - 要填写的值
* @returns 是否填写成功
*/
export async function fillBeisenCascadePicker(
inputEl: HTMLInputElement | HTMLTextAreaElement,
labelText: string,
fillValue: string
): Promise<boolean> {
if (!inputEl || !fillValue) return false
console.log(`OfferPie: [北森级联] "${labelText}" 开始填写 "${fillValue}"`)
// 1. 点击 input 触发弹出面板
inputEl.scrollIntoView({ block: "center", behavior: "instant" })
;(inputEl as HTMLElement).click()
inputEl.focus()
await delay(400)
// 2. 在页面中找 common-unmodeled-layer 面板(取最后一个,即最新弹出的)
const allPanels = document.querySelectorAll(".common-unmodeled-layer")
const panelEl = allPanels.length > 0 ? (allPanels[allPanels.length - 1] as HTMLElement) : null
if (!panelEl) {
console.log(`OfferPie: [北森级联] "${labelText}" 未找到 common-unmodeled-layer 面板`)
return false
}
console.log(`OfferPie: [北森级联] "${labelText}" 找到级联面板 (共${allPanels.length}个,取最后一个)`)
// 3. 在面板内找搜索输入框(优先 phoenix-input__inputfallback 到面板内任意可见 input
let searchInput = panelEl.querySelector("input.phoenix-input__input") as HTMLInputElement | null
if (!searchInput) {
// fallback:在面板内找所有非 hidden 的 input,取第一个可见的
const allPanelInputs = panelEl.querySelectorAll("input:not([type='hidden'])")
console.log(`OfferPie: [北森级联] "${labelText}" phoenix-input__input 未找到,尝试 fallback (面板内共${allPanelInputs.length}个input)`)
for (const inp of Array.from(allPanelInputs)) {
const htmlInp = inp as HTMLInputElement
if (htmlInp.offsetHeight > 0 && htmlInp.offsetWidth > 0) {
searchInput = htmlInp
break
}
}
}
if (!searchInput) {
console.log(`OfferPie: [北森级联] "${labelText}" 面板内未找到搜索输入框,面板 innerHTML 前200字: ${panelEl.innerHTML.substring(0, 200)}`)
return false
}
// 4. 先点击 clear-select-data 清空已选内容
const clearBtn = panelEl.querySelector(".clear-select-data") as HTMLElement | null
if (clearBtn) {
clearBtn.click()
await delay(200) // 等待清空操作生效
console.log(`OfferPie: [北森级联] "${labelText}" 已清空已选内容`)
}
// 5. 在搜索输入框中输入要填写的值
searchInput.focus()
forceSetValue(searchInput, fillValue)
searchInput.dispatchEvent(new Event("input", { bubbles: true }))
searchInput.dispatchEvent(new Event("change", { bubbles: true }))
await delay(300) // 等待搜索结果刷新(太短会导致选项列表未更新,选到错误选项)
// 6. 检测选项集合位置(两种情况)
// 在 left-container 下查找
const leftContainer = panelEl.querySelector(".left-container") as HTMLElement | null
if (!leftContainer) {
console.log(`OfferPie: [北森级联] "${labelText}" 面板内未找到 left-container`)
return false
}
let optionClicked = false
// 情况1: area-data-container
const areaDataContainer = leftContainer.querySelector(".area-data-container") as HTMLElement | null
if (areaDataContainer) {
console.log(`OfferPie: [北森级联] "${labelText}" 检测到情况1 (area-data-container)`)
const firstOptionDiv = areaDataContainer.querySelector(":scope > div") as HTMLElement | null
if (firstOptionDiv) {
const iconSpan = firstOptionDiv.querySelector(".icon-container") as HTMLElement | null
if (iconSpan) {
iconSpan.click()
const svgEl = iconSpan.querySelector("svg") as unknown as HTMLElement | null
if (svgEl) svgEl.dispatchEvent(new MouseEvent("click", { bubbles: true }))
optionClicked = true
await delay(200)
console.log(`OfferPie: [北森级联] "${labelText}" 已点击第一个选项的 icon-container + svg`)
}
}
}
// 情况2: list-data-container
if (!optionClicked) {
const listDataContainer = leftContainer.querySelector(".list-data-container") as HTMLElement | null
if (listDataContainer) {
console.log(`OfferPie: [北森级联] "${labelText}" 检测到情况2 (list-data-container)`)
// list-data-container > 第1子div > 第1子div > 第1子div = 选项集合标签
const level1 = listDataContainer.firstElementChild as HTMLElement | null
const level2 = level1?.firstElementChild as HTMLElement | null
const level3 = level2?.firstElementChild as HTMLElement | null
if (level3) {
// 选项集合的第一个子div
const firstOption = level3.firstElementChild as HTMLElement | null
if (firstOption) {
const iconSpan = firstOption.querySelector(".icon-container") as HTMLElement | null
if (iconSpan) {
iconSpan.click()
// 同时点击 icon-container 里面的 svg
const svgEl = iconSpan.querySelector("svg") as unknown as HTMLElement | null
if (svgEl) svgEl.dispatchEvent(new MouseEvent("click", { bubbles: true }))
optionClicked = true
await delay(200)
console.log(`OfferPie: [北森级联] "${labelText}" 已点击第一个选项的 icon-container + svg`)
}
}
}
}
}
if (!optionClicked) {
console.log(`OfferPie: [北森级联] "${labelText}" 未找到可点击的选项`)
return false
}
// 8. 找确定按钮并点击(兼容两种 footer 类名:area-footer-button / selector-footer-button
// 【注意】footer 下有"取消"和"确定"两个按钮,querySelector 会取到第一个(取消),
// 所以必须通过文字"确定"精确定位 或 取最后一个 phoenix-button 才能点到确定按钮
const footerBtn = (panelEl.querySelector(".area-footer-button") || panelEl.querySelector(".selector-footer-button")) as HTMLElement | null
if (footerBtn) {
// 策略1:通过 phoenix-button__content 文字"确定"精确定位
const allContents = footerBtn.querySelectorAll(".phoenix-button__content")
let confirmContent: HTMLElement | null = null
for (const el of Array.from(allContents)) {
if (el.textContent?.trim() === "确定") {
confirmContent = el as HTMLElement
break
}
}
if (confirmContent) {
confirmContent.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }))
confirmContent.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }))
confirmContent.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))
// 同时点击它的父级 wraper 和祖父级 button
const wraper = confirmContent.parentElement as HTMLElement | null
if (wraper) {
wraper.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }))
wraper.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }))
wraper.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))
}
const btn = wraper?.parentElement as HTMLElement | null
if (btn) {
btn.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }))
btn.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }))
btn.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))
}
}
// 策略2:取最后一个 phoenix-button 也点击一下
const allBtns = footerBtn.querySelectorAll(".phoenix-button")
if (allBtns.length > 0) {
const lastBtn = allBtns[allBtns.length - 1] as HTMLElement
lastBtn.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }))
lastBtn.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }))
lastBtn.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))
// 最后一个 button 里的 wraper 和 content 也点
const lastWraper = lastBtn.querySelector(".phoenix-button__wraper") as HTMLElement | null
if (lastWraper) {
lastWraper.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }))
lastWraper.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }))
lastWraper.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))
}
const lastContent = lastBtn.querySelector(".phoenix-button__content") as HTMLElement | null
if (lastContent) {
lastContent.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }))
lastContent.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }))
lastContent.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))
}
}
await delay(300)
console.log(`OfferPie: [北森级联] "${labelText}" 已点击确定按钮`)
} else {
console.log(`OfferPie: [北森级联] "${labelText}" 未找到确定按钮`)
return false
}
console.log(`OfferPie: ✅ [北森级联] "${labelText}" = "${fillValue}" 填写成功`)
return true
}
+101 -17
View File
@@ -13,6 +13,7 @@ import { matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher"
import { detectPickerField } from "~lib/pickerDetector" import { detectPickerField } from "~lib/pickerDetector"
import { detectAndUploadResume } from "~lib/resumeUpload" import { detectAndUploadResume } from "~lib/resumeUpload"
import { getMockResumeData2, JOB_FORM_LABELS } from "~lib/constants" import { getMockResumeData2, JOB_FORM_LABELS } from "~lib/constants"
import { fillDatePicker } from "~lib/datePicker"
import { getResumeFieldValue } from "~lib/resumeDataHelper" import { getResumeFieldValue } from "~lib/resumeDataHelper"
import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer, getAllPageTitles, findAddButton, clickAddButton } from "~lib/experienceSection" import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer, getAllPageTitles, findAddButton, clickAddButton } from "~lib/experienceSection"
import type { ExperienceSectionLocateResult } from "~lib/experienceSection" import type { ExperienceSectionLocateResult } from "~lib/experienceSection"
@@ -45,6 +46,10 @@ export interface AutoFillCommonResult {
formFields: MatchedFormField[] formFields: MatchedFormField[]
/** 收集到的待填写空白字段 */ /** 收集到的待填写空白字段 */
unmatchedFields: UnmatchedFormField[] unmatchedFields: UnmatchedFormField[]
/** 经历段落定位结果(用于 fillStats 分段展示) */
sectionResults: ExperienceSectionLocateResult[]
/** 经历段落展开结果(用于 fillStats 分段展示) */
expandedResults: ExperienceSectionLocateResult[]
} }
/** /**
@@ -58,6 +63,8 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
resumeData: params.resumeData, resumeData: params.resumeData,
formFields: [], formFields: [],
unmatchedFields: [], unmatchedFields: [],
sectionResults: [],
expandedResults: [],
} }
// 1. 提取 DOM 结构 // 1. 提取 DOM 结构
@@ -101,6 +108,10 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
// 4.6 对比简历数据段数,点击添加按钮补足不够的段数 // 4.6 对比简历数据段数,点击添加按钮补足不够的段数
const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang) const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang)
// 存入 result 供外部使用(如 fillStats 分段展示)
result.sectionResults = sectionResults
result.expandedResults = expandedResults
// 4.65 【预匹配+标绿】经历段落添加完成后,一次性预匹配所有有简历数据的字段并标绿背景 // 4.65 【预匹配+标绿】经历段落添加完成后,一次性预匹配所有有简历数据的字段并标绿背景
// 使用临时 usedInputs,不影响后续正式流程的匹配 // 使用临时 usedInputs,不影响后续正式流程的匹配
{ {
@@ -318,7 +329,7 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
const ok = await fillSearchPickerField(f) const ok = await fillSearchPickerField(f)
if (ok) { result.success++ } else { result.failed++ } if (ok) { result.success++ } else { result.failed++ }
phaseAFields.push({ labelText: f.labelText, inputElement: f.inputElement, filled: ok, fillValue: f.fillValue, section: f.section, segmentIndex: segIdx }) phaseAFields.push({ labelText: f.labelText, inputElement: f.inputElement, filled: ok, fillValue: f.fillValue, section: f.section, segmentIndex: segIdx })
await delay(200) await delay(100)
continue continue
} else { } else {
await detectPickerField(f, lang) await detectPickerField(f, lang)
@@ -326,14 +337,14 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
if (ok) { result.success++ } else { result.failed++ } if (ok) { result.success++ } else { result.failed++ }
} }
// 收集阶段A已处理字段(用于末尾统计 // 收集阶段A已处理字段(用于末尾統計
phaseAFields.push({ labelText: f.labelText, inputElement: f.inputElement, filled: !!f.fillValue, fillValue: f.fillValue, section: f.section, segmentIndex: segIdx }) phaseAFields.push({ labelText: f.labelText, inputElement: f.inputElement, filled: !!f.fillValue, fillValue: f.fillValue, section: f.section, segmentIndex: segIdx })
// 关闭残留弹窗 // 关闭残留弹窗
if (lastTextInput && !lastTextInputIsPicker) { if (lastTextInput && !lastTextInputIsPicker) {
;(lastTextInput as HTMLElement).click() ;(lastTextInput as HTMLElement).click()
lastTextInput.focus() lastTextInput.focus()
await delay(100) await delay(50)
lastTextInput.blur() lastTextInput.blur()
} else { } else {
if (document.activeElement instanceof HTMLElement) { if (document.activeElement instanceof HTMLElement) {
@@ -344,7 +355,7 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
} }
document.body.click() document.body.click()
} }
await delay(300) await delay(150)
if (!f.isPicker && f.inputElement && !isTimePeriodField(f.key) && !isTimeSingleField(f.key)) { if (!f.isPicker && f.inputElement && !isTimePeriodField(f.key) && !isTimeSingleField(f.key)) {
lastTextInput = f.inputElement lastTextInput = f.inputElement
@@ -391,7 +402,7 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
if (lastTextInput) { if (lastTextInput) {
;(lastTextInput as HTMLElement).click() ;(lastTextInput as HTMLElement).click()
lastTextInput.focus() lastTextInput.focus()
await delay(100) await delay(50)
lastTextInput.blur() lastTextInput.blur()
} else { } else {
if (document.activeElement instanceof HTMLElement) { if (document.activeElement instanceof HTMLElement) {
@@ -402,7 +413,7 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
} }
document.body.click() document.body.click()
} }
await delay(300) await delay(150)
if (!f.isPicker && f.inputElement) lastTextInput = f.inputElement if (!f.isPicker && f.inputElement) lastTextInput = f.inputElement
} }
@@ -1035,7 +1046,7 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
} }
console.log("===== OfferPie: 网站未填表单字段数据(JSON =====") console.log("===== OfferPie: 网站未填表单字段数据(JSON =====")
console.log(JSON.stringify(unfilledFormData, null, 2)) // console.log(JSON.stringify(unfilledFormData, null, 2))
console.log("===== OfferPie: JSON 输出完毕 =====") console.log("===== OfferPie: JSON 输出完毕 =====")
// 存 localStorage 缓存,包含简历名字和未填字段数据 // 存 localStorage 缓存,包含简历名字和未填字段数据
@@ -1274,6 +1285,61 @@ async function b2FillSingleField(
return await fillMatchedField(field) return await fillMatchedField(field)
} }
/** 时间/日期相关的标签关键字 */
const B2_DATE_TIME_KEYWORDS = ["时间", "日期", "日期时间", "开始", "结束", "起始", "截止", "入职", "离职", "毕业"]
/**
* 判断标签名是否为时间/日期类字段
* 标签文字中包含时间/日期相关关键字即认为是时间字段
*/
function b2IsDateTimeLabel(label: string): boolean {
return B2_DATE_TIME_KEYWORDS.some((kw) => label.includes(kw))
}
/**
* 阶段B2:填写时间/日期字段
* 走和阶段A同款的流程:直接点击 input → fillDatePicker
* 不经过 detectPickerField(避免方式3主动点击导致 toggle 问题)
* 不经过 fillPickerField(避免步骤1-2再次点击关闭弹出层)
*
* 引用方式和阶段A的 fillTimePeriodField 情况A2 一致:
* input.focus() → input.click() → delay → fillDatePicker(field)
*/
async function b2FillDateTimeField(
inputEl: HTMLInputElement | HTMLTextAreaElement,
labelText: string,
fillValue: string
): Promise<boolean> {
// 构造 MatchedFormField(和阶段A一样,isPicker=true,不设 pickerDropdownElement
const field: MatchedFormField = {
key: "", section: "main", resumeField: "",
sectionIndex: 0, labelText,
labelElement: inputEl.previousElementSibling || inputEl.parentElement || inputEl,
labelSelector: "",
inputElement: inputEl,
inputSelector: buildSelector(inputEl),
buttonElement: null, buttonSelector: "",
inputType: "text",
radioContainer: null,
isPicker: true,
pickerDropdownElement: null, pickerDropdownSelector: "",
fillValue,
}
// 和阶段A同样的方式:点击 input 展开日期面板,然后直接调 fillDatePicker
inputEl.focus()
;(inputEl as HTMLElement).click()
await delay(150)
const ok = await fillDatePicker(field)
if (ok) {
console.log(`OfferPie: ✅ [B2-时间字段] "${labelText}" = "${fillValue}" 填写成功`)
} else {
console.log(`OfferPie: ❌ [B2-时间字段] "${labelText}" = "${fillValue}" 填写失败`)
}
return ok
}
/** /**
* 阶段B2 主流程:从缓存读取 unfilledFormData,填写有值的字段 * 阶段B2 主流程:从缓存读取 unfilledFormData,填写有值的字段
*/ */
@@ -1340,9 +1406,15 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
if (!field.value) { result.skipped++; continue } if (!field.value) { result.skipped++; continue }
const inputEl = b2FindInputByLabelInContainer(field.label, seg.containerElement, usedInputs) const inputEl = b2FindInputByLabelInContainer(field.label, seg.containerElement, usedInputs)
if (!inputEl) { result.skipped++; continue } if (!inputEl) { result.skipped++; continue }
const ok = await b2FillSingleField(inputEl, field.label, field.value, lang) // 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } if (b2IsDateTimeLabel(field.label)) {
await delay(200) const ok = await b2FillDateTimeField(inputEl, field.label, field.value)
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
} else {
const ok = await b2FillSingleField(inputEl, field.label, field.value, lang)
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
}
await delay(100)
} }
} }
} else { } else {
@@ -1392,7 +1464,7 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
}).length }).length
const clicked = await clickAddButton(addBtn, titleEl, nextTitleEl, beforeCount) const clicked = await clickAddButton(addBtn, titleEl, nextTitleEl, beforeCount)
if (!clicked) { console.log(` [B2] "${sectionData.title}" 点击添加按钮无效`); break } if (!clicked) { console.log(` [B2] "${sectionData.title}" 点击添加按钮无效`); break }
await delay(300) await delay(150)
} }
} }
@@ -1402,9 +1474,15 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
if (!field.value) { result.skipped++; continue } if (!field.value) { result.skipped++; continue }
const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs) const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs)
if (!inputEl) { result.skipped++; continue } if (!inputEl) { result.skipped++; continue }
const ok = await b2FillSingleField(inputEl, field.label, field.value, lang) // 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } if (b2IsDateTimeLabel(field.label)) {
await delay(200) const ok = await b2FillDateTimeField(inputEl, field.label, field.value)
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
} else {
const ok = await b2FillSingleField(inputEl, field.label, field.value, lang)
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
}
await delay(100)
} }
} }
} }
@@ -1415,9 +1493,15 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
if (!field.value) { result.skipped++; continue } if (!field.value) { result.skipped++; continue }
const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs) const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs)
if (!inputEl) { result.skipped++; continue } if (!inputEl) { result.skipped++; continue }
const ok = await b2FillSingleField(inputEl, field.label, field.value, lang) // 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ } if (b2IsDateTimeLabel(field.label)) {
await delay(200) const ok = await b2FillDateTimeField(inputEl, field.label, field.value)
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
} else {
const ok = await b2FillSingleField(inputEl, field.label, field.value, lang)
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
}
await delay(100)
} }
} }
} }
+14 -14
View File
@@ -733,7 +733,7 @@ export function locateExperienceSections(
rootEl: Element = document.body, rootEl: Element = document.body,
lang: "zh" | "en" lang: "zh" | "en"
): ExperienceSectionLocateResult[] { ): ExperienceSectionLocateResult[] {
console.log("===== OfferPie: 开始定位经历区块 =====") // console.log("===== OfferPie: 开始定位经历区块 =====")
// 定位5种经历的大标题 // 定位5种经历的大标题
const located: { config: ExperienceSectionConfig; titleElement: Element; titleText: string }[] = [] const located: { config: ExperienceSectionConfig; titleElement: Element; titleText: string }[] = []
@@ -749,8 +749,8 @@ export function locateExperienceSections(
} }
if (located.length === 0) { if (located.length === 0) {
console.log(" ❌ 未找到任何经历区块大标题") // console.log(" ❌ 未找到任何经历区块大标题")
console.log("===== OfferPie: 经历区块定位完毕 =====") // console.log("===== OfferPie: 经历区块定位完毕 =====")
return notFound.map((config) => ({ return notFound.map((config) => ({
section: config.section, titleElement: null, titleText: "", section: config.section, titleElement: null, titleText: "",
titleSignature: { layer1: null, layer2: null }, expandedCount: 0, segmentRanges: [], titleSignature: { layer1: null, layer2: null }, expandedCount: 0, segmentRanges: [],
@@ -778,14 +778,14 @@ export function locateExperienceSections(
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1 if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1
return 0 return 0
}) })
console.log(` (fallback: 用经历大标题本身作为范围边界,共 ${allPageTitles.length} 个)`) // console.log(` (fallback: 用经历大标题本身作为范围边界,共 ${allPageTitles.length} 个)`)
} }
console.log(` --- 页面所有大标题(共 ${allPageTitles.length} 个,按 DOM 顺序)---`) // console.log(` --- 页面所有大标题(共 ${allPageTitles.length} 个,按 DOM 顺序)---`)
for (const pt of allPageTitles) { for (const pt of allPageTitles) {
console.log(` 标题: "${pt.text}" | TitleSignature: layer1=<${pt.signature.layer1?.tag || "null"} class="${pt.signature.layer1?.className || ""}"> layer2=<${pt.signature.layer2?.tag || "null"} class="${pt.signature.layer2?.className || ""}">`) // console.log(` 标题: "${pt.text}" | TitleSignature: layer1=<${pt.signature.layer1?.tag || "null"} class="${pt.signature.layer1?.className || ""}"> layer2=<${pt.signature.layer2?.tag || "null"} class="${pt.signature.layer2?.className || ""}">`)
} }
console.log(` --- 大标题列表结束 ---`) // console.log(` --- 大标题列表结束 ---`)
// 对每个经历大标题,检测默认第1段的边界 // 对每个经历大标题,检测默认第1段的边界
const results: ExperienceSectionLocateResult[] = [] const results: ExperienceSectionLocateResult[] = []
@@ -800,25 +800,25 @@ export function locateExperienceSections(
const segmentRanges: SegmentRange[] = firstSegment ? [firstSegment] : [] const segmentRanges: SegmentRange[] = firstSegment ? [firstSegment] : []
const expandedCount = segmentRanges.length const expandedCount = segmentRanges.length
console.log( // console.log(
` [${config.section}] ✅ 标题: "${titleText}"` + // ` [${config.section}] ✅ 标题: "${titleText}"` +
` | 范围边界: "${allPageTitles[idx + 1]?.text || "页面底部"}"` + // ` | 范围边界: "${allPageTitles[idx + 1]?.text || "页面底部"}"` +
` | 已展开段数: ${expandedCount}` // ` | 已展开段数: ${expandedCount}`
) // )
results.push({ section: config.section, titleElement, titleText, titleSignature, expandedCount, segmentRanges }) results.push({ section: config.section, titleElement, titleText, titleSignature, expandedCount, segmentRanges })
} }
// 未找到的区块 // 未找到的区块
for (const config of notFound) { for (const config of notFound) {
console.log(` [${config.section}] ❌ 未找到区块标题(关键词: ${(lang === "zh" ? config.zh : config.en).join("/")}`) // console.log(` [${config.section}] ❌ 未找到区块标题(关键词: ${(lang === "zh" ? config.zh : config.en).join("/")}`)
results.push({ results.push({
section: config.section, titleElement: null, titleText: "", section: config.section, titleElement: null, titleText: "",
titleSignature: { layer1: null, layer2: null }, expandedCount: 0, segmentRanges: [], titleSignature: { layer1: null, layer2: null }, expandedCount: 0, segmentRanges: [],
}) })
} }
console.log("===== OfferPie: 经历区块定位完毕 =====") // console.log("===== OfferPie: 经历区块定位完毕 =====")
return results return results
} }
+830
View File
@@ -0,0 +1,830 @@
/**
* 页面表单字段扫描与统计模块
*
* 功能:扫描页面所有字段,按大标题分组,标记每个字段的状态、来源、是否为简历格式数据
* 用途:
* 1. 自动填写完成后的统计打印
* 2. handleSaveUserInput 读取 B2 阶段字段数据用于更新 unfilledFormData 缓存
*
* 支持特殊网站模式参数(如 "beisen"),在通用规则扫描后追加特殊组件规则
*/
import { getAllPageTitles, locateExperienceSections } from "./experienceSection"
import type { ExperienceSectionLocateResult } from "./experienceSection"
import type { MatchedFormField, UnmatchedFormField } from "./types"
import { isRequiredField, setFieldHighlight } from "./formStyle"
// ====================================================================
// 一、类型定义
// ====================================================================
/** 字段状态颜色 */
export type FieldStatColor = "green" | "greenTwo" | "red" | "yellow"
/** 字段来源阶段 */
export type FieldStatSource = "A" | "B" | "B2" | "C" | "D"
/** 统一字段统计结构 */
export interface FieldStat {
/** 标签文字 */
labelText: string
/** input 元素引用 */
inputElement: Element | null
/** 状态颜色:green=简历有数据已填 greenTwo=B2阶段填充 red=必填未填 yellow=非必填未填 */
color: FieldStatColor
/** 是否已填写 */
filled: boolean
/** 来源阶段 */
source: FieldStatSource
/** 经历段落索引(经历类型时使用) */
segmentIndex: number
/** 是否为简历格式数据(阶段A/B的字段=trueB2/C/D的字段=false */
isResumeFormat: boolean
/** 当前字段值 */
value: string
}
/** 按大标题分组的统计结果 */
export interface TitleStat {
/** 大标题文字 */
titleText: string
/** 大标题元素 */
titleElement: Element
/** 是否为经历类型区块 */
isExpType: boolean
/** 经历段数 */
segmentCount: number
/** 该标题下所有字段 */
fields: FieldStat[]
/** 绿色(简历有数据)字段数 */
greenCount: number
/** greenTwoB2填充)字段数 */
greenTwoCount: number
/** 红色(必填未填)字段数 */
redCount: number
/** 黄色(非必填未填)字段数 */
yellowCount: number
/** 已填字段数 */
filledCount: number
}
/** 阶段A字段输入格式 */
export interface PhaseAFieldInput {
labelText: string
inputElement: Element | null
filled: boolean
fillValue: string
section: string
segmentIndex: number
}
/** 主方法入参 */
export interface ScanPageFieldsParams {
/** 阶段A已处理字段(经历区域) */
phaseAFields?: PhaseAFieldInput[]
/** 阶段B已处理字段(非经历区域) */
mainFields?: MatchedFormField[]
/** 阶段C收集的空白字段 */
unmatchedFields?: UnmatchedFormField[]
/** 经历段落展开结果(不传时内部自动定位,segmentRanges 为空) */
expandedResults?: ExperienceSectionLocateResult[]
/** 经历段落定位结果(不传时内部自动调用 locateExperienceSections 定位大标题) */
sectionResults?: ExperienceSectionLocateResult[]
/** 页面语言(不传时自动检测) */
lang?: "zh" | "en"
/** 特殊网站模式(不传则只走通用规则) */
siteMode?: "beisen"
/** 是否设置高亮背景色(默认false,统计模式下可设为true) */
applyHighlight?: boolean
}
// ====================================================================
// 二、特殊网站规则区
// ====================================================================
/**
* 北森(zhiye.com)特殊表单组件规则
* 包含北森 Phoenix UI 自定义组件的类名和取值逻辑
*/
const BEISEN_RULES = {
/** phoenix-radio-group 单选组配置 */
radioGroup: {
/** 单选组容器选择器 */
groupSelector: ".phoenix-radio-group",
/** 单选项选择器 */
itemSelector: ".phoenix-radio-group__radioItem",
/** 选中状态类名 */
checkedClass: ".phoenix-radio--checked",
/** 标签标题容器 */
titleSelector: ".form-item__title .form-item__text",
/** 表单控件容器(radio group 的父级定位用) */
controlSelector: ".form-item__control",
},
/** 多级级联选择器配置 */
cascadePicker: {
/** 级联选择器关键字(字段标签含这些关键字时可能是级联选择器) */
keywords: ["地区", "居住地", "籍贯", "户籍", "地点", "所在地", "民族", "地址", "现居"],
/** 级联面板外层类名 */
panelClass: "common-unmodeled-layer",
/** 表单控件容器(用于定位标签文字) */
controlSelector: ".form-item__control",
/** 标签标题容器 */
titleSelector: ".form-item__title .form-item__text",
},
}
/**
* 北森特殊规则:扫描 phoenix-radio-group 单选组字段
* 在指定大标题范围内,找到所有 phoenix-radio-group,提取标签和选中值
*/
function scanBeisenRadioGroups(
titleEl: Element,
nextTitleEl: Element | null,
processedInputs: Set<Element>,
applyHighlight: boolean
): FieldStat[] {
const results: FieldStat[] = []
const cfg = BEISEN_RULES.radioGroup
const radioGroups = document.body.querySelectorAll(cfg.groupSelector)
for (const rg of Array.from(radioGroups)) {
if (processedInputs.has(rg)) continue
// 范围检查
const afterTitle = !!(titleEl.compareDocumentPosition(rg) & Node.DOCUMENT_POSITION_FOLLOWING)
const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(rg) & Node.DOCUMENT_POSITION_PRECEDING)
if (!afterTitle || !beforeNext) continue
// 找标签文字
const controlEl = rg.closest(cfg.controlSelector)
if (!controlEl || !controlEl.parentElement) continue
const titleDiv = controlEl.parentElement.querySelector(cfg.titleSelector)
if (!titleDiv) continue
const directText = Array.from(titleDiv.childNodes)
.filter((n) => n.nodeType === Node.TEXT_NODE)
.map((n) => n.textContent?.trim())
.filter(Boolean)
.join("")
if (!directText || directText.length > 30) continue
// 获取选中值
let selectedValue = ""
const radioItems = rg.querySelectorAll(cfg.itemSelector)
for (const item of Array.from(radioItems)) {
const checkedEl = item.querySelector(cfg.checkedClass)
if (checkedEl) {
selectedValue = item.textContent?.trim() || ""
break
}
}
// 判断状态颜色
const rgEl = rg as HTMLElement
const bgColor = rgEl.style.backgroundColor
let color: FieldStatColor
let source: FieldStatSource
const hasValue = !!selectedValue
if (bgColor === "#b7ffc6" || bgColor === "rgb(183, 255, 198)") {
// B2 填充的(greenTwo 背景)
color = "greenTwo"
source = "B2"
} else if (bgColor === "#b7ffc5" || bgColor === "rgb(183, 255, 197)") {
// 简历格式填充的(green 背景)
color = "green"
source = "B"
} else if (hasValue) {
color = "green"
source = "D"
} else {
const required = isRequiredField(titleDiv as Element, null)
color = required ? "red" : "yellow"
source = "D"
if (applyHighlight) setFieldHighlight(rgEl, color)
}
results.push({
labelText: directText,
inputElement: rg,
color,
filled: hasValue,
source,
segmentIndex: 0,
isResumeFormat: source === "B",
value: selectedValue,
})
processedInputs.add(rg)
}
return results
}
/**
* 北森特殊规则:扫描多级级联选择器字段
* 通过字段标签关键字(地区、籍贯、民族等)匹配,检测是否存在级联选择器组件
* 定位方式:找到 form-item__control 内含有 input 且标签文字包含级联关键字的表单项
* 取值方式:读取 input 的当前 value
*/
function scanBeisenCascadePickers(
titleEl: Element,
nextTitleEl: Element | null,
processedInputs: Set<Element>,
applyHighlight: boolean
): FieldStat[] {
const results: FieldStat[] = []
const cfg = BEISEN_RULES.cascadePicker
// 在指定大标题范围内查找所有 input
const allInputs = document.body.querySelectorAll(INPUT_SEL_STAT)
for (const inp of Array.from(allInputs)) {
if (processedInputs.has(inp)) continue
// 范围检查
const afterTitle = !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING)
if (!afterTitle || !beforeNext) continue
// 找标签文字:通过 form-item__control 的同级 form-item__title 定位
const controlEl = inp.closest(cfg.controlSelector)
if (!controlEl || !controlEl.parentElement) continue
const titleDiv = controlEl.parentElement.querySelector(cfg.titleSelector)
if (!titleDiv) continue
const directText = Array.from(titleDiv.childNodes)
.filter((n) => n.nodeType === Node.TEXT_NODE)
.map((n) => n.textContent?.trim())
.filter(Boolean)
.join("")
if (!directText || directText.length > 30) continue
// 检查标签文字是否包含级联选择器关键字
if (!cfg.keywords.some((kw) => directText.includes(kw))) continue
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
const hasValue = !!(inputEl.value && inputEl.value.trim().length > 0)
// 判断状态颜色
let color: FieldStatColor
let source: FieldStatSource
const bgColor = inputEl.style.backgroundColor
if (bgColor === "#b7ffc6" || bgColor === "rgb(183, 255, 198)") {
color = "greenTwo"
source = "B2"
} else if (bgColor === "#b7ffc5" || bgColor === "rgb(183, 255, 197)") {
color = "green"
source = "B"
} else if (hasValue) {
color = "green"
source = "D"
} else {
const required = isRequiredField(titleDiv as Element, inputEl)
color = required ? "red" : "yellow"
source = "D"
if (applyHighlight) setFieldHighlight(inputEl, color)
}
results.push({
labelText: directText,
inputElement: inp,
color,
filled: hasValue,
source,
segmentIndex: 0,
isResumeFormat: source === "B",
value: inputEl.value?.trim() || "",
})
processedInputs.add(inp)
}
return results
}
// ====================================================================
// 三、通用辅助方法
// ====================================================================
/** 排除的标签文字(不视为有效标签) */
const STAT_EXCLUDE_LABEL_TEXTS = ["请输入", "输入", ":", "", "请选择", "选择", "*", "必填"]
/** input 选择器(排除 hidden/submit/button/checkbox/radio/file/disabled */
const INPUT_SEL_STAT = "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([disabled]), textarea:not([disabled])"
/** 表单项容器选择器集合 */
const FORM_ITEM_SELS = [
".form-item", ".form-group", ".form-field",
".el-form-item", ".ant-form-item", ".ant-row",
".arco-form-item", ".t-form-item", ".n-form-item",
"[class*='form-item']", "[class*='form-group']", "[class*='formItem']",
]
/** 5大经历 section 名集合 */
const FIVE_EXP_SECTIONS = new Set(["education", "work", "internship", "project", "competition"])
/**
* 查找 input 元素对应的标签文字
*/
function findLabelForInput(inp: Element): { labelText: string; labelElement: Element | null } {
let labelText = ""
let labelElement: Element | null = null
let container: Element | null = null
for (const sel of FORM_ITEM_SELS) {
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 (STAT_EXCLUDE_LABEL_TEXTS.some((ex) => directText === ex)) continue
if (el.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING) {
labelText = directText
labelElement = el
break
}
}
}
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 && !STAT_EXCLUDE_LABEL_TEXTS.some((ex) => text === ex)) {
labelText = text
labelElement = prev
break
}
prev = prev.previousElementSibling
}
}
if (!labelText) labelText = (inp as HTMLInputElement).getAttribute("placeholder") || "(未知字段)"
return { labelText, labelElement }
}
/**
* 对非5大经历的其他经历类型区块,通过标签重复次数检测段数
* 思路:统计范围内每个标签文字出现的次数,出现次数最多的标签文字作为分段标记
*/
function detectSegmentsForOtherExp(
titleEl: Element,
nextTitleEl: Element | null,
fieldsInRange: FieldStat[]
): { segmentCount: number; fieldSegMap: Map<Element, number> } {
// 统计每个标签文字出现的次数
const labelCounts = new Map<string, number>()
const labelElements = new Map<string, Element[]>()
for (const f of fieldsInRange) {
if (!f.inputElement) continue
const count = labelCounts.get(f.labelText) || 0
labelCounts.set(f.labelText, count + 1)
const arr = labelElements.get(f.labelText) || []
arr.push(f.inputElement)
labelElements.set(f.labelText, arr)
}
// 找出出现次数最多的标签 → 作为段数依据
let maxCount = 1
let markerLabel = ""
for (const [label, count] of labelCounts) {
if (count > maxCount) {
maxCount = count
markerLabel = label
}
}
if (maxCount <= 1) {
// 所有标签只出现1次 → 只有1段经历
const fieldSegMap = new Map<Element, number>()
for (const f of fieldsInRange) {
if (f.inputElement) fieldSegMap.set(f.inputElement, 0)
}
return { segmentCount: 1, fieldSegMap }
}
// 用 markerLabel 的出现位置来划分段落边界
const markerInputs = labelElements.get(markerLabel) || []
const fieldSegMap = new Map<Element, number>()
for (const f of fieldsInRange) {
if (!f.inputElement) continue
let segIdx = 0
for (let i = markerInputs.length - 1; i >= 0; i--) {
const markerPos = markerInputs[i].compareDocumentPosition(f.inputElement)
if ((markerPos & Node.DOCUMENT_POSITION_FOLLOWING) || markerInputs[i] === f.inputElement) {
segIdx = i
break
}
}
fieldSegMap.set(f.inputElement, segIdx)
}
return { segmentCount: maxCount, fieldSegMap }
}
/**
* 判断背景色是否为 green#b7ffc5
*/
function isBgGreen(el: HTMLElement): boolean {
const bg = el.style.backgroundColor
return bg === "#b7ffc5" || bg === "rgb(183, 255, 197)"
}
/**
* 判断背景色是否为 greenTwo#b7ffc6
*/
function isBgGreenTwo(el: HTMLElement): boolean {
const bg = el.style.backgroundColor
return bg === "#b7ffc6" || bg === "rgb(183, 255, 198)"
}
// ====================================================================
// 四、主方法
// ====================================================================
/**
* 扫描页面所有字段,按大标题分组统计
*
* @param params - 扫描参数
* @returns 按大标题分组的统计结果数组
*/
export function scanPageFields(params: ScanPageFieldsParams): TitleStat[] {
const {
phaseAFields,
mainFields,
unmatchedFields,
siteMode,
applyHighlight = false,
} = params
// 如果没传 sectionResults,自动检测语言并重新定位大标题
let lang = params.lang
if (!lang) {
const pageText = document.body.innerText || ""
const chineseChars = (pageText.match(/[\u4e00-\u9fff]/g) || []).length
lang = chineseChars > 20 ? "zh" : "en"
}
const sectionResults = params.sectionResults || locateExperienceSections(document.body, lang)
const expandedResults = params.expandedResults || sectionResults
const allTitles = getAllPageTitles(sectionResults)
if (allTitles.length === 0) return []
const processedInputs = new Set<Element>()
// 收集各阶段字段到统一格式
const allFieldStats: FieldStat[] = []
// 阶段A字段(经历区域,简历格式)
if (phaseAFields) {
for (const f of phaseAFields) {
allFieldStats.push({
labelText: f.labelText,
inputElement: f.inputElement,
color: "green",
filled: f.filled,
source: "A",
segmentIndex: f.segmentIndex,
isResumeFormat: true,
value: f.fillValue || (f.inputElement as HTMLInputElement)?.value || "",
})
if (f.inputElement) processedInputs.add(f.inputElement)
}
}
// 阶段B字段(非经历区域,简历格式)
if (mainFields) {
for (const f of mainFields) {
const hasResumeValue = !!f.fillValue
const filled = !!(f.fillValue && f.inputElement?.value)
if (hasResumeValue) {
allFieldStats.push({
labelText: f.labelText,
inputElement: f.inputElement,
color: "green",
filled,
source: "B",
segmentIndex: 0,
isResumeFormat: true,
value: f.fillValue || "",
})
} else {
const required = isRequiredField(f.labelElement, f.inputElement)
const color: FieldStatColor = required ? "red" : "yellow"
if (applyHighlight) setFieldHighlight(f.inputElement, color)
allFieldStats.push({
labelText: f.labelText,
inputElement: f.inputElement,
color,
filled: false,
source: "B",
segmentIndex: 0,
isResumeFormat: true,
value: "",
})
}
if (f.inputElement) processedInputs.add(f.inputElement)
}
}
// 阶段C字段(空白字段,非简历格式)
if (unmatchedFields) {
for (const uf of unmatchedFields) {
const required = isRequiredField(uf.labelElement, uf.inputElement)
allFieldStats.push({
labelText: uf.labelText,
inputElement: uf.inputElement,
color: required ? "red" : "yellow",
filled: false,
source: "C",
segmentIndex: 0,
isResumeFormat: false,
value: "",
})
if (uf.inputElement) processedInputs.add(uf.inputElement)
}
}
// 按大标题分组
const titleStats: TitleStat[] = []
for (let tIdx = 0; tIdx < allTitles.length; tIdx++) {
const titleEl = allTitles[tIdx].element
const titleText = allTitles[tIdx].text
const nextTitleEl = tIdx < allTitles.length - 1 ? allTitles[tIdx + 1].element : null
// 判断是否为5大经历区块
const expResult = expandedResults.find((r) => r.titleElement === titleEl)
const isFiveExp = !!expResult && FIVE_EXP_SECTIONS.has(expResult.section)
// 找出已处理字段中属于此标题范围内的
const fieldsInSection: FieldStat[] = allFieldStats.filter((f) => {
if (!f.inputElement) return false
const afterTitle = titleEl === f.inputElement || !!(titleEl.compareDocumentPosition(f.inputElement) & Node.DOCUMENT_POSITION_FOLLOWING)
const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(f.inputElement) & Node.DOCUMENT_POSITION_PRECEDING)
return afterTitle && beforeNext
})
// 【补扫阶段D】在此标题范围内查找未被任何阶段处理的 input
const allInputsInRange = document.body.querySelectorAll(INPUT_SEL_STAT)
for (const inp of Array.from(allInputsInRange)) {
if (processedInputs.has(inp)) continue
const afterTitle = titleEl === inp || !!(titleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_FOLLOWING)
const beforeNext = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(inp) & Node.DOCUMENT_POSITION_PRECEDING)
if (!afterTitle || !beforeNext) continue
const inputEl = inp as HTMLInputElement | HTMLTextAreaElement
const alreadyHasValue = !!(inputEl.value && inputEl.value.trim().length > 0)
const { labelText, labelElement } = findLabelForInput(inp)
// 根据背景色判断来源和颜色
let color: FieldStatColor
let source: FieldStatSource
let isResumeFormat = false
if (isBgGreen(inputEl) && alreadyHasValue) {
// green 背景且有值 → 简历格式字段已填写
color = "green"
source = "D"
isResumeFormat = true
} else if (isBgGreenTwo(inputEl) && alreadyHasValue) {
// greenTwo 背景且有值 → B2阶段已填写
color = "greenTwo"
source = "B2"
isResumeFormat = false
} else if (!isBgGreen(inputEl) && !isBgGreenTwo(inputEl) && alreadyHasValue) {
// 无特殊背景但有值 → 已填写
color = "green"
source = "D"
isResumeFormat = false
} else {
// 没有值(不管背景色是什么)→ 走必填判断
const required = isRequiredField(labelElement, inputEl)
color = required ? "red" : "yellow"
source = "D"
isResumeFormat = isBgGreen(inputEl) // 预标绿的仍标记为简历格式字段
if (applyHighlight) setFieldHighlight(inputEl, color)
}
fieldsInSection.push({
labelText,
inputElement: inp,
color,
filled: alreadyHasValue,
source,
segmentIndex: 0,
isResumeFormat,
value: inputEl.value?.trim() || "",
})
processedInputs.add(inp)
}
// 特殊网站规则:北森 phoenix-radio-group
if (siteMode === "beisen") {
const beisenFields = scanBeisenRadioGroups(titleEl, nextTitleEl, processedInputs, applyHighlight)
fieldsInSection.push(...beisenFields)
}
// 特殊网站规则:北森多级级联选择器(地区、籍贯、民族等)
if (siteMode === "beisen") {
const cascadeFields = scanBeisenCascadePickers(titleEl, nextTitleEl, processedInputs, applyHighlight)
fieldsInSection.push(...cascadeFields)
}
if (fieldsInSection.length === 0) {
titleStats.push({
titleText,
titleElement: titleEl,
isExpType: false,
segmentCount: 0,
fields: [],
greenCount: 0,
greenTwoCount: 0,
redCount: 0,
yellowCount: 0,
filledCount: 0,
})
continue
}
// 确定经历类型和段数
let isExpType = false
let segmentCount = 0
if (isFiveExp && expResult) {
// 5大经历:直接用 expandedResults 的 segmentRanges
isExpType = true
segmentCount = expResult.segmentRanges.length
// 更新阶段D/C补扫字段的 segmentIndex
for (const f of fieldsInSection) {
if (!f.inputElement) continue
if (f.source === "D" || f.source === "C" || f.source === "B2") {
for (let sIdx = 0; sIdx < expResult.segmentRanges.length; sIdx++) {
const seg = expResult.segmentRanges[sIdx]
if (seg.containerElement && seg.containerElement.contains(f.inputElement)) {
f.segmentIndex = sIdx
break
}
}
}
}
} else {
// 非5大经历:检测是否有"添加"按钮
const addBtnKeywords = ["添加", "新增", "Add", "增加"]
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT)
let wNode: Node | null = walker.nextNode()
let hasAddBtn = false
while (wNode) {
const el = wNode as Element
const afterT = !!(titleEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)
const beforeN = !nextTitleEl || !!(nextTitleEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)
if (afterT && beforeN) {
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 < 20 && addBtnKeywords.some((k) => directText.includes(k))) {
hasAddBtn = true
break
}
}
wNode = walker.nextNode()
}
if (hasAddBtn) {
isExpType = true
const { segmentCount: detectedCount, fieldSegMap } = detectSegmentsForOtherExp(titleEl, nextTitleEl, fieldsInSection)
segmentCount = detectedCount
for (const f of fieldsInSection) {
if (f.inputElement && fieldSegMap.has(f.inputElement)) {
f.segmentIndex = fieldSegMap.get(f.inputElement)!
}
}
}
}
// 统计数据
const greenCount = fieldsInSection.filter((f) => f.color === "green").length
const greenTwoCount = fieldsInSection.filter((f) => f.color === "greenTwo").length
const redCount = fieldsInSection.filter((f) => f.color === "red").length
const yellowCount = fieldsInSection.filter((f) => f.color === "yellow").length
const filledCount = fieldsInSection.filter((f) => f.filled).length
titleStats.push({
titleText,
titleElement: titleEl,
isExpType,
segmentCount,
fields: fieldsInSection,
greenCount,
greenTwoCount,
redCount,
yellowCount,
filledCount,
})
}
return titleStats
}
// ====================================================================
// 五、统计打印方法(可选调用)
// ====================================================================
/**
* 将扫描结果打印到控制台(与原有统计格式一致)
*/
export function printFieldStats(titleStats: TitleStat[]): void {
//console.log("===== OfferPie: 填写结果统计(按大标题分组) =====")
for (const ts of titleStats) {
if (ts.fields.length === 0) {
//console.log(` 📂 "${ts.titleText}" — 无匹配字段`)
continue
}
const expLabel = ts.isExpType ? ` | 📑经历类型(${ts.segmentCount}段)` : ""
const b2Label = ts.greenTwoCount > 0 ? ` | 🟩B2填充 ${ts.greenTwoCount}` : ""
//console.log(` 📂 "${ts.titleText}" — 总计 ${ts.fields.length} 个字段 | 已填 ${ts.filledCount} | 🟢简历有数据 ${ts.greenCount} | 🟩B2填充 ${ts.greenTwoCount} | 🔴必填未填 ${ts.redCount} | 🟡非必填未填 ${ts.yellowCount}${expLabel}`)
if (ts.isExpType && ts.segmentCount > 0) {
for (let sIdx = 0; sIdx < ts.segmentCount; sIdx++) {
const segFields = ts.fields.filter((f) => f.segmentIndex === sIdx)
if (segFields.length === 0) continue
//console.log(` --- 第${sIdx + 1}段 ---`)
for (const f of segFields) {
const colorIcon = f.color === "green" ? "🟢" : f.color === "greenTwo" ? "🟩" : f.color === "red" ? "🔴" : "🟡"
const filledStr = f.filled ? "✅已填" : "⬜未填"
const sourceLabel = f.source === "A" ? "经历填写" : f.source === "B" ? "基础信息填写" : f.source === "B2" ? "B2缓存填写" : f.source === "C" ? "空白字段收集" : "补扫发现"
//console.log(` ${colorIcon} "${f.labelText}" | ${filledStr} | 来源: ${sourceLabel} | 值: "${f.value}"`)
}
}
} else {
for (const f of ts.fields) {
const colorIcon = f.color === "green" ? "🟢" : f.color === "greenTwo" ? "🟩" : f.color === "red" ? "🔴" : "🟡"
const filledStr = f.filled ? "✅已填" : "⬜未填"
const sourceLabel = f.source === "A" ? "经历填写" : f.source === "B" ? "基础信息填写" : f.source === "B2" ? "B2缓存填写" : f.source === "C" ? "空白字段收集" : "补扫发现"
//console.log(` ${colorIcon} "${f.labelText}" | ${filledStr} | 来源: ${sourceLabel} | 值: "${f.value}"`)
}
}
}
//console.log("===== OfferPie: 统计打印完毕 =====")
}
// ====================================================================
// 六、便捷方法
// ====================================================================
/**
* 从统计结果中提取非简历格式字段(B2阶段字段)
* 用于 handleSaveUserInput 更新 unfilledFormData 缓存
*
* @param titleStats - scanPageFields 的返回结果
* @returns 按大标题分组的非简历格式字段数据
*/
export function extractNonResumeFields(titleStats: TitleStat[]): {
title: string
isExperience: boolean
formItems: { label: string; value: string }[] | { label: string; value: string }[][]
}[] {
const result: {
title: string
isExperience: boolean
formItems: { label: string; value: string }[] | { label: string; value: string }[][]
}[] = []
for (const ts of titleStats) {
// 只取非简历格式字段
const nonResumeFields = ts.fields.filter((f) => !f.isResumeFormat)
if (nonResumeFields.length === 0) continue
if (ts.isExpType && ts.segmentCount > 0) {
// 经历类型:按段分组
const segments: { label: string; value: string }[][] = []
for (let sIdx = 0; sIdx < ts.segmentCount; sIdx++) {
const segFields = nonResumeFields.filter((f) => f.segmentIndex === sIdx)
if (segFields.length > 0) {
segments.push(segFields.map((f) => ({ label: f.labelText, value: f.value })))
}
}
if (segments.length > 0) {
result.push({ title: ts.titleText, isExperience: true, formItems: segments })
}
} else {
// 非经历类型:平铺
result.push({
title: ts.titleText,
isExperience: false,
formItems: nonResumeFields.map((f) => ({ label: f.labelText, value: f.value })),
})
}
}
return result
}