// ==UserScript== // @name 内蒙古自治区政府采购网(PDF/Excel/Word附件预览) // @namespace http://tampermonkey.net/ // @version 2026.9.3 // @description 在政府采购公告底部预览PDF、Excel、Word附件,压缩包提示下载后查看 // @author AN drew // @match https://www.ccgp-neimenggu.gov.cn/ // @match https://www.ccgp-neimenggu.gov.cn/maincms-web/* // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @connect ccgp-neimenggu.gov.cn // @require https://lib.baomitu.com/pdf.js/2.13.216/pdf.min.js // @require https://lib.baomitu.com/xlsx/0.18.5/xlsx.full.min.js // @require https://lib.baomitu.com/mammoth/1.6.0/mammoth.browser.min.js // @run-at document-idle // ==/UserScript== (function() { 'use strict'; // 启用 JavaScript 严格模式,避免一些常见错误 // 配置 PDF.js 的 Worker 脚本路径,用于多线程解析 PDF pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://lib.baomitu.com/pdf.js/2.13.216/pdf.worker.min.js'; // 注入全局样式(与原有样式保持一致,并增加 zip 占位样式;以下所有 /* ... */ 注释均为原有,完整保留) GM_addStyle(` .articleContent { height: auto !important; min-height: auto !important; max-height: none !important; overflow: visible !important; } #articleCont { height: auto !important; min-height: auto !important; max-height: none !important; overflow: visible !important; } .attachment-block { margin-top: 25px; border: 1px solid #ddd; border-radius: 8px; background: #f9f9f9; box-shadow: 0 2px 8px rgba(0,0,0,0.1); overflow: hidden; } .attachment-header { padding: 10px 15px; background: #f0f7ff; border-bottom: 1px solid #ddd; font-size: 16px; font-weight: bold; color: #015293; display: flex; align-items: center; gap: 8px; } .attachment-header::before { content: "📎"; font-size: 20px; } .pdf-scroll-container { max-height: 80vh; overflow-y: auto; background: #525659; padding: 15px; box-sizing: border-box; transition: none; } .pdf-page-canvas { display: block; margin: 0 auto 15px auto; box-shadow: 0 2px 5px rgba(0,0,0,0.3); background: white; } .pdf-page-canvas:last-child { margin-bottom: 0; } .word-toolbar, .excel-toolbar, .pdf-toolbar, .zip-toolbar { display: flex; justify-content: flex-end; align-items: center; gap: 10px; padding: 8px 15px; background: #fff; border-bottom: 1px solid #eee; } .word-toolbar button, .excel-toolbar button, .pdf-toolbar button, .zip-toolbar button { padding: 4px 10px; font-size: 14px; cursor: pointer; border: 1px solid #ccc; background: #fff; border-radius: 4px; height: 30px; /* 固定高度,避免被全局样式撑大 */ line-height: 1; /* 防止全局 line-height 影响 */ box-sizing: border-box; /* 确保 padding 在高度内计算 */ } .word-toolbar button:hover, .excel-toolbar button:hover, .pdf-toolbar button:hover, .zip-toolbar button:hover { background: #e6e6e6; } .word-toolbar .zoom-value, .excel-toolbar .zoom-value, .pdf-toolbar .zoom-value, .zip-toolbar .zoom-value { min-width: 50px; text-align: center; font-size: 14px; } .excel-container { max-height: 70vh; overflow: auto; background: #ffffff; padding: 0; border-top: 1px solid #ddd; } .sheet-tabs { display: flex; flex-wrap: wrap; gap: 2px; padding: 8px 10px 0 10px; background: #f0f2f5; border-bottom: 1px solid #ccc; } .sheet-tab { padding: 6px 16px; background: #e9ecef; border: 1px solid #ccc; border-bottom: none; border-radius: 6px 6px 0 0; cursor: pointer; font-size: 13px; font-weight: 500; color: #333; margin-right: 2px; transition: all 0.1s; } .sheet-tab:hover { background: #dde2e6; } .sheet-tab.active { background: #fff; font-weight: 600; color: #015293; border-bottom: 2px solid #015293; box-shadow: 0 -1px 3px rgba(0,0,0,0.05); } .table-wrapper { overflow-x: auto; padding: 12px; background: #fff; } .excel-table { border-collapse: collapse; width: 100%; font-size: 14px; background: white; border: 1px solid #ddd; table-layout: auto; } .excel-table td, .excel-table th { border: 1px solid #bbb; padding: 8px 10px; text-align: left; vertical-align: middle; } .excel-table th { background-color: #e6f0fa; font-weight: 600; color: #1a1e24; position: sticky; top: 0; z-index: 10; border-bottom: 2px solid #015293; } .excel-table tr:nth-child(even) { background-color: #f8fafc; } .excel-table tr:hover { background-color: #eef3f9; } .word-container { max-height: 70vh; overflow: auto; background: #fdfdfd; padding: 25px 30px; border-top: 1px solid #ddd; font-family: '微软雅黑', '宋体', 'Helvetica Neue', Arial, sans-serif; font-size: 15px; line-height: 1.7; color: #2c3e50; } .word-container h1 { font-size: 2em; margin: 1.2em 0 0.6em; padding-bottom: 0.3em; border-bottom: 2px solid #015293; color: #015293; } .word-container h2 { font-size: 1.75em; margin: 1.1em 0 0.5em; padding-left: 12px; border-left: 5px solid #015293; color: #1e466e; } .word-container h3 { font-size: 1.5em; margin: 1em 0 0.4em; color: #2c5b8c; } .word-container h4 { font-size: 1.3em; margin: 0.9em 0 0.3em; color: #3a6a9e; } .word-container h5, .word-container h6 { font-size: 1.1em; margin: 0.8em 0 0.2em; color: #4a7fb5; } .word-container p { margin: 0.9em 0; text-indent: 2em; text-align: justify; word-break: break-word; } .word-container ul, .word-container ol { margin: 0.8em 0 0.8em 1.5em; padding-left: 1em; } .word-container li { margin: 0.3em 0; } .word-container table { border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); } .word-container th { background-color: #e6f0fa; color: #1a1e24; font-weight: 600; border: 1px solid #bbb; padding: 10px 12px; text-align: left; position: sticky; top: 0; z-index: 5; } .word-container td { border: 1px solid #ccc; padding: 8px 12px; vertical-align: top; } .word-container td p, .word-container th p { margin: 0 !important; text-indent: 0 !important; text-align: inherit !important; } .word-container td strong, .word-container th strong { white-space: nowrap; } .word-container tr:nth-child(even) { background-color: #f8fafc; } .word-container tr:nth-child(even):hover { background-color: #eef3f9; } .word-container tr:hover { background-color: #eef3f9; transition: background 0.15s; } .word-container img { max-width: 100%; height: auto; display: block; margin: 15px auto; box-shadow: 0 2px 6px rgba(0,0,0,0.1); border-radius: 4px; } .word-container blockquote { margin: 1em 0; padding: 0.5em 1.5em; background: #f5f7fa; border-left: 4px solid #015293; color: #4a5a6a; } .word-container pre, .word-container code { font-family: 'Consolas', 'Monaco', monospace; background: #f4f6f8; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; } .word-container pre { padding: 1em; overflow-x: auto; line-height: 1.5; } .loading-message { padding: 20px; text-align: center; color: #fff; font-size: 14px; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; min-height: 200px; } .zip-message, .doc-unsupported { padding: 20px; text-align: center; font-size: 16px; color: #856404; background: #fff3cd; border-radius: 4px; margin: 10px; } @media (max-width: 767px) { .attachment-header { font-size: 14px; } .pdf-scroll-container, .excel-container, .word-container { max-height: 70vh; } .excel-table td, .excel-table th { padding: 6px 8px; font-size: 13px; } } .amount-highlight { display: inline; white-space: nowrap; /* 金额不换行 */ } .wan-digit { position: relative; display: inline; color: inherit; } .wan-digit::after { content: ""; position: absolute; left: 50%; bottom: -0.5em; width: 8px; height: 8px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpolygon points='5,1 9,9 1,9' fill='%233b9eff'/%3E%3C/svg%3E"); background-size: contain; background-repeat: no-repeat; background-position: center; transform: translateX(-53%); pointer-events: none; } #wan-tooltip { position: fixed; z-index: 10000; pointer-events: none; display: none; background: #3b9eff; color: #fff; padding: 6px 10px; border-radius: 5px; font-size: 15px; font-weight: 600; line-height: 1.3; white-space: nowrap; box-shadow: 0 2px 8px rgba(0,0,0,0.25); transform: translate(-50%, -100%); } .attachment-block button svg { vertical-align: sub; margin-right: 3px; } `); // 当 enabled: false 时,是否清除存储的列宽数据(true=清除,false=保留) const CLEAR_STORAGE_ON_DISABLE = true; // 默认为 true,即重置 // ================================================================ // 多页面表格列宽配置(基于实际 HTML 结构) // ================================================================ const TABLE_CONFIGS = [ { id: 'notice', match: '/noticeInformationNm', tableSelector: 'table.el-table__body', storageKey: 'colWidths_notice', minWidth: 30, enabled: true, // 采购公告 }, { id: 'singleSource', match: '/singleSourceNm', tableSelector: 'table.el-table__body', storageKey: 'colWidths_singleSource', minWidth: 30, enabled: false, // 单一来源 }, { id: 'electronicStore', match: '/electronicStoreNm', tableSelector: 'table.el-table__body', storageKey: 'colWidths_electronicStore', minWidth: 30, enabled: false, // 电子卖场 }, { id: 'framework', match: '/frameworkNm', tableSelector: 'table.el-table__body', storageKey: 'colWidths_framework', minWidth: 30, enabled: false, // 框架协议 }, ]; // 获取当前页面匹配的配置 function getCurrentConfig() { const path = window.location.pathname; for (const config of TABLE_CONFIGS) { if (path.includes(config.match)) { return config; } } return null; } // ================================================================ // 清理所有表格上的手柄(用于 SPA 切换时重置状态) // ================================================================ function removeAllHandles() { const tables = document.querySelectorAll('table.el-table__body'); tables.forEach(table => { table.querySelectorAll('.col-resize-handle').forEach(el => el.remove()); // 重置表格 position(避免残留样式干扰) table.style.position = ''; }); console.log('[列宽调节] 已清理所有手柄'); } // ================================================================ // 为当前页面初始化列宽调节(独立于附件预览,专供列表页使用) // ================================================================ function initTableResizeForCurrentPage() { // ----- 1. 获取当前页面的配置 ----- const config = getCurrentConfig(); // ----- 定义辅助函数(仅在本作用域使用)----- const TABLE_RESIZE_CSS = ` /* 列宽拖拽手柄 */ .col-resize-handle { transition: background-color 0.15s, border-color 0.15s; } .col-resize-handle:hover { border-right-color: #3b9eff !important; background-color: rgba(59, 158, 255, 0.15) !important; } .col-resize-handle:active { border-right-color: #015293 !important; background-color: rgba(1, 82, 147, 0.25) !important; } /* 页面宽度 */ .el-container .container { display: flex; width: 1600px !important; } .el-container .container .container_l { width: 14% !important; max-width: 196px !important; flex-shrink: 0; } .el-container .container .container_r { flex: 1; width: 0 !important; min-width: 0; } /* 表格布局 */ table.el-table__body { width: 100% !important; table-layout: fixed; } `; function resetContainerWidth() { const containers = document.querySelectorAll('.el-container .container'); containers.forEach(el => { // 移除内联样式(如果有) el.style.width = ''; el.style.display = ''; // 如果希望完全恢复,也可以重置为 'auto' 或 '100%',但最好让页面默认值生效 }); } function applyTableResizeStyles() { if (document.getElementById('table-resize-styles')) return; const style = document.createElement('style'); style.id = 'table-resize-styles'; style.textContent = TABLE_RESIZE_CSS; document.head.appendChild(style); } function removeTableResizeStyles() { const style = document.getElementById('table-resize-styles'); if (style) style.remove(); resetContainerWidth(); // 重置容器宽度 } function resetColumnWidths(table) { if (!table) return; // 移除所有 col 的 width 属性 const cols = table.querySelectorAll('colgroup col'); cols.forEach(col => col.removeAttribute('width')); // 重置表格定位 table.style.position = ''; // 移除所有手柄 table.querySelectorAll('.col-resize-handle').forEach(el => el.remove()); // 删除存储的百分比数据(如果有) if (config && config.storageKey) { if(CLEAR_STORAGE_ON_DISABLE) { GM_setValue(config.storageKey, null); // 或 GM_deleteValue(config.storageKey) } } } // ----- 2. 处理没有配置的情况(如页面不在匹配列表中)----- if (!config) { removeTableResizeStyles(); // 清理所有可能的表格(防止遗留影响) document.querySelectorAll('table.el-table__body').forEach(table => { resetColumnWidths(table); }); return; } // ----- 3. 如果当前配置被禁用 ----- if (config.enabled === false) { removeTableResizeStyles(); // 移除样式 const table = document.querySelector(config.tableSelector); if (table) { resetColumnWidths(table); // 恢复默认列宽并清理手柄 } // 即使没有表格,也确保存储被清除(防止数据残留) if(CLEAR_STORAGE_ON_DISABLE) { GM_setValue(config.storageKey, null); } return; } // ----- 4. 如果 enabled === true,启用功能 ----- applyTableResizeStyles(); // 注入样式 // 获取表格 const table = document.querySelector(config.tableSelector); if (!table) return; // 表格不存在则退出 // 检查是否已经初始化(有手柄) if (table.querySelector('.col-resize-handle')) return; // 检查是否有数据行(无数据则不初始化) const tbody = table.querySelector('tbody'); if (!tbody || tbody.children.length === 0) return; // ----- 5. 调用原有的初始化逻辑(保持和之前一致)----- initResizableColumns(config); // 此函数需要你在外部定义,或直接内联其代码 // 注意:initResizableColumns 内部会读取存储并设置 col width、创建手柄等 } // ================================================================ // 通用表格列宽拖拽调节(存储百分比,只改变相邻两列) // ================================================================ function initResizableColumns(config) { if (!config) return; const table = document.querySelector(config.tableSelector); if (!table) { console.log(`[列宽调节] 未找到表格: ${config.tableSelector}`); return; } const tbody = table.querySelector('tbody'); if (tbody && tbody.children.length === 0) { console.log('[列宽调节] 表格无数据,跳过'); return; } if (table.querySelector('.col-resize-handle')) { console.log('[列宽调节] 表格已初始化,跳过'); return; } const colgroup = table.querySelector('colgroup'); if (!colgroup) return; const cols = colgroup.querySelectorAll('col'); if (cols.length < 2) return; const storageKey = config.storageKey || 'tableColWidths_default'; const minWidth = config.minWidth || 30; // 像素,用于拖拽时的最小宽度 // ----- 获取当前实际渲染列宽(像素)----- function getActualColumnWidths() { const firstRow = table.querySelector('tbody tr'); if (!firstRow) return []; const tds = firstRow.querySelectorAll('td'); const widths = []; for (let i = 0; i < cols.length; i++) { if (i < tds.length) { widths.push(tds[i].offsetWidth); } else { const w = parseFloat(cols[i].getAttribute('width')) || 110; widths.push(w); } } return widths; } // ----- 读取或初始化百分比 ----- let savedWidths = GM_getValue(storageKey, null); const tableWidth = table.clientWidth; if (savedWidths && Array.isArray(savedWidths) && savedWidths.length === cols.length) { savedWidths.forEach((pct, i) => { cols[i].setAttribute('width', pct + '%'); }); console.log(`[列宽调节] 恢复百分比: ${savedWidths.join(', ')}`); } else { const actualWidths = getActualColumnWidths(); if (actualWidths.length === cols.length && tableWidth > 0) { const percentages = actualWidths.map(px => (px / tableWidth * 100).toFixed(2)); GM_setValue(storageKey, percentages); cols.forEach((col, i) => { col.setAttribute('width', percentages[i] + '%'); }); console.log(`[列宽调节] 首次初始化百分比: ${percentages.join(', ')}`); } else { const defaultPct = (100 / cols.length).toFixed(2); cols.forEach(col => col.setAttribute('width', defaultPct + '%')); const defaultArr = Array(cols.length).fill(defaultPct); GM_setValue(storageKey, defaultArr); } } // 确保表格相对定位 if (window.getComputedStyle(table).position === 'static') { table.style.position = 'relative'; } // ----- 更新手柄位置(根据实际像素)----- function updateHandlesPosition() { const handles = table.querySelectorAll('.col-resize-handle'); if (handles.length === 0) return; const widths = getActualColumnWidths(); let left = 0; for (let i = 0; i < cols.length - 1; i++) { left += widths[i] || 0; if (handles[i]) { handles[i].style.left = (left - 3) + 'px'; } } } // ----- 创建手柄 ----- for (let i = 0; i < cols.length - 1; i++) { const handle = document.createElement('div'); handle.className = 'col-resize-handle'; handle.dataset.index = i; handle.style.position = 'absolute'; handle.style.top = '0'; handle.style.width = '6px'; handle.style.height = '100%'; handle.style.cursor = 'col-resize'; handle.style.zIndex = '10'; handle.style.backgroundColor = 'transparent'; handle.style.borderRight = '2px solid #ddd'; table.appendChild(handle); } updateHandlesPosition(); // ===== 拖拽逻辑(只改变相邻两列) ===== let dragIndex = -1; let startX = 0; let startActualWidth = 0; // 左列开始时的实际像素宽度 let totalPct = 0; // 两列百分比之和 function onMouseDown(e) { const handle = e.target.closest('.col-resize-handle'); if (!handle) return; e.preventDefault(); dragIndex = parseInt(handle.dataset.index); startX = e.clientX; // 读取当前两列的百分比,计算总和 const leftCol = cols[dragIndex]; const rightCol = cols[dragIndex + 1]; const leftPct = parseFloat(leftCol.getAttribute('width')) || 0; const rightPct = parseFloat(rightCol.getAttribute('width')) || 0; totalPct = leftPct + rightPct; // 获取左列当前实际像素宽度(作为基准) const actualWidths = getActualColumnWidths(); startActualWidth = actualWidths[dragIndex] || 0; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); document.body.style.userSelect = 'none'; table.style.cursor = 'col-resize'; } function onMouseMove(e) { if (dragIndex === -1) return; const dx = e.clientX - startX; const currentTableWidth = table.clientWidth; if (currentTableWidth === 0) return; // 计算左列新的实际像素宽度 let newActual = Math.max(minWidth, startActualWidth + dx); // 换算为百分比 let newLeftPct = (newActual / currentTableWidth) * 100; // 限制左右列最小百分比(2%) const minPct = 2; newLeftPct = Math.max(minPct, Math.min(totalPct - minPct, newLeftPct)); // 更新两列 const leftCol = cols[dragIndex]; const rightCol = cols[dragIndex + 1]; leftCol.setAttribute('width', newLeftPct + '%'); rightCol.setAttribute('width', (totalPct - newLeftPct) + '%'); // 更新手柄位置 updateHandlesPosition(); } function onMouseUp() { if (dragIndex !== -1) { const pcts = Array.from(cols).map(col => parseFloat(col.getAttribute('width')) || 0); const saved = pcts.map(p => p.toFixed(2)); GM_setValue(storageKey, saved); console.log(`[列宽调节] 保存百分比: ${saved.join(', ')}`); dragIndex = -1; } document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); document.body.style.userSelect = ''; table.style.cursor = ''; } table.querySelectorAll('.col-resize-handle').forEach(h => { h.addEventListener('mousedown', onMouseDown); }); let resizeTimer = null; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(updateHandlesPosition, 200); }); console.log(`[列宽调节] 已初始化: ${storageKey} (相邻两列模式)`); } // 标记是否已插入预览(防止重复插入) let previewInserted = false; // 记录已处理过的附件 URL,避免重复加载 const processedUrls = new Set(); // ===== 并发控制:限制同时执行的异步任务数量 ===== // limit:最大并发数;array:任务列表;iteratorFn:对每个元素执行的异步函数,返回 Promise async function asyncPool(limit, array, iteratorFn) { // 存储所有任务的 Promise const results = []; // 存储当前正在执行的任务(用于控制并发数) const executing = []; // 遍历任务列表 for (const item of array) { // 立即创建一个 Promise 并开始执行迭代器函数(包装为微任务) const p = Promise.resolve().then(() => iteratorFn(item)); results.push(p); // 记录该任务的 Promise // 如果任务总数大于设定的并发限制,则开始控制并发 if (limit <= array.length) { // 任务完成后从 executing 数组中移除自身 const e = p.then(() => executing.splice(executing.indexOf(e), 1)); executing.push(e); // 将清理函数加入执行中列表 // 如果当前正在执行的任务数量达到限制,则等待任意一个完成 if (executing.length >= limit) { await Promise.race(executing); } } } // 等待所有任务完成 return Promise.all(results); } // 将页面标题同步到浏览器标签页标题 function updatePageTitle() { // 查找页面中的标题元素(类名为 articleTitle) const titleElem = document.querySelector('.articleTitle'); if (titleElem) { // 获取标题文本并去除首尾空白 const titleText = titleElem.textContent.trim(); if (titleText) { // 设置为浏览器标签页标题 document.title = titleText; } } } // 从 URL 中提取文件扩展名(小写) function getFileTypeFromUrl(url) { try { // 解析 URL const urlObj = new URL(url); // 获取路径部分(转为小写) const path = urlObj.pathname.toLowerCase(); // 获取文件名(最后一个 / 之后的部分) const fileName = path.split('/').pop() || ''; // 匹配扩展名(以点开头,后面是字母数字) const extMatch = fileName.match(/\.([a-z0-9]+)$/i); // 如果匹配到则返回扩展名(转为小写),否则返回空字符串 return extMatch ? extMatch[1] : ''; } catch (e) { // URL 解析失败时返回空字符串 return ''; } } // 判断是否为 PDF 附件链接(基于扩展名或链接文本包含 .pdf) function isPdfAttachmentLink(link) { // 先尝试从 URL 提取扩展名 const ext = getFileTypeFromUrl(link.href); if (ext === 'pdf') return true; // 扩展名是 pdf // 再检查链接文字(小写)是否包含 .pdf const text = (link.textContent || '').trim().toLowerCase(); if (text.includes('.pdf')) return true; // 均不满足则不是 PDF return false; } // 判断是否为 Excel 附件链接 function isExcelAttachment(link) { const ext = getFileTypeFromUrl(link.href); if (ext === 'xls' || ext === 'xlsx') return true; const text = (link.textContent || '').trim().toLowerCase(); if (text.includes('.xls')) return true; return false; } // 判断是否为 Word 附件链接 function isWordAttachment(link) { const ext = getFileTypeFromUrl(link.href); if (ext === 'doc' || ext === 'docx') return true; if (!ext) { const text = (link.textContent || '').trim().toLowerCase(); return text.includes('.docx') || (text.includes('.doc') && !text.includes('.docx')); } return false; } // 判断是否为 ZIP 压缩包 function isZipAttachment(link) { const ext = getFileTypeFromUrl(link.href); if (ext === 'zip') return true; const text = (link.textContent || '').trim().toLowerCase(); if (text.includes('.zip')) return true; return false; } // 判断是否为旧版 Word (.doc) 附件(不支持预览) function isUnsupportedDocAttachment(link) { const ext = getFileTypeFromUrl(link.href); if (ext === 'doc') return true; // 如果无法从 URL 获取扩展名,再检查链接文字,但必须排除 .docx if (!ext) { const text = (link.textContent || '').trim().toLowerCase(); return text.includes('.doc') && !text.includes('.docx'); } return false; } // 从链接中获取可读的附件名称(优先取链接文字,否则从 URL 解析) function getAttachmentName(link) { // 先取链接文字并去除首尾空白 let name = (link.textContent || '').trim(); if (!name) { // 如果链接文字为空,则尝试从 URL 中解析文件名 try { const url = new URL(link.href); // 解码并取最后一个路径段作为名称 name = decodeURIComponent(url.pathname.split('/').pop()) || '附件'; } catch { name = '附件'; // 解析失败则使用默认名称 } } // 如果名称过长(超过 100 字符),截断并加省略号 return name.length > 100 ? name.substring(0, 97) + '...' : name; } // 在指定容器中查找所有可识别的附件链接,并整理为对象数组(适配政府采购网) function findAllAttachments(container) { // 获取容器中所有 标签 const links = container.querySelectorAll('a'); const attachments = []; const seenKeys = new Set(); // 用于去重 // 遍历每个链接 for (let link of links) { // 跳过已经处理过的 URL(全局记录) if (processedUrls.has(link.href)) continue; // 判断附件类型 let type = null; if (isPdfAttachmentLink(link)) type = 'pdf'; else if (isExcelAttachment(link)) type = 'excel'; else if (isZipAttachment(link)) type = 'zip'; else if (isUnsupportedDocAttachment(link)) { type = 'word_unsupported'; // 旧版 .doc 单独标记 } else if (isWordAttachment(link)) { type = 'word'; // .docx 正常预览 } else continue; // 不是可识别的附件,跳过 // 生成去重键(后续用于判断是否已存在相同附件) const url = link.href; const name = (link.textContent || '').trim(); let fileName = ''; try { const urlObj = new URL(url); const path = urlObj.pathname; fileName = path.split('/').pop().toLowerCase() || ''; } catch (e) { const parts = url.split('?')[0].split('/'); fileName = parts[parts.length - 1].toLowerCase() || ''; } let key = `${type}:${name}|${fileName}`; // 如果键已存在,说明是重复附件,跳过 if (seenKeys.has(key)) continue; seenKeys.add(key); // 将附件信息存入数组 attachments.push({ url: link.href, name: getAttachmentName(link), type: type, element: link // 保留原始 DOM 元素引用(备用) }); } return attachments; } // 跨域获取文件(使用 GM_xmlhttpRequest) function fetchBlob(url) { // 返回一个 Promise,用于异步获取 Blob return new Promise((resolve, reject) => { // 调用 GM_xmlhttpRequest 发起请求 GM_xmlhttpRequest({ method: 'GET', // 使用 GET 方法 url: url, // 请求目标 URL responseType: 'blob', // 指定响应类型为 Blob headers: { 'Referer': window.location.href, // 模拟引用来源 'User-Agent': navigator.userAgent }, cookie: document.cookie, // 携带当前页面的 Cookie(可能用于鉴权) onload: function(res) { // 请求成功回调 if (res.status === 200) { resolve(res.response); // 返回 Blob 数据 } else { reject(new Error(`HTTP ${res.status}`)); // 非 200 状态视为错误 } }, onerror: reject, // 网络错误时拒绝 ontimeout: () => reject(new Error('请求超时')) // 超时拒绝 }); }); } // 判断是否需要通过代理获取文件(跨域检测) function shouldProxyUrl(url) { try { // 解析 URL 获取主机名 const linkHost = new URL(url).host; const currentHost = window.location.host; // 如果附件链接的主机与当前页面主机不同,则需要代理 return linkHost !== currentHost; } catch (e) { // URL 解析失败时,保守起见也走代理 return true; } } // 在正文中标记金额(万位添加标记) function markAmounts(container) { // 清除已有标记 const existingHighlights = container.querySelectorAll('.amount-highlight'); if (existingHighlights.length > 0) { existingHighlights.forEach(el => { const text = el.textContent; el.replaceWith(text); }); } // ========== 识别表格中的金额列及其单位(修正版) ========== const amountTdSet = new WeakSet(); const unitMap = new WeakMap(); function isAmountHeader(text) { return /金额|元|价/.test(text); } function extractUnit(text) { const match = text.match(/(万元|元)/); return match ? match[1] : '元'; } function getHeaderRow(table) { const thead = table.querySelector('thead'); if (thead) { const rows = thead.querySelectorAll('tr'); if (rows.length > 0) return rows[0]; } const firstRow = table.querySelector('tr'); if (firstRow) { const cells = firstRow.querySelectorAll('th, td'); for (const cell of cells) { if (isAmountHeader(cell.textContent)) { return firstRow; } } } return null; } const tables = container.querySelectorAll('table'); for (const table of tables) { const headerRow = getHeaderRow(table); if (!headerRow) continue; // 获取所有行(包括表头和数据行) const rows = table.rows; // HTMLCollection // 计算总列数:取表头行所有单元格的 colspan 之和 let totalCols = 0; const headerCells = headerRow.cells; for (let i = 0; i < headerCells.length; i++) { const cell = headerCells[i]; const cs = parseInt(cell.getAttribute('colspan')) || 1; totalCols += cs; } // 识别表头中的金额列,记录起始列和单位 const amountColSet = new Set(); const colUnitMap = new Map(); let curCol = 0; for (let i = 0; i < headerCells.length; i++) { const cell = headerCells[i]; const cs = parseInt(cell.getAttribute('colspan')) || 1; const text = cell.textContent.trim(); if (isAmountHeader(text)) { const unit = extractUnit(text); for (let j = curCol; j < curCol + cs; j++) { amountColSet.add(j); colUnitMap.set(j, unit); } } curCol += cs; } if (amountColSet.size === 0) continue; // 遍历每一行(跳过表头行) const rowSpanRemaining = new Array(totalCols).fill(0); for (let r = 0; r < rows.length; r++) { const row = rows[r]; if (row === headerRow) continue; // ===== 关键修改:行首减1,并标记被占用的列 ===== const occupied = new Array(totalCols).fill(false); for (let c = 0; c < totalCols; c++) { if (rowSpanRemaining[c] > 0) { occupied[c] = true; // 当前行该列被跨行单元格占用,没有td rowSpanRemaining[c]--; // 剩余占用行数减1 } } let curColPos = 0; const cells = row.cells; for (let i = 0; i < cells.length; i++) { const cell = cells[i]; // 跳过被占用的列(这些列没有td) while (curColPos < totalCols && occupied[curColPos]) { curColPos++; } if (curColPos >= totalCols) break; const cs = parseInt(cell.getAttribute('colspan')) || 1; const rs = parseInt(cell.getAttribute('rowspan')) || 1; // 识别金额列(原有逻辑) if (amountColSet.has(curColPos)) { let unit = colUnitMap.get(curColPos) || '元'; const cellText = cell.textContent; if (/万元/.test(cellText)) unit = '万元'; else if (/元/.test(cellText)) unit = '元'; amountTdSet.add(cell); unitMap.set(cell, unit); } // 如果当前单元格有 rowspan,设置后续行被占用的行数 if (rs > 1) { for (let j = curColPos; j < curColPos + cs; j++) { // 当前行已经处理,后续还需要 rs-1 行被占用 rowSpanRemaining[j] = Math.max(rowSpanRemaining[j], rs - 1); } } curColPos += cs; } // 无需在行末减1(已在行首处理) } } // ========== 识别结束 ========== const regex = /(\d+(?:,\d{3})*)(\.\d+)?(\s*元)?/g; const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null, false); const nodesToReplace = []; let node; while (node = walker.nextNode()) { const parent = node.parentElement; if (!parent) continue; let isAllowed = false; let isTableCell = false; const closestP = parent.closest('p, .u-content'); if (closestP) { isAllowed = true; } const closestTd = parent.closest('td'); if (closestTd && (amountTdSet.has(closestTd) || closestTd.classList.contains('alignright'))) { isAllowed = true; isTableCell = true; } if (!isAllowed) continue; if (node.textContent.match(regex)) { nodesToReplace.push({ node, isTableCell, td: closestTd }); } } nodesToReplace.forEach(({ node: textNode, isTableCell, td }) => { const parent = textNode.parentNode; if (isTableCell && td) { const tr = td.parentElement; const table = tr && tr.closest('table'); if (table) { const thead = table.querySelector('thead'); if (thead) { const ths = thead.querySelectorAll('th'); const lastTh = ths[ths.length - 1]; if (lastTh && lastTh.textContent.includes('得分')) { const tds = tr.querySelectorAll('td'); const lastTd = tds[tds.length - 1]; if (td === lastTd) { return; } } } } } let unit = '元'; if (isTableCell && td && unitMap.has(td)) { unit = unitMap.get(td); } const fragment = document.createDocumentFragment(); let lastIndex = 0; const text = textNode.textContent; let match; regex.lastIndex = 0; while ((match = regex.exec(text)) !== null) { if (match.index > 0 && text[match.index - 1] === '万') continue; const unitCheck = match[3] || ''; if (!isTableCell && !unitCheck.includes('元')) continue; const fullMatch = match[0]; const numWithComma = match[1]; const decimal = match[2] || ''; const numStr = numWithComma.replace(/,/g, ''); let displayDecimal = ''; if (decimal) { let decimalDigits = decimal.substring(1); decimalDigits = decimalDigits.replace(/0+$/, ''); if (decimalDigits.length > 0) { displayDecimal = '.' + decimalDigits; } } // ---- 计算气泡显示内容 ---- let wanAmount; if (unit === '万元') { const fullNum = numStr + displayDecimal; wanAmount = fullNum.replace(/\.?0+$/, '') + ' 万元'; } else { const fullNum = parseFloat(numStr + displayDecimal); if (fullNum >= 10000) { const value = fullNum / 10000; wanAmount = value.toFixed(6).replace(/\.?0+$/, '') + ' 万元'; } else { wanAmount = fullNum.toFixed(6).replace(/\.?0+$/, '') + ' 元'; } } const prefix = text.substring(lastIndex, match.index); let numberHtml; const unitChar = (match[3] || '').includes('元') ? '元' : ''; // ---- 角标插入逻辑 ---- if (unit === '万元') { // 万元单位:角标标记在个位(整数部分最后一位) if (numStr.length >= 1) { const insertPos = numWithComma.length - 1; const beforeDigit = numWithComma.substring(0, insertPos); const wanDigit = numWithComma.charAt(insertPos); numberHtml = beforeDigit + `${wanDigit}` + decimal + unitChar; } else { numberHtml = numWithComma + decimal + unitChar; } } else { // 元单位:仅当整数部分 >= 5 位(即 >= 1 万元)时,标记在万位 if (numStr.length >= 5) { const wanIndex = numStr.length - 5; let digitCount = 0; let insertPos = -1; for (let i = 0; i < numWithComma.length; i++) { if (/\d/.test(numWithComma[i])) { if (digitCount === wanIndex) { insertPos = i; break; } digitCount++; } } if (insertPos !== -1) { const beforeDigit = numWithComma.substring(0, insertPos); const wanDigit = numWithComma.charAt(insertPos); const afterDigit = numWithComma.substring(insertPos + 1); numberHtml = beforeDigit + `${wanDigit}` + afterDigit + decimal + unitChar; } else { numberHtml = numWithComma + decimal + unitChar; } } else { // 小于 1 万元:不插入角标,直接显示原数字 numberHtml = numWithComma + decimal + unitChar; } } const replacementHtml = prefix + `` + numberHtml + ``; const span = document.createElement('span'); span.innerHTML = replacementHtml; fragment.appendChild(span); lastIndex = match.index + fullMatch.length; } if (lastIndex < text.length) { fragment.appendChild(document.createTextNode(text.substring(lastIndex))); } parent.replaceChild(fragment, textNode); }); } // ================================================================ // PDF 预览(使用 PDF.js) // ================================================================ async function createPdfViewer(pdfUrl, title, container) { // 创建整个预览卡片容器 const block = document.createElement('div'); block.className = 'attachment-block'; // 创建卡片头部,显示文件名 const header = document.createElement('div'); header.className = 'attachment-header'; header.textContent = title; // 设置标题文字 block.appendChild(header); // 创建工具栏(缩放按钮) const toolbar = document.createElement('div'); toolbar.className = 'pdf-toolbar'; const zoomOut = document.createElement('button'); zoomOut.textContent = '−'; // 缩小按钮 const zoomIn = document.createElement('button'); zoomIn.textContent = '+'; // 放大按钮 const zoomReset = document.createElement('button'); zoomReset.textContent = '重置'; // 重置缩放 const zoomSpan = document.createElement('span'); zoomSpan.className = 'zoom-value'; zoomSpan.textContent = '100%'; // 显示当前缩放百分比 const downloadBtn = document.createElement('button'); downloadBtn.innerHTML = `下载` downloadBtn.addEventListener('click', () => window.open(pdfUrl, '_blank')); toolbar.appendChild(zoomOut); toolbar.appendChild(zoomSpan); toolbar.appendChild(zoomIn); toolbar.appendChild(zoomReset); toolbar.appendChild(downloadBtn); block.appendChild(toolbar); // 创建 PDF 滚动容器(用于放置渲染后的 canvas) const scrollDiv = document.createElement('div'); scrollDiv.className = 'pdf-scroll-container'; block.appendChild(scrollDiv); // 加载提示 const loadingDiv = document.createElement('div'); loadingDiv.className = 'loading-message'; loadingDiv.textContent = '正在加载 PDF,请稍候...'; scrollDiv.appendChild(loadingDiv); // 将整个卡片添加到传入的容器中 container.appendChild(block); let finalUrl = pdfUrl; // 最终的 PDF 地址(可能是原始地址或对象 URL) let isBlobUrl = false; // 标记 finalUrl 是否是 Blob URL(用于后续释放) // 若跨域则通过代理获取文件 if (shouldProxyUrl(pdfUrl)) { loadingDiv.textContent = '正在通过代理获取 PDF 文件,请稍候...'; try { // 通过 GM_xmlhttpRequest 获取 Blob const blob = await fetchBlob(pdfUrl); // 创建对象 URL,使 PDF.js 可以直接加载 finalUrl = URL.createObjectURL(blob); isBlobUrl = true; // 标记为 Blob URL } catch (e) { console.error('代理获取失败,尝试直接加载:', e); loadingDiv.textContent = '代理获取失败,尝试直接加载...'; // 不改变 finalUrl,继续尝试直接加载原始地址(可能因 CORS 失败) } } // 使用 PDF.js 渲染 try { // 加载 PDF 文档 const pdf = await pdfjsLib.getDocument(finalUrl).promise; const totalPages = pdf.numPages; // 总页数 loadingDiv.textContent = `共 ${totalPages} 页,正在渲染...`; // 缩放参数定义 const MIN_SCALE = 0.25; const MAX_SCALE = 3.0; const SCALE_STEP = 0.25; let currentScale = 1.5; // 初始缩放比例 // 缓存已渲染的页面(按缩放比例缓存,避免重复渲染) const scaleCache = new Map(); // 内部函数:按指定比例渲染所有页面 async function renderAtScale(targetScale) { // 如果缓存中有该比例的结果,直接复用 if (scaleCache.has(targetScale)) { const cached = scaleCache.get(targetScale); scrollDiv.innerHTML = ''; // 清空容器 // 将缓存的 canvas 重新添加到容器中 cached.canvases.forEach(item => scrollDiv.appendChild(item.canvas)); // 更新缩放显示 zoomSpan.textContent = Math.round(targetScale * 100) + '%'; return; } // 记录当前容器高度,防止渲染时高度变化导致跳动 const currentHeight = scrollDiv.clientHeight; scrollDiv.style.minHeight = currentHeight + 'px'; scrollDiv.style.height = currentHeight + 'px'; scrollDiv.style.overflowY = 'hidden'; // 暂时隐藏滚动条 // 清空并创建居中加载提示 scrollDiv.innerHTML = ''; const tempLoading = document.createElement('div'); tempLoading.className = 'loading-message'; tempLoading.textContent = `正在渲染 ${Math.round(targetScale * 100)}% 页面...`; // 移除动态 line-height,改为依靠 flex 居中 scrollDiv.appendChild(tempLoading); // 让浏览器有时间更新 DOM await new Promise(resolve => setTimeout(resolve, 20)); const canvases = []; // 存储所有 canvas 元素 // 逐页渲染 for (let pageNum = 1; pageNum <= totalPages; pageNum++) { // 获取当前页面 const page = await pdf.getPage(pageNum); // 根据缩放比例计算视口尺寸 const viewport = page.getViewport({ scale: targetScale }); // 创建 canvas 并设置尺寸 const canvas = document.createElement('canvas'); canvas.className = 'pdf-page-canvas'; const context = canvas.getContext('2d'); canvas.height = viewport.height; canvas.width = viewport.width; // 渲染页面到 canvas await page.render({ canvasContext: context, viewport: viewport }).promise; canvases.push({ canvas, page }); // 更新加载进度 tempLoading.textContent = `渲染第 ${pageNum} / ${totalPages} 页...`; } // 将渲染结果存入缓存 scaleCache.set(targetScale, { canvases }); // 清空加载提示,添加所有 canvas scrollDiv.innerHTML = ''; canvases.forEach(item => scrollDiv.appendChild(item.canvas)); // 恢复滚动属性 scrollDiv.style.minHeight = ''; scrollDiv.style.height = ''; scrollDiv.style.overflowY = 'auto'; // 更新缩放显示 zoomSpan.textContent = Math.round(targetScale * 100) + '%'; } // 按当前缩放比例渲染 await renderAtScale(currentScale); // 绑定缩放按钮事件 zoomIn.addEventListener('click', async () => { if (currentScale < MAX_SCALE) { currentScale = Math.min(currentScale + SCALE_STEP, MAX_SCALE); await renderAtScale(currentScale); } }); zoomOut.addEventListener('click', async () => { if (currentScale > MIN_SCALE) { currentScale = Math.max(currentScale - SCALE_STEP, MIN_SCALE); await renderAtScale(currentScale); } }); zoomReset.addEventListener('click', async () => { currentScale = 1.5; // 重置到初始比例 await renderAtScale(currentScale); }); } catch (error) { // 捕获渲染过程中的错误,显示友好提示 console.error('PDF 渲染失败:', error); loadingDiv.innerHTML = '

PDF 加载失败,请稍后重试或检查网络。
若持续失败,请尝试右键链接另存为查看。

'; } finally { // 如果使用了对象 URL,在 5 秒后释放内存 if (isBlobUrl) { setTimeout(() => URL.revokeObjectURL(finalUrl), 5000); } } } // ================================================================ // Excel 预览(使用 SheetJS / XLSX) // ================================================================ async function createExcelViewer(excelUrl, title, container) { // ---------- 1. 构建预览卡片(DOM结构) ---------- const block = document.createElement('div'); block.className = 'attachment-block'; // 卡片头部:显示文件名 const header = document.createElement('div'); header.className = 'attachment-header'; header.textContent = title; block.appendChild(header); // 工具栏:包含"下载"按钮(方便用户保存原始文件) const toolbar = document.createElement('div'); toolbar.className = 'excel-toolbar'; const downloadBtn = document.createElement('button'); downloadBtn.innerHTML = `下载` downloadBtn.addEventListener('click', () => window.open(excelUrl, '_blank')); toolbar.appendChild(downloadBtn); block.appendChild(toolbar); // Excel 内容容器(用于显示表格或加载提示) const excelContainer = document.createElement('div'); excelContainer.className = 'excel-container'; block.appendChild(excelContainer); // 加载中的提示 const loadingDiv = document.createElement('div'); loadingDiv.className = 'loading-message'; loadingDiv.textContent = '正在加载 Excel 文件,请稍候...'; excelContainer.appendChild(loadingDiv); container.appendChild(block); // ---------- 2. 获取并解析 Excel 文件 ---------- try { let blob; // 跨域时通过 GM_xmlhttpRequest 代理获取,否则直接 fetch if (shouldProxyUrl(excelUrl)) { loadingDiv.textContent = '正在通过代理获取 Excel 文件...'; blob = await fetchBlob(excelUrl); } else { const res = await fetch(excelUrl); if (!res.ok) throw new Error(`HTTP ${res.status}`); blob = await res.blob(); } // 读取二进制数据并用 XLSX 解析为工作簿对象 const data = await blob.arrayBuffer(); const workbook = XLSX.read(data, { type: 'array' }); const sheetNames = workbook.SheetNames; // 所有工作表名称列表 if (sheetNames.length === 0) throw new Error('工作簿中没有工作表'); // 清空加载提示,准备渲染表格 excelContainer.innerHTML = ''; // 创建工作表切换标签栏 const tabsDiv = document.createElement('div'); tabsDiv.className = 'sheet-tabs'; excelContainer.appendChild(tabsDiv); // 表格包裹容器(用于水平滚动) const tableWrapper = document.createElement('div'); tableWrapper.className = 'table-wrapper'; excelContainer.appendChild(tableWrapper); // 存储每个工作表对应的 HTML 内容(键为工作表名称) const sheetTables = {}; const MAX_ROWS = 100; // 为防止大表格卡顿,最多显示 100 行数据 // ---------- 3. 遍历所有工作表,生成 HTML ---------- sheetNames.forEach(sheetName => { const worksheet = workbook.Sheets[sheetName]; // ★ 核心改进:使用 sheet_to_json 替代 sheet_to_html // header: 1 表示以数组形式返回(每行是一个数组),defval: '' 保证空单元格返回空字符串 let rows = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: '' }); let html; // 处理空工作表(rows 为空数组或 undefined) if (!rows || rows.length === 0) { // 显示友好提示,而不是抛出异常 html = `
📭 该工作表为空
`; } else { // 准备显示的行数据(可能截断) let displayRows = rows; let truncated = false; if (rows.length > MAX_ROWS) { displayRows = rows.slice(0, MAX_ROWS); truncated = true; } // 手动构建 HTML 表格 html = ''; displayRows.forEach((row, index) => { // 第一行作为表头('; // 计算当前所有显示行中的最大列数,保证每一行列数一致 const maxCols = Math.max(...displayRows.map(r => r.length)); for (let i = 0; i < maxCols; i++) { // 安全取值,避免 undefined/null const cell = (row[i] !== undefined && row[i] !== null) ? String(row[i]) : ''; html += `<${tag}>${cell}`; } html += ''; }); html += '
),其余为数据行() const tag = index === 0 ? 'th' : 'td'; html += '
'; // 如果发生了截断,追加提示信息 if (truncated) { html += `

⚠️ 表格共 ${rows.length} 行,仅显示前 ${MAX_ROWS} 行。如需完整数据请下载文件。

`; } } // 保存该工作表的 HTML 内容 sheetTables[sheetName] = html; // 创建对应的标签(tab) const tab = document.createElement('div'); tab.className = 'sheet-tab'; tab.textContent = sheetName; tab.dataset.sheet = sheetName; // 点击标签时切换显示对应工作表的内容 tab.addEventListener('click', () => { tabsDiv.querySelectorAll('.sheet-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); tableWrapper.innerHTML = sheetTables[sheetName]; }); tabsDiv.appendChild(tab); }); // 默认激活第一个标签,并显示第一个工作表的内容 if (tabsDiv.firstChild) { tabsDiv.firstChild.classList.add('active'); tableWrapper.innerHTML = sheetTables[sheetNames[0]]; } } catch (error) { // 捕获所有异常,输出错误并显示友好的失败信息 console.error('Excel 预览失败:', error); excelContainer.innerHTML = '

Excel 加载失败,请稍后重试或直接下载。

'; } } // ================================================================ // Word 预览(使用 mammoth.js 将 docx 转为 HTML) // ================================================================ async function createWordViewer(docUrl, title, container) { // 构建预览卡片 const block = document.createElement('div'); block.className = 'attachment-block'; const header = document.createElement('div'); header.className = 'attachment-header'; header.textContent = title; block.appendChild(header); // 工具栏带下载按钮 const toolbar = document.createElement('div'); toolbar.className = 'word-toolbar'; const downloadBtn = document.createElement('button'); downloadBtn.innerHTML = `下载` downloadBtn.addEventListener('click', () => window.open(docUrl, '_blank')); toolbar.appendChild(downloadBtn); block.appendChild(toolbar); const wordContainer = document.createElement('div'); wordContainer.className = 'word-container'; block.appendChild(wordContainer); const loadingDiv = document.createElement('div'); loadingDiv.className = 'loading-message'; loadingDiv.textContent = '正在加载 Word 文档,请稍候...'; wordContainer.appendChild(loadingDiv); container.appendChild(block); try { let blob; if (shouldProxyUrl(docUrl)) { loadingDiv.textContent = '正在通过代理获取 Word 文件...'; blob = await fetchBlob(docUrl); } else { const res = await fetch(docUrl); if (!res.ok) throw new Error(`HTTP ${res.status}`); blob = await res.blob(); } // 读取二进制数据 const arrayBuffer = await blob.arrayBuffer(); // 使用 mammoth 将 docx 转换为 HTML const result = await mammoth.convertToHtml({ arrayBuffer: arrayBuffer }); const html = result.value; // 将 HTML 放入容器 wordContainer.innerHTML = html; // 注入额外样式,与页面整体风格统一(已有的样式表中已定义,但为了保险再注入一次) const style = document.createElement('style'); style.textContent = ` .word-container { font-family: '微软雅黑', '宋体', 'Helvetica Neue', Arial, sans-serif; font-size: 15px; line-height: 1.7; color: #2c3e50; } .word-container h1 { font-size: 2em; margin: 1.2em 0 0.6em; padding-bottom: 0.3em; border-bottom: 2px solid #015293; color: #015293; } .word-container h2 { font-size: 1.75em; margin: 1.1em 0 0.5em; padding-left: 12px; border-left: 5px solid #015293; color: #1e466e; } .word-container h3 { font-size: 1.5em; margin: 1em 0 0.4em; color: #2c5b8c; } .word-container h4 { font-size: 1.3em; margin: 0.9em 0 0.3em; color: #3a6a9e; } .word-container h5, .word-container h6 { font-size: 1.1em; margin: 0.8em 0 0.2em; color: #4a7fb5; } .word-container p { margin: 0.9em 0; text-indent: 2em; text-align: justify; } .word-container table { border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px; } .word-container th { background-color: #e6f0fa; color: #1a1e24; font-weight: 600; border: 1px solid #bbb; padding: 10px 12px; } .word-container td { border: 1px solid #ccc; padding: 8px 12px; } .word-container td p, .word-container th p { margin: 0 !important; text-indent: 0 !important; text-align: inherit !important; } .word-container td strong, .word-container th strong { white-space: nowrap; } .word-container tr:nth-child(even) { background-color: #f8fafc; } .word-container tr:nth-child(even):hover { background-color: #eef3f9; } .word-container img { max-width: 100%; height: auto; margin: 15px auto; display: block; } .word-container ul, .word-container ol { margin: 0.8em 0 0.8em 1.5em; padding-left: 1em; } `; wordContainer.appendChild(style); } catch (error) { console.error('Word 预览失败:', error); wordContainer.innerHTML = '

Word 文档加载失败,请稍后重试或直接下载。

'; } } // ================================================================ // ZIP 占位视图(仅提示下载,不预览内容) // ================================================================ async function createZipViewer(zipUrl, title, container) { const block = document.createElement('div'); block.className = 'attachment-block'; const header = document.createElement('div'); header.className = 'attachment-header'; header.textContent = title; block.appendChild(header); const toolbar = document.createElement('div'); toolbar.className = 'zip-toolbar'; const downloadBtn = document.createElement('button'); downloadBtn.innerHTML = `下载` downloadBtn.addEventListener('click', () => window.open(zipUrl, '_blank')); toolbar.appendChild(downloadBtn); block.appendChild(toolbar); const contentDiv = document.createElement('div'); contentDiv.className = 'zip-message'; contentDiv.innerHTML = '📦 此附件为压缩包(.zip),暂不支持在线预览,请点击下载按钮解压后查看。'; block.appendChild(contentDiv); container.appendChild(block); } // ================================================================ // 旧版 Word (.doc) 占位视图(不支持预览) // ================================================================ async function createUnsupportedDocViewer(docUrl, title, container) { const block = document.createElement('div'); block.className = 'attachment-block'; const header = document.createElement('div'); header.className = 'attachment-header'; header.textContent = title; block.appendChild(header); const toolbar = document.createElement('div'); toolbar.className = 'zip-toolbar'; // 复用 ZIP 工具栏样式 const downloadBtn = document.createElement('button'); downloadBtn.innerHTML = `下载` downloadBtn.addEventListener('click', () => window.open(docUrl, '_blank')); toolbar.appendChild(downloadBtn); block.appendChild(toolbar); const contentDiv = document.createElement('div'); contentDiv.className = 'zip-message'; // 复用 ZIP 提示样式 contentDiv.innerHTML = '📄 此附件为旧版 Word 文档(.doc)格式,暂不支持在线预览,请点击下载按钮使用 Word 打开查看。'; block.appendChild(contentDiv); container.appendChild(block); } // ================================================================ // 金额气泡提示:创建全局气泡并绑定事件委托 // ================================================================ // 用于记录已经绑定过气泡事件的容器,防止重复绑定导致事件堆积 const tooltipBoundContainers = new WeakSet(); // 创建全局气泡并绑定事件委托 function initWanTooltip(contText) { // 如果该容器已经绑定过事件,直接返回,避免重复添加监听器 if (tooltipBoundContainers.has(contText)) return; // 标记当前容器为已绑定 tooltipBoundContainers.add(contText); // 创建全局气泡元素(如果不存在) let tooltip = document.getElementById('wan-tooltip'); if (!tooltip) { tooltip = document.createElement('div'); tooltip.id = 'wan-tooltip'; document.body.appendChild(tooltip); } // 鼠标进入金额区域时显示气泡 contText.addEventListener('mouseover', (e) => { const target = e.target.closest('.amount-highlight[data-wan]'); if (!target) { // 鼠标移到非金额区域,隐藏气泡 tooltip.style.display = 'none'; return; } const wanValue = target.getAttribute('data-wan'); if (!wanValue) return; // 更新气泡内容和位置 tooltip.textContent = wanValue; tooltip.style.display = 'block'; tooltip.style.background = wanValue.includes('万元') ? '#3b9eff' : '#35d25a'; const rect = target.getBoundingClientRect(); tooltip.style.left = (rect.left + rect.width / 2) + 'px'; tooltip.style.top = (rect.top - 8) + 'px'; }); // 鼠标离开金额区域时,判断是否真正离开(避免子元素干扰) contText.addEventListener('mouseout', (e) => { const target = e.target.closest('.amount-highlight[data-wan]'); if (!target) return; // 不是从金额元素离开,忽略 const related = e.relatedTarget; // 如果鼠标移动到金额元素内部的子元素,不隐藏气泡 if (related && target.contains(related)) return; // 真正离开金额区域,隐藏气泡 tooltip.style.display = 'none'; }); // 鼠标移动时实时更新气泡位置(跟随鼠标水平移动) contText.addEventListener('mousemove', (e) => { const target = e.target.closest('.amount-highlight[data-wan]'); if (!target) { tooltip.style.display = 'none'; return; } const wanValue = target.getAttribute('data-wan'); tooltip.style.background = wanValue.includes('万元') ? '#3b9eff' : '#35d25a'; const rect = target.getBoundingClientRect(); tooltip.style.left = (rect.left + rect.width / 2) + 'px'; tooltip.style.top = (rect.top - 8) + 'px'; }); } // ================================================================ // 核心流程:扫描页面、提取附件、并发预览 // ================================================================ async function checkAndInsert() { // 更新页面标题 updatePageTitle(); // 政府采购网公告内容容器 const articleCont = document.querySelector('#articleCont'); if (!articleCont) return; // 金额标记仅作用于公告正文(#noticeArea),避免干扰附件区域 const noticeArea = document.querySelector('#noticeArea'); if (noticeArea) { if (contentObserver) { contentObserver.disconnect(); } markAmounts(noticeArea); initWanTooltip(noticeArea); if (contentObserver) { const targetNode = document.querySelector('#articleCont') || document.body; contentObserver.observe(targetNode, { childList: true, subtree: true }); } } // 扫描整个 articleCont 内的附件链接 const attachments = findAllAttachments(articleCont); if (attachments.length === 0) return; // 插入容器仍为 articleCont(在末尾追加) const container = articleCont; // 检查是否已插入预览块 if (container.querySelector('.attachment-block')) { previewInserted = true; return; } // 将所有附件 URL 加入已处理集合 attachments.forEach(att => processedUrls.add(att.url)); // 并发控制,限制同时预览数量(此处设为 3) const CONCURRENCY_LIMIT = 3; // 使用 asyncPool 以限制并发数的方式执行每个附件的预览 await asyncPool(CONCURRENCY_LIMIT, attachments, async (att) => { // 决定最终使用的标题 let finalTitle = att.name; if (!finalTitle) finalTitle = '附件'; try { // 根据类型调用对应的预览函数 if (att.type === 'pdf') { await createPdfViewer(att.url, finalTitle, container); } else if (att.type === 'excel') { await createExcelViewer(att.url, finalTitle, container); } else if (att.type === 'word') { await createWordViewer(att.url, finalTitle, container); } else if (att.type === 'zip') { await createZipViewer(att.url, finalTitle, container); } else if (att.type === 'word_unsupported') { await createUnsupportedDocViewer(att.url, finalTitle, container); } } catch (err) { // 单个附件预览失败不影响其他附件 console.error('预览附件失败:', att.url, err); } }); container.style.height = 'auto'; container.style.overflow = 'visible'; previewInserted = true; // 标记已插入 } // 监听查询按钮点击,触发列宽初始化 function bindQueryButtonListener() { const queryButtons = document.querySelectorAll('button.el-button--primary'); queryButtons.forEach(btn => { if (btn.dataset.colResizeBound) return; btn.dataset.colResizeBound = 'true'; btn.addEventListener('click', function() { setTimeout(() => { const config = getCurrentConfig(); if (config && config.enabled !== false) { initResizableColumns(config); } }, 800); }); }); } // ================================================================ // 启动观察:监听页面动态内容变化,重复执行检查 // ================================================================ // 全局保存 MutationObserver 实例,用于后续断开/重连 let contentObserver = null; // 全局变量存储轮询 ID let _pollIntervalId = null; function startPolling() { if (_pollIntervalId) { clearInterval(_pollIntervalId); _pollIntervalId = null; } let retryCount = 0; const maxRetries = 30; _pollIntervalId = setInterval(() => { const currentConfig = getCurrentConfig(); if (!currentConfig || currentConfig.enabled === false) { // 如果当前配置禁用,清除轮询并调用重置 clearInterval(_pollIntervalId); _pollIntervalId = null; initTableResizeForCurrentPage(); // 确保样式和列宽被重置 return; } const table = document.querySelector(currentConfig.tableSelector); if (table && table.querySelector('.col-resize-handle')) { clearInterval(_pollIntervalId); _pollIntervalId = null; return; } initTableResizeForCurrentPage(); retryCount++; if (retryCount >= maxRetries) { clearInterval(_pollIntervalId); _pollIntervalId = null; console.log('[列宽调节] 定时轮询超时,放弃'); } }, 500); } let tableObserver = null; function startObserving() { // 页面加载完成后立即执行一次检查 checkAndInsert(); // 确定要监听的容器:优先 #articleCont,否则监听 body const targetNode = document.querySelector('#articleCont') || document.body; // 防抖定时器,避免短时间内多次触发 checkAndInsert let checkTimer = null; // 创建 MutationObserver,用于监听 DOM 变化(如 AJAX 动态加载内容) contentObserver = new MutationObserver(() => { // 清除之前的定时器,确保在最后一次变化后的 200ms 才执行 clearTimeout(checkTimer); checkTimer = setTimeout(() => checkAndInsert(), 200); }); // 开始观察:监听子节点变化以及所有后代节点的变化 contentObserver.observe(targetNode, { childList: true, subtree: true }); removeAllHandles(); // 清理handle // ===== 列表页列宽调节 ===== const config = getCurrentConfig(); if (config && config.enabled !== false) { initTableResizeForCurrentPage(); startPolling(); // MutationObserver 监听表格变化 tableObserver = new MutationObserver(() => { const currentConfig = getCurrentConfig(); // 动态获取 if (!currentConfig || currentConfig.enabled === false) return; const table = document.querySelector(currentConfig.tableSelector); if (table && !table.querySelector('.col-resize-handle')) { const tbody = table.querySelector('tbody'); if (tbody && tbody.children.length > 0) { initResizableColumns(currentConfig); } } }); tableObserver.observe(document.body, { childList: true, subtree: true }); } // ===== 监听 SPA 路由切换 ===== let lastPath = window.location.pathname; function checkRouteChange() { const currentPath = window.location.pathname; if (currentPath !== lastPath) { lastPath = currentPath; // 停止轮询 if (_pollIntervalId) { clearInterval(_pollIntervalId); _pollIntervalId = null; } // 断开旧观察者 if (tableObserver) { tableObserver.disconnect(); tableObserver = null; } // 重置列宽状态(样式、手柄、存储) initTableResizeForCurrentPage(); // 如果新配置启用,重新启动轮询和观察者 const newConfig = getCurrentConfig(); if (newConfig && newConfig.enabled !== false) { startPolling(); // 重建 tableObserver tableObserver = new MutationObserver(() => { const currentConfig = getCurrentConfig(); if (!currentConfig || currentConfig.enabled === false) return; const table = document.querySelector(currentConfig.tableSelector); if (table && !table.querySelector('.col-resize-handle')) { const tbody = table.querySelector('tbody'); if (tbody && tbody.children.length > 0) { initResizableColumns(currentConfig); } } }); tableObserver.observe(document.body, { childList: true, subtree: true }); } } } const originalPushState = history.pushState; const originalReplaceState = history.replaceState; history.pushState = function(...args) { originalPushState.apply(this, args); checkRouteChange(); }; history.replaceState = function(...args) { originalReplaceState.apply(this, args); checkRouteChange(); }; window.addEventListener('popstate', checkRouteChange); // 绑定查询按钮 setInterval(bindQueryButtonListener, 2000); } // 根据页面加载状态启动脚本 if (document.readyState === 'loading') { // 如果页面仍在加载,则等待 DOMContentLoaded 事件后再启动 document.addEventListener('DOMContentLoaded', startObserving); } else { // 否则立即启动 startObserving(); } })();