处理北森级联选择器操作bug,修改插件面板功能,添加填写表单填写动态跟踪进度区、简历外表单信息自动保存功能和手动修改面板、插件收起、插件面板拖动功能
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
@@ -8,46 +8,125 @@ $radius-sm: 12px;
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 384px;
|
||||
min-height: 480px;
|
||||
width: 380px;
|
||||
min-height: 470px;
|
||||
max-height: calc(100vh - 20px);
|
||||
background: #fff;
|
||||
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);
|
||||
z-index: 2147483647;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
color: $primary;
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 12px;
|
||||
gap: 12px;
|
||||
|
||||
&-link {
|
||||
&-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&-logo {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&-status {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.op-close-btn {
|
||||
.op-collapse-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #333;
|
||||
color: #666;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.op-title {
|
||||
@@ -61,8 +140,6 @@ $radius-sm: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: $bg-card;
|
||||
border-radius: $radius-md;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
@@ -103,15 +180,15 @@ $radius-sm: 12px;
|
||||
|
||||
.op-autofill-btn {
|
||||
width: 100%;
|
||||
height: 52px;
|
||||
background: $primary;
|
||||
height: 40px;
|
||||
background: #52CAD1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: $radius-md;
|
||||
font-size: 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 6px;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover { opacity: 0.85; }
|
||||
@@ -120,11 +197,11 @@ $radius-sm: 12px;
|
||||
|
||||
.op-credits {
|
||||
&-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 2px;
|
||||
padding: 0 2px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&-text {
|
||||
@@ -133,11 +210,12 @@ $radius-sm: 12px;
|
||||
}
|
||||
|
||||
&-link {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,23 +230,21 @@ $radius-sm: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: $bg-card;
|
||||
padding: 2px 0px;
|
||||
border-radius: $radius-sm;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #e8e8e8;
|
||||
width: 36px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
background: #E7F8F9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
font-size: 8px;
|
||||
color: #52CAD1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -184,6 +260,7 @@ $radius-sm: 12px;
|
||||
&-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color:#666;
|
||||
}
|
||||
|
||||
&-tag {
|
||||
@@ -233,21 +310,21 @@ $radius-sm: 12px;
|
||||
}
|
||||
|
||||
&-value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
color:#666;
|
||||
}
|
||||
|
||||
&-bar-bg {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #f0f0f0;
|
||||
background: #D7F3F4;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&-bar-fill {
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: $primary;
|
||||
background: #52CAD1;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
@@ -284,3 +361,316 @@ $radius-sm: 12px;
|
||||
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
|
||||
+765
-216
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -12,10 +12,10 @@ const envConfigs: Record<string, {
|
||||
cookieSourceUrl: string
|
||||
}> = {
|
||||
dev: {
|
||||
dataBaseApi: 'http://localhost:8080/api',
|
||||
dataBaseApi: 'http://127.0.0.1:8080/api',
|
||||
// aiBaseApi: 'http://192.168.31.133:8000',
|
||||
aiBaseApi: 'http://localhost:8000',
|
||||
cookieSourceUrl: 'http://192.168.31.135:5173',
|
||||
aiBaseApi: 'http://127.0.0.1:8000',
|
||||
cookieSourceUrl: 'http://127.0.0.1:5173',
|
||||
},
|
||||
prod: {
|
||||
dataBaseApi: 'https://www.offerpai.com.cn/api',
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
* 不要在此文件中自行编写选择器操作逻辑,必须引用 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 { matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher"
|
||||
import { detectPickerField } from "~lib/pickerDetector"
|
||||
import { detectAndUploadResume } from "~lib/resumeUpload"
|
||||
import { getMockResumeData2, JOB_FORM_LABELS } from "~lib/constants"
|
||||
import { getResumeFieldValue } from "~lib/resumeDataHelper"
|
||||
import { fillDatePicker } from "~lib/datePicker"
|
||||
import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer, getAllPageTitles, findAddButton, clickAddButton } from "~lib/experienceSection"
|
||||
import type { ExperienceSectionLocateResult } from "~lib/experienceSection"
|
||||
import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, UnmatchedFormField } from "~lib/types"
|
||||
@@ -45,6 +46,10 @@ export interface AutoFillBeisenResult {
|
||||
formFields: MatchedFormField[]
|
||||
/** 收集到的待填写空白字段 */
|
||||
unmatchedFields: UnmatchedFormField[]
|
||||
/** 经历段落定位结果(用于 fillStats 分段展示) */
|
||||
sectionResults: ExperienceSectionLocateResult[]
|
||||
/** 经历段落展开结果(用于 fillStats 分段展示) */
|
||||
expandedResults: ExperienceSectionLocateResult[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +63,8 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
|
||||
resumeData: params.resumeData,
|
||||
formFields: [],
|
||||
unmatchedFields: [],
|
||||
sectionResults: [],
|
||||
expandedResults: [],
|
||||
}
|
||||
|
||||
// 1. 提取 DOM 结构
|
||||
@@ -101,6 +108,10 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
|
||||
// 4.6 对比简历数据段数,点击添加按钮补足不够的段数
|
||||
const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang)
|
||||
|
||||
// 存入 result 供外部使用(如 fillStats 分段展示)
|
||||
result.sectionResults = sectionResults
|
||||
result.expandedResults = expandedResults
|
||||
|
||||
// 4.65 【预匹配+标绿】经历段落添加完成后,一次性预匹配所有有简历数据的字段并标绿背景
|
||||
// 使用临时 usedInputs,不影响后续正式流程的匹配
|
||||
{
|
||||
@@ -318,7 +329,14 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
|
||||
const ok = await fillSearchPickerField(f)
|
||||
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(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
|
||||
} else {
|
||||
await detectPickerField(f, lang)
|
||||
@@ -333,7 +351,7 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
|
||||
if (lastTextInput && !lastTextInputIsPicker) {
|
||||
;(lastTextInput as HTMLElement).click()
|
||||
lastTextInput.focus()
|
||||
await delay(100)
|
||||
await delay(50)
|
||||
lastTextInput.blur()
|
||||
} else {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
@@ -344,7 +362,7 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
|
||||
}
|
||||
document.body.click()
|
||||
}
|
||||
await delay(300)
|
||||
await delay(150)
|
||||
|
||||
if (!f.isPicker && f.inputElement && !isTimePeriodField(f.key) && !isTimeSingleField(f.key)) {
|
||||
lastTextInput = f.inputElement
|
||||
@@ -377,6 +395,15 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
|
||||
|
||||
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)
|
||||
|
||||
console.log(
|
||||
@@ -391,7 +418,7 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
|
||||
if (lastTextInput) {
|
||||
;(lastTextInput as HTMLElement).click()
|
||||
lastTextInput.focus()
|
||||
await delay(100)
|
||||
await delay(50)
|
||||
lastTextInput.blur()
|
||||
} else {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
@@ -402,7 +429,7 @@ export async function handleAutoFillBeisen(params: AutoFillBeisenParams): Promis
|
||||
}
|
||||
document.body.click()
|
||||
}
|
||||
await delay(300)
|
||||
await delay(150)
|
||||
|
||||
if (!f.isPicker && f.inputElement) lastTextInput = f.inputElement
|
||||
}
|
||||
@@ -1323,6 +1350,61 @@ async function b2FillSingleField(
|
||||
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 单选组字段
|
||||
* 在指定大标题范围内,通过标签文字找到对应的 radio group,点击匹配选项
|
||||
@@ -1378,21 +1460,21 @@ async function b2FillBeisenRadioGroup(
|
||||
const childEl = item.firstElementChild as HTMLElement | null
|
||||
if (childEl) {
|
||||
childEl.click()
|
||||
await delay(100)
|
||||
await delay(50)
|
||||
// 子子级也点一下确保触发
|
||||
const grandChildEl = childEl.firstElementChild as HTMLElement | null
|
||||
if (grandChildEl) {
|
||||
grandChildEl.click()
|
||||
await delay(100)
|
||||
await delay(50)
|
||||
}
|
||||
} else {
|
||||
// fallback:直接点击 radioItem 自身
|
||||
;(item as HTMLElement).click()
|
||||
await delay(100)
|
||||
await delay(50)
|
||||
}
|
||||
|
||||
// 4. 验证选中结果:检查子级是否出现 phoenix-radio--checked 类名
|
||||
await delay(200)
|
||||
await delay(100)
|
||||
const checkedEl = item.querySelector(".phoenix-radio--checked")
|
||||
if (checkedEl) {
|
||||
// 选中成功,radio group 容器变 greenTwo
|
||||
@@ -1480,9 +1562,19 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
|
||||
if (!field.value) { result.skipped++; continue }
|
||||
const inputEl = b2FindInputByLabelInContainer(field.label, seg.containerElement, usedInputs)
|
||||
if (!inputEl) { result.skipped++; continue }
|
||||
// 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField
|
||||
if (b2IsDateTimeLabel(field.label)) {
|
||||
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(200)
|
||||
}
|
||||
await delay(100)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1532,7 +1624,7 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
|
||||
}).length
|
||||
const clicked = await clickAddButton(addBtn, titleEl, nextTitleEl, beforeCount)
|
||||
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 }
|
||||
const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs)
|
||||
if (!inputEl) { result.skipped++; continue }
|
||||
// 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField
|
||||
if (b2IsDateTimeLabel(field.label)) {
|
||||
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(200)
|
||||
}
|
||||
await delay(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1555,15 +1657,25 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
|
||||
if (!field.value) { result.skipped++; continue }
|
||||
const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs)
|
||||
if (inputEl) {
|
||||
// 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField
|
||||
if (b2IsDateTimeLabel(field.label)) {
|
||||
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 {
|
||||
// 找不到 input,尝试北森 phoenix-radio-group 单选组
|
||||
const radioOk = await b2FillBeisenRadioGroup(field.label, field.value, titleEl, nextTitleEl)
|
||||
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} =====`)
|
||||
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__input,fallback 到面板内任意可见 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
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher"
|
||||
import { detectPickerField } from "~lib/pickerDetector"
|
||||
import { detectAndUploadResume } from "~lib/resumeUpload"
|
||||
import { getMockResumeData2, JOB_FORM_LABELS } from "~lib/constants"
|
||||
import { fillDatePicker } from "~lib/datePicker"
|
||||
import { getResumeFieldValue } from "~lib/resumeDataHelper"
|
||||
import { locateExperienceSections, expandExperienceSections, sortExperienceByTime, relocateSegmentContainer, getAllPageTitles, findAddButton, clickAddButton } from "~lib/experienceSection"
|
||||
import type { ExperienceSectionLocateResult } from "~lib/experienceSection"
|
||||
@@ -45,6 +46,10 @@ export interface AutoFillCommonResult {
|
||||
formFields: MatchedFormField[]
|
||||
/** 收集到的待填写空白字段 */
|
||||
unmatchedFields: UnmatchedFormField[]
|
||||
/** 经历段落定位结果(用于 fillStats 分段展示) */
|
||||
sectionResults: ExperienceSectionLocateResult[]
|
||||
/** 经历段落展开结果(用于 fillStats 分段展示) */
|
||||
expandedResults: ExperienceSectionLocateResult[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +63,8 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
|
||||
resumeData: params.resumeData,
|
||||
formFields: [],
|
||||
unmatchedFields: [],
|
||||
sectionResults: [],
|
||||
expandedResults: [],
|
||||
}
|
||||
|
||||
// 1. 提取 DOM 结构
|
||||
@@ -101,6 +108,10 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
|
||||
// 4.6 对比简历数据段数,点击添加按钮补足不够的段数
|
||||
const expandedResults = await expandExperienceSections(sectionResults, currentResumeData, lang)
|
||||
|
||||
// 存入 result 供外部使用(如 fillStats 分段展示)
|
||||
result.sectionResults = sectionResults
|
||||
result.expandedResults = expandedResults
|
||||
|
||||
// 4.65 【预匹配+标绿】经历段落添加完成后,一次性预匹配所有有简历数据的字段并标绿背景
|
||||
// 使用临时 usedInputs,不影响后续正式流程的匹配
|
||||
{
|
||||
@@ -318,7 +329,7 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
|
||||
const ok = await fillSearchPickerField(f)
|
||||
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(200)
|
||||
await delay(100)
|
||||
continue
|
||||
} else {
|
||||
await detectPickerField(f, lang)
|
||||
@@ -326,14 +337,14 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
|
||||
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 })
|
||||
|
||||
// 关闭残留弹窗
|
||||
if (lastTextInput && !lastTextInputIsPicker) {
|
||||
;(lastTextInput as HTMLElement).click()
|
||||
lastTextInput.focus()
|
||||
await delay(100)
|
||||
await delay(50)
|
||||
lastTextInput.blur()
|
||||
} else {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
@@ -344,7 +355,7 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
|
||||
}
|
||||
document.body.click()
|
||||
}
|
||||
await delay(300)
|
||||
await delay(150)
|
||||
|
||||
if (!f.isPicker && f.inputElement && !isTimePeriodField(f.key) && !isTimeSingleField(f.key)) {
|
||||
lastTextInput = f.inputElement
|
||||
@@ -391,7 +402,7 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
|
||||
if (lastTextInput) {
|
||||
;(lastTextInput as HTMLElement).click()
|
||||
lastTextInput.focus()
|
||||
await delay(100)
|
||||
await delay(50)
|
||||
lastTextInput.blur()
|
||||
} else {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
@@ -402,7 +413,7 @@ export async function handleAutoFillCommon(params: AutoFillCommonParams): Promis
|
||||
}
|
||||
document.body.click()
|
||||
}
|
||||
await delay(300)
|
||||
await delay(150)
|
||||
|
||||
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(JSON.stringify(unfilledFormData, null, 2))
|
||||
// console.log(JSON.stringify(unfilledFormData, null, 2))
|
||||
console.log("===== OfferPie: JSON 输出完毕 =====")
|
||||
|
||||
// 存 localStorage 缓存,包含简历名字和未填字段数据
|
||||
@@ -1274,6 +1285,61 @@ async function b2FillSingleField(
|
||||
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,填写有值的字段
|
||||
*/
|
||||
@@ -1340,9 +1406,15 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
|
||||
if (!field.value) { result.skipped++; continue }
|
||||
const inputEl = b2FindInputByLabelInContainer(field.label, seg.containerElement, usedInputs)
|
||||
if (!inputEl) { result.skipped++; continue }
|
||||
// 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField
|
||||
if (b2IsDateTimeLabel(field.label)) {
|
||||
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(200)
|
||||
}
|
||||
await delay(100)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1392,7 +1464,7 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
|
||||
}).length
|
||||
const clicked = await clickAddButton(addBtn, titleEl, nextTitleEl, beforeCount)
|
||||
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 }
|
||||
const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs)
|
||||
if (!inputEl) { result.skipped++; continue }
|
||||
// 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField
|
||||
if (b2IsDateTimeLabel(field.label)) {
|
||||
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(200)
|
||||
}
|
||||
await delay(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1415,9 +1493,15 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
|
||||
if (!field.value) { result.skipped++; continue }
|
||||
const inputEl = b2FindInputByLabel(field.label, titleEl, nextTitleEl, usedInputs)
|
||||
if (!inputEl) { result.skipped++; continue }
|
||||
// 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField
|
||||
if (b2IsDateTimeLabel(field.label)) {
|
||||
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(200)
|
||||
}
|
||||
await delay(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,7 +733,7 @@ export function locateExperienceSections(
|
||||
rootEl: Element = document.body,
|
||||
lang: "zh" | "en"
|
||||
): ExperienceSectionLocateResult[] {
|
||||
console.log("===== OfferPie: 开始定位经历区块 =====")
|
||||
// console.log("===== OfferPie: 开始定位经历区块 =====")
|
||||
|
||||
// 定位5种经历的大标题
|
||||
const located: { config: ExperienceSectionConfig; titleElement: Element; titleText: string }[] = []
|
||||
@@ -749,8 +749,8 @@ export function locateExperienceSections(
|
||||
}
|
||||
|
||||
if (located.length === 0) {
|
||||
console.log(" ❌ 未找到任何经历区块大标题")
|
||||
console.log("===== OfferPie: 经历区块定位完毕 =====")
|
||||
// console.log(" ❌ 未找到任何经历区块大标题")
|
||||
// console.log("===== OfferPie: 经历区块定位完毕 =====")
|
||||
return notFound.map((config) => ({
|
||||
section: config.section, titleElement: null, titleText: "",
|
||||
titleSignature: { layer1: null, layer2: null }, expandedCount: 0, segmentRanges: [],
|
||||
@@ -778,14 +778,14 @@ export function locateExperienceSections(
|
||||
if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1
|
||||
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) {
|
||||
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段的边界
|
||||
const results: ExperienceSectionLocateResult[] = []
|
||||
@@ -800,25 +800,25 @@ export function locateExperienceSections(
|
||||
const segmentRanges: SegmentRange[] = firstSegment ? [firstSegment] : []
|
||||
const expandedCount = segmentRanges.length
|
||||
|
||||
console.log(
|
||||
` [${config.section}] ✅ 标题: "${titleText}"` +
|
||||
` | 范围边界: "${allPageTitles[idx + 1]?.text || "页面底部"}"` +
|
||||
` | 已展开段数: ${expandedCount}`
|
||||
)
|
||||
// console.log(
|
||||
// ` [${config.section}] ✅ 标题: "${titleText}"` +
|
||||
// ` | 范围边界: "${allPageTitles[idx + 1]?.text || "页面底部"}"` +
|
||||
// ` | 已展开段数: ${expandedCount}`
|
||||
// )
|
||||
|
||||
results.push({ section: config.section, titleElement, titleText, titleSignature, expandedCount, segmentRanges })
|
||||
}
|
||||
|
||||
// 未找到的区块
|
||||
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({
|
||||
section: config.section, titleElement: null, titleText: "",
|
||||
titleSignature: { layer1: null, layer2: null }, expandedCount: 0, segmentRanges: [],
|
||||
})
|
||||
}
|
||||
|
||||
console.log("===== OfferPie: 经历区块定位完毕 =====")
|
||||
// console.log("===== OfferPie: 经历区块定位完毕 =====")
|
||||
return results
|
||||
}
|
||||
|
||||
|
||||
@@ -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的字段=true,B2/C/D的字段=false) */
|
||||
isResumeFormat: boolean
|
||||
/** 当前字段值 */
|
||||
value: string
|
||||
}
|
||||
|
||||
/** 按大标题分组的统计结果 */
|
||||
export interface TitleStat {
|
||||
/** 大标题文字 */
|
||||
titleText: string
|
||||
/** 大标题元素 */
|
||||
titleElement: Element
|
||||
/** 是否为经历类型区块 */
|
||||
isExpType: boolean
|
||||
/** 经历段数 */
|
||||
segmentCount: number
|
||||
/** 该标题下所有字段 */
|
||||
fields: FieldStat[]
|
||||
/** 绿色(简历有数据)字段数 */
|
||||
greenCount: number
|
||||
/** greenTwo(B2填充)字段数 */
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user