摩卡级联操作,地区选择操作

This commit is contained in:
2026-08-05 10:58:35 +08:00
parent e8c306946f
commit d5b273ebc0
6 changed files with 770 additions and 41 deletions
+14
View File
@@ -140,3 +140,17 @@ src/
- 禁止将新增逻辑直接内联到已有的大方法中导致方法膨胀、职责混乱 - 禁止将新增逻辑直接内联到已有的大方法中导致方法膨胀、职责混乱
- 独立方法应有清晰的中文注释说明:用途、入参、返回值、安全退化行为 - 独立方法应有清晰的中文注释说明:用途、入参、返回值、安全退化行为
- 调用独立方法的位置需加注释说明为什么调用、结果如何使用 - 调用独立方法的位置需加注释说明为什么调用、结果如何使用
## 弹出层点击操作规范
- **任何需要触发弹出层(下拉面板、日期选择器、级联选择器等)的点击操作,禁止使用普通的 `.click()`**
- 必须使用 `src/utils/domEvent.ts` 导出的 `simulateClick` 方法:
```typescript
import { simulateClick } from "~utils/domEvent"
simulateClick(el) // 默认含 pointer 事件 + 自动取元素中心坐标
simulateClick(el, { focus: true }) // 需要先 focus 时
simulateClick(el, { pointer: false }) // 不需要 pointer 事件时
```
- 原因:React / Vue 等框架的 UI 组件库(如 Shimo Design、Ant Design 等)经常将事件监听绑定在 `mousedown` 或 `pointerdown` 而非 `click` 上,普通 `.click()` 只触发 click 事件,无法激活这些组件的弹出层逻辑
- `simulateClick` 内部完整事件链:pointerdown → mousedown → pointerup → mouseup → click
- 禁止在业务代码中自行编写 `dispatchEvent(new MouseEvent(...))` 序列,必须统一引用 `~utils/domEvent`
+47
View File
@@ -35,3 +35,50 @@ export interface MemberStatusData {
export function getMemberStatus() { export function getMemberStatus() {
return http.get<MemberStatusData>('/member/status') return http.get<MemberStatusData>('/member/status')
} }
// ============ 行政区划相关接口 ============
/** 行政区划树节点 */
export interface RegionTreeNode {
/** 地区编码 */
code?: string
/** 地区名称 */
name?: string
/** 递归子级地区列表 */
children?: RegionTreeNode[]
}
/** 获取省市区三级行政区划树 */
export function getRegionTree() {
return http.get<RegionTreeNode[]>('/public/regions/tree')
}
// ============ 行政区划数据缓存 ============
/** 模块级缓存:省市区树数据(调用一次接口后缓存,避免重复请求) */
let _regionTreeCache: RegionTreeNode[] | null = null
/**
* 获取省市区树数据(带缓存)
* 首次调用时请求接口并缓存,后续调用直接返回缓存数据
*/
export async function getRegionTreeCached(): Promise<RegionTreeNode[]> {
if (_regionTreeCache) return _regionTreeCache
try {
const data = await getRegionTree()
_regionTreeCache = data || []
return _regionTreeCache
} catch (e) {
console.warn("OfferPie: getRegionTreeCached 请求失败", e)
return []
}
}
/**
* 预加载省市区树数据到缓存(SidebarPanel 初始化时调用,提前拉取数据)
*/
export function preloadRegionTree(): void {
getRegionTreeCached().then(() => {
console.log("[OfferPie] 省市区数据预加载完成")
}).catch(() => {})
}
+35 -3
View File
@@ -8,7 +8,7 @@ import { useState, useEffect, useRef, useCallback } from "react"
import { getCookieValue } from "~utils/cookie" import { getCookieValue } from "~utils/cookie"
import { get as storageGet, set as storageSet, remove as storageRemove } from "~utils/storage" import { get as storageGet, set as storageSet, remove as storageRemove } from "~utils/storage"
import { getCustomizeResume } from "~api/aiApi" import { getCustomizeResume } from "~api/aiApi"
import { getMemberStatus } from "~api/dataApi" import { getMemberStatus, preloadRegionTree } from "~api/dataApi"
import { handleAutoFillCommon } from "~handlers/handleAutoFillCommon" import { handleAutoFillCommon } from "~handlers/handleAutoFillCommon"
import { handleAutoFillBeisen } from "~handlers/handleAutoFillBeisen" import { handleAutoFillBeisen } from "~handlers/handleAutoFillBeisen"
import { handleAutoFillMoka } from "~handlers/handleAutoFillMoka" import { handleAutoFillMoka } from "~handlers/handleAutoFillMoka"
@@ -305,6 +305,9 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
return return
} }
// 预加载省市区行政区划数据(供摩卡级联选择器使用)
preloadRegionTree()
// 查询会员状态 // 查询会员状态
getMemberStatus().then((memberData) => { getMemberStatus().then((memberData) => {
console.log("[OfferPie] 会员状态:", memberData) console.log("[OfferPie] 会员状态:", memberData)
@@ -666,6 +669,35 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
} }
} }
/**
* 过滤日期/时间字段值中的年龄后缀,如 "1997-05 (29岁)" → "1997-05"
* 匹配中英文括号包裹的"数字+岁/years old"格式
*/
const stripAgeSuffix = (label: string, value: string): string => {
if (!value) return value
const dateKeywords = ["日期", "时间", "年月", "生日", "出生", "date", "birth", "年龄"]
const isDateField = dateKeywords.some((k) => label.toLowerCase().includes(k.toLowerCase()))
if (!isDateField) return value
// 去掉中英文括号包裹的年龄信息:(29岁)(29岁)(29 years old) 等
return value.replace(/\s*[(\uff08]\s*\d+\s*(岁|years?\s*old)\s*[)\uff09]/gi, "").trim()
}
/**
* 地区类字段空格转斜杠:如 "山西 大同市" → "山西/大同市"
* 适用于:籍贯、户口、所在地、出生地、工作地等地区相关字段
* @param label - 字段标签名(用于判断是否为地区字段)
* @param value - 字段当前值
* @returns 处理后的值(非地区字段原样返回)
*/
const formatRegionValue = (label: string, value: string): string => {
if (!value) return value
const regionKeywords = ["籍贯", "户口", "所在地", "出生地", "工作地", "居住地", "家庭地址", "户籍", "地区", "城市", "省市", "location", "birthplace", "hometown", "residence"]
const isRegionField = regionKeywords.some((k) => label.toLowerCase().includes(k.toLowerCase()))
if (!isRegionField) return value
// 将空格替换为 /(如 "山西 大同市" → "山西/大同市"
return value.replace(/\s+/g, "/")
}
// 提取非简历格式字段(B2阶段字段) // 提取非简历格式字段(B2阶段字段)
const nonResumeData = extractNonResumeFields(titleStats) const nonResumeData = extractNonResumeFields(titleStats)
@@ -692,7 +724,7 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
for (const nf of newFields) { for (const nf of newFields) {
const of_ = oldFields.find((f: any) => f.label === nf.label) const of_ = oldFields.find((f: any) => f.label === nf.label)
if (of_) { if (of_) {
if (nf.value) of_.value = nf.value if (nf.value) of_.value = formatRegionValue(nf.label || "", stripAgeSuffix(nf.label || "", nf.value))
} else { } else {
oldFields.push(nf) oldFields.push(nf)
} }
@@ -706,7 +738,7 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps)
for (const nf of newFields) { for (const nf of newFields) {
const of_ = oldFields.find((f: any) => f.label === nf.label) const of_ = oldFields.find((f: any) => f.label === nf.label)
if (of_) { if (of_) {
if (nf.value) of_.value = nf.value if (nf.value) of_.value = formatRegionValue(nf.label || "", stripAgeSuffix(nf.label || "", nf.value))
} else { } else {
oldFields.push(nf) oldFields.push(nf)
} }
+441 -1
View File
@@ -9,6 +9,7 @@
import { fillMatchedField, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill" import { fillMatchedField, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill"
import { delay } from "~utils/delay" import { delay } from "~utils/delay"
import { simulateClick } from "~utils/domEvent"
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"
@@ -22,6 +23,8 @@ import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, Unmatche
import { setFieldHighlight, isRequiredField } from "~lib/formStyle" import { setFieldHighlight, isRequiredField } from "~lib/formStyle"
import { get as storageGet, set as storageSet } from "~utils/storage" import { get as storageGet, set as storageSet } from "~utils/storage"
import { findLabelForInput } from "~lib/labelFinder" import { findLabelForInput } from "~lib/labelFinder"
import { getRegionTreeCached } from "~api/dataApi"
import type { RegionTreeNode } from "~api/dataApi"
/** 摩卡模式自动填写的参数 */ /** 摩卡模式自动填写的参数 */
export interface AutoFillMokaParams { export interface AutoFillMokaParams {
@@ -340,6 +343,13 @@ export async function handleAutoFillMoka(params: AutoFillMokaParams): Promise<Au
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("mid") await delay("mid")
continue continue
} else if (isMokaCascadeField(f.labelText) && f.inputElement) {
// 摩卡多级级联选择器字段(地区、籍贯等)
const ok = await fillMokaCascadePicker(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("mid")
continue
} else { } else {
await detectPickerField(f, lang) await detectPickerField(f, lang)
const ok = await fillMatchedField(f) const ok = await fillMatchedField(f)
@@ -397,6 +407,15 @@ export async function handleAutoFillMoka(params: AutoFillMokaParams): Promise<Au
if (!f.fillValue) { result.skipped++; continue } if (!f.fillValue) { result.skipped++; continue }
// 摩卡多级级联选择器字段(地区、籍贯等)优先处理
if (isMokaCascadeField(f.labelText) && f.inputElement) {
console.log(` [${f.key}] "${f.labelText}" → 摩卡级联选择器 | fillValue: "${f.fillValue}"`)
const ok = await fillMokaCascadePicker(f.inputElement, f.labelText, f.fillValue)
if (ok) { result.success++ } else { result.failed++ }
await delay("mid")
continue
}
await detectPickerField(f, lang) await detectPickerField(f, lang)
console.log( console.log(
@@ -1301,9 +1320,45 @@ async function b2FillDateTimeField(
} }
// 和阶段A同样的方式:点击 input 展开日期面板,然后直接调 fillDatePicker // 和阶段A同样的方式:点击 input 展开日期面板,然后直接调 fillDatePicker
// 【Moka适配】逐个尝试四个可能的触发元素,检测到弹出层出现就停止
inputEl.focus() inputEl.focus()
;(inputEl as HTMLElement).click() const labelContainer = inputEl.closest('[class*="sd-Input-container"]') as HTMLElement | null
const dropdownContainer = inputEl.closest('[class*="sd-Dropdown-container"]') as HTMLElement | null
const addonBtn = (labelContainer || inputEl.parentElement)?.querySelector('[class*="sd-Input-addon"]') as HTMLElement | null
// sd-Icon-container 是 addon 内部的图标容器,某些情况事件绑定在这一层
const iconBtn = addonBtn?.querySelector('[class*="sd-Icon-container"]') as HTMLElement | null
/** 检测弹出层是否出现 */
const checkPanel = (): boolean => {
const container = inputEl.closest('[class*="sd-Dropdown-container"]')
const wrapper = container?.querySelector('[class*="sd-panal-menu-wrapper"]') as HTMLElement | null
if (wrapper && wrapper.offsetHeight > 0 && wrapper.offsetWidth > 0) return true
// Portal 场景兜底
const all = document.querySelectorAll('[class*="sd-panal-menu-wrapper"]')
for (const w of Array.from(all)) {
const hw = w as HTMLElement
if (hw.offsetHeight > 0 && hw.offsetWidth > 0) return true
}
return false
}
const clickTargets = [iconBtn, addonBtn, inputEl as HTMLElement, labelContainer, dropdownContainer].filter(Boolean) as HTMLElement[]
let panelOpened = false
for (const target of clickTargets) {
simulateClick(target)
await delay("mid") await delay("mid")
if (checkPanel()) {
panelOpened = true
break
}
}
// 弹出层未出现,跳过该字段
if (!panelOpened) {
console.log(`OfferPie: ⚠ [B2-时间字段] Moka适配: 仿真点击后弹出层仍未出现,跳过 "${labelText}"`)
return false
}
const ok = await fillDatePicker(field) const ok = await fillDatePicker(field)
if (ok) { if (ok) {
@@ -1466,6 +1521,13 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise<FillC
} }
} }
if (!inputEl) { result.skipped++; continue } if (!inputEl) { result.skipped++; continue }
// 摩卡多级级联选择器字段(籍贯、地区等)优先处理
if (isMokaCascadeField(field.label)) {
const ok = await fillMokaCascadePicker(inputEl, field.label, field.value)
if (ok) { result.success++; usedInputs.add(inputEl); setFieldHighlight(inputEl, "greenTwo") } else { result.failed++ }
await delay("mid")
continue
}
// 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField // 时间/日期字段走阶段A同款流程:直接点击 input → fillDatePicker,不经过 detectPickerField
if (b2IsDateTimeLabel(field.label)) { if (b2IsDateTimeLabel(field.label)) {
const ok = await b2FillDateTimeField(inputEl, field.label, field.value) const ok = await b2FillDateTimeField(inputEl, field.label, field.value)
@@ -1482,3 +1544,381 @@ 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
} }
// ====================================================================
// 摩卡级联选择器字段识别
// ====================================================================
/** 摩卡级联选择器关键字(字段标签含这些关键字时走级联填写逻辑) */
const MOKA_CASCADE_KEYWORDS = ["地区", "居住地", "籍贯", "户籍", "地点", "所在地", "现居住"]
/**
* 判断字段标签是否为摩卡级联选择器字段
* 通过标签文字中是否包含地区相关关键字来识别
*
* @param labelText - 字段标签文字
* @returns 是否为摩卡级联字段
*/
function isMokaCascadeField(labelText: string): boolean {
if (!labelText) return false
return MOKA_CASCADE_KEYWORDS.some((kw) => labelText.includes(kw))
}
// ====================================================================
// 摩卡级联选择器填写逻辑(省市区等多级级联组件)
// ====================================================================
/** 摩卡级联选项数据项 */
interface MokaCascadeOptionItem {
/** 选项文字 */
text: string
/** 选项 span 元素(含4个类名特征的 span) */
spanEl: HTMLElement
/** 选项文字所在的 div 元素(类名含 sd-Tag-text */
textDivEl: HTMLElement
/** 是否已被点击选中 */
clicked?: number
}
/**
* 在 sd-Dropdown-dropdown 容器内收集当前级的所有选项
* 查找所有类名同时包含 sd-Tag-container、sd-Tag-md、sd-Tag-dark、tag 四个特征的 span 标签
* 每个 span 内部有一个类名包含 sd-Tag-text 的 div,其文字即为选项值
*
* @param dropdownEl - sd-Dropdown-dropdown 弹出层容器元素
* @returns 当前级的选项集合
*/
function collectMokaCascadeOptions(dropdownEl: HTMLElement): MokaCascadeOptionItem[] {
const options: MokaCascadeOptionItem[] = []
// 查找所有 span 标签
const allSpans = dropdownEl.querySelectorAll("span")
for (const span of Array.from(allSpans)) {
const classList = span.className || ""
// 检查类名是否同时包含4个特征
if (
classList.includes("sd-Tag-container") &&
classList.includes("sd-Tag-md") &&
classList.includes("sd-Tag-dark") &&
classList.includes("tag")
) {
// 在 span 内部找类名包含 sd-Tag-text 的 div
const textDiv = span.querySelector("div[class*='sd-Tag-text']") as HTMLElement | null
if (textDiv) {
const text = textDiv.textContent?.trim() || ""
if (text) {
options.push({
text,
spanEl: span as HTMLElement,
textDivEl: textDiv,
})
}
}
}
}
return options
}
/**
* 判断两个选项集合是否内容不同(用于检测点击后级联是否切换到了下一级)
* 完全不一样且非空则认为是新一级的数据
*
* @param prev - 上一次收集的选项集合
* @param curr - 当前收集的选项集合
* @returns true 表示是不同级别的数据
*/
function isCascadeLevelChanged(prev: MokaCascadeOptionItem[], curr: MokaCascadeOptionItem[]): boolean {
if (curr.length === 0) return false
if (prev.length === 0) return true
// 对比文字内容,完全不一样则认为切换了级别
const prevTexts = prev.map((o) => o.text).sort().join(",")
const currTexts = curr.map((o) => o.text).sort().join(",")
return prevTexts !== currTexts
}
/**
* 在选项集合中模糊匹配目标文字
* 去掉"省"、"市"、"区"、"县"等后缀后比较
*
* @param options - 选项集合
* @param target - 要匹配的目标文字
* @returns 匹配到的选项,未匹配到返回 null
*/
function findMatchingOption(options: MokaCascadeOptionItem[], target: string): MokaCascadeOptionItem | null {
if (!target || options.length === 0) return null
const cleanTarget = stripRegionSuffix(target)
// 优先精确匹配
for (const opt of options) {
if (opt.text === target) return opt
}
// 去后缀匹配
for (const opt of options) {
if (stripRegionSuffix(opt.text) === cleanTarget) return opt
}
// 包含匹配(目标包含选项 or 选项包含目标,至少2字)
for (const opt of options) {
const cleanOpt = stripRegionSuffix(opt.text)
if (cleanOpt.length >= 2 && cleanTarget.length >= 2) {
if (cleanOpt.includes(cleanTarget) || cleanTarget.includes(cleanOpt)) return opt
}
}
return null
}
/** 去掉行政区划后缀 */
function stripRegionSuffix(name: string): string {
return name
.replace(/(?:维吾尔|回族|壮族|藏族)?自治区$/, "")
.replace(/特别行政区$/, "")
.replace(/[省市区县旗盟州]$/, "")
}
/**
* 从接口获取的省市区树中,根据单个地区名反查完整的省→市→区路径
* 用于简历数据只给了"广州市"或"海珠区"这种单级数据时,补全父级信息
*
* @param regionTree - 接口返回的省市区树数据
* @param keyword - 要查找的地区名(如"广州市"、"海珠区"
* @returns 匹配到的路径数组 [省, 市, 区],未找到返回 null
*/
function lookupRegionPath(regionTree: RegionTreeNode[], keyword: string): string[] | null {
if (!keyword || !regionTree || regionTree.length === 0) return null
const cleanKeyword = stripRegionSuffix(keyword.trim())
if (cleanKeyword.length < 2) return null
for (const province of regionTree) {
const provName = province.name || ""
// 省级匹配
if (isRegionMatch(provName, cleanKeyword)) {
return [provName]
}
if (!province.children) continue
for (const city of province.children) {
const cityName = city.name || ""
// 市级匹配
if (isRegionMatch(cityName, cleanKeyword)) {
return [provName, cityName]
}
if (!city.children) continue
for (const district of city.children) {
const distName = district.name || ""
// 区级匹配
if (isRegionMatch(distName, cleanKeyword)) {
return [provName, cityName, distName]
}
}
}
}
return null
}
/** 地区名匹配判断 */
function isRegionMatch(regionName: string, cleanKeyword: string): boolean {
if (!regionName) return false
const cleanRegion = stripRegionSuffix(regionName)
if (cleanRegion === cleanKeyword) return true
if (cleanRegion.length >= 2 && cleanKeyword.length >= 2) {
if (cleanRegion.includes(cleanKeyword) || cleanKeyword.includes(cleanRegion)) return true
}
return false
}
/**
* 摩卡级联选择器填写主方法
* 操作流程:
* 1. simulateClick 点击输入框,打开弹出层
* 2. 在输入框后面找到类名包含 sd-Dropdown-dropdown 的弹出层
* 3. 收集第一级选项集合
* 4. 解析目标值(可能是"广东省/广州市/海珠区"多级,也可能是"广州市"单级)
* 5. 如果是单级数据,调用 getRegionTree 接口反查完整路径
* 6. 逐级匹配并点击选项,每次点击后等待并收集下一级选项
* 7. 所有级别匹配完成后,点击确认按钮
*
* @param inputEl - 级联选择器的输入框元素
* @param labelText - 字段标签文字(用于日志)
* @param fillValue - 要填写的目标值(如"广东省/广州市/海珠区"或"广州市"
* @returns 是否填写成功
*/
export async function fillMokaCascadePicker(
inputEl: HTMLInputElement | HTMLTextAreaElement,
labelText: string,
fillValue: string
): Promise<boolean> {
if (!inputEl || !fillValue) return false
console.log(`OfferPie: [摩卡级联] "${labelText}" 开始填写 "${fillValue}"`)
// 1. simulateClick 点击输入框,打开弹出层
// 依次尝试:input → span.sd-Input-input → label.sd-Input-container
inputEl.scrollIntoView({ block: "center", behavior: "instant" })
const inputSpan = inputEl.closest("span[class*='sd-Input-input']") as HTMLElement | null
const inputLabel = inputEl.closest("label[class*='sd-Input-container']") as HTMLElement | null
// 先点击 input 本身
simulateClick(inputEl as HTMLElement, { focus: true })
await delay("mid")
// 检查是否出现了 sd-Dropdown-dropdown 弹出层
let dropdownEl: HTMLElement | null = null
// 在 input 往上找到的 sd-Dropdown-container 内部查找
const dropdownContainer = inputEl.closest("[class*='sd-Dropdown-container']") as HTMLElement | null
if (dropdownContainer) {
dropdownEl = dropdownContainer.querySelector("[class*='sd-Dropdown-dropdown']") as HTMLElement | null
}
// 如果 input 点击没触发弹出层,尝试点击 span.sd-Input-input
if (!dropdownEl && inputSpan) {
simulateClick(inputSpan, { focus: true })
await delay("mid")
if (dropdownContainer) {
dropdownEl = dropdownContainer.querySelector("[class*='sd-Dropdown-dropdown']") as HTMLElement | null
}
}
// 如果还没出来,尝试点击 label.sd-Input-container
if (!dropdownEl && inputLabel) {
simulateClick(inputLabel, { focus: true })
await delay("mid")
if (dropdownContainer) {
dropdownEl = dropdownContainer.querySelector("[class*='sd-Dropdown-dropdown']") as HTMLElement | null
}
}
// 如果还没出来,尝试点击 sd-Dropdown-container 本身
if (!dropdownEl && dropdownContainer) {
simulateClick(dropdownContainer, { focus: true })
await delay("mid")
dropdownEl = dropdownContainer.querySelector("[class*='sd-Dropdown-dropdown']") as HTMLElement | null
}
// fallback:从 input 向上逐级找父级的后续兄弟中的第一个 sd-Dropdown-dropdown
if (!dropdownEl) {
let searchEl: Element | null = inputEl
for (let i = 0; i < 15 && searchEl; i++) {
let sibling = searchEl.nextElementSibling
while (sibling) {
if (sibling.className && sibling.className.includes("sd-Dropdown-dropdown")) {
dropdownEl = sibling as HTMLElement
break
}
const found = sibling.querySelector("[class*='sd-Dropdown-dropdown']") as HTMLElement | null
if (found) { dropdownEl = found; break }
sibling = sibling.nextElementSibling
}
if (dropdownEl) break
searchEl = searchEl.parentElement
}
}
// 如果没检测到弹出层,说明可能是纯输入框,直接写值并返回
if (!dropdownEl) {
console.log(`OfferPie: [摩卡级联] "${labelText}" 未检测到级联弹出层,尝试直接写值`)
const { forceSetValue } = await import("~lib/autofill")
forceSetValue(inputEl, fillValue)
inputEl.dispatchEvent(new Event("input", { bubbles: true }))
inputEl.dispatchEvent(new Event("change", { bubbles: true }))
return true
}
console.log(`OfferPie: [摩卡级联] "${labelText}" 找到弹出层`)
// 3. 收集第一级选项集合
let currentOptions = collectMokaCascadeOptions(dropdownEl)
console.log(`OfferPie: [摩卡级联] "${labelText}" 第1级选项数: ${currentOptions.length}`)
if (currentOptions.length === 0) {
console.log(`OfferPie: [摩卡级联] "${labelText}" 第1级选项为空,填写失败`)
return false
}
// 4. 解析目标值,确定要逐级匹配的路径
let targetParts: string[] = []
// 检查是否包含 / 分隔符(多级数据如"广东省/广州市/海珠区"
if (fillValue.includes("/")) {
targetParts = fillValue.split("/").map((s) => s.trim()).filter(Boolean)
} else {
// 单级数据(如"广州市"或"海珠区"),需要通过接口反查完整路径
targetParts = [fillValue.trim()]
// 5. 调用 getRegionTreeCached 获取完整省市区数据(已缓存),反查路径
try {
const regionTree = await getRegionTreeCached()
if (regionTree && regionTree.length > 0) {
const fullPath = lookupRegionPath(regionTree, fillValue.trim())
if (fullPath && fullPath.length > 0) {
targetParts = fullPath
console.log(`OfferPie: [摩卡级联] "${labelText}" 反查完整路径: ${targetParts.join(" → ")}`)
}
}
} catch (e) {
console.warn(`OfferPie: [摩卡级联] "${labelText}" 获取区划数据失败,使用原始值`, e)
}
}
console.log(`OfferPie: [摩卡级联] "${labelText}" 目标路径: [${targetParts.join(", ")}]`)
// 6. 逐级匹配并点击选项
// 记录操作痕迹数据:每一级的选项集合 + clicked 状态
const levelOptionsHistory: MokaCascadeOptionItem[][] = [currentOptions]
for (let levelIdx = 0; levelIdx < targetParts.length; levelIdx++) {
const targetText = targetParts[levelIdx]
console.log(`OfferPie: [摩卡级联] "${labelText}" 匹配第${levelIdx + 1}级: "${targetText}"`)
// 在当前级选项集合中匹配目标
const matchedOption = findMatchingOption(currentOptions, targetText)
if (!matchedOption) {
console.log(`OfferPie: [摩卡级联] "${labelText}" 第${levelIdx + 1}级未找到匹配项 "${targetText}",停止匹配`)
break
}
// 标记 clicked 状态(先清除同级所有 clicked,再给匹配项加 clicked
for (const opt of currentOptions) {
opt.clicked = undefined
}
matchedOption.clicked = 1
console.log(`OfferPie: [摩卡级联] "${labelText}" 第${levelIdx + 1}级匹配到: "${matchedOption.text}",点击`)
// 点击选项(同时点击 span 和内部的 textDiv,不确定哪个触发事件)
simulateClick(matchedOption.spanEl)
await delay("low")
simulateClick(matchedOption.textDivEl)
await delay("mid")
// 如果还有下一级要匹配,等待并收集新的选项集合
if (levelIdx < targetParts.length - 1) {
await delay("mid")
const newOptions = collectMokaCascadeOptions(dropdownEl)
// 判断是否切换到了下一级(选项内容完全不同)
if (isCascadeLevelChanged(currentOptions, newOptions)) {
currentOptions = newOptions
levelOptionsHistory.push(currentOptions)
console.log(`OfferPie: [摩卡级联] "${labelText}" 第${levelIdx + 2}级选项数: ${currentOptions.length}`)
} else {
// 没有切换到新级别,可能这一级已经是最深层了
console.log(`OfferPie: [摩卡级联] "${labelText}" 点击后选项未变化,可能已到最深层`)
break
}
}
}
// 7. 点击确认按钮(在 sd-Dropdown-dropdown 内部找包含 sd_global_focus_controller_class 类名的 button
await delay("mid")
const confirmBtn = dropdownEl.querySelector("button[class*='sd_global_focus_controller_class']") as HTMLElement | null
if (confirmBtn) {
simulateClick(confirmBtn)
await delay("mid")
console.log(`OfferPie: ✅ [摩卡级联] "${labelText}" = "${fillValue}" 已点击确认按钮,填写成功`)
return true
} else {
console.log(`OfferPie: [摩卡级联] "${labelText}" 未找到确认按钮(sd_global_focus_controller_class`)
return false
}
}
+127 -6
View File
@@ -788,6 +788,87 @@ async function navigateToYearMonth(
// ============ 月份面板检测与点击 ============ // ============ 月份面板检测与点击 ============
/**
* Moka 域名下专用的月份面板查找
* Moka 使用 Shimo Design 组件库,月份面板弹出层特征:
* - 类名包含 "sd-Dropdown-dropdown"
* - position: fixed
* - 内含 12 个月份格子(sd-basic-year 相关类名)
*
* @returns { monthPanel, monthCells } 或 null(非 Moka 域名或未找到)
* 安全退化:非 mokahr.com 域名直接返回 null,不影响通用逻辑
*/
function findMokaMonthPanel(inputElement?: HTMLElement): { monthPanel: HTMLElement; monthCells: HTMLElement[] } | null {
// 仅在 Moka 域名下生效
if (!location.hostname.includes("mokahr.com")) return null
if (!inputElement) return null
// 【Moka适配】弹出层定位策略(按优先级):
// DOM 结构:<div.sd-Dropdown-container> → <label><input>...</label> + <span><div.sd-Dropdown-dropdown><div.sd-panal-menu-wrapper>...</span>
// 弹出层和 label 是同一个 sd-Dropdown-container 的子元素
let searchRoot: Element | null = null
// 策略1(首选):从 input 向上找 sd-Dropdown-container,在容器内搜索 sd-panal-menu-wrapper
const dropdownContainer = inputElement.closest('[class*="sd-Dropdown-container"]')
if (dropdownContainer?.querySelector('[class*="sd-panal-menu-wrapper"]')) {
searchRoot = dropdownContainer
}
// 策略2:从 label 的后续兄弟中找
if (!searchRoot) {
const label = inputElement.closest("label")
if (label) {
let sibling = label.nextElementSibling
while (sibling) {
if (sibling.querySelector('[class*="sd-panal-menu-wrapper"]')) {
searchRoot = sibling
break
}
sibling = sibling.nextElementSibling
}
}
}
// 策略3:在 input 的父级容器(如 ctrl-* div)内搜索
if (!searchRoot) {
const container = inputElement.closest('[class*="ctrl-"]')
if (container?.querySelector('[class*="sd-panal-menu-wrapper"]')) {
searchRoot = container
}
}
// 策略4:全局搜索可见的 sd-panal-menu-wrapperPortal 渲染到 body 末尾的场景)
if (!searchRoot) {
const allWrappers = document.querySelectorAll('[class*="sd-panal-menu-wrapper"]')
for (const w of Array.from(allWrappers)) {
const hw = w as HTMLElement
if (hw.offsetHeight > 0 && hw.offsetWidth > 0) {
searchRoot = hw.parentElement || hw
break
}
}
}
if (!searchRoot) {
console.log(`OfferPie: [datePicker] Moka适配: 未在 input 附近找到 sd-panal-menu-wrapper`)
return null
}
const wrapper = searchRoot.querySelector('[class*="sd-panal-menu-wrapper"]') as HTMLElement | null
if (!wrapper || !isVisible(wrapper)) {
console.log(`OfferPie: [datePicker] Moka适配: sd-panal-menu-wrapper 不可见`)
return null
}
const cells = findMonthCells(wrapper)
console.log(`OfferPie: [datePicker] Moka适配: 找到 wrappermonthCells=${cells.length}`)
if (cells.length >= 10) {
console.log(`OfferPie: [datePicker] Moka适配: ✅ 找到月份面板,${cells.length} 个月份格子`)
return { monthPanel: wrapper, monthCells: cells }
}
return null
}
/** /**
* 检测当前弹出的是否为月份选择面板,如果是则导航年份并点击目标月份 * 检测当前弹出的是否为月份选择面板,如果是则导航年份并点击目标月份
* *
@@ -806,6 +887,31 @@ async function tryFillMonthPanel(
inputElement: HTMLElement inputElement: HTMLElement
): Promise<boolean> { ): Promise<boolean> {
// 在页面中查找可见的月份面板 // 在页面中查找可见的月份面板
let monthPanel: HTMLElement | null = null
let monthCells: HTMLElement[] = []
let matchedSelector = ""
// 【Moka适配】优先用 Moka 专用逻辑精确定位弹出层,避免通用逻辑误匹配到页面主容器
let mokaResult = findMokaMonthPanel(inputElement as HTMLElement)
// 【Moka适配】弹出层可能还未渲染完毕(addon.click() 后 DOM 异步插入),等待后重试
if (!mokaResult && location.hostname.includes("mokahr.com")) {
await delay("high")
mokaResult = findMokaMonthPanel(inputElement as HTMLElement)
}
if (mokaResult) {
monthPanel = mokaResult.monthPanel
monthCells = mokaResult.monthCells
matchedSelector = "Moka适配(sd-Dropdown)"
}
// 【Moka适配】Moka 域名下如果专用逻辑找不到弹出层,直接返回 false,不走通用兜底(避免误匹配到页面主容器)
if (!mokaResult && location.hostname.includes("mokahr.com")) {
console.log(`OfferPie: [datePicker] Moka适配: 弹出层未出现,跳过月份面板填写`)
return false
}
// 通用搜索逻辑(Moka 适配未命中时执行)
if (!monthPanel) {
const panelSelectors = [ const panelSelectors = [
'[class*="month-panel"]', '[class*="month-picker"]', '[class*="month-panel"]', '[class*="month-picker"]',
'[class*="picker-panel"]', '[class*="calendar-month"]', '[class*="picker-panel"]', '[class*="calendar-month"]',
@@ -814,9 +920,6 @@ async function tryFillMonthPanel(
'[class*="dropdown"]', '[class*="overlay"]', '[class*="dropdown"]', '[class*="overlay"]',
] ]
let monthPanel: HTMLElement | null = null
let monthCells: HTMLElement[] = []
for (const sel of panelSelectors) { for (const sel of panelSelectors) {
const panels = document.querySelectorAll(sel) const panels = document.querySelectorAll(sel)
for (const panel of Array.from(panels)) { for (const panel of Array.from(panels)) {
@@ -826,6 +929,7 @@ async function tryFillMonthPanel(
if (cells.length >= 10) { if (cells.length >= 10) {
monthPanel = htmlPanel monthPanel = htmlPanel
monthCells = cells monthCells = cells
matchedSelector = sel
break break
} }
} }
@@ -845,22 +949,33 @@ async function tryFillMonthPanel(
} }
} }
} }
}
if (!monthPanel || monthCells.length === 0) { if (!monthPanel || monthCells.length === 0) {
console.log(`OfferPie: [datePicker] 未检测到月份面板`) console.log(`OfferPie: [datePicker] 未检测到月份面板`)
return false return false
} }
console.log(`OfferPie: [datePicker] ✅ 检测到月份面板,共 ${monthCells.length} 个月份格子`) console.log(`OfferPie: [datePicker] ✅ 检测到月份面板,共 ${monthCells.length} 个月份格子,匹配="${matchedSelector || "body兜底"}"`)
// ---- 分析月份面板头部,找到年份显示和候选按钮 ---- // ---- 分析月份面板头部,找到年份显示和候选按钮 ----
// 月份格子区域作为边界(头部在月份格子之上) // 月份格子区域作为边界(头部在月份格子之上)
const monthGridArea = monthCells[0].closest("table, tbody, [class*='body'], [class*='content'], div") as HTMLElement || monthCells[0].parentElement as HTMLElement // 【Moka适配】Moka 的月份格子容器类名为 sd-panel-table-container 或 sd-basic-year-container
// 通用 closest("div") 可能匹配到太小的 div(如 sd-Dropdown-container 40px),
// 需要优先匹配 table/tbody 或含有 panel/content/basic-year 关键词的容器
let monthGridArea = (
monthCells[0].closest("table, tbody, [class*='panel-table'], [class*='basic-year-container'], [class*='body'], [class*='content']") as HTMLElement
) || monthCells[0].closest("div:not([class*='Dropdown-container'])") as HTMLElement
|| monthCells[0].parentElement as HTMLElement
// 确保 monthGridArea 在 monthPanel 范围内,否则回退到 parentElement
if (monthGridArea && !monthPanel.contains(monthGridArea)) {
monthGridArea = monthCells[0].parentElement as HTMLElement
}
// 用 analyzeHeaderArea 分析头部(复用已有逻辑) // 用 analyzeHeaderArea 分析头部(复用已有逻辑)
const headerInfo = analyzeHeaderArea(monthPanel, monthGridArea) const headerInfo = analyzeHeaderArea(monthPanel, monthGridArea)
const currentYear = headerInfo.currentYear const currentYear = headerInfo.currentYear
console.log(`OfferPie: [datePicker] 月份面板当前年份: ${currentYear},目标年份: ${targetYear}`) console.log(`OfferPie: [datePicker] 月份面板当前年份: ${currentYear},目标年份: ${targetYear},候选按钮数: ${headerInfo.headerButtons.length}`)
// ---- 用按钮探测逻辑找年份加减按钮(点击观察年份变化) ---- // ---- 用按钮探测逻辑找年份加减按钮(点击观察年份变化) ----
if (currentYear !== 0 && currentYear !== targetYear && headerInfo.headerButtons.length > 0) { if (currentYear !== 0 && currentYear !== targetYear && headerInfo.headerButtons.length > 0) {
@@ -1170,8 +1285,14 @@ export async function fillDatePicker(field: MatchedFormField): Promise<boolean>
console.log(`OfferPie: [datePicker] 目标日期: ${targetYear}${targetMonth}${targetDay === 0 ? "(仅年月)" : targetDay + "日"}`) console.log(`OfferPie: [datePicker] 目标日期: ${targetYear}${targetMonth}${targetDay === 0 ? "(仅年月)" : targetDay + "日"}`)
// ---- 步骤2:写入日期字符串(触发面板联动) ---- // ---- 步骤2:写入日期字符串(触发面板联动) ----
// 【Moka适配】Moka 的日期 input 为 readonly,写值必定失败且会导致弹出层关闭,跳过
if (location.hostname.includes("mokahr.com") && (inputElement as HTMLInputElement).readOnly) {
console.log(`OfferPie: [datePicker] Moka适配: 跳过 forceSetValuereadonly input),等待弹出层渲染`)
await delay("high")
} else {
forceSetValue(inputElement, fillValue) forceSetValue(inputElement, fillValue)
await delay("high") await delay("high")
}
// ---- 步骤2.5:如果只有年月(day=0),检测是否为月份面板 ---- // ---- 步骤2.5:如果只有年月(day=0),检测是否为月份面板 ----
if (targetDay === 0) { if (targetDay === 0) {
+75
View File
@@ -0,0 +1,75 @@
/**
* DOM 事件模拟工具
* 封装仿真鼠标事件,用于触发 React/Vue 等框架 UI 组件库的弹出层
*
* 【重要】项目中所有需要触发弹出层的点击操作,必须使用此模块导出的 simulateClick
* 禁止直接使用 .click()(普通 click 事件无法触发绑定在 mousedown 上的组件逻辑)
*/
/** 仿真点击的配置选项 */
interface SimulateClickOptions {
/** 是否触发 focus 事件(默认 false */
focus?: boolean
/** 是否触发 pointerdown/pointerup 事件(默认 true,部分组件库监听 pointer 事件) */
pointer?: boolean
/** 点击坐标 clientX(默认取元素中心) */
clientX?: number
/** 点击坐标 clientY(默认取元素中心) */
clientY?: number
}
/**
* 仿真鼠标点击:完整的 mousedown → mouseup → click 事件链
* 模拟真实用户点击行为,兼容 React / Vue / Shimo Design / Ant Design 等组件库
*
* @param el - 目标 DOM 元素
* @param options - 可选配置(focus、pointer 事件、坐标)
*
* 使用场景:
* - 触发下拉选择器弹出层
* - 触发日期选择器面板
* - 触发级联选择器展开
* - 任何需要模拟真实鼠标点击的场景
*/
export function simulateClick(el: HTMLElement, options?: SimulateClickOptions): void {
const { focus = false, pointer = true, clientX, clientY } = options || {}
// 计算点击坐标(默认取元素中心点)
const rect = el.getBoundingClientRect()
const x = clientX ?? (rect.left + rect.width / 2)
const y = clientY ?? (rect.top + rect.height / 2)
/** 构造 MouseEvent 的公共参数 */
const mouseEventInit: MouseEventInit = {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
button: 0,
buttons: 1,
}
// 1. focus(可选)
if (focus) {
el.focus()
}
// 2. pointer 事件(可选,部分组件库如 Radix UI 监听 pointerdown
if (pointer) {
el.dispatchEvent(new PointerEvent("pointerdown", { ...mouseEventInit, pointerId: 1, pointerType: "mouse" }))
}
// 3. mousedown
el.dispatchEvent(new MouseEvent("mousedown", mouseEventInit))
// 4. pointerup(可选)
if (pointer) {
el.dispatchEvent(new PointerEvent("pointerup", { ...mouseEventInit, pointerId: 1, pointerType: "mouse" }))
}
// 5. mouseup
el.dispatchEvent(new MouseEvent("mouseup", mouseEventInit))
// 6. click
el.dispatchEvent(new MouseEvent("click", mouseEventInit))
}