1017 lines
37 KiB
TypeScript
1017 lines
37 KiB
TypeScript
/**
|
||
* 日期选择器填写模块
|
||
* 负责:通用日期选择器的日历面板操作
|
||
*
|
||
* 核心思路:
|
||
* 1. 定位日期格子区域(28~31个数字格子)
|
||
* 2. 向上找到星期行(日一二三四五六 / Sun Mon...)
|
||
* 3. 从星期行继续向上到选择器顶部,识别年份显示(1900~2100范围)和月份显示(1~12范围)
|
||
* 4. 收集年月显示区前后的所有可点击标签,逐个点击并观察年/月数字变化
|
||
* 5. 确定年份±按钮和月份±按钮的位置
|
||
* 6. 通过点击按钮导航到目标年月,最后点击目标日期格子
|
||
*
|
||
* 【注意】此模块的按钮探测逻辑是通用日期选择器适配的核心,不要简化或删除
|
||
*/
|
||
|
||
import type { MatchedFormField } from "./types"
|
||
import { delay, forceSetValue, closePopup } from "./autofill"
|
||
|
||
// ============ 常量 ============
|
||
|
||
/** 中文数字月份映射 */
|
||
const ZH_MONTH_NUMS: Record<string, number> = {
|
||
"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6,
|
||
"七": 7, "八": 8, "九": 9, "十": 10, "十一": 11, "十二": 12,
|
||
}
|
||
|
||
/** 英文月份映射 */
|
||
const EN_MONTH_MAP: Record<string, number> = {
|
||
"jan": 1, "january": 1, "feb": 2, "february": 2, "mar": 3, "march": 3,
|
||
"apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6,
|
||
"jul": 7, "july": 7, "aug": 8, "august": 8, "sep": 9, "september": 9,
|
||
"oct": 10, "october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12,
|
||
}
|
||
|
||
/** 星期行关键词(中英文) */
|
||
const WEEKDAY_KEYWORDS_ZH = ["日", "一", "二", "三", "四", "五", "六"]
|
||
const WEEKDAY_KEYWORDS_EN = ["su", "mo", "tu", "we", "th", "fr", "sa"]
|
||
|
||
// ============ 工具函数 ============
|
||
|
||
/** 判断元素是否可见 */
|
||
function isVisible(el: Element): boolean {
|
||
const h = el as HTMLElement
|
||
return h.offsetHeight > 0 && h.offsetWidth > 0
|
||
}
|
||
|
||
/** 获取元素的纯文本(不含子元素干扰的自身文本) */
|
||
function getDirectText(el: Element): string {
|
||
let text = ""
|
||
for (const node of Array.from(el.childNodes)) {
|
||
if (node.nodeType === Node.TEXT_NODE) text += node.textContent || ""
|
||
}
|
||
return text.trim()
|
||
}
|
||
|
||
/** 获取元素的完整文本 */
|
||
function getFullText(el: Element): string {
|
||
return el.textContent?.trim() || ""
|
||
}
|
||
|
||
/**
|
||
* 从文本中提取年份数字(1900~2100范围)
|
||
* 支持格式:2021、2021年、"2021 年" 等
|
||
*/
|
||
function extractYear(text: string): number | null {
|
||
const m = text.match(/\b(19\d{2}|20\d{2})\b/)
|
||
if (m) return parseInt(m[1], 10)
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 从文本中提取月份数字(1~12)
|
||
* 支持格式:数字(1~12)、中文(一月~十二月)、英文(Jan~December)
|
||
*/
|
||
function extractMonth(text: string): number | null {
|
||
// 英文月份
|
||
const lower = text.toLowerCase().trim()
|
||
for (const [key, val] of Object.entries(EN_MONTH_MAP)) {
|
||
if (lower === key || lower.startsWith(key)) return val
|
||
}
|
||
// 中文月份:"十二月" > "十一月" > "十月" > "X月"
|
||
for (const [zh, num] of Object.entries(ZH_MONTH_NUMS)) {
|
||
if (text.includes(zh + "月")) return num
|
||
if (text === zh) return num
|
||
}
|
||
// 纯数字月份
|
||
const numMatch = text.match(/^0?(\d{1,2})月?$/)
|
||
if (numMatch) {
|
||
const n = parseInt(numMatch[1], 10)
|
||
if (n >= 1 && n <= 12) return n
|
||
}
|
||
return null
|
||
}
|
||
|
||
// ============ 日历面板结构分析 ============
|
||
|
||
/** 日历面板结构信息 */
|
||
interface CalendarPanelInfo {
|
||
/** 选择器弹窗的根元素 */
|
||
panelRoot: HTMLElement
|
||
/** 星期行元素 */
|
||
weekdayRow: HTMLElement
|
||
/** 日期格子区域元素 */
|
||
dayGridArea: HTMLElement
|
||
/** 年份显示标签 */
|
||
yearLabel: HTMLElement | null
|
||
/** 月份显示标签 */
|
||
monthLabel: HTMLElement | null
|
||
/** 当前显示的年份 */
|
||
currentYear: number
|
||
/** 当前显示的月份 */
|
||
currentMonth: number
|
||
/** 头部区域(星期行之上)的所有可点击标签,按 DOM 顺序排列 */
|
||
headerButtons: HTMLElement[]
|
||
}
|
||
|
||
/** 导航按钮映射 */
|
||
interface NavButtons {
|
||
/** 年份减小按钮 */
|
||
yearPrev: HTMLElement | null
|
||
/** 年份增大按钮 */
|
||
yearNext: HTMLElement | null
|
||
/** 月份减小按钮 */
|
||
monthPrev: HTMLElement | null
|
||
/** 月份增大按钮 */
|
||
monthNext: HTMLElement | null
|
||
/** 年份显示标签 */
|
||
yearLabel: HTMLElement | null
|
||
/** 月份显示标签 */
|
||
monthLabel: HTMLElement | null
|
||
}
|
||
|
||
/**
|
||
* 判断一行元素是否为星期行
|
||
* 检测逻辑:子元素中包含至少4个星期关键词
|
||
*/
|
||
function isWeekdayRow(el: HTMLElement): boolean {
|
||
const text = getFullText(el).toLowerCase()
|
||
// 中文星期行检测
|
||
let zhHits = 0
|
||
for (const kw of WEEKDAY_KEYWORDS_ZH) {
|
||
if (text.includes(kw)) zhHits++
|
||
}
|
||
if (zhHits >= 4) return true
|
||
// 英文星期行检测
|
||
let enHits = 0
|
||
for (const kw of WEEKDAY_KEYWORDS_EN) {
|
||
if (text.includes(kw)) enHits++
|
||
}
|
||
if (enHits >= 4) return true
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* 判断一个区域是否为日期格子区域
|
||
* 检测逻辑:包含多个1~31范围的数字,且数量>=20
|
||
*/
|
||
function isDayGridArea(el: HTMLElement): boolean {
|
||
const cells = el.querySelectorAll("td, [class*='cell'], [class*='day'], [role='gridcell']")
|
||
if (cells.length < 20) {
|
||
// 兜底:直接检查子元素文本
|
||
let dayCount = 0
|
||
const allEls = el.querySelectorAll("*")
|
||
for (const child of Array.from(allEls)) {
|
||
const t = getDirectText(child)
|
||
const n = parseInt(t, 10)
|
||
if (!isNaN(n) && n >= 1 && n <= 31 && t === String(n)) dayCount++
|
||
}
|
||
return dayCount >= 20
|
||
}
|
||
let dayCount = 0
|
||
for (const cell of Array.from(cells)) {
|
||
const t = getFullText(cell)
|
||
const n = parseInt(t, 10)
|
||
if (!isNaN(n) && n >= 1 && n <= 31) dayCount++
|
||
}
|
||
return dayCount >= 20
|
||
}
|
||
|
||
|
||
/**
|
||
* 在页面中查找可见的日历面板并分析其结构
|
||
* 步骤:
|
||
* 1. 找到日期格子区域(含20+个1~31数字的区域)
|
||
* 2. 从日期格子向上找星期行(日一二三四五六)
|
||
* 3. 从星期行继续向上到面板顶部,识别年月显示和可点击按钮
|
||
*/
|
||
function analyzeCalendarPanel(): CalendarPanelInfo | null {
|
||
// ---- 步骤1:找到日期格子区域 ----
|
||
// 从常见的日历容器选择器开始搜索
|
||
const panelSelectors = [
|
||
'[class*="picker-panel"]', '[class*="date-panel"]', '[class*="calendar"]',
|
||
'[class*="datepicker"]', '[class*="date-picker"]',
|
||
'[class*="picker-body"]', '[class*="picker-content"]',
|
||
'[class*="popover"]', '[class*="popper"]', '[class*="popup"]',
|
||
'[class*="dropdown"]', '[class*="overlay"]',
|
||
]
|
||
|
||
let dayGridArea: HTMLElement | null = null
|
||
let panelRoot: HTMLElement | null = null
|
||
|
||
// 先尝试从已知面板选择器中找
|
||
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
|
||
if (isDayGridArea(htmlPanel)) {
|
||
dayGridArea = htmlPanel
|
||
panelRoot = htmlPanel
|
||
break
|
||
}
|
||
// 在面板内部找日期格子区域
|
||
const children = htmlPanel.querySelectorAll("table, tbody, [class*='body'], [class*='content'], [class*='grid'], div")
|
||
for (const child of Array.from(children)) {
|
||
if (isVisible(child) && isDayGridArea(child as HTMLElement)) {
|
||
dayGridArea = child as HTMLElement
|
||
panelRoot = htmlPanel
|
||
break
|
||
}
|
||
}
|
||
if (dayGridArea) break
|
||
}
|
||
if (dayGridArea) break
|
||
}
|
||
|
||
// 兜底:遍历 body 直接子元素中新出现的弹出层
|
||
if (!dayGridArea) {
|
||
for (const child of Array.from(document.body.children)) {
|
||
const htmlChild = child as HTMLElement
|
||
if (!isVisible(htmlChild) || htmlChild.offsetHeight < 100) continue
|
||
if (isDayGridArea(htmlChild)) {
|
||
dayGridArea = htmlChild
|
||
panelRoot = htmlChild
|
||
break
|
||
}
|
||
const innerEls = htmlChild.querySelectorAll("table, tbody, div")
|
||
for (const inner of Array.from(innerEls)) {
|
||
if (isVisible(inner) && isDayGridArea(inner as HTMLElement)) {
|
||
dayGridArea = inner as HTMLElement
|
||
panelRoot = htmlChild
|
||
break
|
||
}
|
||
}
|
||
if (dayGridArea) break
|
||
}
|
||
}
|
||
|
||
if (!dayGridArea || !panelRoot) {
|
||
console.warn("OfferPie: [datePicker] 未找到日期格子区域")
|
||
return null
|
||
}
|
||
console.log(`OfferPie: [datePicker] 找到日期格子区域: ${dayGridArea.tagName}.${(dayGridArea.className || "").toString().split(" ")[0]}`)
|
||
|
||
// ---- 步骤2:从日期格子向上找星期行 ----
|
||
let weekdayRow: HTMLElement | null = null
|
||
|
||
// 策略A:在日期格子的前面兄弟元素或父级的前面兄弟中找
|
||
let searchEl: HTMLElement | null = dayGridArea
|
||
for (let depth = 0; depth < 5 && searchEl && searchEl !== panelRoot.parentElement; depth++) {
|
||
// 检查前面的兄弟元素
|
||
let prevSib = searchEl.previousElementSibling
|
||
while (prevSib) {
|
||
if (isVisible(prevSib) && isWeekdayRow(prevSib as HTMLElement)) {
|
||
weekdayRow = prevSib as HTMLElement
|
||
break
|
||
}
|
||
// 也检查兄弟元素的子元素
|
||
const innerRows = prevSib.querySelectorAll("tr, div, thead")
|
||
for (const row of Array.from(innerRows)) {
|
||
if (isVisible(row) && isWeekdayRow(row as HTMLElement)) {
|
||
weekdayRow = row as HTMLElement
|
||
break
|
||
}
|
||
}
|
||
if (weekdayRow) break
|
||
prevSib = prevSib.previousElementSibling
|
||
}
|
||
if (weekdayRow) break
|
||
searchEl = searchEl.parentElement
|
||
}
|
||
|
||
// 策略B:在日期格子内部的 thead 或第一行中找
|
||
if (!weekdayRow) {
|
||
const thead = dayGridArea.querySelector("thead")
|
||
if (thead && isWeekdayRow(thead as HTMLElement)) {
|
||
weekdayRow = thead as HTMLElement
|
||
}
|
||
}
|
||
if (!weekdayRow) {
|
||
const firstRow = dayGridArea.querySelector("tr")
|
||
if (firstRow && isWeekdayRow(firstRow as HTMLElement)) {
|
||
weekdayRow = firstRow as HTMLElement
|
||
}
|
||
}
|
||
|
||
// 策略C:在 panelRoot 内所有元素中找
|
||
if (!weekdayRow) {
|
||
const allEls = panelRoot.querySelectorAll("tr, div, ul, thead")
|
||
for (const el of Array.from(allEls)) {
|
||
if (isVisible(el) && isWeekdayRow(el as HTMLElement)) {
|
||
weekdayRow = el as HTMLElement
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!weekdayRow) {
|
||
console.warn("OfferPie: [datePicker] 未找到星期行")
|
||
} else {
|
||
console.log(`OfferPie: [datePicker] 找到星期行: ${weekdayRow.tagName}.${(weekdayRow.className || "").toString().split(" ")[0]}`)
|
||
}
|
||
|
||
// ---- 步骤3:确定面板根元素(从星期行/日期格子向上找到最外层面板) ----
|
||
// 向上扩展 panelRoot 到包含整个日历头部的容器
|
||
let expandedRoot = panelRoot
|
||
const refEl = weekdayRow || dayGridArea
|
||
let parent: HTMLElement | null = refEl.parentElement
|
||
for (let i = 0; i < 10 && parent; i++) {
|
||
if (parent === document.body) break
|
||
// 如果父级包含年份数字,说明头部在更上层
|
||
const parentText = getFullText(parent)
|
||
if (extractYear(parentText) !== null) {
|
||
expandedRoot = parent
|
||
}
|
||
parent = parent.parentElement
|
||
}
|
||
panelRoot = expandedRoot
|
||
|
||
// ---- 步骤4:在面板头部区域识别年月显示和可点击按钮 ----
|
||
const { yearLabel, monthLabel, currentYear, currentMonth, headerButtons } =
|
||
analyzeHeaderArea(panelRoot, weekdayRow || dayGridArea)
|
||
|
||
return {
|
||
panelRoot,
|
||
weekdayRow: weekdayRow || dayGridArea,
|
||
dayGridArea,
|
||
yearLabel,
|
||
monthLabel,
|
||
currentYear,
|
||
currentMonth,
|
||
headerButtons,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 分析面板头部区域,找到年月显示标签和所有可点击按钮
|
||
* 头部区域 = panelRoot 内、在 weekdayRow 之前的所有内容
|
||
*/
|
||
function analyzeHeaderArea(
|
||
panelRoot: HTMLElement,
|
||
boundaryEl: HTMLElement
|
||
): {
|
||
yearLabel: HTMLElement | null
|
||
monthLabel: HTMLElement | null
|
||
currentYear: number
|
||
currentMonth: number
|
||
headerButtons: HTMLElement[]
|
||
} {
|
||
// 收集头部区域的所有叶子级可见元素(按 DOM 顺序)
|
||
const headerElements: HTMLElement[] = []
|
||
const walker = document.createTreeWalker(panelRoot, NodeFilter.SHOW_ELEMENT, {
|
||
acceptNode(node: Node) {
|
||
const el = node as HTMLElement
|
||
// 到达星期行或日期格子区域就停止
|
||
if (el === boundaryEl || boundaryEl.contains(el)) return NodeFilter.FILTER_REJECT
|
||
if (!isVisible(el)) return NodeFilter.FILTER_REJECT
|
||
return NodeFilter.FILTER_ACCEPT
|
||
},
|
||
})
|
||
|
||
let node: Node | null = walker.currentNode
|
||
while ((node = walker.nextNode())) {
|
||
const el = node as HTMLElement
|
||
// 只收集叶子节点或文本内容有意义的节点
|
||
const hasBlockChildren = Array.from(el.children).some(
|
||
(c) => isVisible(c) && (c as HTMLElement).offsetHeight > 5
|
||
)
|
||
if (!hasBlockChildren || getDirectText(el).length > 0) {
|
||
headerElements.push(el)
|
||
}
|
||
}
|
||
|
||
// 从头部元素中识别年份和月份显示标签
|
||
let yearLabel: HTMLElement | null = null
|
||
let monthLabel: HTMLElement | null = null
|
||
let currentYear = 0
|
||
let currentMonth = 0
|
||
|
||
for (const el of headerElements) {
|
||
const text = getFullText(el)
|
||
const directText = getDirectText(el)
|
||
|
||
// 尝试提取年份(优先用自身直接文本)
|
||
const yearFromDirect = extractYear(directText)
|
||
const yearFromFull = extractYear(text)
|
||
if (yearFromDirect !== null && !yearLabel) {
|
||
yearLabel = el
|
||
currentYear = yearFromDirect
|
||
console.log(`OfferPie: [datePicker] 找到年份标签: "${directText}" → ${currentYear}`)
|
||
} else if (yearFromFull !== null && !yearLabel && el.children.length === 0) {
|
||
yearLabel = el
|
||
currentYear = yearFromFull
|
||
console.log(`OfferPie: [datePicker] 找到年份标签: "${text}" → ${currentYear}`)
|
||
}
|
||
|
||
// 尝试提取月份
|
||
const monthFromDirect = extractMonth(directText)
|
||
const monthFromFull = extractMonth(text)
|
||
if (monthFromDirect !== null && !monthLabel) {
|
||
monthLabel = el
|
||
currentMonth = monthFromDirect
|
||
console.log(`OfferPie: [datePicker] 找到月份标签: "${directText}" → ${currentMonth}`)
|
||
} else if (monthFromFull !== null && !monthLabel && el.children.length === 0) {
|
||
monthLabel = el
|
||
currentMonth = monthFromFull
|
||
console.log(`OfferPie: [datePicker] 找到月份标签: "${text}" → ${currentMonth}`)
|
||
}
|
||
}
|
||
|
||
// 有些选择器年月在同一个标签里,如 "2021年 12月" 或 "2021-12"
|
||
if (yearLabel && !monthLabel) {
|
||
const text = getFullText(yearLabel)
|
||
const monthVal = extractMonth(text.replace(/\d{4}/, "").trim())
|
||
if (monthVal !== null) {
|
||
// 年月在同一标签,需要在其他元素中找独立的月份标签
|
||
for (const el of headerElements) {
|
||
if (el === yearLabel) continue
|
||
const t = getFullText(el)
|
||
const m = extractMonth(t)
|
||
if (m !== null && el.children.length === 0) {
|
||
monthLabel = el
|
||
currentMonth = m
|
||
console.log(`OfferPie: [datePicker] 找到独立月份标签: "${t}" → ${currentMonth}`)
|
||
break
|
||
}
|
||
}
|
||
// 如果还是没找到独立月份标签,说明年月确实在同一个标签
|
||
if (!monthLabel) {
|
||
currentMonth = monthVal
|
||
console.log(`OfferPie: [datePicker] 年月在同一标签: year=${currentYear}, month=${currentMonth}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 收集所有可点击的按钮元素(排除年月显示标签本身)
|
||
const headerButtons: HTMLElement[] = []
|
||
for (const el of headerElements) {
|
||
if (el === yearLabel || el === monthLabel) continue
|
||
// 跳过纯文本内容是年份或月份的(可能是显示标签的子元素)
|
||
if (yearLabel && yearLabel.contains(el)) continue
|
||
if (monthLabel && monthLabel.contains(el)) continue
|
||
// 按钮特征:有点击交互的小元素(箭头、图标等)
|
||
const text = getFullText(el)
|
||
const isLikelyButton =
|
||
el.tagName === "BUTTON" ||
|
||
el.tagName === "A" ||
|
||
el.tagName === "I" ||
|
||
el.getAttribute("role") === "button" ||
|
||
el.classList.toString().includes("btn") ||
|
||
el.classList.toString().includes("arrow") ||
|
||
el.classList.toString().includes("icon") ||
|
||
el.classList.toString().includes("prev") ||
|
||
el.classList.toString().includes("next") ||
|
||
el.classList.toString().includes("super") ||
|
||
el.style.cursor === "pointer" ||
|
||
// 箭头符号:< > « » ‹ › ← → ≪ ≫
|
||
/^[<>«»‹›←→≪≫‹›\u2039\u203A\u00AB\u00BB]+$/.test(text) ||
|
||
// 很短的文本(1~2字符),可能是箭头按钮
|
||
(text.length <= 2 && text.length > 0 && !extractYear(text) && extractMonth(text) === null)
|
||
if (isLikelyButton) {
|
||
headerButtons.push(el)
|
||
}
|
||
}
|
||
|
||
console.log(`OfferPie: [datePicker] 头部区域找到 ${headerButtons.length} 个候选按钮`)
|
||
headerButtons.forEach((btn, i) => {
|
||
console.log(`OfferPie: 按钮[${i}] <${btn.tagName}> class="${(btn.className || "").toString().slice(0, 50)}" text="${getFullText(btn).slice(0, 10)}"`)
|
||
})
|
||
|
||
return { yearLabel, monthLabel, currentYear, currentMonth, headerButtons }
|
||
}
|
||
|
||
|
||
// ============ 按钮探测(核心逻辑) ============
|
||
|
||
/**
|
||
* 读取当前面板中年份显示标签的年份数字
|
||
*/
|
||
function readCurrentYear(panel: CalendarPanelInfo): number | null {
|
||
if (panel.yearLabel) {
|
||
const text = getFullText(panel.yearLabel)
|
||
return extractYear(text)
|
||
}
|
||
// 兜底:在面板头部重新搜索年份
|
||
const headerText = getFullText(panel.panelRoot)
|
||
return extractYear(headerText)
|
||
}
|
||
|
||
/**
|
||
* 读取当前面板中月份显示标签的月份数字
|
||
*/
|
||
function readCurrentMonth(panel: CalendarPanelInfo): number | null {
|
||
if (panel.monthLabel) {
|
||
const text = getFullText(panel.monthLabel)
|
||
return extractMonth(text)
|
||
}
|
||
// 如果年月在同一标签
|
||
if (panel.yearLabel) {
|
||
const text = getFullText(panel.yearLabel)
|
||
const withoutYear = text.replace(/\d{4}/, "").trim()
|
||
return extractMonth(withoutYear)
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 【核心】通过逐个点击头部按钮,观察年/月数字变化来确定导航按钮
|
||
*
|
||
* 逻辑:
|
||
* 1. 记录当前年月数字
|
||
* 2. 依次点击每个候选按钮
|
||
* 3. 点击后读取年月数字,判断哪个变了、变大还是变小
|
||
* 4. 如果月份变小 → 该按钮是月份减小按钮
|
||
* 5. 如果年份变小 → 该按钮是年份减小按钮
|
||
* 6. 同理判断增大按钮
|
||
* 7. 每次点击后再点一次恢复原状(反向点击)
|
||
*
|
||
* 【注意】此探测逻辑是通用日期选择器适配的核心机制,不要删除
|
||
*/
|
||
async function detectNavButtons(panel: CalendarPanelInfo): Promise<NavButtons> {
|
||
const result: NavButtons = {
|
||
yearPrev: null, yearNext: null,
|
||
monthPrev: null, monthNext: null,
|
||
yearLabel: panel.yearLabel,
|
||
monthLabel: panel.monthLabel,
|
||
}
|
||
|
||
const buttons = panel.headerButtons
|
||
if (buttons.length === 0) {
|
||
console.warn("OfferPie: [datePicker] 头部无候选按钮,无法探测导航")
|
||
return result
|
||
}
|
||
|
||
// 记录初始年月
|
||
const initYear = readCurrentYear(panel)
|
||
const initMonth = readCurrentMonth(panel)
|
||
console.log(`OfferPie: [datePicker] 探测导航按钮,初始 year=${initYear}, month=${initMonth}`)
|
||
|
||
if (initYear === null && initMonth === null) {
|
||
console.warn("OfferPie: [datePicker] 无法读取当前年月,跳过按钮探测")
|
||
return result
|
||
}
|
||
|
||
// 逐个点击按钮探测
|
||
for (let i = 0; i < buttons.length; i++) {
|
||
const btn = buttons[i]
|
||
const beforeYear = readCurrentYear(panel)
|
||
const beforeMonth = readCurrentMonth(panel)
|
||
|
||
// 点击按钮
|
||
btn.click()
|
||
await delay(150)
|
||
|
||
const afterYear = readCurrentYear(panel)
|
||
const afterMonth = readCurrentMonth(panel)
|
||
|
||
console.log(`OfferPie: [datePicker] 点击按钮[${i}] → year: ${beforeYear}→${afterYear}, month: ${beforeMonth}→${afterMonth}`)
|
||
|
||
let detected = false
|
||
|
||
// 判断月份变化
|
||
if (beforeMonth !== null && afterMonth !== null && beforeMonth !== afterMonth) {
|
||
if (afterMonth < beforeMonth || (beforeMonth === 1 && afterMonth === 12)) {
|
||
// 月份减小(注意12→1的跨年情况反过来就是1→12)
|
||
result.monthPrev = btn
|
||
console.log(`OfferPie: [datePicker] ✅ 按钮[${i}] = 月份减小`)
|
||
detected = true
|
||
} else if (afterMonth > beforeMonth || (beforeMonth === 12 && afterMonth === 1)) {
|
||
// 月份增大
|
||
result.monthNext = btn
|
||
console.log(`OfferPie: [datePicker] ✅ 按钮[${i}] = 月份增大`)
|
||
detected = true
|
||
}
|
||
}
|
||
|
||
// 判断年份变化(月份没变的情况下)
|
||
if (!detected && beforeYear !== null && afterYear !== null && beforeYear !== afterYear) {
|
||
if (afterYear < beforeYear) {
|
||
result.yearPrev = btn
|
||
console.log(`OfferPie: [datePicker] ✅ 按钮[${i}] = 年份减小`)
|
||
detected = true
|
||
} else if (afterYear > beforeYear) {
|
||
result.yearNext = btn
|
||
console.log(`OfferPie: [datePicker] ✅ 按钮[${i}] = 年份增大`)
|
||
detected = true
|
||
}
|
||
}
|
||
|
||
// 如果月份变了同时年份也变了(跨年情况,如从1月减到12月,年份也减1)
|
||
if (beforeMonth !== null && afterMonth !== null && beforeMonth !== afterMonth &&
|
||
beforeYear !== null && afterYear !== null && beforeYear !== afterYear) {
|
||
// 这种情况说明点的是月份按钮,年份变化是跨年副作用
|
||
if (afterYear < beforeYear) {
|
||
// 年减小 + 月从1变12 → 月份减小按钮
|
||
result.monthPrev = btn
|
||
result.yearPrev = null // 撤销可能的误判
|
||
console.log(`OfferPie: [datePicker] 修正:按钮[${i}] = 月份减小(跨年)`)
|
||
} else {
|
||
// 年增大 + 月从12变1 → 月份增大按钮
|
||
result.monthNext = btn
|
||
result.yearNext = null
|
||
console.log(`OfferPie: [datePicker] 修正:按钮[${i}] = 月份增大(跨年)`)
|
||
}
|
||
}
|
||
|
||
// 点击后恢复:再点一次反向按钮,或者如果还没找到反向按钮就再点一次同按钮
|
||
// 简单策略:直接再点一次让它回去(大部分选择器点两次同方向会偏移,所以需要找反向)
|
||
if (detected) {
|
||
// 需要恢复到初始状态:找到已知的反向按钮点一次
|
||
let restoreBtn: HTMLElement | null = null
|
||
if (result.monthPrev === btn && result.monthNext) restoreBtn = result.monthNext
|
||
else if (result.monthNext === btn && result.monthPrev) restoreBtn = result.monthPrev
|
||
else if (result.yearPrev === btn && result.yearNext) restoreBtn = result.yearNext
|
||
else if (result.yearNext === btn && result.yearPrev) restoreBtn = result.yearPrev
|
||
|
||
if (restoreBtn) {
|
||
restoreBtn.click()
|
||
await delay(150)
|
||
} else {
|
||
// 没有已知反向按钮,尝试用位置对称的按钮恢复
|
||
// 如果当前按钮在前半部分,对称按钮在后半部分
|
||
const symmetricIdx = buttons.length - 1 - i
|
||
if (symmetricIdx !== i && symmetricIdx >= 0 && symmetricIdx < buttons.length) {
|
||
buttons[symmetricIdx].click()
|
||
await delay(150)
|
||
// 验证是否恢复
|
||
const checkYear = readCurrentYear(panel)
|
||
const checkMonth = readCurrentMonth(panel)
|
||
if (checkYear !== beforeYear || checkMonth !== beforeMonth) {
|
||
// 没恢复成功,再点一次原按钮的对称位置
|
||
buttons[symmetricIdx].click()
|
||
await delay(150)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果四个按钮都找到了,提前结束
|
||
if (result.yearPrev && result.yearNext && result.monthPrev && result.monthNext) {
|
||
console.log("OfferPie: [datePicker] 四个导航按钮全部找到,停止探测")
|
||
break
|
||
}
|
||
}
|
||
|
||
// 确保恢复到初始年月
|
||
await restoreToDate(panel, result, initYear || 0, initMonth || 0)
|
||
|
||
console.log(`OfferPie: [datePicker] 导航按钮探测结果:` +
|
||
` yearPrev=${result.yearPrev ? "✅" : "❌"}` +
|
||
` yearNext=${result.yearNext ? "✅" : "❌"}` +
|
||
` monthPrev=${result.monthPrev ? "✅" : "❌"}` +
|
||
` monthNext=${result.monthNext ? "✅" : "❌"}`)
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 恢复面板到指定年月(用于探测后恢复初始状态)
|
||
*/
|
||
async function restoreToDate(
|
||
panel: CalendarPanelInfo, nav: NavButtons,
|
||
targetYear: number, targetMonth: number
|
||
): Promise<void> {
|
||
if (targetYear === 0 && targetMonth === 0) return
|
||
const curYear = readCurrentYear(panel)
|
||
const curMonth = readCurrentMonth(panel)
|
||
if (curYear === targetYear && curMonth === targetMonth) return
|
||
|
||
// 先恢复年份
|
||
if (curYear !== null && targetYear !== 0 && curYear !== targetYear) {
|
||
const yearDiff = targetYear - curYear
|
||
const yearBtn = yearDiff > 0 ? nav.yearNext : nav.yearPrev
|
||
if (yearBtn) {
|
||
for (let i = 0; i < Math.abs(yearDiff) && i < 50; i++) {
|
||
yearBtn.click()
|
||
await delay(80)
|
||
}
|
||
}
|
||
}
|
||
// 再恢复月份
|
||
if (curMonth !== null && targetMonth !== 0 && readCurrentMonth(panel) !== targetMonth) {
|
||
const monthNow = readCurrentMonth(panel) || curMonth
|
||
let monthDiff = targetMonth - monthNow
|
||
// 处理跨年的月份差
|
||
if (Math.abs(monthDiff) > 6) {
|
||
monthDiff = monthDiff > 0 ? monthDiff - 12 : monthDiff + 12
|
||
}
|
||
const monthBtn = monthDiff > 0 ? nav.monthNext : nav.monthPrev
|
||
if (monthBtn) {
|
||
for (let i = 0; i < Math.abs(monthDiff) && i < 24; i++) {
|
||
monthBtn.click()
|
||
await delay(80)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// ============ 导航到目标年月 ============
|
||
|
||
/**
|
||
* 通过点击导航按钮,将日历面板切换到目标年月
|
||
* 先调整年份,再调整月份
|
||
*/
|
||
async function navigateToYearMonth(
|
||
panel: CalendarPanelInfo, nav: NavButtons,
|
||
targetYear: number, targetMonth: number
|
||
): Promise<boolean> {
|
||
console.log(`OfferPie: [datePicker] 导航到目标: ${targetYear}年${targetMonth}月`)
|
||
|
||
// ---- 调整年份 ----
|
||
let curYear = readCurrentYear(panel)
|
||
if (curYear !== null && curYear !== targetYear) {
|
||
const yearDiff = targetYear - curYear
|
||
const yearBtn = yearDiff > 0 ? nav.yearNext : nav.yearPrev
|
||
|
||
if (yearBtn) {
|
||
// 有年份按钮,直接用年份按钮
|
||
const steps = Math.abs(yearDiff)
|
||
console.log(`OfferPie: [datePicker] 年份需要${yearDiff > 0 ? "增加" : "减少"} ${steps} 年`)
|
||
for (let i = 0; i < steps && i < 100; i++) {
|
||
yearBtn.click()
|
||
await delay(100)
|
||
// 验证年份确实变了
|
||
const newYear = readCurrentYear(panel)
|
||
if (newYear === targetYear) break
|
||
}
|
||
} else if (nav.monthPrev || nav.monthNext) {
|
||
// 没有年份按钮,用月份按钮跨年(每12次月份切换 = 1年)
|
||
const totalMonthDiff = yearDiff * 12
|
||
const monthBtn = totalMonthDiff > 0 ? nav.monthNext : nav.monthPrev
|
||
if (monthBtn) {
|
||
console.log(`OfferPie: [datePicker] 无年份按钮,用月份按钮跨 ${Math.abs(totalMonthDiff)} 个月`)
|
||
for (let i = 0; i < Math.abs(totalMonthDiff) && i < 200; i++) {
|
||
monthBtn.click()
|
||
await delay(60)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- 调整月份 ----
|
||
let curMonth = readCurrentMonth(panel)
|
||
if (curMonth !== null && curMonth !== targetMonth) {
|
||
const monthBtn = targetMonth > curMonth ? nav.monthNext : nav.monthPrev
|
||
|
||
if (monthBtn) {
|
||
let monthDiff = targetMonth - curMonth
|
||
// 处理跨年边界(如当前12月要到目标1月)
|
||
if (monthDiff > 6 && nav.monthPrev) {
|
||
// 往回走更近
|
||
monthDiff = monthDiff - 12
|
||
} else if (monthDiff < -6 && nav.monthNext) {
|
||
monthDiff = monthDiff + 12
|
||
}
|
||
const btn = monthDiff > 0 ? nav.monthNext : nav.monthPrev
|
||
if (btn) {
|
||
const steps = Math.abs(monthDiff)
|
||
console.log(`OfferPie: [datePicker] 月份需要${monthDiff > 0 ? "增加" : "减少"} ${steps} 月`)
|
||
for (let i = 0; i < steps && i < 24; i++) {
|
||
btn.click()
|
||
await delay(100)
|
||
const newMonth = readCurrentMonth(panel)
|
||
if (newMonth === targetMonth) break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 验证最终结果
|
||
const finalYear = readCurrentYear(panel)
|
||
const finalMonth = readCurrentMonth(panel)
|
||
console.log(`OfferPie: [datePicker] 导航结果: ${finalYear}年${finalMonth}月 (目标: ${targetYear}年${targetMonth}月)`)
|
||
return finalYear === targetYear && finalMonth === targetMonth
|
||
}
|
||
|
||
// ============ 点击日期格子 ============
|
||
|
||
/**
|
||
* 在日历面板中点击目标日期数字
|
||
* 排除非当月的日期格子(prev-month / next-month / other-month 等)
|
||
*/
|
||
async function clickDayCell(
|
||
panel: CalendarPanelInfo, targetDay: number,
|
||
labelText: string, fillValue: string, inputElement: HTMLElement
|
||
): Promise<boolean> {
|
||
const cellSelectors = [
|
||
'td[class*="calendar-cell"]', 'div[class*="calendar-date"]',
|
||
'td[class*="date-table"] span', 'td[class*="picker-cell"] div[class*="cell-inner"]',
|
||
'td[role="gridcell"]', 'td[class*="cell"]',
|
||
'[class*="date-picker"] td', '[class*="calendar"] td',
|
||
]
|
||
|
||
// 优先在日期格子区域内搜索
|
||
const searchRoots = [panel.dayGridArea, panel.panelRoot]
|
||
|
||
for (const root of searchRoots) {
|
||
for (const sel of cellSelectors) {
|
||
const visibleCells = Array.from(root.querySelectorAll(sel)).filter((el) => isVisible(el))
|
||
if (visibleCells.length === 0) continue
|
||
|
||
for (const cell of visibleCells) {
|
||
const cellText = getFullText(cell)
|
||
const cellNum = parseInt(cellText, 10)
|
||
if (cellNum !== targetDay || isNaN(cellNum)) continue
|
||
|
||
// 排除非当月格子
|
||
const cls = ((cell as HTMLElement).className || "") + " " + ((cell.parentElement as HTMLElement)?.className || "")
|
||
if (cls.includes("prev-month") || cls.includes("next-month") || cls.includes("other-month") ||
|
||
cls.includes("prev_month") || cls.includes("next_month") || cls.includes("other_month") ||
|
||
cls.includes("last-month") || cls.includes("disabled")) continue
|
||
|
||
console.log(`OfferPie: 📅 找到日期格子 "${cellText}"`)
|
||
|
||
// 找到最深的叶子节点点击(兼容不同组件库的事件绑定位置)
|
||
let deepest: HTMLElement = cell as HTMLElement
|
||
for (const child of Array.from(cell.querySelectorAll("*"))) {
|
||
if (parseInt(getFullText(child), 10) === targetDay && child.children.length === 0) {
|
||
deepest = child as HTMLElement
|
||
break
|
||
}
|
||
}
|
||
|
||
// 三层点击:叶子 → 父级 → 原始元素
|
||
deepest.click()
|
||
await delay(50)
|
||
if (deepest.parentElement) deepest.parentElement.click()
|
||
await delay(50)
|
||
if (cell !== deepest && cell !== deepest.parentElement) (cell as HTMLElement).click()
|
||
|
||
await delay(300)
|
||
await closePopup(inputElement)
|
||
console.log(`OfferPie: ✅ [日期] 已选择 "${labelText}" = "${fillValue}" (日期 ${targetDay})`)
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
|
||
console.warn(`OfferPie: ⚠ [日期] "${labelText}" 未找到日期格子 ${targetDay}`)
|
||
return false
|
||
}
|
||
|
||
// ============ 日期解析 ============
|
||
|
||
/**
|
||
* 从填写值中解析出年月日
|
||
* 支持格式:
|
||
* - YYYY-MM-DD(2021-12-25)
|
||
* - YYYY/MM/DD(2021/12/25)
|
||
* - YYYY.MM.DD(2021.12.25)
|
||
* - YYYYMMDD(20211225)
|
||
* - 时间戳毫秒(1640390400000)
|
||
* - 时间戳秒(1640390400)
|
||
* - YYYY年MM月DD日
|
||
*/
|
||
function parseDateValue(fillValue: string): { year: number; month: number; day: number } | null {
|
||
// YYYY-MM-DD / YYYY/MM/DD / YYYY.MM.DD
|
||
const sepMatch = fillValue.match(/^(\d{4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})/)
|
||
if (sepMatch) {
|
||
return { year: parseInt(sepMatch[1], 10), month: parseInt(sepMatch[2], 10), day: parseInt(sepMatch[3], 10) }
|
||
}
|
||
|
||
// YYYY年MM月DD日
|
||
const zhMatch = fillValue.match(/(\d{4})年(\d{1,2})月(\d{1,2})日?/)
|
||
if (zhMatch) {
|
||
return { year: parseInt(zhMatch[1], 10), month: parseInt(zhMatch[2], 10), day: parseInt(zhMatch[3], 10) }
|
||
}
|
||
|
||
// YYYYMMDD
|
||
const compactMatch = fillValue.match(/^(\d{4})(\d{2})(\d{2})$/)
|
||
if (compactMatch) {
|
||
return { year: parseInt(compactMatch[1], 10), month: parseInt(compactMatch[2], 10), day: parseInt(compactMatch[3], 10) }
|
||
}
|
||
|
||
// 时间戳(毫秒或秒)
|
||
if (/^\d{10,13}$/.test(fillValue)) {
|
||
const ts = fillValue.length >= 13 ? parseInt(fillValue, 10) : parseInt(fillValue, 10) * 1000
|
||
const date = new Date(ts)
|
||
if (!isNaN(date.getTime())) {
|
||
return { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() }
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
// ============ 主入口 ============
|
||
|
||
/**
|
||
* 日期选择器填写主入口
|
||
*
|
||
* 完整流程:
|
||
* 1. 解析目标日期(年月日)
|
||
* 2. 先写入日期字符串到 input(触发部分组件库的面板联动)
|
||
* 3. 分析日历面板结构(日期格子 → 星期行 → 头部年月显示和按钮)
|
||
* 4. 通过逐个点击头部按钮探测年份±和月份±导航按钮
|
||
* 5. 用导航按钮切换到目标年月
|
||
* 6. 在日期格子中点击目标日期
|
||
* 7. 关闭弹窗
|
||
*
|
||
* 【注意】按钮探测逻辑是通用适配的核心,不要简化或删除
|
||
*/
|
||
export async function fillDatePicker(field: MatchedFormField): Promise<boolean> {
|
||
const { labelText, inputElement, fillValue } = field
|
||
if (!inputElement) return false
|
||
|
||
// ---- 步骤1:解析目标日期 ----
|
||
const dateInfo = parseDateValue(fillValue)
|
||
if (!dateInfo) {
|
||
console.warn(`OfferPie: [datePicker] 无法解析日期值: "${fillValue}"`)
|
||
// 兜底:直接写入值 + Enter
|
||
forceSetValue(inputElement, fillValue)
|
||
await delay(300)
|
||
inputElement.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, bubbles: true }))
|
||
await delay(300)
|
||
await closePopup(inputElement as HTMLElement)
|
||
return true
|
||
}
|
||
|
||
const { year: targetYear, month: targetMonth, day: targetDay } = dateInfo
|
||
console.log(`OfferPie: [datePicker] 目标日期: ${targetYear}年${targetMonth}月${targetDay}日`)
|
||
|
||
// ---- 步骤2:写入日期字符串(触发面板联动) ----
|
||
forceSetValue(inputElement, fillValue)
|
||
await delay(500)
|
||
|
||
// ---- 步骤3:分析日历面板结构 ----
|
||
const panel = analyzeCalendarPanel()
|
||
if (!panel) {
|
||
console.warn("OfferPie: [datePicker] 无法分析日历面板,尝试直接点击日期格子")
|
||
// 兜底:尝试旧逻辑直接找日期格子
|
||
return await fallbackClickDay(targetDay, labelText, fillValue, inputElement as HTMLElement)
|
||
}
|
||
|
||
// ---- 步骤4:探测导航按钮 ----
|
||
const nav = await detectNavButtons(panel)
|
||
|
||
// 如果连月份按钮都没找到,直接尝试点击日期格子(可能面板已经在正确的月份)
|
||
if (!nav.monthPrev && !nav.monthNext && !nav.yearPrev && !nav.yearNext) {
|
||
console.warn("OfferPie: [datePicker] 未找到任何导航按钮,直接尝试点击日期格子")
|
||
const clicked = await clickDayCell(panel, targetDay, labelText, fillValue, inputElement as HTMLElement)
|
||
if (clicked) return true
|
||
return await fallbackClickDay(targetDay, labelText, fillValue, inputElement as HTMLElement)
|
||
}
|
||
|
||
// ---- 步骤5:导航到目标年月 ----
|
||
const navigated = await navigateToYearMonth(panel, nav, targetYear, targetMonth)
|
||
if (!navigated) {
|
||
console.warn("OfferPie: [datePicker] 导航到目标年月失败,仍尝试点击日期格子")
|
||
}
|
||
|
||
// ---- 步骤6:点击目标日期格子 ----
|
||
await delay(200)
|
||
const clicked = await clickDayCell(panel, targetDay, labelText, fillValue, inputElement as HTMLElement)
|
||
if (clicked) return true
|
||
|
||
// ---- 步骤7:兜底 ----
|
||
return await fallbackClickDay(targetDay, labelText, fillValue, inputElement as HTMLElement)
|
||
}
|
||
|
||
/**
|
||
* 兜底:在全局范围搜索日期格子并点击(旧逻辑保留作为 fallback)
|
||
*/
|
||
async function fallbackClickDay(
|
||
targetDay: number, labelText: string, fillValue: string, inputElement: HTMLElement
|
||
): Promise<boolean> {
|
||
const cellSelectors = [
|
||
'td[class*="calendar-cell"]', 'div[class*="calendar-date"]',
|
||
'td[class*="date-table"] span', 'td[class*="picker-cell"] div[class*="cell-inner"]',
|
||
'td[role="gridcell"]', 'td[class*="cell"]',
|
||
'[class*="date-picker"] td', '[class*="calendar"] td',
|
||
]
|
||
for (const sel of cellSelectors) {
|
||
const visibleCells = Array.from(document.querySelectorAll(sel)).filter((el) => isVisible(el))
|
||
if (visibleCells.length === 0) continue
|
||
for (const cell of visibleCells) {
|
||
const cellText = getFullText(cell)
|
||
const cellNum = parseInt(cellText, 10)
|
||
if (cellNum !== targetDay || isNaN(cellNum)) continue
|
||
const cls = ((cell as HTMLElement).className || "") + " " + ((cell.parentElement as HTMLElement)?.className || "")
|
||
if (cls.includes("prev-month") || cls.includes("next-month") || cls.includes("other-month")) continue
|
||
let deepest: HTMLElement = cell as HTMLElement
|
||
for (const child of Array.from(cell.querySelectorAll("*"))) {
|
||
if (parseInt(getFullText(child), 10) === targetDay && child.children.length === 0) {
|
||
deepest = child as HTMLElement
|
||
break
|
||
}
|
||
}
|
||
deepest.click(); await delay(50)
|
||
if (deepest.parentElement) deepest.parentElement.click(); await delay(50)
|
||
if (cell !== deepest && cell !== deepest.parentElement) (cell as HTMLElement).click()
|
||
await delay(300)
|
||
await closePopup(inputElement)
|
||
console.log(`OfferPie: ✅ [日期-兜底] 已选择 "${labelText}" = "${fillValue}" (日期 ${targetDay})`)
|
||
return true
|
||
}
|
||
}
|
||
|
||
// 最终兜底:Enter 确认
|
||
console.warn(`OfferPie: ⚠ [日期] "${labelText}" 未找到日历格子,Enter 确认`)
|
||
inputElement.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, bubbles: true }))
|
||
await delay(300)
|
||
await closePopup(inputElement)
|
||
return true
|
||
}
|