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