初始化

This commit is contained in:
zk
2026-05-09 10:12:21 +08:00
commit 4e36c82bc4
22 changed files with 13508 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
/**
* 选择器识别模块
* 负责:检测表单字段是否为选择器类型(下拉、日期、级联等)
* 检测方式:
* 1. UI组件库类名匹配(快速路径)
* 2. 提示文字检测("请选择"等)
* 3. 主动点击检测:点击 input 及其单子标签父级链,对比点击前后 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"
? ["请选择", "请输入并选择", "点击选择"]
: ["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 直接子元素 */
function snapshotDOM(): { bodyChildren: Set<Element>; visiblePopups: 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 }
}
/** 对比 DOM 快照,检测是否有新增弹出层 */
function hasNewPopup(before: ReturnType<typeof snapshotDOM>): boolean {
// 检查 body 下新增的直接子元素
for (const child of Array.from(document.body.children)) {
if (!before.bodyChildren.has(child) && (child as HTMLElement).offsetHeight > 10) 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
}
}
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(200)
}
/**
* 主动点击检测:点击 input 及其单子标签父级链,对比 DOM 变化判断是否有弹出层
* 如果检测到弹出层,关闭后返回 true
*/
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(200)
if (hasNewPopup(before)) {
console.log(`OfferPie: [${field.key}] 点击 ${target.tagName}.${(target.className || "").toString().split(" ")[0]} 后检测到弹出层`)
await dismissPopup()
return true
}
}
// 没检测到弹出层,确保关闭焦点状态
await dismissPopup()
return false
}
/**
* 检测单个字段是否为数据选择器类型
* 优先级: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
}
}