diff --git a/.kiro/steering/project-guidelines.md b/.kiro/steering/project-guidelines.md index 9edf671..4297d28 100644 --- a/.kiro/steering/project-guidelines.md +++ b/.kiro/steering/project-guidelines.md @@ -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` diff --git a/src/api/dataApi.ts b/src/api/dataApi.ts index 7de3b3b..a190097 100644 --- a/src/api/dataApi.ts +++ b/src/api/dataApi.ts @@ -35,3 +35,50 @@ export interface MemberStatusData { export function getMemberStatus() { return http.get('/member/status') } + +// ============ 行政区划相关接口 ============ + +/** 行政区划树节点 */ +export interface RegionTreeNode { + /** 地区编码 */ + code?: string + /** 地区名称 */ + name?: string + /** 递归子级地区列表 */ + children?: RegionTreeNode[] +} + +/** 获取省市区三级行政区划树 */ +export function getRegionTree() { + return http.get('/public/regions/tree') +} + +// ============ 行政区划数据缓存 ============ + +/** 模块级缓存:省市区树数据(调用一次接口后缓存,避免重复请求) */ +let _regionTreeCache: RegionTreeNode[] | null = null + +/** + * 获取省市区树数据(带缓存) + * 首次调用时请求接口并缓存,后续调用直接返回缓存数据 + */ +export async function getRegionTreeCached(): Promise { + 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(() => {}) +} diff --git a/src/components/SidebarPanel.tsx b/src/components/SidebarPanel.tsx index 47e9eb5..440c48b 100644 --- a/src/components/SidebarPanel.tsx +++ b/src/components/SidebarPanel.tsx @@ -8,7 +8,7 @@ import { useState, useEffect, useRef, useCallback } from "react" import { getCookieValue } from "~utils/cookie" import { get as storageGet, set as storageSet, remove as storageRemove } from "~utils/storage" import { getCustomizeResume } from "~api/aiApi" -import { getMemberStatus } from "~api/dataApi" +import { getMemberStatus, preloadRegionTree } from "~api/dataApi" import { handleAutoFillCommon } from "~handlers/handleAutoFillCommon" import { handleAutoFillBeisen } from "~handlers/handleAutoFillBeisen" import { handleAutoFillMoka } from "~handlers/handleAutoFillMoka" @@ -305,6 +305,9 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) return } + // 预加载省市区行政区划数据(供摩卡级联选择器使用) + preloadRegionTree() + // 查询会员状态 getMemberStatus().then((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阶段字段) const nonResumeData = extractNonResumeFields(titleStats) @@ -692,7 +724,7 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) for (const nf of newFields) { const of_ = oldFields.find((f: any) => f.label === nf.label) if (of_) { - if (nf.value) of_.value = nf.value + if (nf.value) of_.value = formatRegionValue(nf.label || "", stripAgeSuffix(nf.label || "", nf.value)) } else { oldFields.push(nf) } @@ -706,7 +738,7 @@ export function SidebarPanel({ sourceUrl, jobInfo, onClose }: SidebarPanelProps) for (const nf of newFields) { const of_ = oldFields.find((f: any) => f.label === nf.label) if (of_) { - if (nf.value) of_.value = nf.value + if (nf.value) of_.value = formatRegionValue(nf.label || "", stripAgeSuffix(nf.label || "", nf.value)) } else { oldFields.push(nf) } diff --git a/src/handlers/handleAutoFillMoka.ts b/src/handlers/handleAutoFillMoka.ts index e294838..31fc76d 100644 --- a/src/handlers/handleAutoFillMoka.ts +++ b/src/handlers/handleAutoFillMoka.ts @@ -9,6 +9,7 @@ import { fillMatchedField, isSearchPickerField, fillSearchPickerField, isTimePeriodField, isTimeSingleField, fillTimePeriodField, fillTimeSingleField } from "~lib/autofill" import { delay } from "~utils/delay" +import { simulateClick } from "~utils/domEvent" import { extractDomStructure, detectPageLanguage, isJobApplicationForm, buildSelector } from "~lib/dom" import { matchFormFieldsInRange, matchMainFields } from "~lib/formMatcher" import { detectPickerField } from "~lib/pickerDetector" @@ -22,6 +23,8 @@ import type { MatchedFormField, ResumeData, ExperienceSection, JobInfo, Unmatche import { setFieldHighlight, isRequiredField } from "~lib/formStyle" import { get as storageGet, set as storageSet } from "~utils/storage" import { findLabelForInput } from "~lib/labelFinder" +import { getRegionTreeCached } from "~api/dataApi" +import type { RegionTreeNode } from "~api/dataApi" /** 摩卡模式自动填写的参数 */ export interface AutoFillMokaParams { @@ -340,6 +343,13 @@ export async function handleAutoFillMoka(params: AutoFillMokaParams): Promise { + 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") + if (checkPanel()) { + panelOpened = true + break + } + } + + // 弹出层未出现,跳过该字段 + if (!panelOpened) { + console.log(`OfferPie: ⚠ [B2-时间字段] Moka适配: 仿真点击后弹出层仍未出现,跳过 "${labelText}"`) + return false + } const ok = await fillDatePicker(field) if (ok) { @@ -1466,6 +1521,13 @@ async function handleFillCachedData(params: FillCachedDataParams): Promise 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 { + 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 + } +} diff --git a/src/lib/datePicker.ts b/src/lib/datePicker.ts index 3ca8d4e..81ea7a8 100644 --- a/src/lib/datePicker.ts +++ b/src/lib/datePicker.ts @@ -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 结构: + ... + // 弹出层和 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,42 +887,66 @@ async function tryFillMonthPanel( inputElement: HTMLElement ): Promise { // 在页面中查找可见的月份面板 - const panelSelectors = [ - '[class*="month-panel"]', '[class*="month-picker"]', - '[class*="picker-panel"]', '[class*="calendar-month"]', - '[class*="picker-body"]', '[class*="picker-content"]', - '[class*="popover"]', '[class*="popper"]', '[class*="popup"]', - '[class*="dropdown"]', '[class*="overlay"]', - ] - let monthPanel: HTMLElement | null = null let monthCells: HTMLElement[] = [] + let matchedSelector = "" - for (const sel of panelSelectors) { - const panels = document.querySelectorAll(sel) - for (const panel of Array.from(panels)) { - const htmlPanel = panel as HTMLElement - if (!isVisible(htmlPanel)) continue - const cells = findMonthCells(htmlPanel) - if (cells.length >= 10) { - monthPanel = htmlPanel - monthCells = cells - break - } - } - if (monthPanel) break + // 【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)" } - // 兜底:在 body 直接子元素中找 + // 【Moka适配】Moka 域名下如果专用逻辑找不到弹出层,直接返回 false,不走通用兜底(避免误匹配到页面主容器) + if (!mokaResult && location.hostname.includes("mokahr.com")) { + console.log(`OfferPie: [datePicker] Moka适配: 弹出层未出现,跳过月份面板填写`) + return false + } + + // 通用搜索逻辑(Moka 适配未命中时执行) if (!monthPanel) { - for (const child of Array.from(document.body.children)) { - const htmlChild = child as HTMLElement - if (!isVisible(htmlChild) || htmlChild.offsetHeight < 50) continue - const cells = findMonthCells(htmlChild) - if (cells.length >= 10) { - monthPanel = htmlChild - monthCells = cells - break + const panelSelectors = [ + '[class*="month-panel"]', '[class*="month-picker"]', + '[class*="picker-panel"]', '[class*="calendar-month"]', + '[class*="picker-body"]', '[class*="picker-content"]', + '[class*="popover"]', '[class*="popper"]', '[class*="popup"]', + '[class*="dropdown"]', '[class*="overlay"]', + ] + + for (const sel of panelSelectors) { + const panels = document.querySelectorAll(sel) + for (const panel of Array.from(panels)) { + const htmlPanel = panel as HTMLElement + if (!isVisible(htmlPanel)) continue + const cells = findMonthCells(htmlPanel) + if (cells.length >= 10) { + monthPanel = htmlPanel + monthCells = cells + matchedSelector = sel + break + } + } + if (monthPanel) break + } + + // 兜底:在 body 直接子元素中找 + if (!monthPanel) { + for (const child of Array.from(document.body.children)) { + const htmlChild = child as HTMLElement + if (!isVisible(htmlChild) || htmlChild.offsetHeight < 50) continue + const cells = findMonthCells(htmlChild) + if (cells.length >= 10) { + monthPanel = htmlChild + monthCells = cells + break + } } } } @@ -851,16 +956,26 @@ async function tryFillMonthPanel( 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 分析头部(复用已有逻辑) const headerInfo = analyzeHeaderArea(monthPanel, monthGridArea) 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) { @@ -1170,8 +1285,14 @@ export async function fillDatePicker(field: MatchedFormField): Promise console.log(`OfferPie: [datePicker] 目标日期: ${targetYear}年${targetMonth}月${targetDay === 0 ? "(仅年月)" : targetDay + "日"}`) // ---- 步骤2:写入日期字符串(触发面板联动) ---- - forceSetValue(inputElement, fillValue) - await delay("high") + // 【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) + await delay("high") + } // ---- 步骤2.5:如果只有年月(day=0),检测是否为月份面板 ---- if (targetDay === 0) { diff --git a/src/utils/domEvent.ts b/src/utils/domEvent.ts new file mode 100644 index 0000000..ae11fb5 --- /dev/null +++ b/src/utils/domEvent.ts @@ -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)) +}