feat: 新增 MIS 考勤向后填充用户脚本
为减少逐条录入考勤的重复劳动,新增单文件用户脚本, 在考勤页面按行向后填充出差、出勤与项目信息。 - 固定表头并复制原表头交互,滚动时保持可见 - 三列各自独立开关,控制是否向后填充 - 出勤填充跳过节假日,避免覆盖休息日 - 仅处理当月且行号更大的记录,防止误改历史数据 - 保存前要求已选项目,失败时提示人工核对 - 保存后恢复滚动位置,并自动分页加载全部记录
This commit is contained in:
@@ -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 = '<span class="mis-fill-track" aria-hidden="true"></span><span class="mis-fill-label"></span>';
|
||||
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);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user