// ==UserScript== // @name MIS 考勤向后填充 // @namespace https://mis.murongtech.com/ // @version 0.4.2 // @description 固定考勤表头,三列独立开关控制向后填充,出勤跳过节假日,自动保存 // @match https://mis.murongtech.com/mrmis/toMenu.do* // @updateURL https://git.alawn.cn/Alawn/script/raw/branch/main/mis-attendance-fill.user.js // @downloadURL https://git.alawn.cn/Alawn/script/raw/branch/main/mis-attendance-fill.user.js // @run-at document-idle // @sandbox JavaScript // @grant unsafeWindow // ==/UserScript== (() => { "use strict"; if (new URLSearchParams(location.search).get("menu_id") !== "332005") return; const page = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; const projectFields = [ "pro_id", "pro_nm", "pro_state", "fin_pro_id", "fin_pro_nm", "assistant_id", "assistant_nm", "att_man", "att_man_nm", "cen_man", "pro_man", "pro_ass", ]; const saveFields = [ "att_no", "att_dt", "att_tm", "hld_flg", "travel_flg", "att_typ", ...projectFields, "rmk", ]; const key = (row) => [row.att_dt, row.att_tm, row.id_num].join("|"); const date = (row) => String(row.att_dt ?? "") .replace(/\D/g, "") .slice(0, 8); const later = (row, source) => date(row).slice(0, 6) === date(source).slice(0, 6) && (date(row) > date(source) || (date(row) === date(source) && Number(row.id_num) > Number(source.id_num))); // Keep this UI in the userscript so colleagues can install a single file. function installHeader(table, enabled) { const labels = { travel_flg: "是否出差", att_typ: "出勤情况", pro_id: "所在项目", }; const style = document.createElement("style"); style.textContent = ` #mis-fixed-header th { background: #fff; } .mis-fill-heading { white-space: nowrap; } /* MIS sets border-radius: 0 !important on every button and span. */ .mis-fill-switch { appearance: none; display: inline-flex; align-items: center; gap: 5px; margin: 0 0 0 8px; padding: 3px 2px; min-width: 0; min-height: 0; border: 0 !important; border-radius: 4px !important; background: transparent !important; box-shadow: none !important; color: #7e8996; font: 500 11px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; vertical-align: middle; cursor: pointer; white-space: nowrap; letter-spacing: 0; } .mis-fill-switch[aria-checked="true"] { color: #63758a; } .mis-fill-switch:hover { color: #245d9d; } .mis-fill-switch:focus-visible { outline: 2px solid #327bd0; outline-offset: 3px; } .mis-fill-track { display: inline-block; position: relative; width: 26px; height: 14px; border: 0 !important; border-radius: 999px !important; background: #b6bfcb; flex: none; } .mis-fill-track::after { content: ''; display: block; position: absolute; width: 10px; height: 10px; top: 2px; left: 2px; background: #fff; border: 0 !important; border-radius: 50% !important; box-shadow: 0 1px 2px #182c4426; } .mis-fill-switch[aria-checked="true"] .mis-fill-track { background: #3980d5; } .mis-fill-switch[aria-checked="true"] .mis-fill-track::after { left: 14px; } #mis-fixed-header { position: fixed; z-index: 100; overflow: hidden; background: #fff; border: 0; border-radius: 0; box-shadow: 0 3px 12px #21345116, 0 1px 0 #dbe3ed; } #mis-fixed-header table { margin: 0 !important; table-layout: fixed; border-collapse: collapse; } #mis-fixed-header th { box-sizing: border-box; } @media print { #mis-fixed-header, .mis-fill-switch { display: none !important; } } `; document.head.append(style); const floating = document.createElement("div"); floating.id = "mis-fixed-header"; floating.className = "fixed-table-container"; floating.hidden = true; document.body.append(floating); let frame = 0, dirty = true, sourceHead, copyHead; const switches = () => document.querySelectorAll(".mis-fill-switch"); const paint = () => switches().forEach((button) => { const field = button.dataset.misFill; button.setAttribute("aria-checked", String(enabled[field])); button.setAttribute("aria-label", `${labels[field]}自动向后填充`); button.title = `${labels[field]}:自动向后填充已${enabled[field] ? "开启" : "关闭"},点击${enabled[field] ? "关闭" : "开启"}`; button.querySelector(".mis-fill-label").textContent = "向后填充"; }); function decorate(head) { Object.entries(labels).forEach(([field, label]) => { const th = [...head.querySelectorAll("th")].find( (cell) => cell.dataset.field === field || cell.textContent.trim() === label, ); if (!th || th.querySelector(".mis-fill-switch")) return; const title = th.querySelector(".th-inner") || th; title.classList.add("mis-fill-heading"); const button = document.createElement("button"); button.type = "button"; button.className = "mis-fill-switch"; button.dataset.misFill = field; button.setAttribute("role", "switch"); button.innerHTML = ''; title.append(button); }); paint(); } function layout() { frame = 0; const element = document.querySelector("#murong-table"); const head = element.tHead; if (!head) { floating.hidden = true; return; } if (dirty || head !== sourceHead) { decorate(head); sourceHead = head; const copy = element.cloneNode(false); copy.removeAttribute("id"); copyHead = head.cloneNode(true); copyHead .querySelectorAll("[id]") .forEach((node) => node.removeAttribute("id")); copy.append(copyHead); floating.replaceChildren(copy); dirty = false; } const rect = head.getBoundingClientRect(), bounds = element.getBoundingClientRect(); let top = 0, left = Math.max(0, rect.left), right = Math.min(innerWidth, rect.right), bottom = innerHeight; for ( let parent = element.parentElement; parent && parent !== document.body; parent = parent.parentElement ) { const css = getComputedStyle(parent), box = parent.getBoundingClientRect(); if (/(auto|scroll|hidden|clip)/.test(css.overflowY)) { top = Math.max(top, box.top + parent.clientTop); bottom = Math.min( bottom, box.top + parent.clientTop + parent.clientHeight, ); } if (/(auto|scroll|hidden|clip)/.test(css.overflowX)) { left = Math.max(left, box.left + parent.clientLeft); right = Math.min( right, box.left + parent.clientLeft + parent.clientWidth, ); } } document.querySelectorAll(".navbar-fixed-top").forEach((nav) => { const box = nav.getBoundingClientRect(); if (box.top <= 0 && box.bottom > 0) top = Math.max(top, box.bottom); }); floating.hidden = !( rect.width && rect.top < top && bounds.bottom > top + rect.height && bottom > top + rect.height && right > left ); if (floating.hidden) return; Object.assign(floating.style, { top: `${top}px`, left: `${left}px`, width: `${right - left}px`, }); const copyTable = copyHead.parentElement; Object.assign(copyTable.style, { width: `${rect.width}px`, transform: `translateX(${rect.left - left}px)`, }); const cells = head.querySelectorAll("th"); copyHead.querySelectorAll("th").forEach((cell, index) => { cell.style.width = `${cells[index].getBoundingClientRect().width}px`; cell.style.height = `${cells[index].getBoundingClientRect().height}px`; const input = cell.querySelector("input"); const original = cells[index].querySelector("input"); if (input && original) { input.checked = original.checked; input.indeterminate = original.indeterminate; } }); } const schedule = () => { if (!frame) frame = requestAnimationFrame(layout); }; document.addEventListener( "click", (event) => { const button = event.target.closest(".mis-fill-switch"); if (button) { event.preventDefault(); event.stopImmediatePropagation(); const field = button.dataset.misFill; enabled[field] = !enabled[field]; paint(); return; } // Retain the original header's sorting and select-all interactions. if ( !floating.contains(event.target) || !copyHead?.contains(event.target) ) return; event.preventDefault(); event.stopImmediatePropagation(); const path = []; for ( let node = event.target; node !== copyHead; node = node.parentElement ) { path.unshift([...node.parentElement.children].indexOf(node)); } let original = sourceHead; path.forEach((index) => { original = original.children[index]; }); original?.click(); dirty = true; schedule(); }, true, ); document.addEventListener("scroll", schedule, { capture: true, passive: true, }); window.addEventListener("resize", schedule, { passive: true }); table().on( "post-header.bs.table post-body.bs.table reset-view.bs.table", () => { dirty = true; schedule(); }, ); new ResizeObserver(schedule).observe( document.querySelector("#murong-table"), ); layout(); } function initialize($) { const table = () => $("#murong-table"); const rows = () => table().bootstrapTable("getData") || []; const projects = new Map(); const pending = new Set(); const enabled = { travel_flg: true, att_typ: true, pro_id: true }; let saving = false; let generation = 0; const status = document.createElement("span"); status.id = "mis-brush-status"; status.style.cssText = "display:none;margin-left:12px;color:#7c8795;font-size:12px;line-height:20px;vertical-align:middle;"; status.setAttribute("role", "status"); status.title = "考勤向后填充 v0.4.2"; document.querySelector("#addBtn").insertAdjacentElement("afterend", status); const show = (message, failed = false) => { status.textContent = message; status.style.display = "inline-block"; status.style.color = failed ? "#b84b48" : "#7c8795"; }; installHeader(table, enabled); const remember = (row) => projects.set( key(row), JSON.stringify(projectFields.map((field) => row[field] ?? "")), ); const fullData = () => { const data = rows(); const total = Number(table().bootstrapTable("getOptions").totalRows); if (total > data.length) throw new Error("记录未全部加载,稍后再选择一次。"); return data; }; // Capture before MIS handles the edit, including nested scroll containers. function rememberScroll() { const elements = new Set([document.scrollingElement]); for ( let element = document.querySelector("#murong-table"); element; element = element.parentElement ) { elements.add(element); } const positions = [...elements].filter(Boolean).map((element) => ({ element, top: element.scrollTop, left: element.scrollLeft, })); const version = generation; return () => { if (version !== generation) return; positions.forEach(({ element, top, left }) => { element.scrollTop = top; element.scrollLeft = left; }); }; } async function save() { if (saving) return; saving = true; show("保存中…"); const version = generation; let count = 0; try { while (pending.size && version === generation) { const id = [...pending][0]; pending.delete(id); const row = rows().find((item) => key(item) === id); if (!row || String(row.chk_sts) !== "00") continue; // MIS requires a project. A later project selection queues these rows again. if (!String(row.pro_id ?? "").trim()) continue; const params = { rec_num: 1, flg: "01" }; saveFields.forEach((field) => { params[`rec_1.${field}`] = row[field] ?? ""; }); const result = await new Promise((resolve, reject) => $.ajax({ type: "POST", url: "/mrmis/attAdd.do", data: params, dataType: "json", timeout: 20000, success: resolve, error: (_xhr, reason) => reject( new Error(reason === "timeout" ? "保存超时" : "保存失败"), ), }), ); if (version !== generation) break; if (String(result?.gda?.msg_cd ?? "").slice(3) !== "00000") throw new Error(result?.gda?.msg_inf || "保存失败"); count++; } if (version === generation) { show(count ? `已保存 ${count} 条` : "待选择项目"); } } catch (error) { if (version === generation) { pending.clear(); show(`${error.message},请查询核对`, true); } } finally { saving = false; if (pending.size) void save(); } } function brush(source, fields, restoreScroll) { const autoFill = enabled[fields[0]]; const data = autoFill ? fullData() : []; if (String(source.chk_sts) !== "00") return; const patch = Object.fromEntries( fields.map((field) => [field, source[field] ?? ""]), ); const skipHolidays = fields.includes("att_typ"); data.forEach((row, index) => { if (String(row.chk_sts) !== "00" || !later(row, source)) return; if (skipHolidays && String(row.hld_flg) === "1") return; table().bootstrapTable("updateRow", { index, row: { ...patch } }); restoreScroll(); remember(row); pending.add(key(row)); }); remember(source); pending.add(key(source)); void save(); } // Capture before MIS rerenders the edited row; propagate after its handler finishes. document.addEventListener( "change", (event) => { const input = event.target; const field = { TRAVEL_FLG: "travel_flg", ATT_TYP: "att_typ" }[ input.name ]; const tr = input.closest("#murong-table tbody tr[data-index]"); if (!field || !tr || input.disabled) return; const index = Number(tr.dataset.index); const source = rows()[index]; if (!source) return; const restoreScroll = rememberScroll(); const id = key(source), value = input.value, version = generation; setTimeout(() => { if (version !== generation) return; const current = rows().find((row) => key(row) === id); if (!current) return; current[field] = value; try { brush(current, [field], restoreScroll); } catch (error) { show(error.message, true); } finally { restoreScroll(); requestAnimationFrame(restoreScroll); } }, 0); }, true, ); // MIS saves immediately after its project picker resolves. Copy the complete metadata then. document.addEventListener( "click", (event) => { const button = event.target.closest('buttom[name="save"]'); const tr = button?.closest("#murong-table tbody tr[data-index]"); if (!tr) return; const source = rows()[Number(tr.dataset.index)]; if (!source?.pro_id) return; const signature = JSON.stringify( projectFields.map((field) => source[field] ?? ""), ); if (projects.get(key(source)) === signature) return; const version = generation; const restoreScroll = rememberScroll(); setTimeout(() => { if (version !== generation) return; try { brush(source, projectFields, restoreScroll); } catch (error) { show(error.message, true); } finally { restoreScroll(); requestAnimationFrame(restoreScroll); } }, 0); }, true, ); function loaded() { generation++; pending.clear(); projects.clear(); rows().forEach(remember); const options = table().bootstrapTable("getOptions"); if (Number(options.totalRows) > rows().length) { table().bootstrapTable("refreshOptions", { pageSize: Math.max(100, Number(options.totalRows)), pageNumber: 1, }); } status.textContent = ""; status.style.display = "none"; } table().on("load-success.bs.table", loaded); if (rows().length) loaded(); } let attempts = 0; function start() { const $ = page.jQuery; if ( !$?.fn?.bootstrapTable || !document.querySelector("#addBtn") || !$("#murong-table").data("bootstrap.table") ) return false; initialize($); return true; } if (!start()) { const timer = setInterval(() => { if (start() || ++attempts >= 60) clearInterval(timer); }, 500); } })();