From 8f5eda3f2c2836cc63c64e43359eec0724a72ecb Mon Sep 17 00:00:00 2001 From: Alawn Zhao Date: Tue, 3 Nov 2026 10:31:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20MIS=20=E8=80=83?= =?UTF-8?q?=E5=8B=A4=E5=90=91=E5=90=8E=E5=A1=AB=E5=85=85=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为减少逐条录入考勤的重复劳动,新增单文件用户脚本, 在考勤页面按行向后填充出差、出勤与项目信息。 - 固定表头并复制原表头交互,滚动时保持可见 - 三列各自独立开关,控制是否向后填充 - 出勤填充跳过节假日,避免覆盖休息日 - 仅处理当月且行号更大的记录,防止误改历史数据 - 保存前要求已选项目,失败时提示人工核对 - 保存后恢复滚动位置,并自动分页加载全部记录 --- mis-attendance-fill.user.js | 332 ++++++++++++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 mis-attendance-fill.user.js diff --git a/mis-attendance-fill.user.js b/mis-attendance-fill.user.js new file mode 100644 index 0000000..3553f48 --- /dev/null +++ b/mis-attendance-fill.user.js @@ -0,0 +1,332 @@ +// ==UserScript== +// @name MIS 考勤向后填充 +// @namespace https://mis.murongtech.com/ +// @version 0.4.2 +// @description 固定考勤表头,三列独立开关控制向后填充,出勤跳过节假日,自动保存 +// @match https://mis.murongtech.com/mrmis/toMenu.do* +// @updateURL none +// @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); + } +})();