Files
offerpai_browser_plug/src/lib/datePicker.ts
T
2026-05-09 10:12:21 +08:00

1017 lines
37 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. 定位日期格子区域(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-DD2021-12-25
* - YYYY/MM/DD2021/12/25
* - YYYY.MM.DD2021.12.25
* - YYYYMMDD20211225
* - 时间戳毫秒(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
}