摩卡级联操作,地区选择操作
This commit is contained in:
@@ -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`
|
||||||
|
|||||||
@@ -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(() => {})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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-wrapper(Portal 渲染到 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适配: 找到 wrapper,monthCells=${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适配: 跳过 forceSetValue(readonly 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) {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user