/** * 选择器识别模块 * 负责:检测表单字段是否为选择器类型(下拉、日期、级联等) * 检测方式: * 1. UI组件库类名匹配(快速路径) * 2. 提示文字检测("请选择"等) * 3. 主动点击检测:点击 input 及其单子标签父级链,对比点击前后全页面 DOM 可见性变化判断是否有弹出层 * * 【注意】方式3 的核心机制: * - 点击前记录全页面所有可见元素快照(offsetHeight > 0 && offsetWidth > 0) * - 点击后对比哪些元素是新变为可见的,以此判断弹出层出现 * - 检测到弹出层后【不关闭】,保存弹出层 DOM 引用到 field.pickerDropdownElement * - fillPickerField 步骤0 直接在已有弹出层内搜索选项,避免二次点击导致 toggle 关闭 * - 禁止使用固定的 CSS 类名选择器(如 POPUP_SELECTORS)来检测弹出层,必须走纯 DOM 差异对比 * - 这和 fillPickerField 步骤1/3 的 DOM 差异检测是同一套方法,保持一致 */ import type { MatchedFormField, UILibPickerConfig } from "./types" import { UI_LIB_PICKER_CONFIGS } from "./constants" import { delay } from "~utils/delay" /** 通过提示文字判断是否为选择器 */ function hasPickerHintText(field: MatchedFormField, lang: "zh" | "en"): boolean { const hints = lang === "zh" ? ["请选择", "请输入并选择", "点击选择"] : ["Please select", "Choose", "Click to select", "Pick a"] if (field.inputElement) { const placeholder = field.inputElement.getAttribute("placeholder") || "" if (hints.some((h) => placeholder.includes(h))) return true if (field.inputElement.hasAttribute("readonly")) { let parent: Element | null = field.inputElement.parentElement for (let i = 0; i < 3 && parent; i++) { const cls = parent.className || "" if (typeof cls === "string" && ( cls.includes("select") || cls.includes("picker") || cls.includes("date") || cls.includes("cascader") || cls.includes("dropdown") || cls.includes("calendar") )) return true parent = parent.parentElement } } // 检查紧邻兄弟元素的文字 for (const sib of [field.inputElement.nextElementSibling, field.inputElement.previousElementSibling]) { if (sib) { const text = sib.textContent?.trim() || "" if (text.length < 30 && hints.some((h) => text.includes(h))) return true } } } return false } /** 检查 input 附近是否有已知 UI 组件库的选择器触发区域类名 */ function matchUILibTrigger(field: MatchedFormField): UILibPickerConfig | null { const classSet = new Set() let el: Element | null = field.inputElement || field.labelElement for (let i = 0; i < 5 && el; i++) { if (el.className && typeof el.className === "string") { el.className.trim().split(/\s+/).forEach((c) => classSet.add(c)) } el = el.parentElement } if (field.buttonElement?.className && typeof field.buttonElement.className === "string") { field.buttonElement.className.trim().split(/\s+/).forEach((c) => classSet.add(c)) } for (const lib of UI_LIB_PICKER_CONFIGS) { if (lib.triggerClasses.some((cls) => classSet.has(cls))) return lib } return null } /** * 记录当前 body 直接子元素 + 全页面可见元素快照(用于点击前后 DOM 差异对比) * 【注意】不使用任何固定 CSS 选择器,记录全页面所有可见元素,保证通用性 * offsetHeight/offsetWidth > 0 即视为可见,不设更高阈值(避免漏判小尺寸弹出层) */ function snapshotDOM(): { bodyChildren: Set; visibleEls: Set } { const bodyChildren = new Set(Array.from(document.body.children)) // 记录所有可见的元素(offsetHeight > 0 且 offsetWidth > 0) const visibleEls = new Set() document.querySelectorAll("*").forEach((el) => { const htmlEl = el as HTMLElement if (htmlEl.offsetHeight > 0 && htmlEl.offsetWidth > 0) visibleEls.add(el) }) return { bodyChildren, visibleEls } } /** 对比 DOM 快照,检测是否有新出现的可见元素(弹出层) */ function hasNewPopup(before: ReturnType): boolean { // 检查 body 下新增的直接子元素(offsetHeight > 0 才算有效弹出层) for (const child of Array.from(document.body.children)) { if (!before.bodyChildren.has(child) && (child as HTMLElement).offsetHeight > 0) return true } // 检查新变为可见的元素(点击前不可见,点击后可见) const allEls = document.querySelectorAll("*") for (const el of Array.from(allEls)) { const htmlEl = el as HTMLElement if (htmlEl.offsetHeight > 0 && htmlEl.offsetWidth > 0 && !before.visibleEls.has(el)) return true } return false } /** 关闭可能打开的弹出层 */ async function dismissPopup() { if (document.activeElement instanceof HTMLElement) { document.activeElement.dispatchEvent( new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, bubbles: true }) ) document.activeElement.blur() } document.body.click() await delay("mid") } /** * 主动点击检测:点击 input 及其单子标签父级链,对比 DOM 变化判断是否有弹出层 * 如果检测到弹出层,【不关闭弹出层】,将弹出层 DOM 引用保存到 field.pickerDropdownElement,返回 true * 点击选择后弹出层会自行关闭,无需手动 dismiss * * 【注意】此处不可调用 dismissPopup 关闭弹出层!原因: * - fillPickerField 步骤0 需要直接在已有弹出层内搜索选项 * - 如果这里关闭了弹出层,步骤2 再点击会触发 toggle 导致弹出层反而不展开 * - Phoenix 等组件的弹出层用 Escape/blur/body.click 关闭不可靠,会引发状态不一致 */ async function detectByClick(field: MatchedFormField): Promise { if (!field.inputElement) return false // 收集要点击的元素:input 本身 + 单子标签父级链(最多5层) const clickTargets: HTMLElement[] = [field.inputElement as HTMLElement] let current: HTMLElement | null = field.inputElement as HTMLElement for (let i = 0; i < 5 && current?.parentElement; i++) { const parentEl = current.parentElement if (parentEl.children.length <= 2) { clickTargets.push(parentEl) current = parentEl } else { break } } // 逐个尝试点击,检测 DOM 变化 for (const target of clickTargets) { const before = snapshotDOM() target.click() await delay("mid") if (hasNewPopup(before)) { console.log(`OfferPie: [${field.key}] 点击 ${target.tagName}.${(target.className || "").toString().split(" ")[0]} 后检测到弹出层`) // 【注意】不关闭弹出层,保存弹出层 DOM 引用供 fillPickerField 步骤0 使用 const popupEl = findNewPopupElement(before) if (popupEl) { field.pickerDropdownElement = popupEl console.log(`OfferPie: [${field.key}] 保存弹出层引用: ${popupEl.tagName}.${(popupEl.className || "").toString().split(" ")[0]} offsetHeight=${popupEl.offsetHeight}`) } return true } } // 没检测到弹出层,确保关闭焦点状态 await dismissPopup() return false } /** * 从 DOM 快照对比中找到新出现的弹出层元素 * 逻辑和 fillPickerField 步骤3 一致:找点击后新变为可见的最大元素 */ function findNewPopupElement(before: ReturnType): HTMLElement | null { // 优先:body 下新增的、有实际高度的直接子元素 for (const child of Array.from(document.body.children)) { if (!before.bodyChildren.has(child) && (child as HTMLElement).offsetHeight > 0) { return child as HTMLElement } } // 其次:全页面找新变为可见的元素,取面积最大的那个 let best: HTMLElement | null = null let bestArea = 0 document.querySelectorAll("*").forEach((el) => { const htmlEl = el as HTMLElement if (htmlEl.offsetHeight > 0 && htmlEl.offsetWidth > 0 && !before.visibleEls.has(el)) { const area = htmlEl.offsetHeight * htmlEl.offsetWidth if (area > bestArea) { best = htmlEl; bestArea = area } } }) return best } /** * 检测单个字段是否为数据选择器类型 * 优先级:1.UI组件库类名 → 2.提示文字 → 3.主动点击检测DOM变化 */ export async function detectPickerField(field: MatchedFormField, lang: "zh" | "en"): Promise { if (field.inputType === "radio") return // 方式1:UI 组件库类名匹配 const matchedLib = matchUILibTrigger(field) if (matchedLib) { console.log(`OfferPie: [${field.key}] 匹配到 UI 组件库: ${matchedLib.libName}`) field.isPicker = true field.inputType = "picker" field.pickerDropdownSelector = matchedLib.optionItemClasses.map((cls) => "." + CSS.escape(cls)).join(", ") return } // 方式2:提示文字检测 if (hasPickerHintText(field, lang)) { console.log(`OfferPie: [${field.key}] 有选择器提示文字,标记为 picker`) field.isPicker = true field.inputType = "picker" return } // 方式3:主动点击检测 DOM 变化 const detected = await detectByClick(field) if (detected) { console.log(`OfferPie: [${field.key}] 通过点击检测到弹出层,标记为 picker`) field.isPicker = true field.inputType = "picker" return } }