diff --git a/mis-attendance-fill.user.js b/mis-attendance-fill.user.js index 3553f48..fcf6120 100644 --- a/mis-attendance-fill.user.js +++ b/mis-attendance-fill.user.js @@ -4,28 +4,60 @@ // @version 0.4.2 // @description 固定考勤表头,三列独立开关控制向后填充,出勤跳过节假日,自动保存 // @match https://mis.murongtech.com/mrmis/toMenu.do* -// @updateURL none +// @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))); + "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'); + 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; } @@ -53,150 +85,234 @@ @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'; + 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 = '向后填充'; - }); + 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'; + 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 = ''; + button.setAttribute("role", "switch"); + button.innerHTML = + ''; title.append(button); }); paint(); } function layout() { frame = 0; - const element = document.querySelector('#murong-table'); + const element = document.querySelector("#murong-table"); const head = element.tHead; - if (!head) { floating.hidden = true; return; } + if (!head) { + floating.hidden = true; + return; + } if (dirty || head !== sourceHead) { decorate(head); sourceHead = head; const copy = element.cloneNode(false); - copy.removeAttribute('id'); + copy.removeAttribute("id"); copyHead = head.cloneNode(true); - copyHead.querySelectorAll('[id]').forEach(node => node.removeAttribute('id')); + 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(); + 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); + 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); + right = Math.min( + right, + box.left + parent.clientLeft + parent.clientWidth, + ); } } - document.querySelectorAll('.navbar-fixed-top').forEach(nav => { + 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); + 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` }); + 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) => { + 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 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')); + 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 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 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'; + 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 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('记录未全部加载,稍后再选择一次。'); + 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) { + 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 positions = [...elements].filter(Boolean).map((element) => ({ + element, + top: element.scrollTop, + left: element.scrollLeft, })); const version = generation; return () => { @@ -211,29 +327,42 @@ async function save() { if (saving) return; saving = true; - show('保存中…'); + 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; + 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 (!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 || '保存失败'); + if (String(result?.gda?.msg_cd ?? "").slice(3) !== "00000") + throw new Error(result?.gda?.msg_inf || "保存失败"); count++; } if (version === generation) { - show(count ? `已保存 ${count} 条` : '待选择项目'); + show(count ? `已保存 ${count} 条` : "待选择项目"); } } catch (error) { if (version === generation) { @@ -249,13 +378,15 @@ 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'); + 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 } }); + 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)); @@ -266,67 +397,104 @@ } // 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); + 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); + 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(); + pending.clear(); + projects.clear(); rows().forEach(remember); - const options = table().bootstrapTable('getOptions'); + const options = table().bootstrapTable("getOptions"); if (Number(options.totalRows) > rows().length) { - table().bootstrapTable('refreshOptions', { pageSize: Math.max(100, Number(options.totalRows)), pageNumber: 1 }); + table().bootstrapTable("refreshOptions", { + pageSize: Math.max(100, Number(options.totalRows)), + pageNumber: 1, + }); } - status.textContent = ''; - status.style.display = 'none'; + status.textContent = ""; + status.style.display = "none"; } - table().on('load-success.bs.table', loaded); + 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; + 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); + const timer = setInterval(() => { + if (start() || ++attempts >= 60) clearInterval(timer); + }, 500); } })();