// ==UserScript== // @name 内蒙古自治区公共资源交易网(PDF/Excel/Word附件预览) // @namespace http://tampermonkey.net/ // @version 2026.7.31 // @description 在招投标公告底部预览PDF、Excel、Word附件,不支持zip // @author AN drew // @match https://ggzyjy.nmg.gov.cn/*index_* // @match https://ggzyjy.nmg.gov.cn/jyxx/qtjy/* // @match http://219.148.175.179/*index_* // @match http://219.148.175.179/jyxx/qtjy/* // @grant GM_addStyle // @grant GM_xmlhttpRequest // @connect ccgp-neimenggu.gov.cn // @connect ggzy.huhhot.gov.cn // @connect btggzyjy.cn // @connect hlbeggzyjy.org.cn // @connect xamggzyjyzx.org.cn // @connect ggzy.tongliao.gov.cn // @connect ggzy.chifeng.gov.cn // @connect cfggzy.cn // @connect xmggzyjy.org.cn // @connect ggzy.wulanchabu.gov.cn // @connect ordosggzyjy.org.cn // @connect ggzyjy.bynr.gov.cn // @connect whggzy.com // @connect alsggzyjy.cn // @connect ggzy.manzhouli.gov.cn // @connect ylbzj.nmg.gov.cn // @connect whpp.ejy365.com // @connect impc.e-bidding.org // @connect 39.97.6.153 // @connect 111.56.142.67 // @connect 116.114.11.13 // @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'; // 注入全局样式(以下所有 /* ... */ 注释均为原有,完整保留) GM_addStyle(` .cont_k.inner { height: auto !important; min-height: auto !important; max-height: none !important; overflow: visible !important; } .boxcenter { 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 { 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 { padding: 4px 10px; font-size: 14px; cursor: pointer; border: 1px solid #ccc; background: #fff; border-radius: 4px; } .word-toolbar button:hover, .excel-toolbar button:hover, .pdf-toolbar button:hover { background: #e6e6e6; } .word-toolbar .zoom-value, .excel-toolbar .zoom-value, .pdf-toolbar .zoom-value { min-width: 50px; text-align: center; font-size: 14px; } /* Excel 预览样式 */ .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 预览样式(美化版) */ .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 表格内单元格的段落样式 */ .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; } @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; } .wan-digit { position: relative; display: inline; color: inherit; /* 保持数字原有颜色 */ } .wan-digit::after { content: "▲"; position: absolute; left: 50%; bottom: -1em; transform: translateX(-90%); font-size: 8px; line-height: 1; color: #3b9eff; font-weight: normal; white-space: pre; pointer-events: none; } .u-content .wan-digit::after { transform: translateX(-92%); } td .wan-digit::after { transform: translateX(-50%); } /* 全局金额气泡(万元) */ #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%); } `); // 标记是否已插入预览(防止重复插入) 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() { // 查找页面中的标题元素(类名为 text_tit) const titleElem = document.querySelector('.text_tit'); 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; const text = (link.textContent || '').trim().toLowerCase(); if (text.includes('.doc')) return true; return false; } // 生成附件的唯一键(用于去重) function getAttachmentKey(link) { 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() || ''; } catch (e) { fileName = url.split('/').pop() || ''; } // 如果文件名存在,并且链接文字包含去掉扩展名的文件名(即名称匹配),则使用名称+文件名的组合键 if (fileName && name.includes(fileName.replace(/\.(pdf|xlsx?|docx?)$/i, ''))) { return `name:${name}|file:${fileName}`; } // 否则直接使用 URL 作为键 return url; } // 从链接中获取可读的附件名称(优先取链接文字,否则从 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 (isWordAttachment(link)) type = 'word'; // 链接文本为“附件”且无后缀,根据 URL 特征视为 PDF else if ((link.textContent || '').trim() === '附件') { if (link.href.includes('TPFrame') || link.href.includes('fileController.do?getFile&file_id=') || link.href.includes('downALoad?attachId=') || link.href.includes('downloadbaogangattach?attachguid=')) { type = 'pdf'; } else { continue; // 其他无后缀的“附件”链接不处理 } } 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; if (fileName === 'instructions.pdf') { key = 'special:instructions'; // 特殊文件单独处理 } else { 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) { if (container.querySelector('.amount-highlight')) return; const regex = /(\d{1,3}(?:,\d{3})*)(\.\d+)?(\s*元)?/g; const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null, false); const nodesToReplace = []; let node; while (node = walker.nextNode()) { if (node.textContent.match(regex)) { nodesToReplace.push(node); } } nodesToReplace.forEach(textNode => { const parent = textNode.parentNode; 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 fullMatch = match[0]; const numWithComma = match[1]; const decimal = match[2] || ''; const numStr = numWithComma.replace(/,/g, ''); if (numStr.length < 5) continue; // 确定万位数字在逗号字符串中的位置 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) continue; // 分割:万位数字之前、万位数字、之后 const beforeDigit = numWithComma.substring(0, insertPos); const wanDigit = numWithComma.charAt(insertPos); const afterDigit = numWithComma.substring(insertPos + 1); const unit = (match[3] || '').includes('元') ? '元' : ''; const prefix = text.substring(lastIndex, match.index); // 计算万元金额(保留5位小数后去除尾部零) const wanAmount = (parseFloat(numStr + decimal) / 10000).toFixed(6).replace(/\.?0+$/, '') + ' 万元'; const replacementHtml = prefix + `` + beforeDigit + `${wanDigit}` + afterDigit + decimal + `` + unit; 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.textContent = '⬇ 下载'; //下载按钮 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.textContent = '⬇ 下载'; 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.textContent = '⬇ 下载'; 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 表格内单元格的段落样式 */ .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 文档加载失败,请稍后重试或直接下载。

'; } } // 创建全局气泡并绑定事件委托 // 用于记录已经绑定过气泡事件的容器,防止重复绑定导致事件堆积 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'; 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 rect = target.getBoundingClientRect(); tooltip.style.left = (rect.left + rect.width / 2) + 'px'; tooltip.style.top = (rect.top - 8) + 'px'; }); } // ================================================================ // 核心流程:扫描页面、提取附件、并发预览 // ================================================================ async function checkAndInsert() { // 更新页面标题 updatePageTitle(); // 获取正文容器(即 .cont_text) const contText = document.querySelector('.cont_text'); if (!contText) return; // 临时断开 MutationObserver,避免 markAmounts 中的 DOM 操作触发自身,导致递归调用 if (contentObserver) { contentObserver.disconnect(); } // 标记金额(会修改 DOM,增加 .amount-highlight 等元素) markAmounts(contText); // 初始化气泡事件委托(仅首次执行,后续会被防重复机制跳过) initWanTooltip(contText); // 重新连接观察器,继续监听后续的内容变化 if (contentObserver) { const targetNode = document.querySelector('.cont_text') || document.body; contentObserver.observe(targetNode, { childList: true, subtree: true }); } // 查找所有附件链接 const attachments = findAllAttachments(contText); if (attachments.length === 0) return; // 没有附件则退出 // 获取预览插入的目标容器(.cont_k.inner) const container = document.querySelector('.cont_k.inner'); if (!container) return; // 如果容器中已经有预览块,说明已插入过,直接返回 if (container.querySelector('.attachment-block')) { previewInserted = true; return; } // 判断是否使用文档标题作为预览卡片标题(当正文只有“无”时) let useDocTitle = false; const nodes = Array.from(contText.childNodes); // 过滤出有意义的节点(非空文本或元素节点) const meaningful = nodes.filter(node => { if (node.nodeType === Node.TEXT_NODE) return node.textContent.trim() !== ''; if (node.nodeType === Node.ELEMENT_NODE) return true; return false; }); // 如果至少有两个有意义的节点,且第一个是文本且内容为“无”,则使用文档标题 if (meaningful.length >= 2) { const first = meaningful[0]; if (first.nodeType === Node.TEXT_NODE && first.textContent.trim() === '无') { useDocTitle = true; } } // 获取页面文档标题 const titleElem = document.querySelector('.text_tit'); const docTitle = titleElem ? titleElem.textContent.trim() : ''; // 将所有附件 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 (useDocTitle && (!att.name || att.name === '附件')) { finalTitle = docTitle; } 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); } } catch (err) { // 单个附件预览失败不影响其他附件 console.error('预览附件失败:', att.url, err); } }); // 修正容器高度,防止滚动条遮挡内容 container.style.height = 'auto'; container.style.overflow = 'visible'; previewInserted = true; // 标记已插入 } // ================================================================ // 启动观察:监听页面动态内容变化,重复执行检查 // ================================================================ // 全局保存 MutationObserver 实例,用于后续断开/重连 let contentObserver = null; function startObserving() { // 页面加载完成后立即执行一次检查 checkAndInsert(); // 确定要监听的容器:优先 .cont_text,否则监听 body const targetNode = document.querySelector('.cont_text') || 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 }); } // 根据页面加载状态启动脚本 if (document.readyState === 'loading') { // 如果页面仍在加载,则等待 DOMContentLoaded 事件后再启动 document.addEventListener('DOMContentLoaded', startObserving); } else { // 否则立即启动 startObserving(); } })();