// ==UserScript== // @name iTalent 考勤加班助手 // @namespace italent-attendance-helper // @version 2.4.0 // @description 北森(iTalent)考勤数据解析 + 加班识别 + CSV 导出 + 加班申请自动填充。考勤数据经油猴本地存储跨页面/刷新保留;「批量加班」弹窗不再注入悬浮按钮,改由油猴菜单触发:菜单「填充加班」/「导出弹窗表单结构」经 postMessage 广播给弹窗 iframe 执行。填充逻辑不变:按真实结构(.customFieldsTr)定位明细行,加班日期走日历弹层点选、开始/结束时间走时间面板点选(时段+时+分),时长由系统自动计算。填充完成面板消息精简为一行结果,诊断日志进控制台(F12)。只在点「解析页面」时采集,不自动同步、不拦截请求。 // @author WorkBuddy // @match https://*.italent.cn/* // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_setClipboard // @grant GM_setValue // @grant GM_getValue // @run-at document-idle // @license MIT // ==/UserScript== (function () { "use strict"; // ============================================================ // 0. 配置(写死,无需设置界面) // ============================================================ const STANDARD_HOURS = 8.0; // 标准工时(h) const OT_THRESHOLD = 0.5; // 工作时长超过标准工时该值(h)才计为加班 // ============================================================ // 1. 数据解析 + 加班识别(与 Python 验证过的逻辑一致) // ============================================================ function parseRecords(data) { const records = []; for (const rec of data.biz_data || []) { const g = (key, field = "text") => { const v = rec[key]; if (v === null || v === undefined) return ""; return typeof v === "object" ? v[field] ?? "" : String(v); }; records.push({ id: g("_id", "value"), date: g("SwipingCardDate", "value"), date_text: g("SwipingCardDate"), date_type: resolveDateType(g("SwipingCardDate", "value"), false, g("DateType")), shift: g("OIdWorkShift"), first_card: g("ActualForFirstCard"), last_card: g("ActualForLastCard"), work_hours: parseFloat(g("WorkPeriodExcludeRestTime")) || 0, absence_min: g("AbsenceDuration"), attendance_status: g("AttendanceStatus", "value"), attendance_detail: g("AttendanceStatusDetail", "value"), }); } return records; } function detectOvertime(records) { for (const rec of records) { // 类型可能带标注:按实际安排判断(调休→休息日,补班→工作日),见 isWorkdayType const isWorkDay = isWorkdayType(rec.date_type); if (!isWorkDay) { // 非工作日(周末/节假日/调休):只要来上班了,全部工时都是加班 const ot = +rec.work_hours.toFixed(2); rec.overtime_hours = ot; rec.is_overtime = ot >= OT_THRESHOLD; continue; } const ot = +(rec.work_hours - STANDARD_HOURS).toFixed(2); rec.overtime_hours = ot; rec.is_overtime = ot >= OT_THRESHOLD; } return records; } function summarize(records) { // 工作日数按实际安排统计("非工作日(国家补班)"算工作日,"工作日(国家调休)"不算) const workDays = records.filter((r) => isWorkdayType(r.date_type)); const otDays = records.filter((r) => r.is_overtime); return { total: records.length, workDays: workDays.length, otDays: otDays.length, totalOtHours: +otDays.reduce((s, r) => s + r.overtime_hours, 0).toFixed(2), otDates: otDays.map((r) => r.date), }; } function processAttendanceData(data) { const records = detectOvertime(parseRecords(data)); const summary = summarize(records); return { records, summary }; } // ============================================================ // 2. UI(悬浮面板,注入北森页面右下角) // ============================================================ GM_addStyle(` #italent-ot-helper { position:fixed; right:20px; bottom:20px; width:520px; max-height:72vh; background:#fff; border:1px solid #e3e8ef; border-radius:12px; z-index:2147483647; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; color:#1f2329; box-shadow:0 8px 32px rgba(31,35,41,.16); display:flex; flex-direction:column; overflow:hidden; } #italent-ot-helper * { box-sizing:border-box; margin:0; padding:0; } #italent-ot-helper .ot-header { display:flex; align-items:center; justify-content:space-between; padding:10px 14px; background:#2b6cf6; color:#fff; cursor:move; user-select:none; flex:0 0 auto; } #italent-ot-helper .ot-brand { font-weight:600; font-size:14px; display:flex; align-items:center; gap:6px; } #italent-ot-helper .ot-min-btn { border:none; background:rgba(255,255,255,.18); color:#fff; border-radius:6px; width:26px; height:26px; cursor:pointer; font-size:14px; line-height:1; } #italent-ot-helper .ot-min-btn:hover { background:rgba(255,255,255,.32); } #italent-ot-helper .ot-body { padding:12px 14px; overflow-y:auto; flex:1 1 auto; min-height:0; } #italent-ot-helper .ot-toolbar { display:flex; gap:8px; align-items:center; margin-bottom:10px; } #italent-ot-helper .ot-btn { padding:6px 14px; border:none; border-radius:6px; cursor:pointer; font-size:13px; font-weight:500; background:#2b6cf6; color:#fff; } #italent-ot-helper .ot-btn:hover { opacity:.9; } #italent-ot-helper .ot-btn:disabled { opacity:.45; cursor:not-allowed; } #italent-ot-helper .ot-btn.ghost { background:#fff; color:#2b6cf6; border:1px solid #2b6cf6; } #italent-ot-helper .ot-btn.danger { background:#fff; color:#e5484d; border:1px solid #e5484d; } #italent-ot-helper .ot-status { font-size:12px; color:#5f6b7a; min-height:18px; margin-bottom:8px; } #italent-ot-helper .ot-status.ok { color:#0e8345; } #italent-ot-helper .ot-status.err { color:#e5484d; } #italent-ot-helper .ot-status.warn { color:#b7791f; } #italent-ot-helper .ot-summary { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px; } #italent-ot-helper .ot-sum-item { flex:1; min-width:90px; background:#f5f7fa; border:1px solid #e6eaf0; border-radius:8px; padding:8px 10px; text-align:center; } #italent-ot-helper .ot-sum-item .v { font-size:18px; font-weight:700; color:#2b6cf6; } #italent-ot-helper .ot-sum-item .l { font-size:11px; color:#5f6b7a; margin-top:2px; } #italent-ot-helper .ot-sum-item.ot .v { color:#e5484d; } #italent-ot-helper .ot-tbl-wrap { max-height:34vh; overflow:auto; border:1px solid #e6eaf0; border-radius:8px; } #italent-ot-helper table { width:100%; border-collapse:collapse; font-size:12px; white-space:nowrap; } #italent-ot-helper th { position:sticky; top:0; background:#f5f7fa; color:#4b5563; font-weight:600; padding:7px 8px; text-align:left; border-bottom:1px solid #e6eaf0; z-index:1; } #italent-ot-helper td { padding:6px 8px; border-bottom:1px solid #f0f2f5; color:#1f2329; } #italent-ot-helper tr.ot-row { background:#fff1f0; } #italent-ot-helper tr.ot-row td:first-child { color:#e5484d; font-weight:600; } #italent-ot-helper tr:hover td { background:#f0f6ff; } #italent-ot-helper .ot-tip { font-size:11px; color:#8a94a6; margin-top:8px; line-height:1.6; } #italent-ot-helper.minimized { width:56px; height:56px; border-radius:28px; cursor:pointer; overflow:hidden; } #italent-ot-helper.minimized .ot-body { display:none; } #italent-ot-helper.minimized .ot-header { height:100%; border-radius:28px; cursor:pointer; } #italent-ot-helper.minimized .ot-brand span.txt { display:none; } #italent-ot-helper.minimized .ot-brand { font-size:20px; } #italent-ot-helper.minimized .ot-min-btn { display:none; } `); let helperRoot = null; let dragState = null; function $id(id) { return helperRoot.querySelector(`#${id}`); } function createUI() { helperRoot = document.createElement("div"); helperRoot.id = "italent-ot-helper"; helperRoot.innerHTML = `
iTalent 考勤加班助手
就绪。打开北森「我的考勤」列表页后点「解析页面」,脚本会自动滚动表格采集全部数据并累积保存;翻页/换月后再点可继续累积。
日期类型班次首次打卡末次打卡 工作时长(h)加班(h)考勤状态
数据只从当前页面读取,不上传任何服务器。点「解析页面」才会采集(平时完全不动);采集到的记录按日期累积合并,翻页/换月后再点一次即可继续累加。加班识别:工作日工作时长超过 ${STANDARD_HOURS}h + ${OT_THRESHOLD}h 判定为加班;非工作日(周末/节假日)来上班即全部计为加班。工作日/非工作日按日历显示原本类型,国家安排在括号标注:「工作日(国家调休)」=原本工作日被调成休息日(来上班全部计加班),「非工作日(国家补班)」=原本周末被调成上班日(超过 ${STANDARD_HOURS}h + ${OT_THRESHOLD}h 计加班)。
`; document.body.appendChild(helperRoot); // 最小化 $id("ot-min-btn").addEventListener("click", (e) => { e.stopPropagation(); helperRoot.classList.add("minimized"); }); $id("ot-header").addEventListener("click", () => { if (helperRoot.classList.contains("minimized")) { helperRoot.classList.remove("minimized"); } }); // 拖拽 const header = $id("ot-header"); header.addEventListener("mousedown", (e) => { if (helperRoot.classList.contains("minimized")) return; if (e.target.closest("button")) return; dragState = { sx: e.clientX, sy: e.clientY, ox: helperRoot.offsetLeft, oy: helperRoot.offsetTop }; e.preventDefault(); }); document.addEventListener("mousemove", (e) => { if (!dragState) return; helperRoot.style.right = "auto"; helperRoot.style.bottom = "auto"; helperRoot.style.left = dragState.ox + (e.clientX - dragState.sx) + "px"; helperRoot.style.top = dragState.oy + (e.clientY - dragState.sy) + "px"; }); document.addEventListener("mouseup", () => { dragState = null; }); // 解析当前页面(唯一采集入口:按钮触发) $id("ot-page").addEventListener("click", async () => { const btn = $id("ot-page"); if (btn.disabled) return; btn.disabled = true; try { setStatus("正在解析页面…"); const n = await scanAndParsePage(); if (n === 0) { // 顶层没找到表格 → 请求 iframe 内的脚本实例解析上报(考勤模块可能在 iframe 里) broadcastScanRequest(); setStatus("正在扫描页面与考勤模块(iframe)…"); await sleep(1500); if (!collectedMap.size) { setStatus( "当前页面没找到考勤表格。请打开北森「我的考勤」列表页(能看到打卡记录表格的位置)后再点「解析页面」", "err" ); } } } catch (e) { setStatus("解析出错:" + ((e && e.message) || e), "err"); } finally { btn.disabled = false; } }); // 清空累积数据 $id("ot-clear").addEventListener("click", () => { collectedMap.clear(); lastRecords = []; $id("ot-summary").style.display = "none"; renderTable([]); setStatus("已清空全部累积记录。点「解析页面」重新采集。"); }); // 导出 $id("ot-export").addEventListener("click", exportCSV); // 兜底入口:顶层点「填充加班」→ 广播给所有 iframe(弹窗内脚本收到后执行填充) $id("ot-fill-top").addEventListener("click", async () => { const btn = $id("ot-fill-top"); if (btn.disabled) return; if (!collectedMap.size) { setStatus("还没有考勤数据。请先在「我的考勤」页点「解析页面」采集数据,再来填充。", "warn"); return; } btn.disabled = true; try { setStatus("已通知加班弹窗自动填充…(若弹窗已打开,请稍候,结果将显示在状态栏)"); broadcastMessage({ type: "ITALENT_OT_FILL_REQUEST" }); await sleep(2500); setStatus("已发送填充指令。请查看加班弹窗:应已自动填写,核对后提交。"); } finally { btn.disabled = false; } }); // 油猴菜单 try { GM_registerMenuCommand("解析考勤页面", () => { $id("ot-page").click(); }); GM_registerMenuCommand("显示/隐藏面板", () => { helperRoot.classList.toggle("minimized"); }); // 弹窗内不再挂悬浮按钮,改用油猴菜单触发(浏览器级菜单,模态弹窗也点得到) GM_registerMenuCommand("填充加班(需先打开加班弹窗)", () => { $id("ot-fill-top").click(); }); GM_registerMenuCommand("导出弹窗表单结构(抓表单)", () => { broadcastMessage({ type: "ITALENT_OT_FORM_CAPTURE_REQUEST" }); setStatus("已请求弹窗 iframe 导出表单结构…(若弹窗已打开,稍候会下载 txt / 已复制到剪贴板)"); }); } catch (_) {} } function setStatus(text, cls) { const el = $id("ot-status"); el.textContent = text; el.className = "ot-status" + (cls ? " " + cls : ""); } function setSummary(summary) { const wrap = $id("ot-summary"); wrap.style.display = "flex"; wrap.innerHTML = `
${summary.total}
记录数
${summary.workDays}
工作日
${summary.otDays}
加班天数
${summary.totalOtHours}
加班时长(h)
`; } function renderTable(records) { const tbody = $id("ot-table").querySelector("tbody"); tbody.innerHTML = ""; if (!records.length) { const tr = document.createElement("tr"); tr.innerHTML = `暂无数据,点「解析页面」采集`; tbody.appendChild(tr); return; } for (const r of records) { const tr = document.createElement("tr"); if (r.is_overtime) tr.className = "ot-row"; const otTxt = r.is_overtime ? `+${r.overtime_hours}` : "—"; tr.innerHTML = ` ${r.date || r.date_text || ""} ${r.date_type || ""} ${r.shift || ""} ${r.first_card || ""} ${r.last_card || ""} ${r.work_hours ? r.work_hours.toFixed(2) : ""} ${otTxt} ${r.attendance_detail || r.attendance_status || ""}`; tbody.appendChild(tr); } } // ============================================================ // 3. 页面表格解析 —— 直接从北森页面上已渲染的考勤表格读取数据 // ============================================================ const COL_RULES = { date: { patterns: [/日期/, /Date/i, /刷卡日/] }, date_type: { patterns: [/类型/, /Type/i] }, shift: { patterns: [/班次/, /Shift/i] }, first_card: { patterns: [/首次打卡/, /上班打卡/, /签到/, /First/i] }, last_card: { patterns: [/末次打卡/, /下班打卡/, /签退/, /Last/i] }, work_hours: { patterns: [/工作时长/, /实际工时/, /出勤时长/, /工时/, /WorkPeriod/i] }, attendance_status: { patterns: [/考勤状态/, /出勤状态/, /状态/, /Status/i] }, }; function pad2(n) { return String(n).padStart(2, "0"); } // "2026/08/05" → "2026-08-05";已是规范格式则原样返回 function normalizeDateText(t) { t = String(t || "").trim(); const m = t.match(/(\d{4})[-\/.](\d{1,2})[-\/.](\d{1,2})/); if (m) return `${m[1]}-${pad2(m[2])}-${pad2(m[3])}`; return t; } // 从 "2026/08/05 08:30" / "08:30:00" 中提取 "08:30" function extractTime(t) { t = String(t || "").trim(); const m = t.match(/(\d{1,2}):(\d{2})/); return m ? `${pad2(m[1])}:${m[2]}` : ""; } // 工作时长单元格 → 小时数:支持 "11.5" / "11:30" / "11小时30分" / "11h30m" function parseHoursText(t) { t = String(t || "").trim(); if (!t || t === "-" || t === "--" || t === "—") return 0; // 纯数字才直接取数(避免 "11小时30分" 被 parseFloat 截成 11) if (/^[-+]?\d+(\.\d+)?$/.test(t)) return parseFloat(t); const h = t.match(/(\d+(?:\.\d+)?)\s*(?:小时|时|h)/i); if (h) { const mi = t.match(/(\d+(?:\.\d+)?)\s*(?:分钟|分|min)/i); return parseFloat(h[1]) + (mi ? parseFloat(mi[1]) / 60 : 0); } const colon = t.match(/^(\d{1,2}):(\d{2})$/); if (colon) return parseFloat(colon[1]) + parseFloat(colon[2]) / 60; return 0; } // 页面上的类型文字归一化:任意含"工作"的 → 工作日;休息/节假/周末 → 非工作日 function normalizeDateType(t) { t = String(t || "").trim(); if (/工作/.test(t)) return "工作日"; if (/休息|节假|周末|公休/.test(t)) return "非工作日"; return t; } // 没有类型列时按周末推断(周末 = 非工作日) function inferDateType(date) { const d = new Date(date); if (isNaN(d.getTime())) return ""; const day = d.getDay(); return day === 0 || day === 6 ? "非工作日" : "工作日"; } // 综合星期 + shapeCopy/restDay 休息标记(或类型文字)判定日期类型,并标注国家调休/补班。 // 判定日期类型并标注国家调休/补班(前缀=日历原本的类型,括号=国家安排): // 工作日 + 有休息标记(restDay/shapeCopy) → 工作日(国家调休) —— 原本工作日被国家调成休息日 // 周末 + 无休息标记 → 非工作日(国家补班) —— 原本周末被国家调成上班日 // 工作日 + 无标记 → 工作日 // 周末 + 有标记 → 非工作日 function resolveDateType(date, restMarked, rawTypeText) { const isWeekend = inferDateType(date) === "非工作日"; if (restMarked) { return isWeekend ? "非工作日" : "工作日(国家调休)"; } const t = normalizeDateType(rawTypeText || ""); if (t === "工作日") return isWeekend ? "非工作日(国家补班)" : "工作日"; if (t === "非工作日") return isWeekend ? "非工作日" : "工作日(国家调休)"; return isWeekend ? "非工作日" : "工作日"; } // 工时计算用:判断该日期是否按工作日算(标注优先于前缀): // 含"调休"(工作日(国家调休))→ 实际是休息日,来上班全部计加班 // 含"补班"(非工作日(国家补班))→ 实际是工作日,超过标准工时+阈值才计加班 function isWorkdayType(dateType) { const t = String(dateType || ""); if (t.indexOf("调休") !== -1) return false; if (t.indexOf("补班") !== -1) return true; return t.indexOf("工作日") === 0; } // 根据表头文字建立 {字段: 列索引} 映射 function buildColumnMap(ths) { const map = {}; ths.forEach((th, i) => { const txt = (th.textContent || "").trim(); if (!txt) return; for (const key of Object.keys(COL_RULES)) { if (map[key] !== undefined) continue; if (COL_RULES[key].patterns.some((p) => p.test(txt))) map[key] = i; } }); return map; } // 在指定文档中查找原生 考勤表(表头同时含"日期"列 + 班次/打卡/工时任一列) function findNativeTableInDoc(doc) { const tables = doc.querySelectorAll("table"); for (const table of tables) { // 跳过本插件面板自身的表格(表头也含"日期/首次打卡/工作时长",会误匹配成空考勤表) if (table.closest && table.closest("#italent-ot-helper")) continue; const headerRows = table.querySelectorAll("thead tr"); let ths = []; if (headerRows.length) { ths = Array.from(headerRows[headerRows.length - 1].querySelectorAll("th")); } else { const firstRow = table.querySelector("tr"); if (firstRow) ths = Array.from(firstRow.querySelectorAll("th, td")); } const map = buildColumnMap(ths); const hasDate = map.date !== undefined; const hasBody = map.shift !== undefined || map.first_card !== undefined || map.last_card !== undefined || map.work_hours !== undefined; if (hasDate && hasBody) return { table, map }; } return null; } // fixed-data-table(北森新版 div 虚拟表格)考勤表查找。 // 结构:.fixedDataTableLayout_main 容器内, // 表头行
下逐个
// 数据行
下逐个
// 列与表头按 DOM 顺序一一对应(含首列勾选框空列)。 function findFixedDataGridInDoc(doc) { const grids = doc.querySelectorAll('[class*="fixedDataTableLayout_main"]'); for (const grid of grids) { const headerRow = grid.querySelector('[role="row"][aria-rowindex="1"]'); if (!headerRow) continue; const headerCells = Array.from(headerRow.querySelectorAll('[role="columnheader"]')); if (headerCells.length < 4) continue; const map = buildColumnMap(headerCells); const hasDate = map.date !== undefined; const hasBody = map.shift !== undefined || map.first_card !== undefined || map.last_card !== undefined || map.work_hours !== undefined; if (!(hasDate && hasBody)) continue; // 数据行:aria-rowindex > 1 的行 const gridRows = []; const rowEls = grid.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; gridRows.push({ cells }); } if (!gridRows.length) return null; return { table: grid, map, gridRows }; } return null; } // 在指定文档中找考勤表:优先原生
,其次 fixed-data-table div 网格 function findAttendanceTableInDoc(doc) { const native = findNativeTableInDoc(doc); if (native) return native; return findFixedDataGridInDoc(doc); } // 收集当前可访问的文档:顶层 + 同源 iframe(跨域 iframe 访问会抛异常,跳过) function collectDocuments() { const docs = [document]; try { const frames = document.querySelectorAll("iframe"); for (const f of frames) { try { const d = f.contentDocument; if (d && d !== document && d.body) docs.push(d); } catch (_) {} } } catch (_) {} return docs; } // 在页面(含同源 iframe)所有表格中找考勤表 function findAttendanceTable() { for (const doc of collectDocuments()) { const found = findAttendanceTableInDoc(doc); if (found) return found; } return null; } // 检测日期单元格是否为休息日标记:北森页面在休息日(正常周末 + 法定节假日调休) // 的日期单元格内加 (包裹在
中)。 // 这是页面权威标记,能正确识别调休(工作日变休息日)和调班(周末变工作日)。 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); } })();