Files
offerpai_browser_plug/src/lib/pickerDetector.ts
T
2026-07-30 15:29:24 +08:00

222 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 选择器识别模块
* 负责:检测表单字段是否为选择器类型(下拉、日期、级联等)
* 检测方式:
* 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<string>()
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<Element>; visibleEls: Set<Element> } {
const bodyChildren = new Set(Array.from(document.body.children))
// 记录所有可见的元素(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 快照,检测是否有新出现的可见元素(弹出层) */
function hasNewPopup(before: ReturnType<typeof snapshotDOM>): 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<boolean> {
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<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变化
*/
export async function detectPickerField(field: MatchedFormField, lang: "zh" | "en"): Promise<void> {
if (field.inputType === "radio") return
// 方式1UI 组件库类名匹配
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
}
}