/** * 日期选择器填写模块(支持年月日或年月的时间选择) * 负责:通用日期选择器的日历面板操作 * * 核心思路: * 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 = { "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, "十": 10, "十一": 11, "十二": 12, } /** 英文月份映射 */ const EN_MONTH_MAP: Record = { "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 { 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 { 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 { 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 } // ============ 月份面板检测与点击 ============ /** * 检测当前弹出的是否为月份选择面板,如果是则导航年份并点击目标月份 * * 月份面板特征: * - 包含12个格子,文字为 "一月"~"十二月" 或 "1月"~"12月" 或 "Jan"~"Dec" * - 有年份显示和前后翻页按钮 * - 没有星期行和1~31的日期格子 * * 【注意】复用 detectNavButtons 的按钮探测逻辑(点击观察年份变化)来找年份加减按钮 */ async function tryFillMonthPanel( targetYear: number, targetMonth: number, labelText: string, fillValue: string, inputElement: HTMLElement ): Promise { // 在页面中查找可见的月份面板 const panelSelectors = [ '[class*="month-panel"]', '[class*="month-picker"]', '[class*="picker-panel"]', '[class*="calendar-month"]', '[class*="picker-body"]', '[class*="picker-content"]', '[class*="popover"]', '[class*="popper"]', '[class*="popup"]', '[class*="dropdown"]', '[class*="overlay"]', ] let monthPanel: HTMLElement | null = null let monthCells: HTMLElement[] = [] 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 const cells = findMonthCells(htmlPanel) if (cells.length >= 10) { monthPanel = htmlPanel monthCells = cells break } } if (monthPanel) break } // 兜底:在 body 直接子元素中找 if (!monthPanel) { for (const child of Array.from(document.body.children)) { const htmlChild = child as HTMLElement if (!isVisible(htmlChild) || htmlChild.offsetHeight < 50) continue const cells = findMonthCells(htmlChild) if (cells.length >= 10) { monthPanel = htmlChild monthCells = cells break } } } if (!monthPanel || monthCells.length === 0) { console.log(`OfferPie: [datePicker] 未检测到月份面板`) return false } console.log(`OfferPie: [datePicker] ✅ 检测到月份面板,共 ${monthCells.length} 个月份格子`) // ---- 分析月份面板头部,找到年份显示和候选按钮 ---- // 月份格子区域作为边界(头部在月份格子之上) const monthGridArea = monthCells[0].closest("table, tbody, [class*='body'], [class*='content'], div") as HTMLElement || monthCells[0].parentElement as HTMLElement // 用 analyzeHeaderArea 分析头部(复用已有逻辑) const headerInfo = analyzeHeaderArea(monthPanel, monthGridArea) const currentYear = headerInfo.currentYear console.log(`OfferPie: [datePicker] 月份面板当前年份: ${currentYear},目标年份: ${targetYear}`) // ---- 用按钮探测逻辑找年份加减按钮(点击观察年份变化) ---- if (currentYear !== 0 && currentYear !== targetYear && headerInfo.headerButtons.length > 0) { // 构造一个临时的 CalendarPanelInfo 用于 detectNavButtons const tempPanel: CalendarPanelInfo = { panelRoot: monthPanel, weekdayRow: monthGridArea, // 用月份格子区域作为边界 dayGridArea: monthGridArea, yearLabel: headerInfo.yearLabel, monthLabel: null, // 月份面板里没有独立的月份显示标签 currentYear: currentYear, currentMonth: 0, headerButtons: headerInfo.headerButtons, } // 探测导航按钮(只关心年份按钮) const nav = await detectNavButtons(tempPanel) // 导航到目标年份 if (nav.yearPrev || nav.yearNext) { const yearDiff = targetYear - (readCurrentYear(tempPanel) || currentYear) const yearBtn = yearDiff > 0 ? nav.yearNext : nav.yearPrev if (yearBtn) { const steps = Math.abs(yearDiff) console.log(`OfferPie: [datePicker] 月份面板年份导航: ${yearDiff > 0 ? "+" : ""}${yearDiff}年`) for (let i = 0; i < steps && i < 50; i++) { yearBtn.click() await delay(150) const newYear = readCurrentYear(tempPanel) if (newYear === targetYear) break } } } else if (nav.monthPrev || nav.monthNext) { // 有些月份面板的按钮被探测为"月份按钮"(因为点击后年份变了12个月=1年) // 这种情况用月份按钮来导航年份 const yearDiff = targetYear - (readCurrentYear(tempPanel) || currentYear) const btn = yearDiff > 0 ? nav.monthNext : nav.monthPrev if (btn) { const steps = Math.abs(yearDiff) for (let i = 0; i < steps && i < 50; i++) { btn.click() await delay(150) const newYear = readCurrentYear(tempPanel) if (newYear === targetYear) break } } } // 导航后重新获取月份格子(DOM 可能更新了) monthCells = findMonthCells(monthPanel) } // ---- 点击目标月份格子 ---- for (const cell of monthCells) { const cellMonth = getMonthFromCell(cell) if (cellMonth === targetMonth) { const cls = ((cell as HTMLElement).className || "") + " " + ((cell.parentElement as HTMLElement)?.className || "") if (cls.includes("disabled")) continue console.log(`OfferPie: [datePicker] 📅 点击月份格子: ${targetMonth}月`) // 找最内层叶子节点点击(兼容不同组件库) let deepest: HTMLElement = cell const inner = cell.querySelector("[class*='inner'], [class*='content'], span, a") if (inner && isVisible(inner)) deepest = inner as HTMLElement deepest.click() await delay(100) if (deepest !== cell) cell.click() await delay(300) await closePopup(inputElement) console.log(`OfferPie: ✅ [月份选择器] 已选择 "${labelText}" = "${fillValue}" (${targetYear}年${targetMonth}月)`) return true } } console.log(`OfferPie: [datePicker] ❌ 月份面板中未找到 ${targetMonth}月 的格子`) return false } /** * 在容器中查找月份格子元素 * 月份格子特征:文字为 "一月"~"十二月" / "1月"~"12月" / "Jan"~"Dec" / 纯数字1~12 */ function findMonthCells(container: HTMLElement): HTMLElement[] { const cells: HTMLElement[] = [] // 常见月份格子选择器 const selectors = [ '[class*="month-panel-cell"]', '[class*="month-cell"]', '[class*="picker-cell"]', 'td[class*="cell"]', '[class*="month-table"] td', '[class*="month-body"] td', '[role="gridcell"]', ] for (const sel of selectors) { const candidates = container.querySelectorAll(sel) const validCells: HTMLElement[] = [] for (const el of Array.from(candidates)) { if (!isVisible(el)) continue const month = getMonthFromCell(el as HTMLElement) if (month !== null) validCells.push(el as HTMLElement) } if (validCells.length >= 10) return validCells } // 兜底:遍历所有可见的短文本元素 const allEls = container.querySelectorAll("td, div, span, a") const fallbackCells: HTMLElement[] = [] for (const el of Array.from(allEls)) { if (!isVisible(el)) continue const month = getMonthFromCell(el as HTMLElement) if (month !== null) { // 确保不是年份数字(排除 2020~2030 等) const text = getFullText(el) if (extractYear(text) !== null) continue fallbackCells.push(el as HTMLElement) } } // 去重(如果父子元素都匹配,只保留最内层) const filtered = fallbackCells.filter((el) => { return !fallbackCells.some((other) => other !== el && el.contains(other)) }) if (filtered.length >= 10) return filtered return [] } /** * 从格子元素中提取月份数字 * 支持:一月~十二月、1月~12月、Jan~Dec、纯数字1~12 */ function getMonthFromCell(el: HTMLElement): number | null { const text = getFullText(el).trim() // 中文月份:一月~十二月 for (const [zh, num] of Object.entries(ZH_MONTH_NUMS)) { if (text === zh + "月" || text === zh) return num } // 数字月份:1月~12月 或 01~12 const numMatch = text.match(/^0?(\d{1,2})月?$/) if (numMatch) { const n = parseInt(numMatch[1], 10) if (n >= 1 && n <= 12) return n } // 英文月份 const lower = text.toLowerCase() for (const [key, val] of Object.entries(EN_MONTH_MAP)) { if (lower === key || lower === key.slice(0, 3)) return val } return null } // ============ 点击日期格子 ============ /** * 在日历面板中点击目标日期数字 * 排除非当月的日期格子(prev-month / next-month / other-month 等) */ async function clickDayCell( panel: CalendarPanelInfo, targetDay: number, labelText: string, fillValue: string, inputElement: HTMLElement ): Promise { 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 / YYYY.MM / YYYY/MM(只有年月,day=0 表示不需要点日期格子) const yearMonthMatch = fillValue.match(/^(\d{4})[\/\-.](\d{1,2})$/) if (yearMonthMatch) { return { year: parseInt(yearMonthMatch[1], 10), month: parseInt(yearMonthMatch[2], 10), day: 0 } } // 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) } } // YYYY年MM月(只有年月) const zhYMMatch = fillValue.match(/(\d{4})年(\d{1,2})月/) if (zhYMMatch) { return { year: parseInt(zhYMMatch[1], 10), month: parseInt(zhYMMatch[2], 10), day: 0 } } // 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 { 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 === 0 ? "(仅年月)" : targetDay + "日"}`) // ---- 步骤2:写入日期字符串(触发面板联动) ---- forceSetValue(inputElement, fillValue) await delay(500) // ---- 步骤2.5:如果只有年月(day=0),检测是否为月份面板 ---- if (targetDay === 0) { const monthPanelResult = await tryFillMonthPanel(targetYear, targetMonth, labelText, fillValue, inputElement as HTMLElement) if (monthPanelResult) return true // 如果不是月份面板,继续走日期面板逻辑(day 默认选1号) } // ---- 步骤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:点击目标日期格子(如果 day=0 表示只选年月,跳过日期点击) ---- if (targetDay === 0) { // 只需要年月,不需要点日期格子。尝试点击月份面板中的月份 // 如果导航成功了,可能面板已经关闭(某些组件选完月份自动关闭) await delay(200) await closePopup(inputElement as HTMLElement) console.log(`OfferPie: ✅ [日期] 已导航到 "${labelText}" = "${fillValue}" (${targetYear}年${targetMonth}月)`) return true } 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 { 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 }