优化选择器操作逻辑,通用模式代码单独封装

This commit is contained in:
2026-06-11 18:38:49 +08:00
parent c00d9dbe00
commit e3a4ae2381
11 changed files with 931 additions and 585 deletions
+67 -32
View File
@@ -4,22 +4,21 @@
* 检测方式:
* 1. UI组件库类名匹配(快速路径)
* 2. 提示文字检测("请选择"等)
* 3. 主动点击检测:点击 input 及其单子标签父级链,对比点击前后 DOM 变化判断是否有弹出层
* 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 "./autofill"
/** 弹出层相关的 CSS 选择器 */
const POPUP_SELECTORS = [
'[class*="dropdown"]', '[class*="popup"]', '[class*="popper"]',
'[class*="picker-panel"]', '[class*="overlay"]', '[class*="popover"]',
'[class*="select-dropdown"]', '[class*="cascader"]',
'[class*="menu"][class*="content"]',
'[role="listbox"]', '[role="menu"]',
]
/** 通过提示文字判断是否为选择器 */
function hasPickerHintText(field: MatchedFormField, lang: "zh" | "en"): boolean {
const hints = lang === "zh"
@@ -70,32 +69,33 @@ function matchUILibTrigger(field: MatchedFormField): UILibPickerConfig | null {
return null
}
/** 记录当前页面所有可见弹出层和 body 直接子元素 */
function snapshotDOM(): { bodyChildren: Set<Element>; visiblePopups: Set<Element> } {
/**
* 记录当前 body 直接子元素 + 全页面可见元素快照(用于点击前后 DOM 差异对比)
* 【注意】不使用任何固定 CSS 选择器,记录全页面所有可见元素,保证通用性
* offsetHeight/offsetWidth > 0 即视为可见,不设更高阈值(避免漏判小尺寸弹出层)
*/
function snapshotDOM(): { bodyChildren: Set<Element>; visibleEls: Set<Element> } {
const bodyChildren = new Set(Array.from(document.body.children))
const visiblePopups = new Set<Element>()
for (const sel of POPUP_SELECTORS) {
document.querySelectorAll(sel).forEach((el) => {
const htmlEl = el as HTMLElement
if (htmlEl.offsetHeight > 0 && htmlEl.offsetWidth > 0) visiblePopups.add(el)
})
}
return { bodyChildren, visiblePopups }
// 记录所有可见的元素(offsetHeight > 0 且 offsetWidth > 0
const visibleEls = new Set<Element>()
document.querySelectorAll("*").forEach((el) => {
const htmlEl = el as HTMLElement
if (htmlEl.offsetHeight > 0 && htmlEl.offsetWidth > 0) visibleEls.add(el)
})
return { bodyChildren, visibleEls }
}
/** 对比 DOM 快照,检测是否有新弹出层 */
/** 对比 DOM 快照,检测是否有新出现的可见元素(弹出层 */
function hasNewPopup(before: ReturnType<typeof snapshotDOM>): boolean {
// 检查 body 下新增的直接子元素
// 检查 body 下新增的直接子元素offsetHeight > 0 才算有效弹出层)
for (const child of Array.from(document.body.children)) {
if (!before.bodyChildren.has(child) && (child as HTMLElement).offsetHeight > 10) return true
if (!before.bodyChildren.has(child) && (child as HTMLElement).offsetHeight > 0) return true
}
// 检查新变为可见的弹出层
for (const sel of POPUP_SELECTORS) {
const els = document.querySelectorAll(sel)
for (const el of Array.from(els)) {
const htmlEl = el as HTMLElement
if (htmlEl.offsetHeight > 10 && htmlEl.offsetWidth > 0 && !before.visiblePopups.has(el)) 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
}
@@ -114,7 +114,13 @@ async function dismissPopup() {
/**
* 主动点击检测:点击 input 及其单子标签父级链,对比 DOM 变化判断是否有弹出层
* 如果检测到弹出层,关闭后返回 true
* 如果检测到弹出层,【不关闭弹出层】,将弹出层 DOM 引用保存到 field.pickerDropdownElement返回 true
* 点击选择后弹出层会自行关闭,无需手动 dismiss
*
* 【注意】此处不可调用 dismissPopup 关闭弹出层!原因:
* - fillPickerField 步骤0 需要直接在已有弹出层内搜索选项
* - 如果这里关闭了弹出层,步骤2 再点击会触发 toggle 导致弹出层反而不展开
* - Phoenix 等组件的弹出层用 Escape/blur/body.click 关闭不可靠,会引发状态不一致
*/
async function detectByClick(field: MatchedFormField): Promise<boolean> {
if (!field.inputElement) return false
@@ -140,7 +146,12 @@ async function detectByClick(field: MatchedFormField): Promise<boolean> {
if (hasNewPopup(before)) {
console.log(`OfferPie: [${field.key}] 点击 ${target.tagName}.${(target.className || "").toString().split(" ")[0]} 后检测到弹出层`)
await dismissPopup()
// 【注意】不关闭弹出层,保存弹出层 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
}
}
@@ -150,6 +161,30 @@ async function detectByClick(field: MatchedFormField): Promise<boolean> {
return false
}
/**
* 从 DOM 快照对比中找到新出现的弹出层元素
* 逻辑和 fillPickerField 步骤3 一致:找点击后新变为可见的最大元素
*/
function findNewPopupElement(before: ReturnType<typeof snapshotDOM>): 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变化