// ==UserScript== // @name invoiceautocheck // @namespace https://scriptcat.org/zh-CN/script-show-page/3820 // @version 1.0 // @description 销售开票列表:批量粘贴多单号一次性自动勾选;底层优先用 EasyUI getRows/checkRow API,回退 DOM。首字母 x/t/h/c 按类型选列,无首字母在 id/sourceid/uninvoiceamount/remark 搜索 // @author fengxia // @match http://47.107.106.156:8888/account/receivable/showSalesInvoiceBillAddPage.do* // @match http://122.13.25.247:8888/account/receivable/showSalesInvoiceBillAddPage.do* // @match http://47.107.106.156:8888/account/receivable/showSalesInvoiceAddPage.do* // @match http://122.13.25.247:8888/account/receivable/showSalesInvoiceAddPage.do* // @grant none // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ===== 配置常量 ===== const CONFIG = { // 无首字母时,在下列列做“包含”匹配(不区分大小写) generalFields: ['id', 'sourceid', 'uninvoiceamount', 'remark','orderid'], // 首字母 -> { field: 搜索列, prefixes: 该列值需以其中之一开头 } prefixRules: { x: { field: 'orderid', prefixes: ['XD', 'HQXD'] }, t: { field: 'id', prefixes: ['TZD', 'HQTZD'] }, h: { field: 'sourceid', prefixes: ['HH', 'HQHH'] }, c: { field: 'id', prefixes: ['CC'] }, }, // 批量输入分隔符:空白(含换行/制表/全角空格)、半角逗号/分号、全角逗号/顿号/分号 tokenSeparator: /[\s,;,、;]+/, placeholder: '每行/逗号分隔多个单号;回车执行,Shift+回车换行;首字母 x/t/h/c 选类,或直接输入在 id/sourceid/uninvoiceamount/remark 搜索', toastDurationMs: 3000, // toast 提示自动消失时间(毫秒) }; // ===== 权限校验配置 ===== // 启动时先访问 CHECK_URL:命中无权限页面 / 请求失败 / 超时 → 禁用脚本全部功能 const CHECK_URL = `${location.origin}/basefiles/showWorkJobPage.do`; const CHECK_TIMEOUT_MS = 5000; const NO_PERMISSION_KEYWORD = '无权限访问该页面'; // 会话级缓存 key(区分服务器),一次登录会话只校验一次 const CHECK_CACHE_KEY = `invoice_batch_perm_${location.host}`; // ===== 通用工具 ===== // 页面顶部非阻塞 toast 提示,替代 alert function notify(msg) { console.log('[invoice-autocheck-batch]', msg); const toast = document.createElement('div'); toast.textContent = msg; toast.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:99999;background:#333;color:#fff;padding:8px 16px;border-radius:4px;max-width:60%;font-size:14px;box-shadow:0 2px 8px rgba(0,0,0,.3);transition:opacity .5s;'; document.body.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 500); }, CONFIG.toastDurationMs); } // 复制文本到剪贴板:优先 Clipboard API,失败回退 execCommand(页面为 http,多数情况走回退) function copyText(text, okMsg) { const fallback = () => { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;top:-1000px;left:-1000px;opacity:0;'; document.body.appendChild(ta); ta.select(); let ok = false; try { ok = document.execCommand('copy'); } catch (e) { ok = false; } ta.remove(); notify(ok ? okMsg : '复制失败,请手动复制'); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(() => notify(okMsg)).catch(fallback); } else { fallback(); } } function getJq() { return window.$ || window.jQuery || null; } // 把批量输入拆成去重后的单号数组 function splitTokens(raw) { if (!raw) return []; const parts = String(raw).split(CONFIG.tokenSeparator).map(s => s.trim()).filter(Boolean); return Array.from(new Set(parts)); // 去重,避免同一单号重复处理 } // ===== 匹配规则 ===== // 依据单个 token 生成匹配函数:入参为“行数据对象”(API 行对象或 DOM 构造对象) // 无首字母:generalFields 任一列包含完整输入即命中 // 带首字母:rule.field 列的值需以 rule.prefixes 之一开头,且包含“去掉首字母的关键字”或“完整输入” //(同时比对关键字与完整输入,使 xD-2025 与直接粘贴 XD-2025… 都能命中) function buildMatcher(token) { const prefix = token.charAt(0).toLowerCase(); const rule = CONFIG.prefixRules[prefix]; const full = token.toLowerCase(); const keyword = token.slice(1).trim().toLowerCase(); return function (row) { if (!rule) { return CONFIG.generalFields.some(f => String(row[f] == null ? '' : row[f]).toLowerCase().includes(full)); } const value = String(row[rule.field] == null ? '' : row[rule.field]); if (!value) return false; const upper = value.toUpperCase(); const lower = value.toLowerCase(); const prefixOk = rule.prefixes.some(p => upper.indexOf(p) === 0); return prefixOk && ((keyword && lower.includes(keyword)) || lower.includes(full)); }; } // ===== 通道一:EasyUI datagrid API ===== // 定位目标单据表格:遍历所有已初始化 datagrid 的 table,按包含目标字段数打分取最高 function findGrid() { const jq = getJq(); if (!jq || !jq.fn || !jq.fn.datagrid) return null; const wantFields = ['id', 'orderid', 'sourceid', 'uninvoiceamount', 'remark']; let best = null; jq('table').each(function () { let fields; try { fields = jq(this).datagrid('getColumnFields'); } catch (e) { return; /* 非 datagrid */ } if (!fields || !fields.length) return; let score = 0; wantFields.forEach(f => { if (fields.indexOf(f) !== -1) score++; }); // 至少含 id 或 orderid,且命中 2 个以上目标字段,才认定为目标单据表 if (score >= 2 && (fields.indexOf('id') !== -1 || fields.indexOf('orderid') !== -1)) { if (!best || score > best.score) best = { el: this, score }; } }); return best ? best.el : null; } // 用 API 读取行数据匹配、checkRow 勾选 function checkViaApi(grid, tokens) { const jq = getJq(); const rows = jq(grid).datagrid('getRows') || []; let alreadySet; try { alreadySet = new Set(jq(grid).datagrid('getChecked') || []); } catch (e) { alreadySet = new Set(); } let clicked = 0, already = 0; const handled = new Set(); // 本批次已处理的行索引,避免多 token 命中同一行重复计数 const unmatched = []; tokens.forEach(token => { const matcher = buildMatcher(token); let hits = 0; rows.forEach((row, index) => { if (!matcher(row)) return; hits++; if (handled.has(index)) return; handled.add(index); if (alreadySet.has(row)) { already++; return; } jq(grid).datagrid('checkRow', index); // EasyUI 官方勾选,自动同步冻结列选择框与内部状态 clicked++; }); if (hits === 0) unmatched.push(token); }); return { matchedRows: handled.size, clicked, already, noCheckbox: 0, unmatched, via: 'datagrid API' }; } // ===== 通道二:DOM 回退(找不到 datagrid 实例时) ===== // 取某数据行指定列的单元格文本 function getCellText(row, field) { const cell = row.querySelector('td[field="' + field + '"] .datagrid-cell'); return cell ? cell.textContent.trim() : ''; } // 把 DOM 行转成 { field: text } 对象,复用与 API 相同的匹配函数 function domRowToObject(tr) { const obj = {}; tr.querySelectorAll('td[field]').forEach(td => { const cell = td.querySelector('.datagrid-cell'); obj[td.getAttribute('field')] = cell ? cell.textContent.trim() : ''; }); return obj; } // 找到某数据行对应的选择框:同容器 + 同 datagrid-row-index(兼容冻结列 view1/view2 分离) function findCheckbox(row) { const index = row.getAttribute('datagrid-row-index'); if (index === null) return null; const container = row.closest('.datagrid') || document; return container.querySelector('tr[datagrid-row-index="' + index + '"] input[name="ck"]'); } function checkViaDom(tokens) { let clicked = 0, already = 0, noCheckbox = 0; const handled = new Set(); const unmatched = []; const allRows = Array.from(document.querySelectorAll('tr.datagrid-row')); tokens.forEach(token => { const matcher = buildMatcher(token); let hits = 0; allRows.forEach(tr => { if (!matcher(domRowToObject(tr))) return; hits++; if (handled.has(tr)) return; handled.add(tr); const cb = findCheckbox(tr); if (!cb) { noCheckbox++; return; } if (cb.checked) { already++; return; } cb.click(); // 触发 EasyUI 自身的选择框事件 clicked++; }); if (hits === 0) unmatched.push(token); }); return { matchedRows: handled.size, clicked, already, noCheckbox, unmatched, via: 'DOM' }; } // ===== 批量执行入口 ===== // 返回 { msg: 统计文本, unmatched: 未匹配单号数组 } function runBatch(raw) { const tokens = splitTokens(raw); if (!tokens.length) { notify('请输入要搜索的单号'); return { msg: '请输入要搜索的单号', unmatched: [] }; } const grid = findGrid(); const res = grid ? checkViaApi(grid, tokens) : checkViaDom(tokens); let msg = '共 ' + tokens.length + ' 个单号,匹配 ' + res.matchedRows + ' 行,新勾选 ' + res.clicked + ' 行'; if (res.already) msg += ',已勾选 ' + res.already + ' 行'; if (res.noCheckbox) msg += ',' + res.noCheckbox + ' 行未找到选择框'; if (res.unmatched.length) msg += ';未匹配 ' + res.unmatched.length + ' 个'; msg += '(' + res.via + ')'; notify(msg); return { msg, unmatched: res.unmatched }; } // ===== 面板 UI ===== function injectPanel() { if (document.getElementById('invoiceAutocheckBatchPanel')) return; const panel = document.createElement('div'); panel.id = 'invoiceAutocheckBatchPanel'; panel.style.cssText = 'position:fixed;top:80px;right:16px;z-index:99998;width:280px;background:#fff;border:1px solid #95b8e7;border-radius:4px;box-shadow:0 2px 10px rgba(0,0,0,.25);font-size:13px;color:#333;'; panel.innerHTML = '
' + '批量自动勾选' + '展开' + '
' + ''; document.body.appendChild(panel); const input = document.getElementById('iacbInput'); const result = document.getElementById('iacbResult'); // 渲染结果:统计文本 + “复制未匹配单号”链接 const renderResult = (msg, unmatched) => { result.textContent = msg; if (!unmatched || !unmatched.length) return; const link = document.createElement('a'); link.href = 'javascript:void(0)'; link.textContent = '复制未匹配单号'; link.style.cssText = 'margin-left:8px;color:#3462a4;cursor:pointer;text-decoration:underline;white-space:nowrap;'; link.addEventListener('click', () => { copyText(unmatched.join('\n'), '已复制 ' + unmatched.length + ' 个未匹配单号'); }); result.appendChild(link); }; // 执行:勾选后全选文本,便于直接覆盖录入下一批 const doRun = () => { const res = runBatch(input.value); renderResult(res.msg, res.unmatched); input.select(); }; document.getElementById('iacbRun').addEventListener('click', doRun); document.getElementById('iacbClear').addEventListener('click', () => { input.value = ''; result.textContent = ''; input.focus(); }); // 回车执行,Shift+回车换行 input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doRun(); } }); // 收起/展开(默认收起) const body = document.getElementById('iacbBody'); const toggle = document.getElementById('iacbToggle'); toggle.addEventListener('click', () => { const hidden = body.style.display === 'none'; body.style.display = hidden ? '' : 'none'; toggle.textContent = hidden ? '收起' : '展开'; if (hidden) input.focus(); }); // 标题栏拖动 const title = document.getElementById('iacbTitle'); let dragging = false, offsetX = 0, offsetY = 0; title.addEventListener('mousedown', e => { if (e.target === toggle) return; // 点“收起/展开”不触发拖动 dragging = true; const rect = panel.getBoundingClientRect(); offsetX = e.clientX - rect.left; offsetY = e.clientY - rect.top; panel.style.right = 'auto'; e.preventDefault(); }); document.addEventListener('mousemove', e => { if (!dragging) return; panel.style.left = (e.clientX - offsetX) + 'px'; panel.style.top = (e.clientY - offsetY) + 'px'; }); document.addEventListener('mouseup', () => { dragging = false; }); } // ===== 权限校验 ===== // GET 原始文本(带超时),用于权限校验 function fetchRawText(url, timeoutMs) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.timeout = timeoutMs; xhr.onload = () => resolve({ status: xhr.status, text: xhr.responseText }); xhr.onerror = () => reject(new Error('网络错误')); xhr.ontimeout = () => reject(new Error('请求超时')); xhr.send(); }); } // 权限校验:命中无权限页面 / 请求失败 / 超时 → false(禁用);通过后缓存,一次登录只请求一次 async function checkPermission() { if (sessionStorage.getItem(CHECK_CACHE_KEY) === '1') return true; try { const { status, text } = await fetchRawText(CHECK_URL, CHECK_TIMEOUT_MS); if (status === 200 && !text.includes(NO_PERMISSION_KEYWORD)) { sessionStorage.setItem(CHECK_CACHE_KEY, '1'); // 仅缓存“通过”结果 return true; } return false; // 命中无权限页面 } catch (err) { console.error('[invoice-autocheck-batch] 权限校验失败,默认禁用:', err.message); return false; // 失败/超时不缓存,下次访问自动重试 } } // 启动:先校验权限,通过后才注入面板(失败/超时/无权限则全部禁用) (async function init() { const ok = await checkPermission(); if (!ok) { console.warn('[invoice-autocheck-batch] 无权限或校验未通过,脚本所有功能已禁用'); return; } injectPanel(); // 面板不依赖表格渲染,直接注入;表格在点击执行时才查找 })(); })();