中)。
// 这是页面权威标记,能正确识别调休(工作日变休息日)和调班(周末变工作日)。
function isRestDayCell(cellEl) {
if (!cellEl) return false;
return !!(cellEl.querySelector('[class*="shapeCopy"], [class*="restDay"]'));
}
// 从已找到的考勤表(found)中提取当前 DOM 里渲染的行 → 记录数组。
// 注意:fixed-data-table 是虚拟滚动,滚动时行会被创建/回收,
// 因此每次调用都必须重新查询当前 DOM 中的行(不能复用 find 时的旧引用)。
function extractRecordsFromFound(found) {
if (!found) return null;
const { table, map } = found;
let rows;
if (found.gridRows) {
// div 网格模式:重新查询当前 DOM 中的数据行(滚动采集时行会增删)
const cur = [];
const rowEls = table.querySelectorAll('[role="row"]');
for (const rowEl of rowEls) {
const idx = parseInt(rowEl.getAttribute("aria-rowindex") || "0", 10);
if (idx <= 1) continue;
const cells = Array.from(rowEl.querySelectorAll('[role="gridcell"]'));
if (!cells.length) continue;
cur.push(cells);
}
rows = cur.length ? cur : found.gridRows.map((r) => r.cells);
} else {
const bodyRows = table.querySelectorAll("tbody tr");
const trs = bodyRows.length
? Array.from(bodyRows)
: Array.from(table.querySelectorAll("tr")).slice(1); // 无 tbody 时跳过表头行
rows = trs.map((tr) => Array.from(tr.querySelectorAll("td")));
}
const records = [];
for (const tds of rows) {
if (!tds.length) continue;
const cell = (i) => (tds[i] ? tds[i].textContent.trim() : "");
const date = normalizeDateText(cell(map.date));
if (!date) continue;
const dateCellEl = tds[map.date];
const rawType = map.date_type !== undefined ? cell(map.date_type) : "";
// shapeCopy/restDay 是页面权威标记(含法定节假日调休调班);
// 结合星期标注:工作日有休息标记 → 工作日(国家调休),周末无标记 → 非工作日(国家补班)
const dateType = found.gridRows
? // fdt 网格:shapeCopy 权威(有=休息日,无=工作日),周末无标记即国家补班
resolveDateType(date, isRestDayCell(dateCellEl), "工作日")
: // 原生 table:shapeCopy 优先,其次类型列文字,最后周末推断
resolveDateType(date, isRestDayCell(dateCellEl), rawType);
records.push({
id: date,
date,
date_text: cell(map.date),
date_type: dateType,
shift: map.shift !== undefined ? cell(map.shift) : "",
first_card: map.first_card !== undefined ? extractTime(cell(map.first_card)) : "",
last_card: map.last_card !== undefined ? extractTime(cell(map.last_card)) : "",
work_hours: parseHoursText(cell(map.work_hours)),
attendance_status: map.attendance_status !== undefined ? cell(map.attendance_status) : "",
attendance_detail: map.attendance_status !== undefined ? cell(map.attendance_status) : "",
});
}
return records;
}
// 解析考勤表格 → 记录数组
function parsePageTable() {
return extractRecordsFromFound(findAttendanceTable());
}
// 从页面分页栏读取总条数(如"共29条"),找不到返回 0
function getPageTotal() {
for (const doc of collectDocuments()) {
try {
const body = doc.body;
if (!body) continue;
const txt = body.innerText || body.textContent || "";
const m = txt.match(/共\s*(\d+)\s*条/);
if (m) return parseInt(m[1], 10);
} catch (_) {}
}
return 0;
}
// 从 fixed-data-table 网格根节点读数据总行数(aria-rowcount 含表头行,减 1)
function getGridDataRows(tableEl) {
try {
const n = parseInt(tableEl.getAttribute("aria-rowcount") || "0", 10);
if (n > 1) return n - 1;
} catch (_) {}
return 0;
}
// ============================================================
// 4. 虚拟滚动采集 —— fixed-data-table 只渲染视口附近的行,
// 必须自动滚动(下滚到底 + 回顶)边滚边收集,才能拿全数据
// ============================================================
// 在表格内找可滚动容器(虚拟滚动表格通常有内容高度超高的滚动元素)
function findScrollContainer(tableEl) {
if (!tableEl) return null;
const all = tableEl.querySelectorAll("*");
for (const el of all) {
try {
if (el.scrollHeight > el.clientHeight + 10 && el.clientHeight > 30) return el;
} catch (_) {}
}
// 兜底:fixed-data-table 的已知滚动容器类名
return tableEl.querySelector(
'[class*="fixedDataTableLayout_rowsContainer"], [class*="fixedDataTableLayout_rows"], [class*="fixedDataTableLayout_main"]'
);
}
// 找出所有能驱动表格滚动的元素:
// fixed-data-table 的垂直滚动条是 ScrollbarLayout_face —— 一个原生可滚动 div,
// 直接改它的 scrollTop 就能驱动表格虚拟滚动(比只发 wheel 事件可靠得多)。
function findScrollDrivers(tableEl) {
const list = [];
const seen = new Set();
const push = (el) => {
if (el && !seen.has(el)) {
seen.add(el);
list.push(el);
}
};
if (!tableEl) return list;
try { tableEl.querySelectorAll('[class*="ScrollbarLayout_faceVertical"]').forEach(push); } catch (_) {}
try { tableEl.querySelectorAll('[class*="ScrollbarLayout_face"]').forEach(push); } catch (_) {}
try { tableEl.querySelectorAll('[class*="fixedDataTableLayout_rowsContainer"]').forEach(push); } catch (_) {}
push(findScrollContainer(tableEl));
return list;
}
function dispatchWheel(el, dy) {
if (!el) return;
try {
if (typeof WheelEvent === "function") {
el.dispatchEvent(new WheelEvent("wheel", { deltaY: dy, bubbles: true, cancelable: true }));
} else {
const ev = document.createEvent("WheelEvent");
ev.initWheelEvent("wheel", true, true, window, 0, 0, 0, 0, 0, 0, dy, 0, 0, 0);
el.dispatchEvent(ev);
}
} catch (_) {}
}
function setScrollTop(el, y) {
if (!el) return;
try {
el.scrollTop = y;
el.dispatchEvent(new Event("scroll", { bubbles: true }));
} catch (_) {}
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
// 双保险向下滚一步:wheel 事件(fdt 的 onWheel 处理器)+ 直接改滚动条 face 的 scrollTop
function scrollDownStep(tableEl, drivers, step) {
dispatchWheel(tableEl, step);
for (const sc of drivers) {
try {
sc.scrollTop = sc.scrollTop + step;
sc.dispatchEvent(new Event("scroll", { bubbles: true }));
} catch (_) {}
dispatchWheel(sc, step);
}
}
function scrollToTop(tableEl, drivers) {
dispatchWheel(tableEl, -100000);
for (const sc of drivers) setScrollTop(sc, 0);
}
// ============================================================
// 5. 累积存储 + 渲染 —— 多次点击/翻页/换月的数据合并去重,按日期排序
// ============================================================
const collectedMap = new Map(); // date -> record(跨多次点击持续累积)
let lastRecords = []; // 供 CSV 导出
// 把一批记录合并进目标 Map(按日期去重,后采的覆盖先采的)
function mergeRecords(records, targetMap) {
let added = 0;
for (const r of records || []) {
if (!r || !r.date) continue;
if (!targetMap.has(r.date)) added++;
targetMap.set(r.date, r);
}
return { added, total: targetMap.size };
}
// 渲染当前累积的全部记录(排序 + 加班识别 + 汇总)
function renderCollected(source, note) {
const records = Array.from(collectedMap.values()).sort((a, b) =>
(a.date || "").localeCompare(b.date || "")
);
const parsed = detectOvertime(records);
const summary = summarize(parsed);
lastRecords = parsed;
setSummary(summary);
renderTable(parsed);
const range =
parsed.length ? `${parsed[0].date} ~ ${parsed[parsed.length - 1].date}` : "";
let msg = `已累积 ${summary.total} 条记录${range ? `(${range})` : ""},${summary.workDays} 个工作日`;
if (summary.otDays) {
msg += `,${summary.otDays} 天加班、累计 ${summary.totalOtHours}h`;
}
msg += note ? `。${note}` : "";
msg += ` [来源:${source}]`;
setStatus(msg, "ok");
persistRecords(); // 写入油猴本地存储,跨页面/刷新保留(供加班申请自动填充使用)
return { parsed, summary };
}
// ============================================================
// 6. 采集主流程(唯一入口:点「解析页面」按钮)
// ============================================================
let isCollecting = false;
async function scanAndParsePage() {
if (isCollecting) return 0;
const found = findAttendanceTable();
if (!found) return 0;
const total = Math.max(getPageTotal(), getGridDataRows(found.table));
const domRecords = extractRecordsFromFound(found) || [];
const pageMap = new Map(); // 本次采集(当前视图)的记录
for (const r of domRecords) pageMap.set(r.date, r);
if (!pageMap.size) {
setStatus("找到表格但没解析到考勤行", "err");
return 0;
}
const drivers = findScrollDrivers(found.table);
const needScroll = drivers.length > 0 && (total === 0 || pageMap.size < total);
if (!needScroll) {
// DOM 已完整(普通表格或行数=总数)→ 直接合并渲染
const { added } = mergeRecords(domRecords, collectedMap);
renderCollected("页面解析", added ? `本次新增 ${added} 条` : "无新增(已存在)");
return collectedMap.size;
}
return autoCollect(found, total, pageMap, drivers);
}
// 虚拟滚动表格:自动下滚到底、再回顶,边滚边收集;
// 完成后合并进累积 Map 并渲染。pageMap.size 对比 total 判断是否采全。
async function autoCollect(found, total, pageMap, drivers) {
isCollecting = true;
try {
const collect = () => {
try {
const cur = extractRecordsFromFound(found) || [];
for (const r of cur) pageMap.set(r.date, r);
} catch (_) {}
};
const step = Math.max(
280,
Math.floor(Math.max(360, ...drivers.map((d) => d.clientHeight || 0)) * 0.9)
);
setStatus(
`检测到虚拟滚动表格,自动滚动采集全量数据…(当前 ${pageMap.size} 行,共 ${total || "?"} 行)`
);
let lastCount = pageMap.size;
let stuck = 0;
let guard = 0;
const MAX_STEPS = 150; // 兜底上限,避免无限滚动
// 1) 向下滚,直到收齐 / 到底且连续多步无新行
while (guard++ < MAX_STEPS) {
if (total > 0 && pageMap.size >= total) break;
scrollDownStep(found.table, drivers, step);
await sleep(240);
collect();
if (pageMap.size !== lastCount) {
lastCount = pageMap.size;
stuck = 0;
setStatus(`正在滚动采集… 已收集 ${pageMap.size}${total ? " / " + total : ""} 行`);
} else {
stuck++;
}
// 到底判断:任一滚动元素已到最大位置,且连续 4 步无新行
const atBottom = drivers.some(
(sc) => sc.scrollTop + sc.clientHeight >= sc.scrollHeight - 2
);
if (stuck >= 4 && atBottom) break;
if (stuck >= 12) break; // 滚动可能失效,避免死循环
}
// 2) 滚回顶部补收首屏(下滚过程中顶部行已被回收)
scrollToTop(found.table, drivers);
await sleep(300);
collect();
// 3) 总数已知且还差行 → 从顶再快滚一轮补漏(顶→底一遍)
if (total > 0 && pageMap.size < total && guard < MAX_STEPS) {
let stuck2 = 0;
while (guard++ < MAX_STEPS) {
if (pageMap.size >= total) break;
scrollDownStep(found.table, drivers, step);
await sleep(240);
const before = pageMap.size;
collect();
if (pageMap.size === before) {
stuck2++;
if (stuck2 >= 6) break;
} else {
stuck2 = 0;
}
}
scrollToTop(found.table, drivers);
await sleep(200);
collect();
}
} finally {
// 恢复表格到顶部,方便用户继续查看/翻页
try { scrollToTop(found.table, drivers); } catch (_) {}
isCollecting = false;
}
if (!pageMap.size) {
setStatus("未解析到考勤行", "err");
return 0;
}
const { added } = mergeRecords(Array.from(pageMap.values()), collectedMap);
if (total > 0 && pageMap.size < total) {
// 没采全:明确告诉用户还差多少、怎么办(累积模型下再点一次即可继续补)
renderCollected(
"页面滚动采集",
`已采到 ${pageMap.size}/${total} 条。请手动滚动表格或翻页后再点一次「解析页面」继续累积补全`
);
const el = $id("ot-status");
el.className = "ot-status warn";
} else {
renderCollected("页面滚动采集", added ? `本次新增 ${added} 条` : "无新增(已存在)");
}
return collectedMap.size;
}
// ============================================================
// 6.5 iframe 协作(手动触发):顶层按钮点下且顶层找不到表格时,
// 请求 iframe 内的脚本实例解析自己的文档并上报(无任何自动监听)
// ============================================================
const IS_TOP_FRAME = (() => {
try {
return window.self === window.top;
} catch (_) {
return true;
}
})();
function broadcastScanRequest() {
try {
const frames = document.querySelectorAll("iframe");
for (const f of frames) {
try {
f.contentWindow.postMessage({ type: "ITALENT_OT_SCAN_REQUEST" }, "*");
} catch (_) {}
}
} catch (_) {}
}
// 向所有 iframe 广播任意协作消息(填充指令等)
function broadcastMessage(msg) {
try {
const frames = document.querySelectorAll("iframe");
for (const f of frames) {
try {
f.contentWindow.postMessage(msg, "*");
} catch (_) {}
}
} catch (_) {}
}
// ============================================================
// 6.6 表单结构采集(弹窗内手动触发):
// 「申请加班」等弹窗是跨域 iframe(cloud.italent.cn),
// 保存网页抓不到内部 DOM。此工具在弹窗 iframe 内注入一个小按钮,
// 点击后导出表单控件清单 + 表单 HTML,供分析字段定位、实现自动填充。
// ============================================================
// 找控件关联的 label 文字(北森表单引擎:form-item__label / bc-form-item)
function findFieldLabel(el) {
try {
let node = el;
for (let i = 0; i < 8 && node && node !== document.body; i++) {
// 兄弟节点里的 label
if (node.previousElementSibling) {
const prev = node.previousElementSibling;
const t = (prev.className || "") + "";
if (/label|field-left/i.test(t) || /^label$/i.test(prev.tagName)) {
const txt = (prev.textContent || "").trim();
if (txt) return txt.slice(0, 20);
}
}
// 父级 form-item 里找 label 元素
if (/form-item|field/i.test((node.className || "") + "")) {
const lab = node.querySelector(":scope > .form-item__label, :scope > label, :scope > .field-left");
if (lab) {
const txt = (lab.textContent || "").trim();
if (txt) return txt.slice(0, 20);
}
}
node = node.parentElement;
}
} catch (_) {}
return "";
}
function buildFormReport() {
const lines = [];
lines.push("== iTalent 弹窗表单结构报告 ==");
lines.push("URL: " + location.href);
lines.push("TITLE: " + document.title);
lines.push("TIME: " + new Date().toISOString());
lines.push("");
// 1. 表单控件清单(含 label / placeholder / 值;隐藏的也列出并标注,避免弹窗被遮挡时漏抓)
const ctrls = document.querySelectorAll("input, textarea, select, [contenteditable='true']");
lines.push("== 一、表单控件清单 ==");
let n = 0;
for (const el of ctrls) {
if ((el.getAttribute("type") || "").toLowerCase() === "hidden") continue;
const rect = el.getBoundingClientRect();
const hiddenMark = rect.width === 0 && rect.height === 0 ? " [不可见]" : "";
const label = findFieldLabel(el);
const cls = String(el.className || "").slice(0, 100);
lines.push(
`[${n}]<${el.tagName.toLowerCase()}>${hiddenMark} label="${label}" placeholder="${el.placeholder || ""}"` +
` id="${el.id}" name="${el.name || ""}" type="${el.getAttribute("type") || ""}"` +
` value="${String(el.value || "").slice(0, 50)}" class="${cls}"`
);
n++;
}
if (n === 0) lines.push("(未发现表单控件)");
lines.push("");
// 2. 按钮清单(提交/取消等)
lines.push("== 二、按钮 ==");
let bn = 0;
for (const el of document.querySelectorAll("button, a, [class*='btn'], [class*='button']")) {
const rect = el.getBoundingClientRect();
const hiddenMark = rect.width === 0 && rect.height === 0 ? "[不可见]" : "";
const txt = (el.textContent || "").trim().replace(/\s+/g, " ");
if (!txt || txt.length > 30) continue;
lines.push(`[${bn}]${hiddenMark} <${el.tagName.toLowerCase()}> "${txt}" class="${String(el.className || "").slice(0, 80)}"`);
bn++;
if (bn >= 60) break;
}
lines.push("");
// 3. 表格与网格清单:所有 table 表头 + 行数 + 首行内容;role=grid 的 div 网格
lines.push("== 三、表格与网格清单 ==");
let tn = 0;
for (const tb of document.querySelectorAll("table")) {
const head = tb.querySelector("thead");
const heads = head
? Array.from(head.querySelectorAll("th, td")).map((h) => (h.textContent || "").trim()).join(" | ")
: "(无 thead)";
const trs = tb.querySelectorAll("tbody tr").length;
const rect = tb.getBoundingClientRect();
lines.push(`[table ${tn}] 行=${trs} 可见=${rect.width > 0} 表头: ${heads.slice(0, 300)}`);
lines.push(` class="${String(tb.className || "").slice(0, 120)}"`);
const firstRow = tb.querySelector("tbody tr");
if (firstRow) {
const cells = Array.from(firstRow.querySelectorAll("td, th")).map((c) =>
(c.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40)
);
lines.push(` 首行: ${cells.join(" | ").slice(0, 300)}`);
}
tn++;
if (tn >= 15) break;
}
let gn = 0;
for (const g of document.querySelectorAll('[role="grid"], [role="table"]')) {
const heads = Array.from(g.querySelectorAll('[role="columnheader"]'))
.map((h) => (h.textContent || "").trim())
.join(" | ");
lines.push(`[grid ${gn}] <${g.tagName.toLowerCase()}> class="${String(g.className || "").slice(0, 120)}"`);
lines.push(` 表头: ${heads.slice(0, 300)}`);
lines.push(` 行数: ${g.querySelectorAll('[role="row"]').length}`);
const r2 = g.querySelectorAll('[role="row"]')[1];
if (r2) {
const cells = Array.from(r2.querySelectorAll('[role="gridcell"], td, th')).map((c) =>
(c.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40)
);
lines.push(` 第2行: ${cells.join(" | ").slice(0, 300)}`);
}
gn++;
if (gn >= 15) break;
}
if (tn === 0 && gn === 0) lines.push("(未发现 table 或 role=grid 元素)");
lines.push("");
// 4. 表单区域 HTML(form 元素或 form 容器,截断 400KB)
lines.push("== 四、表单区域 HTML ==");
const formEls = document.querySelectorAll("form, .form, [class*='form-item__control']");
let html = "";
if (formEls.length) {
// 优先取 form 的外层容器;否则取公共祖先
let scope = null;
for (const f of formEls) {
if (f.tagName === "FORM") {
scope = f;
break;
}
}
if (!scope && formEls.length) {
// 找所有 form-item__control 的最深公共容器
scope = formEls[0].closest("div[class*='form'], div[class*='Form'], body");
}
html = scope ? scope.outerHTML : "";
}
if (!html) html = document.body ? document.body.outerHTML : "";
if (html.length > 400000) html = html.slice(0, 400000) + "\n...(已截断)";
lines.push(html);
return lines.join("\n");
}
function downloadReport(report) {
try {
const blob = new Blob([report], { type: "text/plain;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `italent表单结构_${Date.now()}.txt`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(a.href);
a.remove();
}, 500);
return true;
} catch (e) {
return false;
}
}
// 抓表单并三路输出(供油猴菜单广播触发,不再依赖弹窗内悬浮按钮):
// 复制到剪贴板 → 本 iframe 下载 → 上报顶层由顶层面板兜底下载
function captureFormToOutput() {
try {
const report = buildFormReport();
let copied = false;
try {
if (typeof GM_setClipboard === "function") {
GM_setClipboard(report, "text");
copied = true;
}
} catch (_) {}
const downloaded = downloadReport(report);
try {
window.parent.postMessage({ type: "ITALENT_OT_FORM_REPORT", report }, "*");
} catch (_) {}
console.log("[iTalent助手] 表单结构报告:", report);
return downloaded || copied;
} catch (e) {
console.warn("[iTalent助手] 表单抓取失败:", e);
return false;
}
}
// ============================================================
// 6.7 加班申请自动填充(弹窗 iframe 内手动触发)
// 「批量加班」弹窗结构(按用户反馈):
// 加班原因(下拉) + 加班明细表格(列:序号/加班日期/开始时间/结束时间/加班时长/备注/附件/操作)
// 默认只有一行当天,点「添加时段」新增一天一行。
// 填充策略:
// - 加班日期 ← 考勤加班日
// - 开始时间 ← 该行已有值保留(第一行系统默认"当日 17:30");空则复制第一行时间;非工作日用上班打卡时间
// - 结束时间 ← 该日末次打卡时间
// - 加班时长 ← 识别出的加班时长(若系统按起止自动计算可忽略)
// 控件按文本标签启发式定位,定位失败输出诊断信息;可配合「抓表单」按钮导出真实结构精调。
// ============================================================
const STORE_KEY = "italent_ot_records_v1";
// 考勤记录写入油猴本地存储(GM 存储按脚本作用域共享:跨 www/cloud 域名、跨页面、跨刷新)
function persistRecords() {
try {
if (typeof GM_setValue === "function" && lastRecords.length) {
GM_setValue(STORE_KEY, JSON.stringify(lastRecords));
}
} catch (_) {}
}
// 启动时从油猴本地存储恢复考勤记录
function loadPersisted() {
try {
if (typeof GM_getValue !== "function") return;
const raw = GM_getValue(STORE_KEY, "");
if (!raw) return;
const arr = JSON.parse(raw);
if (!Array.isArray(arr) || !arr.length) return;
let n = 0;
for (const r of arr) {
if (!r || !r.date) continue;
if (!collectedMap.has(r.date)) n++;
collectedMap.set(r.date, r);
}
if (n) console.log(`[iTalent助手] 从本地缓存恢复 ${n} 条考勤记录`);
if (IS_TOP_FRAME) renderCollected("本地缓存恢复", n ? `恢复 ${n} 条` : "无新增");
} catch (_) {}
}
// 按文本找按钮(取最深的命中元素,避免父容器重复命中)
// 找匹配文字的按钮:优先真实可点击元素(button/role=button/btn类),过滤隐藏模板,最后才回退 span/div
function findBtnByText(pattern, needVisible) {
const re = new RegExp(pattern);
const isVisible = (el) => {
try {
if (el.hidden) return false;
const st = window.getComputedStyle(el);
if (st.display === "none" || st.visibility === "hidden" || st.opacity === "0") return false;
return el.getClientRects().length > 0; // 有实际布局区域才算可见
} catch (_) {
return true;
}
};
const pick = (els) => {
for (const el of els) {
const t = (el.textContent || "").trim().replace(/\s+/g, "");
if (!t || t.length > 14 || !re.test(t)) continue;
// 跳过包含相同文本子元素的容器(避免匹配到大容器)
let innerHit = false;
for (const k of el.querySelectorAll("button, [role='button'], a, span")) {
const kt = (k.textContent || "").trim().replace(/\s+/g, "");
if (kt && kt === t) {
innerHit = true;
break;
}
}
if (innerHit) continue;
if (needVisible && !isVisible(el)) continue;
return el;
}
return null;
};
// 第一优先:真正的按钮类元素
const hit = pick(
document.querySelectorAll("button, [role='button'], a[class*='btn'], [class*='ant-btn'], [class*='button'], [class*='btn']")
);
if (hit) return hit;
// 第二优先:带点击语义的元素
const any = pick(document.querySelectorAll("a[href], [onclick], [class*='add'], [class*='Add'], [class*='click']"));
if (any) return any;
// 兜底:span/div 文本匹配
return pick(document.querySelectorAll("span, div"));
}
// 模拟真实点击:MouseEvent 分发(比 .click() 更接近用户点击,对 React 合成事件更可靠)
function dispatchClick(el) {
try {
el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
} catch (_) {
try {
el.click();
} catch (__) {}
}
}
// React 受控组件赋值:用原生 setter + input/change 事件
function setNativeValue(el, value) {
try {
const proto = el instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: el instanceof HTMLSelectElement
? HTMLSelectElement.prototype
: HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, "value");
if (setter && setter.set) setter.set.call(el, value);
else el.value = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
} catch (_) {
try {
el.value = value;
} catch (__) {}
}
}
// ===== 表格定位:三种策略,统一返回 { mode, rows: [{date,start,end,dur,note: 输入框元素|null}] } =====
// 用户实测:北森批量加班弹窗是纯 div 布局,页面无任何
/role=grid 元素,
// 语义化定位必然失败 → 新增「几何定位」:表头文字坐标分列 + 输入框纵坐标聚类成行。
// 找包含指定文字的最小可见元素集合(基于文本节点扫描,天然取最深层,速度快)
function findTextElements(kw, maxExtra) {
const limit = kw.length + (maxExtra == null ? 4 : maxExtra);
const out = [];
const root = document.body || document.documentElement;
if (!root) return out;
try {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
let node;
while ((node = walker.nextNode())) {
const t = (node.textContent || "").trim();
if (!t || !t.includes(kw)) continue;
if (t.replace(/\s+/g, "").length > limit) continue;
const el = node.parentElement;
if (!el) continue;
try {
const st = window.getComputedStyle(el);
if (st.display === "none" || st.visibility === "hidden" || st.opacity === "0") continue;
if (!el.getClientRects().length) continue;
} catch (_) {
continue;
}
out.push(el);
}
} catch (_) {}
return out;
}
function findDeepestByText(kw, maxExtra) {
const els = findTextElements(kw, maxExtra);
let best = null;
let bestLen = Infinity;
for (const el of els) {
const len = (el.textContent || "").replace(/\s+/g, "").length;
if (len < bestLen) {
best = el;
bestLen = len;
}
}
return best;
}
// ③ 几何定位:不依赖 table/grid 语义,靠表头文字坐标分列 + 输入框纵坐标聚类成行
function findDetailGridByGeometry() {
const dateLabel = findDeepestByText("加班日期", 4);
if (!dateLabel) return null;
const dr = dateLabel.getBoundingClientRect();
if (dr.width <= 0 || dr.height <= 0) return null;
const colX = { date: dr.left + dr.width / 2 };
// 与「加班日期」同一水平线(±40px)的其它列标签 → 得到每列的中心横坐标
const defs = [
["start", "开始时间"],
["end", "结束时间"],
["dur", "加班时长"],
["note", "备注"],
];
for (const [key, kw] of defs) {
let best = null;
let bestLen = Infinity;
for (const el of findTextElements(kw, 4)) {
const r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) continue;
if (Math.abs(r.top - dr.top) > 40) continue; // 必须在表头同一行
const len = (el.textContent || "").replace(/\s+/g, "").length;
if (len < bestLen) {
best = el;
bestLen = len;
}
}
if (best) {
const r = best.getBoundingClientRect();
colX[key] = r.left + r.width / 2;
}
}
// 收集表头下方 1000px 内的可见输入框
const inputs = [];
for (const el of document.querySelectorAll("input, textarea")) {
const ty = (el.getAttribute("type") || "").toLowerCase();
if (ty === "hidden" || ty === "button" || ty === "submit" || ty === "file" || el.disabled) continue;
const r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) continue;
if (r.top < dr.bottom - 4 || r.top > dr.bottom + 1000) continue;
inputs.push({ el, top: r.top, cx: r.left + r.width / 2 });
}
// 按纵向位置聚类成行(同一行输入框 top 相差 < 22px)
inputs.sort((a, b) => a.top - b.top || a.cx - b.cx);
const bands = [];
for (const it of inputs) {
const last = bands[bands.length - 1];
if (last && Math.abs(it.top - last.top) <= 22) last.items.push(it);
else bands.push({ top: it.top, items: [it] });
}
// 行内输入框归属到横向最近的列;≥2 个输入框才算明细行(排除「加班原因」等单控件区域)
const keys = Object.keys(colX);
const rows = [];
for (const band of bands) {
if (band.items.length < 2) continue;
const cells = {};
for (const it of band.items) {
let bk = null;
let bd = Infinity;
for (const k of keys) {
const d = Math.abs(it.cx - colX[k]);
if (d < bd) {
bd = d;
bk = k;
}
}
if (bk && !cells[bk]) cells[bk] = it.el;
}
if (cells.date || cells.start || cells.end || cells.dur) rows.push(cells);
}
if (!rows.length) return null;
return { mode: "geometry", rows };
}
// 定位「加班明细」表格:① 原生 table ② role=grid ③ 几何定位(北森弹窗实际生效的是这种)
// loose=true 时不过滤无 input 的行(用于数行数)
function findDetailGrid(loose) {
// ⓪ 北森 customTable 结构(抓表单报告确认的真实结构,最优先)
// 行 = .customContent .customFieldsTr;加班日期 = input.input-create-picker(antd 日历选择器)
// 开始/结束时间 = placeholder 命中的 readonly 输入框;加班时长 = .u-input_is-readonly 只读展示(系统自动算,无需填)
try {
const trs = document.querySelectorAll(".customContent .customFieldsTr");
if (trs.length) {
const rows = [];
for (const tr of trs) {
rows.push({
el: tr,
date: tr.querySelector("input.input-create-picker, .ant-calendar-picker input"),
start: tr.querySelector('input[placeholder="开始时间"], input[placeholder*="开始"]'),
end: tr.querySelector('input[placeholder="结束时间"], input[placeholder*="结束"]'),
dur: tr.querySelector(".u-input_is-readonly"),
note: tr.querySelector("input.u-input-noValue, input[placeholder='请输入']"),
});
}
if (rows.some((r) => r.date || r.start || r.end)) return { mode: "custom", rows };
}
} catch (_) {}
// ① 原生 table
for (const table of document.querySelectorAll("table")) {
const headRow = table.querySelector("thead tr");
if (!headRow) continue;
const ths = Array.from(headRow.querySelectorAll("th, td"));
const idx = { date: -1, start: -1, end: -1, dur: -1, note: -1 };
ths.forEach((th, i) => {
const h = (th.textContent || "").trim();
if (h.includes("日期")) idx.date = idx.date < 0 ? i : idx.date;
if (h.includes("开始")) idx.start = idx.start < 0 ? i : idx.start;
if (h.includes("结束")) idx.end = idx.end < 0 ? i : idx.end;
if (h.includes("时长")) idx.dur = idx.dur < 0 ? i : idx.dur;
if (h.includes("备注")) idx.note = idx.note < 0 ? i : idx.note;
});
if (idx.date < 0) continue;
let trs = Array.from(table.querySelectorAll("tbody tr"));
if (!loose) {
trs = trs.filter((tr) => tr.querySelector("input:not([type='hidden']), textarea"));
}
if (!trs.length) continue;
const rows = trs.map((tr) => {
const tds = Array.from(tr.children);
const pick = (i) => (i >= 0 && tds[i] ? tds[i].querySelector("input:not([type='hidden']), textarea") : null);
return { date: pick(idx.date), start: pick(idx.start), end: pick(idx.end), dur: pick(idx.dur), note: pick(idx.note) };
});
return { mode: "table", rows };
}
// ② role=grid 虚拟表格
const grids = document.querySelectorAll(
'[class*="fixedDataTableLayout_main"], [class*="dataGrid"], [role="grid"], [class*="react-datagrid"]'
);
for (const grid of grids) {
const headerRow = grid.querySelector('[role="row"]');
if (!headerRow) continue;
const headerCells = Array.from(headerRow.querySelectorAll('[role="columnheader"]'));
if (headerCells.length < 3) continue;
const idx = { date: -1, start: -1, end: -1, dur: -1, note: -1 };
headerCells.forEach((cell, i) => {
const h = (cell.textContent || "").trim();
if (h.includes("日期")) idx.date = idx.date < 0 ? i : idx.date;
if (h.includes("开始")) idx.start = idx.start < 0 ? i : idx.start;
if (h.includes("结束")) idx.end = idx.end < 0 ? i : idx.end;
if (h.includes("时长")) idx.dur = idx.dur < 0 ? i : idx.dur;
if (h.includes("备注")) idx.note = idx.note < 0 ? i : idx.note;
});
if (idx.date < 0) continue;
const rows = [];
for (const rowEl of grid.querySelectorAll('[role="row"]')) {
const ri = parseInt(rowEl.getAttribute("aria-rowindex") || "0", 10);
if (ri <= 1) continue;
const cells = Array.from(rowEl.querySelectorAll('[role="gridcell"]'));
if (!cells.length) continue;
if (!loose && !cells.some((c) => c.querySelector("input:not([type='hidden']), textarea"))) continue;
const pick = (i) => (i >= 0 && cells[i] ? cells[i].querySelector("input:not([type='hidden']), textarea") : null);
rows.push({ date: pick(idx.date), start: pick(idx.start), end: pick(idx.end), dur: pick(idx.dur), note: pick(idx.note) });
}
if (!rows.length) continue;
return { mode: "grid", rows };
}
// ③ 几何定位(关键:北森弹窗是纯 div 布局,无 table/grid 语义)
return findDetailGridByGeometry();
}
// 失败诊断:dump 可见输入框与关键文字元素(复制面板文字即可让助手定位真实结构)
function dumpFormDiagnostics() {
const lines = [];
try {
const inputs = [];
for (const el of document.querySelectorAll("input, textarea")) {
const ty = (el.getAttribute("type") || "").toLowerCase();
if (ty === "hidden") continue;
try {
const st = window.getComputedStyle(el);
if (st.display === "none" || st.visibility === "hidden") continue;
if (!el.getClientRects().length) continue;
} catch (_) {
continue;
}
inputs.push(el);
}
lines.push("可见输入框 " + inputs.length + " 个:");
inputs.slice(0, 30).forEach((el, i) => {
const r = el.getBoundingClientRect();
lines.push(
"#" + (i + 1) +
" <" + el.tagName.toLowerCase() + (el.readOnly ? " readonly" : "") + (el.disabled ? " disabled" : "") + ">" +
" 值=" + JSON.stringify((el.value || "").slice(0, 16)) +
" placeholder=" + JSON.stringify((el.placeholder || "").slice(0, 16)) +
" 位置 x=" + Math.round(r.left) + " y=" + Math.round(r.top)
);
});
if (inputs.length > 30) lines.push("…其余 " + (inputs.length - 30) + " 个省略");
} catch (_) {}
try {
lines.push("关键文字元素:");
for (const kw of ["加班明细", "加班日期", "开始时间", "结束时间", "加班时长", "备注", "添加时段", "加班原因"]) {
const el = findDeepestByText(kw, 4);
if (!el) {
lines.push("「" + kw + "」未找到");
continue;
}
const r = el.getBoundingClientRect();
lines.push(
"「" + kw + "」→ <" + el.tagName.toLowerCase() +
" class=" + JSON.stringify(String(el.className || "").slice(0, 36)) +
"> x=" + Math.round(r.left) + " y=" + Math.round(r.top)
);
}
} catch (_) {}
return lines.join("\n");
}
// 找「加班原因」等标签关联的控件(下拉/输入)
function findControlByLabel(pattern) {
const re = new RegExp(pattern);
const labels = document.querySelectorAll("label, [class*='label'], [class*='form-item']");
for (const lab of labels) {
const txt = (lab.textContent || "").trim().replace(/\s+/g, "");
if (!txt || txt.length > 30 || !re.test(txt)) continue;
const scope = lab.closest ? lab.closest('[class*="form-item"], [class*="field"], form, .form') : null;
const host = scope || lab.parentElement;
if (!host) continue;
const ctrl = host.querySelector("select, input:not([type='hidden']), textarea");
if (ctrl) return { el: ctrl, host };
}
return null;
}
// ===== 加班日期选择器自动化(抓表单报告确认:日期是 antd 日历选择器 input.input-create-picker,
// 直接写 value 会被 React 重置 —— 必须点开日历弹层点选日期格)=====
// 读日期控件当前值:input.value 优先,退回隐藏提示 div 的文本
function readDateValue(dateInput) {
const v = (dateInput.value || "").trim();
if (v) return v;
const wrap = dateInput.closest(".date-time__content") || dateInput.parentElement;
const tips = wrap ? wrap.querySelector(".date-time_is-hidden-tips") : null;
return tips ? (tips.textContent || "").trim() : "";
}
// 找当前打开的日历弹层(antd2/3 挂在 body 下:.ant-calendar-picker-container)
function findCalendarPopup() {
const cands = [];
try {
for (const el of document.querySelectorAll(
".ant-calendar-picker-container, .ant-calendar-popup, .ant-calendar, [class*='calendar-picker-container'], [class*='datepicker-container']"
)) {
const st = window.getComputedStyle(el);
if (st.display === "none" || st.visibility === "hidden" || st.opacity === "0") continue;
if (!el.getClientRects().length) continue;
cands.push(el);
}
} catch (_) {}
if (!cands.length) return null;
// 优先返回含日期格的容器(range 双面板时能覆盖两个面板)
return cands.find((el) => el.querySelector("td[title], .ant-calendar-date")) || cands[0];
}
// 读日历弹层当前显示的年月(antd:.ant-calendar-year-select / .ant-calendar-month-select,文本如 "2026年"/"8月")
function readCalendarMonth(popup) {
try {
const yEl = popup.querySelector(".ant-calendar-year-select");
const mEl = popup.querySelector(".ant-calendar-month-select");
if (yEl && mEl) {
const y = parseInt((yEl.textContent || "").replace(/[^0-9]/g, ""), 10);
const m = parseInt((mEl.textContent || "").replace(/[^0-9]/g, ""), 10);
if (y > 0 && m > 0) return { y, m };
}
const head = popup.querySelector(".ant-calendar-my-select, .ant-calendar-header, [class*='calendar-header'], [class*='datepicker-header']");
if (head) {
const ym = (head.textContent || "").match(/(\d{4})\D{0,3}(\d{1,2})/);
if (ym) return { y: +ym[1], m: +ym[2] };
}
} catch (_) {}
return null;
}
// 关闭日历弹层(点空白处 / Esc,最多试 3 轮)
async function closeCalendarPopup() {
for (let i = 0; i < 3 && findCalendarPopup(); i++) {
try {
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
document.body.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }));
document.body.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
} catch (_) {}
await sleep(150);
if (findCalendarPopup()) {
try {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", keyCode: 27, which: 27, bubbles: true }));
} catch (_) {}
await sleep(150);
}
}
}
// ===== 时间选择面板自动化(v2.3.0,用户回传面板 HTML 确认的真实结构) =====
// 开始/结束时间不是可直写的输入框,而是 ant-time-picker 弹层面板(与日期日历同套路):
// .ant-time-picker-panel.time-picker-component
// ├ .day-time-picker-content-day → 时段 ul.day-time-picker-during-ul > li.during-item-style(当日/后一日,选中类 during-select-item-style)
// └ .day-time-picker-content-time → .ant-time-picker-panel-combobox
// ├ 第1个 .ant-time-picker-panel-select:时 00~23(末项 "23")
// └ 第2个 .ant-time-picker-panel-select:分 00~59(末项 "59")
// 行内 input[placeholder=开始/结束时间] 只是触发器,直写 value 两种格式均被清(v2.1/v2.2 实测)。
function findTimePanel() {
try {
for (const el of document.querySelectorAll(".ant-time-picker-panel")) {
const st = window.getComputedStyle(el);
if (st.display === "none" || st.visibility === "hidden" || st.opacity === "0") continue;
if (!el.getClientRects().length) continue;
if (!el.querySelector(".ant-time-picker-panel-select")) continue; // 时/分列表必须已渲染
return el;
}
} catch (_) {}
return null;
}
// 关闭时间面板(面板输入框回车 / 点空白 / Esc,最多 3 轮)
async function closeTimePanel() {
for (let i = 0; i < 3 && findTimePanel(); i++) {
try {
const p = findTimePanel();
const inp = p.querySelector(".ant-time-picker-panel-input");
if (inp) {
inp.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", keyCode: 13, which: 13, bubbles: true }));
inp.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", keyCode: 13, which: 13, bubbles: true }));
}
} catch (_) {}
await sleep(160);
if (!findTimePanel()) return;
try {
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
document.body.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }));
document.body.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
} catch (_) {}
await sleep(160);
if (!findTimePanel()) return;
try {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", keyCode: 27, which: 27, bubbles: true }));
} catch (_) {}
await sleep(160);
}
}
// 用时间面板设置时间:点触发器开面板 → 选时段(当日/后一日) → 点时 → 点分 → 关面板 → 读回验证
// getTrigger:返回触发输入框的函数(每次现查,防 React 重渲染换节点)
async function setTimeByPicker(getTrigger, hm, period, log) {
const parts = String(hm).split(":");
const hh = String(parts[0] || "00").padStart(2, "0");
const mm = String(parts[1] || "00").padStart(2, "0");
const want = period === "后一日" ? "后一日" : "当日";
if (findTimePanel()) await closeTimePanel(); // 清残留
// 1. 点开面板(先点触发输入框,不行试点其父级容器)
let panel = null;
for (let t = 0; t < 8 && !panel; t++) {
const el = getTrigger();
if (!el) return { ok: false, val: "", why: "无控件" };
if (t === 3 && el.parentElement) dispatchClick(el.parentElement);
else dispatchClick(el);
await sleep(260);
panel = findTimePanel();
}
if (!panel) return { ok: false, val: "", why: "时间面板未弹出" };
// 2. 选时段(已选中则跳过)
try {
const items = Array.from(panel.querySelectorAll(".day-time-picker-during-ul .during-item-style"));
const li = items.find((x) => (x.textContent || "").trim() === want);
if (li && !/during-select-item-style/.test(String(li.className || ""))) {
dispatchClick(li);
await sleep(200);
}
} catch (_) {}
// 3. 点时、分(面板可能因重渲染换节点,每次重找;时列表末项=23,分列表末项=59)
const clickOpt = (sel, txt) => {
const li = Array.from(sel.querySelectorAll("ul li")).find((x) => (x.textContent || "").trim() === txt);
if (!li) return false;
dispatchClick(li);
return true;
};
let hourOk = false, minOk = false;
for (let t = 0; t < 5 && !(hourOk && minOk); t++) {
const p = findTimePanel() || panel;
if (!p) break;
const sels = Array.from(p.querySelectorAll(".ant-time-picker-panel-combobox .ant-time-picker-panel-select"));
let hourSel = null, minSel = null;
for (const s of sels) {
const lis = s.querySelectorAll("ul li");
const last = lis.length ? (lis[lis.length - 1].textContent || "").trim() : "";
if (last === "59") minSel = s;
else if (!hourSel) hourSel = s;
}
if (hourSel && !hourOk) hourOk = clickOpt(hourSel, hh);
if (minSel && !minOk) minOk = clickOpt(minSel, mm);
if (!hourOk || !minOk) await sleep(240);
}
if (!hourOk || !minOk) {
await closeTimePanel();
return { ok: false, val: "", why: "面板中未找到时/分选项(" + hh + ":" + mm + ")" };
}
await sleep(300); // 等值应用进 React 状态
// 4. 关面板
await closeTimePanel();
// 5. 读回验证
await sleep(250);
const el = getTrigger();
const v = el ? (el.value || "").trim() : "";
if (v && v.indexOf(hh + ":" + mm) >= 0) return { ok: true, val: v };
return { ok: false, val: v, why: "面板已点选但回读未含 " + hh + ":" + mm };
}
// 备路:打开面板后在面板自带输入框直填 + 回车
async function setTimeByTyping(getTrigger, hm, log) {
if (findTimePanel()) await closeTimePanel();
let panel = null;
for (let t = 0; t < 6 && !panel; t++) {
const el = getTrigger();
if (!el) return { ok: false, val: "", why: "无控件" };
dispatchClick(el);
await sleep(260);
panel = findTimePanel();
}
if (!panel) return { ok: false, val: "", why: "面板未弹出" };
const inp = panel.querySelector(".ant-time-picker-panel-input");
if (!inp) {
await closeTimePanel();
return { ok: false, val: "", why: "面板无输入框" };
}
try { inp.focus(); } catch (_) {}
setNativeValue(inp, hm);
try {
inp.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", keyCode: 13, which: 13, bubbles: true }));
inp.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", keyCode: 13, which: 13, bubbles: true }));
} catch (_) {}
await sleep(300);
await closeTimePanel();
await sleep(200);
const el = getTrigger();
const v = el ? (el.value || "").trim() : "";
if (v && v.indexOf(hm) >= 0) return { ok: true, val: v };
return { ok: false, val: v, why: "面板输入回车后未生效" };
}
// 用日历选择器设置日期:点开 → 翻月 → 点日期格 → 兜底「确定」/二次点击 → 验证读回
async function setDateByPicker(dateInput, targetDate, log) {
const target = String(targetDate || "").trim();
if (!target) return { ok: false, val: "", why: "无目标日期" };
if (findCalendarPopup()) await closeCalendarPopup(); // 清掉可能残留的弹层
if (readDateValue(dateInput) === target) return { ok: true, val: target, how: "已一致" };
// 1. 点开日历
dispatchClick(dateInput);
await sleep(450);
let popup = findCalendarPopup();
if (!popup) {
const wrap = dateInput.closest(".ant-calendar-picker") || dateInput.parentElement;
const icon = wrap ? wrap.querySelector(".ant-calendar-picker-icon, [class*='rili']") : null;
if (icon) {
dispatchClick(icon);
await sleep(450);
popup = findCalendarPopup();
}
}
if (!popup) {
// 弹层没出现:退回直接写入(碰运气,多数会被重置,但保留一条路)
setNativeValue(dateInput, target);
await sleep(250);
const v = readDateValue(dateInput);
return { ok: v === target, val: v, why: v === target ? "" : "日历未弹出且直写被重置" };
}
// 2. 翻到目标月份
const ty = parseInt(target.slice(0, 4), 10);
const tm = parseInt(target.slice(5, 7), 10);
for (let i = 0; i < 36; i++) {
const cur = readCalendarMonth(popup);
if (!cur) break;
if (cur.y === ty && cur.m === tm) break;
const goPrev = cur.y > ty || (cur.y === ty && cur.m > tm);
const btn = goPrev
? popup.querySelector(".ant-calendar-prev-month-btn, [class*='prev-month']")
: popup.querySelector(".ant-calendar-next-month-btn, [class*='next-month']");
if (!btn) break;
dispatchClick(btn);
await sleep(220);
}
// 3. 点目标日期格(antd 的 td 带 title="YYYY-MM-DD",内部 .ant-calendar-date 是日期数字)
let cell =
popup.querySelector('td[title="' + target + '"] .ant-calendar-date') ||
popup.querySelector('td[title="' + target + '"]') ||
popup.querySelector('[title="' + target + '"]');
if (!cell) {
const cur = readCalendarMonth(popup);
if (cur && cur.y === ty && cur.m === tm) {
const day = String(parseInt(target.slice(8, 10), 10));
for (const el of popup.querySelectorAll(".ant-calendar-date, [class*='calendar-date'], [class*='day']")) {
if ((el.textContent || "").trim() === day && el.getClientRects().length) {
cell = el;
break;
}
}
}
}
if (!cell) {
await closeCalendarPopup();
return { ok: false, val: readDateValue(dateInput), why: "日历中未找到该日期格" };
}
const cellTd = cell.closest ? cell.closest("td") : null;
const disabled =
(cell.classList && cell.classList.contains("ant-calendar-disabled-cell")) ||
(cellTd && cellTd.classList && cellTd.classList.contains("ant-calendar-disabled-cell"));
if (disabled) {
await closeCalendarPopup();
return { ok: false, val: readDateValue(dateInput), why: "该日期被禁用不可选" };
}
dispatchClick(cell);
await sleep(380);
// 4. 弹层还开着:日期时间选择器要点「确定」,区间选择器要再点一次同一天
if (findCalendarPopup()) {
const okBtn =
popup.querySelector(".ant-calendar-ok-btn:not(.ant-calendar-ok-btn-disabled)") ||
Array.from(popup.querySelectorAll("button, [role='button'], a, span")).find(
(el) => (el.textContent || "").trim() === "确定" && el.getClientRects().length
);
if (okBtn) {
dispatchClick(okBtn);
await sleep(300);
}
if (findCalendarPopup()) {
dispatchClick(cell);
await sleep(300);
}
await closeCalendarPopup();
}
const v = readDateValue(dateInput);
return { ok: v === target, val: v, why: v === target ? "" : "点选后未生效" };
}
// 主流程:补行 + 填充
async function fillOvertimeForm() {
// 所有日志只进控制台(F12);面板消息保持一句话结果,诊断细节看控制台
const log = (m) => console.log("[iTalent助手] " + m);
const otDays = Array.from(collectedMap.values())
.filter((r) => r && r.is_overtime)
.sort((a, b) => (a.date || "").localeCompare(b.date || ""));
if (!otDays.length) {
log("没有可用加班记录:请先在考勤页点「解析页面」采集(数据已自动缓存,可直接过来填充)");
return { ok: false, msg: "没有可用的加班记录" };
}
log("加班记录 " + otDays.length + " 天:" + otDays.map((r) => r.date).join(", "));
const addBtn = findBtnByText("添加时段");
log("「添加时段」按钮:" + (addBtn ? "已找到" : "未找到"));
// 三策略定位(含几何定位:北森弹窗是纯 div 布局)。
// 注意:定位不到也不再提前退出——按钮在就先按次数补行,补完再检测(之前"只加一行"就是提前退出导致的)
let grid = findDetailGrid();
log("加班明细表格:" + (grid ? "已找到(" + grid.mode + "),当前 " + grid.rows.length + " 行" : "未找到(按默认 1 行计算,补行后再检测)"));
const need = otDays.length;
// 宽松统计当前实际行数:新加的行即使控件还没渲染也能数到(不过滤无 input 的行)
const countNow = () => {
const g = findDetailGrid(true);
return g ? g.rows.length : 0;
};
let cur = countNow();
if (!cur && addBtn) cur = 1; // 检测不到行时按弹窗默认 1 行估算(弹窗自带一行当天日期)
log("【阶段1/3】补行:当前 " + cur + " 行,目标 " + need + " 行");
const clicks = Math.max(0, need - cur);
if (clicks === 0) {
log("行数已足够(" + cur + " ≥ " + need + "),跳过补行");
} else {
log("点击「添加时段」" + clicks + " 次(优先真实按钮、过滤隐藏模板、每次重新查找)");
let clicked = 0;
for (let c = 0; c < clicks; c++) {
// 每次点击前重新查找:React 重渲染会替换 DOM 节点;找不到则重试 3 次(按钮可能短暂消失)
let btn = null;
for (let r = 0; r < 3 && !btn; r++) {
btn = findBtnByText("添加时段", true);
if (!btn) await sleep(300);
}
if (!btn) {
log("第 " + (c + 1) + " 次:始终找不到「添加时段」按钮(已点 " + clicked + " 次),补行中止");
break;
}
dispatchClick(btn);
clicked++;
log("已点第 " + clicked + " 次 → <" + btn.tagName.toLowerCase() + " class='" + (typeof btn.className === "string" ? btn.className : "") + "'> 可见=" + (btn.getClientRects().length > 0));
await sleep(500); // 固定节奏等待 React 渲染,不做行数判断(判断不可靠)
const after = countNow();
if (after > cur) cur = after;
}
log("已点击「添加时段」" + clicked + " 次");
}
cur = countNow();
log("【阶段1/3】补行结束:实际 " + cur + " 行(目标 " + need + " 行)");
if (cur < need) {
log("提示:行数未达目标。若反复如此,请点「抓表单」把弹窗结构发给助手精调。");
}
// 【阶段2/3】填日期:日历选择器逐行点选(直接写 value 会被 React 重置,必须走弹层)
log("【阶段2/3】逐行设置加班日期(日历会依次弹出点选,属正常现象,请勿中途点击页面)…");
const getRows = () => {
const g = findDetailGrid();
return g ? g.rows : [];
};
let rows = getRows();
let dateWait = 0;
while (rows.length < Math.min(need, cur) && dateWait++ < 10) {
await sleep(300);
rows = getRows();
}
if (!rows.length) {
log("补行后仍无法定位明细输入框。结构诊断:\n" + dumpFormDiagnostics());
return {
ok: false,
msg: "已点「添加时段」补行,但无法定位明细输入框。请点「抓表单」把弹窗结构发给助手,或查看控制台(F12)详细诊断。",
};
}
log("填充目标:" + Math.min(rows.length, need) + " 行(检测到 " + rows.length + " 行)");
const filled = [];
for (let i = 0; i < need; i++) {
const ot = otDays[i];
const row = getRows()[i] || {};
const report = { date: ot.date };
if (!row.date) {
report.日期 = "❌无日期控件";
} else {
const r = await setDateByPicker(row.date, ot.date, log);
report.日期 = r.ok ? "OK(" + r.val + ")" : "❌" + (r.why || "未生效") + (r.val ? "(现值" + r.val + ")" : "");
if (!r.ok) log("第 " + (i + 1) + " 行日期失败:" + (r.why || "") + "(目标 " + ot.date + ",现值 " + (r.val || "空") + ")");
}
filled.push(report);
await sleep(250); // 等系统按新日期重置该行默认时间
}
// 【阶段3/3】填时间:结束=末次打卡;开始=保留系统默认(工作日17:30/休息日08:30),休息日改首次打卡
// v2.3.0 真相(用户回传面板 HTML):开始/结束时间是 ant-time-picker 弹层面板——
// 时段(当日/后一日) + 时列表 + 分列表;行内 input 只是触发器,直写必被清(v2.1/v2.2 两种格式实测全灭)。
// 与日期日历同套路:点开面板 → 选时段 → 点时 → 点分 → 关面板 → 读回验证。
log("等待系统按新日期重置各行默认时间(约1.5秒)…");
await sleep(1500);
log("【阶段3/3】逐行设置开始/结束时间(时间面板会依次弹出点选,属正常现象,请勿中途点击页面)…");
// 写一个时间单元格:①面板点选(主路)②面板内输入框直填+回车(备路)③触发器直写(末路,基本会被清,留作诊断)
const writeTimeCell = async (i, key, hm, period) => {
const getTrigger = () => (getRows()[i] || {})[key];
const name = key === "end" ? "结束" : "开始";
const r = await setTimeByPicker(getTrigger, hm, period, log);
if (r.ok) return { ok: true, back: r.val };
log("第 " + (i + 1) + " 行" + name + "时间面板点选未成功:" + (r.why || "") + ",尝试面板输入框直填…");
const r2 = await setTimeByTyping(getTrigger, hm, log);
if (r2.ok) return { ok: true, back: r2.val };
const el = getTrigger();
if (el) {
setNativeValue(el, hm);
await sleep(350);
const el2 = getTrigger();
const back = el2 ? (el2.value || "").trim() : "";
if (back) return { ok: true, back };
}
return { ok: false, back: (r.why || "") + (r2.why ? "/" + r2.why : "") };
};
for (let i = 0; i < need; i++) {
const ot = otDays[i];
const report = filled[i];
const isRestDay = /非工作日|调休/.test(ot.date_type || "");
const startHM = isRestDay ? extractTime(ot.first_card) || "08:30" : "17:30";
// 结束时间 ← 当天末次打卡;早于开始时间(跨零点加班)选「后一日」
const ev = extractTime(ot.last_card);
if (!ev) report.结束 = ot.last_card ? "❌打卡时间解析失败" : "❌无末次打卡";
else {
const period = ev <= startHM ? "后一日" : "当日";
const r = await writeTimeCell(i, "end", ev, period);
report.结束 = r.ok ? "OK(" + r.back + ")" : "❌" + (r.back || "写入失败");
}
// 开始时间:休息日改为首次打卡;工作日保留系统默认(空了补 17:30)
const sv = (((getRows()[i] || {}).start || {}).value || "").trim();
if (isRestDay) {
const r = await writeTimeCell(i, "start", startHM, "当日");
report.开始 = r.ok ? "OK(" + r.back + ")" : "❌" + (r.back || "写入失败");
} else if (sv) report.开始 = "保留" + sv;
else {
const r = await writeTimeCell(i, "start", "17:30", "当日");
report.开始 = r.ok ? "OK(" + r.back + ")" : "❌" + (r.back || "写入失败");
}
await sleep(150);
}
// 读回核对 + 自动补救(最多 3 轮):捕捉"写入后又被异步副作用清空"的行,重写后再核对
for (let round = 1; round <= 3; round++) {
await sleep(round === 1 ? 900 : 800);
log(round === 1 ? "读回核对…" : "补救后第 " + round + " 轮读回核对…");
const broken = []; // { i, what: "end"|"start" }
for (let i = 0; i < need; i++) {
const ot = otDays[i];
const row = getRows()[i] || {};
const report = filled[i];
if (row.date) {
const dv = readDateValue(row.date);
report.日期 = dv === ot.date ? "OK(" + dv + ")" : "⚠️回读=" + (dv || "空");
}
if (row.start) {
const sv2 = (row.start.value || "").trim();
report.开始 = sv2 ? "OK(" + sv2 + ")" : "⚠️被重置为空";
if (!sv2) broken.push({ i, what: "start" });
}
if (row.end) {
const ev2 = (row.end.value || "").trim();
report.结束 = ev2 ? "OK(" + ev2 + ")" : "⚠️被重置为空";
if (!ev2) broken.push({ i, what: "end" });
}
if (row.dur) {
const dv = (row.dur.textContent || "").trim();
report.时长 = dv && dv !== "请输入" ? "自动(" + dv + ")" : "待算";
} else report.时长 = "自动";
if (row.note) report.备注 = "留空(手动填)";
}
if (!broken.length) break; // 全部通过
if (round < 3) {
log("检测到 " + broken.length + " 处时间被重置,自动重写(第 " + round + " 轮补救)…");
for (const b of broken) {
const ot = otDays[b.i];
const isRest = /非工作日|调休/.test(ot.date_type || "");
const sh = isRest ? extractTime(ot.first_card) || "08:30" : "17:30";
if (b.what === "end") {
const ev3 = extractTime(ot.last_card);
if (ev3) await writeTimeCell(b.i, "end", ev3, ev3 <= sh ? "后一日" : "当日");
} else {
await writeTimeCell(b.i, "start", sh, "当日");
}
}
}
}
console.table(filled);
// 加班原因:若为原生 select 且有匹配选项则自动选,否则提示手动选
let reasonMsg = "";
try {
const rc = findControlByLabel("加班原因|加班事由|加班类型");
if (rc && rc.el.tagName === "SELECT") {
const opts = Array.from(rc.el.options || []).map((o) => (o.textContent || "").trim());
const pick = opts.find((t) => /加班/.test(t) && t.length < 12);
if (pick) {
setNativeValue(rc.el, pick);
reasonMsg = ",加班原因已选「" + pick + "」";
}
}
} catch (_) {}
if (!reasonMsg) reasonMsg = ",加班原因请手动填写";
// 汇总每列成功/失败数,便于定位(msg 会显示到顶层面板)
const stat = { 日期: 0, 开始: 0, 结束: 0, 时长: 0 };
for (const r of filled) {
const dv = r.日期 || "";
const sv = r.开始 || "";
const ev = r.结束 || "";
const uv = r.时长 || "";
if (dv.startsWith("OK")) stat.日期++;
if (sv.startsWith("OK") || sv.startsWith("保留")) stat.开始++;
if (ev.startsWith("OK")) stat.结束++;
if (uv.startsWith("自动")) stat.时长++;
}
const statStr = Object.keys(stat)
.map((k) => k + " " + stat[k] + "/" + filled.length)
.join(",");
// 有未成功的字段时,面板只加一句提示,明细看控制台(console.table)
const hasFail = filled.some((r) => {
const s = JSON.stringify(r);
return s.indexOf("❌") >= 0 || s.indexOf("⚠️") >= 0 || s.indexOf("被重置") >= 0;
});
return {
ok: true,
msg:
"已填充 " + filled.length + " 行(" + statStr + ")。请核对后再提交" + reasonMsg +
(hasFail ? "\n⚠️ 部分字段未成功,请查看控制台(F12)的明细日志。" : ""),
};
}
if (IS_TOP_FRAME) {
// 顶层:接收 iframe 上报的记录,合并进累积 Map
window.addEventListener("message", (e) => {
try {
const d = e.data;
if (!d || d.type !== "ITALENT_OT_RECORDS" || !Array.isArray(d.records)) return;
if (!d.records.length) return;
mergeRecords(d.records, collectedMap);
renderCollected("iframe 考勤模块");
} catch (_) {}
});
// 顶层:接收弹窗 iframe 上报的表单结构报告并下载(跨域 iframe 内下载可能被拦,这里兜底)
window.addEventListener("message", (e) => {
try {
const d = e.data;
if (!d || d.type !== "ITALENT_OT_FORM_REPORT" || typeof d.report !== "string") return;
downloadReport(d.report);
setStatus("已收到弹窗表单结构报告并下载,请把该 txt 文件发给助手分析", "ok");
} catch (_) {}
});
// 顶层:接收弹窗 iframe 填充完成回执
window.addEventListener("message", (e) => {
try {
const d = e.data;
if (!d || d.type !== "ITALENT_OT_FILL_DONE") return;
setStatus((d.ok ? "填充完成:" : "填充失败:") + (d.msg || ""), d.ok ? "ok" : "err");
} catch (_) {}
});
// 启动诊断:确认脚本注入状态(帮助排查「按钮不出现」问题)
console.log(
"[iTalent助手] v2.4.0 已注入:frame=顶层, url=" + location.href + ", 缓存记录=" + collectedMap.size
);
} else {
// iframe 内:只响应顶层的扫描请求(按钮触发),不自动上报
console.log(
"[iTalent助手] v2.4.0 已注入:frame=iframe, url=" + location.href
);
window.addEventListener("message", (e) => {
const d = e.data;
if (!d) return;
if (d.type === "ITALENT_OT_SCAN_REQUEST") {
try {
const records = parsePageTable();
if (records && records.length) {
window.parent.postMessage({ type: "ITALENT_OT_RECORDS", records }, "*");
}
} catch (_) {}
}
// 顶层兜底按钮/油猴菜单触发:在弹窗 iframe 内执行自动填充
if (d.type === "ITALENT_OT_FILL_REQUEST") {
try {
fillOvertimeForm().then((r) => {
console.log("[iTalent助手] " + r.msg);
try {
window.parent.postMessage({ type: "ITALENT_OT_FILL_DONE", ok: r.ok, msg: r.msg }, "*");
} catch (_) {}
});
} catch (e) {
console.warn("[iTalent助手] 填充失败:", e);
}
}
// 油猴菜单触发:在弹窗 iframe 内导出表单结构(复制 + 下载 + 上报顶层兜底)
if (d.type === "ITALENT_OT_FORM_CAPTURE_REQUEST") {
try {
captureFormToOutput();
} catch (e) {
console.warn("[iTalent助手] 抓表单失败:", e);
}
}
});
// iframe 内:不再挂任何悬浮按钮(界面干净),只恢复考勤缓存供填充使用
const mountTools = () => {
loadPersisted(); // 恢复考勤缓存(GM 存储跨域共享,弹窗 iframe 可读到考勤页采集的数据)
};
if (document.body) mountTools();
else window.addEventListener("DOMContentLoaded", mountTools);
}
// ============================================================
// 7. CSV 导出
// ============================================================
function exportCSV() {
if (!lastRecords.length) {
setStatus("还没有数据可导出,请先点「解析页面」", "warn");
return;
}
const rows = [];
const head = ["日期", "类型", "班次", "首次打卡", "末次打卡", "工作时长(h)", "加班时长(h)", "是否加班", "考勤状态"];
rows.push(head.join(","));
for (const r of lastRecords) {
rows.push([
r.date || r.date_text || "",
r.date_type || "",
(r.shift || "").replace(/,/g, ","),
r.first_card || "",
r.last_card || "",
r.work_hours ? r.work_hours.toFixed(2) : "",
r.is_overtime ? "+" + r.overtime_hours : (r.work_hours > 0 ? "0" : ""),
r.is_overtime ? "是" : (r.work_hours > 0 ? "否" : ""),
(r.attendance_detail || r.attendance_status || "").replace(/,/g, ","),
].join(","));
}
const csv = "\uFEFF" + rows.join("\r\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `考勤数据_${new Date().toISOString().slice(0, 7)}.csv`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(a.href);
a.remove();
}, 500);
}
// ============================================================
// 8. 启动(只在顶层窗口建面板;无任何自动监听/拦截)
// ============================================================
function init() {
if (document.getElementById("italent-ot-helper")) return;
if (!IS_TOP_FRAME) return;
try {
createUI();
loadPersisted(); // 恢复缓存(考勤数据跨页面/刷新保留)
} catch (e) {
console.warn("[iTalent助手] 面板创建失败:", e);
}
}
if (document.body) {
init();
} else {
window.addEventListener("DOMContentLoaded", init);
}
})();