// ==UserScript==
// @name 学生档案批量打印(安徽中职 zzxsgl)
// @namespace local.batch-print-students
// @version 1.1.0
// @description 在校生档案:列表→详情→打印预览→打印→回列表(zzxsgl.ahjygl.gov.cn)
// @author you
// @match http://zzxsgl.ahjygl.gov.cn/*
// @match https://zzxsgl.ahjygl.gov.cn/*
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-idle
// @noframes
// ==/UserScript==
/**
* 站点:安徽省全国中等职业学校学生管理信息系统
*
* URL 示意(你已提供):
* - 列表/查询:…/studInfoManage!… (含「查询结果」表格,非 goStuDetail)
* - 详情: …/studInfoManage!goStuDetail.action?xsqb.id=…
* - 预览/打印 …/zzZxsJbxxAction!showDetail.action?…&isPrint=0 (页面按钮为「打印」)
*
* 脚本猫请选「脚本」;若悬浮面板不出现,多数是列表页没被识别——把 CONFIG.debug 设为 true 看控制台「页面类型」。
*/
(function () {
'use strict';
/** @param {string} u */
function ahIsPrintPreviewUrl(u) {
return (
/zzZxsJbxxAction!\s*showDetail\.action/i.test(u) &&
/[?&]isPrint=0(?:&|$)/i.test(u)
);
}
/** @param {string} u */
function ahIsDetailUrl(u) {
return /studInfoManage!\s*goStuDetail\.action/i.test(u);
}
// ==================== 配置区 ====================
const CONFIG = {
/** 查询结果表中「姓名」列链接;请与 F12 中实际表格一致(第 2 列为姓名时保持不变) */
listStudentLinkSelector: '.grid-table tbody tr td:nth-child(2) a',
/**
* 列表页:stu 列表 + 兜底(含姓名链接且页面上有「查询结果」字样)
* 若不匹配请到 F12 Network 看你的列表 action 名称,可把 studInfoManage 改成实际片段
*/
isListPage() {
const u = location.href;
if (ahIsDetailUrl(u)) return false;
if (/zzZxsJbxxAction!\s*showDetail\.action/i.test(u)) return false;
if (/studInfoManage/i.test(u) && !/goStuDetail/i.test(u)) return true;
const hasNameLinks =
document.querySelector(this.listStudentLinkSelector) !== null;
return !!(
hasNameLinks &&
document.body &&
/查询结果/.test(document.body.innerText || '')
);
},
/** 详情(基本信息页,右上角为「打印预览」) */
isDetailPage() {
return ahIsDetailUrl(location.href);
},
/** zzZxs 打印预览(右上角为「打印」) */
isPrintPreviewPage() {
return ahIsPrintPreviewUrl(location.href);
},
/** 详情页「打印预览」,留空则用文案查找,仅匹配「打印预览」,避免点在预览页的「打印」 */
detailPrintPreviewSelectorCss: '',
/**
* 预览页右上角「打印」——留空则用文案查找(节点文本规整后等于「打印」)
*/
printPagePrintButtonSelectorCss: '',
/** 预览页少见的多余入口 */
printPageExtraPrintSelector: '',
wait: {
domReady: 10000,
afterNavigation: 1200,
afterClickPreview: 1500,
printDialogDelay: 450,
betweenStudents: 700,
popupPoll: 200,
},
/** 若在预览页未找到「打印」按钮,改为直接 window.print */
autoPrintOnPreviewPage: true,
closePreviewWindowAfterPrint: true,
/**
* 本系统通常为:点击「打印预览」后同一标签进入 zzZxsJbxx…showDetail…isPrint=0
* 若你以后发现是新开窗口预览,改成 'popup'
*/
previewOpenMode: 'sameTab',
previewIframeSelector: '',
iframePrintButtonSelector: '',
debug: false,
};
const STORAGE_KEY = 'batchStudentPrint_state_v1';
const DEFAULT_STATE = {
running: false,
stopRequested: false,
listUrl: '',
/** @type {{ href: string, text: string }[]} */
queue: [],
index: -1,
phase: 'idle',
_popupRef: null,
};
function log(...args) {
if (CONFIG.debug) console.log('[批量打印]', ...args);
}
function loadState() {
try {
const raw = GM_getValue(STORAGE_KEY, null);
if (!raw || typeof raw !== 'object') return { ...DEFAULT_STATE };
return { ...DEFAULT_STATE, ...raw, _popupRef: null };
} catch {
return { ...DEFAULT_STATE };
}
}
function saveState(partial) {
const cur = loadState();
const next = { ...cur, ...partial };
const serializable = { ...next };
delete serializable._popupRef;
GM_setValue(STORAGE_KEY, serializable);
return next;
}
function clearState() {
GM_setValue(STORAGE_KEY, null);
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function findClickableByText(root, text, tagNames = 'a,button,span,input') {
const nodes = root.querySelectorAll(tagNames);
for (const el of nodes) {
if (el.textContent && el.textContent.trim().includes(text)) {
return el.closest('a,button') || el;
}
}
return null;
}
/** 预览页工具栏「打印」(排除文案中含「预览」的父级) */
function findPrintPageToolbarPrintEl() {
const css = (CONFIG.printPagePrintButtonSelectorCss || '').trim();
if (css) {
const el = document.querySelector(css);
if (el) return el;
}
const nodes = document.querySelectorAll('a,button,span');
for (const el of nodes) {
const raw = (el.textContent || '').replace(/\u00a0/g, ' ');
const norm = raw.replace(/\s+/g, '').trim();
if (norm !== '打印') continue;
const btn = el.closest('a,button') || el;
const lbl = ((btn.textContent || '').replace(/\s+/g, ''));
if (lbl.includes('打印预览')) continue;
return btn;
}
return null;
}
function waitFor(predicate, timeoutMs, intervalMs = 80) {
const start = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
try {
const v = predicate();
if (v) return resolve(v);
} catch (e) {
return reject(e);
}
if (Date.now() - start > timeoutMs) return resolve(null);
setTimeout(tick, intervalMs);
};
tick();
});
}
function collectStudentLinks() {
const sel = CONFIG.listStudentLinkSelector;
const nodes = Array.from(document.querySelectorAll(sel));
const out = [];
const seen = new Set();
for (const a of nodes) {
if (!(a instanceof HTMLAnchorElement)) continue;
const href = a.href;
if (!href || seen.has(href)) continue;
seen.add(href);
out.push({ href, text: (a.textContent || '').trim() });
}
return out;
}
function ensurePanel() {
let el = document.getElementById('batch-print-student-panel');
if (el) return el;
el = document.createElement('div');
el.id = 'batch-print-student-panel';
el.style.cssText = [
'position:fixed',
'right:16px',
'bottom:16px',
'z-index:2147483646',
'font-family:system-ui,sans-serif',
'font-size:13px',
'background:#1e293b',
'color:#f8fafc',
'padding:12px 14px',
'border-radius:10px',
'box-shadow:0 8px 24px rgba(0,0,0,.35)',
'min-width:200px',
].join(';');
el.innerHTML = `
批量打印
就绪
仅当前页表格;预览为同标签时请保持本标签勿手动关闭。
`;
document.documentElement.appendChild(el);
return el;
}
function setPanelStatus(text) {
const s = document.getElementById('bps-status');
if (s) s.textContent = text;
}
function setupListPage() {
const panel = ensurePanel();
const btnStart = panel.querySelector('#bps-start');
const btnStop = panel.querySelector('#bps-stop');
const state = loadState();
if (state.running && state.phase === 'return_list' && state.queue.length) {
const nextNo = Math.min(state.index + 2, state.queue.length);
setPanelStatus(`继续:下一位 ${nextNo}/${state.queue.length}`);
queueMicrotask(() => resumeListAfterReturn());
} else {
setPanelStatus('就绪');
}
btnStart.addEventListener('click', async () => {
const links = collectStudentLinks();
if (!links.length) {
alert(
'未抓到姓名链接;请修正 CONFIG.listStudentLinkSelector(F12 右键「姓名」→ Copy → Copy selector)'
);
return;
}
saveState({
running: true,
stopRequested: false,
listUrl: location.href,
queue: links,
index: -1,
phase: 'goto_detail',
});
setPanelStatus(`排队 ${links.length} 人…`);
await sleep(CONFIG.wait.betweenStudents);
goNextStudentFromList();
});
btnStop.addEventListener('click', () => {
saveState({ running: false, stopRequested: true, phase: 'idle' });
setPanelStatus('已停止');
});
}
function goNextStudentFromList() {
const state = loadState();
if (!state.running || state.stopRequested) return;
const nextIndex = state.index + 1;
if (nextIndex >= state.queue.length) {
clearState();
setPanelStatus('全部完成');
saveState({ running: false, phase: 'idle' });
return;
}
const item = state.queue[nextIndex];
saveState({
index: nextIndex,
phase: 'goto_detail',
});
setPanelStatus(`打开 ${nextIndex + 1}/${state.queue.length}:${item.text || ''}`);
location.href = item.href;
}
async function resumeListAfterReturn() {
const state = loadState();
if (!state.running || state.stopRequested) return;
await sleep(CONFIG.wait.betweenStudents);
goNextStudentFromList();
}
let openHookInstalled = false;
function installOpenHook() {
if (openHookInstalled) return;
openHookInstalled = true;
const orig = window.open;
window.open = function (url, name, features) {
const w = orig.call(this, url, name, features);
const cur = loadState();
if (w && cur.running && !cur.stopRequested) {
const iv = setInterval(() => {
const s = loadState();
if (!s.running || s.stopRequested) {
clearInterval(iv);
return;
}
try {
if (w.closed) {
clearInterval(iv);
log('预览窗口已关闭,返回列表');
sleep(CONFIG.wait.afterNavigation).then(() => {
saveState({ phase: 'return_list' });
const listUrl = loadState().listUrl;
if (listUrl) location.href = listUrl;
});
}
} catch {
clearInterval(iv);
}
}, CONFIG.wait.popupPoll);
}
return w;
};
}
function findPrintPreviewElOnDetail() {
const css = (CONFIG.detailPrintPreviewSelectorCss || '').trim();
if (css) {
const el = document.querySelector(css);
if (el) return el;
}
return findClickableByText(document.body, '打印预览');
}
async function runDetailPage() {
const state = loadState();
if (!state.running || state.stopRequested) return;
if (state.phase !== 'goto_detail' && state.phase !== 'on_detail') return;
if (CONFIG.previewOpenMode === 'popup') installOpenHook();
saveState({ phase: 'on_detail' });
const btn = await waitFor(
() => findPrintPreviewElOnDetail(),
CONFIG.wait.domReady,
100
);
if (!btn) {
alert('找不到「打印预览」。请填充 CONFIG.detailPrintPreviewSelectorCss 或稍后重试。');
saveState({ running: false, phase: 'idle' });
return;
}
// sameTab:导航会立刻卸载当前文档,必须先写入阶段
if (CONFIG.previewOpenMode === 'sameTab') saveState({ phase: 'goto_print' });
btn.click();
log('已点击 打印预览');
if (CONFIG.previewOpenMode === 'sameTab') return;
await sleep(CONFIG.wait.afterClickPreview);
if (
CONFIG.previewOpenMode === 'iframe' &&
CONFIG.previewIframeSelector
) {
const iframe = await waitFor(
() => document.querySelector(CONFIG.previewIframeSelector),
CONFIG.wait.domReady
);
if (iframe && iframe.contentDocument) {
const doc = iframe.contentDocument;
let pbtn = null;
if (CONFIG.iframePrintButtonSelector) {
pbtn = doc.querySelector(CONFIG.iframePrintButtonSelector);
}
if (!pbtn) pbtn = findClickableByText(doc.body, '打印');
if (pbtn && CONFIG.autoPrintOnPreviewPage) {
pbtn.click();
await sleep(CONFIG.wait.printDialogDelay);
}
try {
iframe.contentWindow.print();
} catch (_) {}
}
await sleep(500);
const listUrl = loadState().listUrl;
if (listUrl) {
saveState({ phase: 'return_list' });
location.href = listUrl;
}
return;
}
let st = loadState();
if (CONFIG.isPrintPreviewPage()) await runPrintPage();
}
async function runPrintPage() {
const state = loadState();
if (!state.running || state.stopRequested) return;
saveState({ phase: 'on_print' });
await sleep(CONFIG.wait.afterNavigation);
if (CONFIG.printPageExtraPrintSelector) {
const extra = document.querySelector(CONFIG.printPageExtraPrintSelector);
if (extra) extra.click();
await sleep(300);
}
const toolbarPrint = findPrintPageToolbarPrintEl();
if (toolbarPrint) {
toolbarPrint.click();
log('已点击 打印(工具栏)');
} else if (CONFIG.autoPrintOnPreviewPage) {
await sleep(CONFIG.wait.printDialogDelay);
try {
window.print();
} catch (_) {}
log('未找到按钮,使用 window.print()');
}
window.addEventListener(
'afterprint',
() => finishPrintAndReturn(),
{ once: true }
);
setTimeout(() => finishPrintAndReturn(), 6500);
}
let _finishing = false;
function finishPrintAndReturn() {
if (_finishing) return;
_finishing = true;
const state = loadState();
// 若为 window.open 子窗口脚本也注入了
if (window.opener) {
try {
window.close();
} catch (_) {}
return;
}
if (CONFIG.closePreviewWindowAfterPrint) {
try {
window.close();
} catch (_) {}
}
saveState({ phase: 'return_list' });
setTimeout(() => {
if (!window.closed && state.listUrl) location.href = state.listUrl;
}, 320);
}
function detectPage() {
if (CONFIG.isListPage()) return 'list';
if (CONFIG.isPrintPreviewPage()) return 'print';
if (CONFIG.isDetailPage()) return 'detail';
return 'unknown';
}
async function main() {
const page = detectPage();
log('页面类型:', page, location.href);
if (page === 'unknown' && CONFIG.debug) {
console.warn(
'[批量打印] URL 未被识别:',
'\n详情应含 goStuDetail.action ',
'\n预览应含 zzZxsJbxxAction!showDetail.action 且 isPrint=0'
);
}
if (page === 'list') {
setupListPage();
return;
}
if (page === 'detail') {
await sleep(CONFIG.wait.afterNavigation);
await runDetailPage();
return;
}
if (page === 'print') {
await runPrintPage();
return;
}
}
main().catch((e) => console.error('[批量打印] 异常', e));
})();