// ==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); } })();