501 lines
18 KiB
JavaScript
501 lines
18 KiB
JavaScript
// ==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 =
|
|
'<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);
|
|
}
|
|
})();
|