// ==UserScript== // @name 内蒙古自治区公共资源交易网(PDF/Excel/Word附件预览) // @namespace http://tampermonkey.net/ // @version 2026.9.11 // @description 在招投标公告底部预览pdf、xlsx、docx附件(不支持xls、doc、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 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, .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 预览样式 */ .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; } .zip-unsupported, .doc-unsupported { padding: 20px; text-align: center; font-size: 16px; color: #856404; background: #fff3cd; border-radius: 4px; margin: 10px; } .doc-frame { width:100%; height:calc(100vh - 100px); border:0; } @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; 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%); } /* 气泡单位配色(新增单位只需在这里加一行) */ #wan-tooltip.tooltip-yuan { background: #35d25a; } /* 元:绿色 */ #wan-tooltip.tooltip-wan { background: #3b9eff; } /* 万元:蓝色 */ #wan-tooltip.tooltip-yi { background: #ff8c00; } /* 亿元:橙色(示例,可自行调整) */ /*下载按钮图标*/ .attachment-block button svg { vertical-align: sub; margin-right: 3px; } `); // 标记是否已插入预览(防止重复插入) 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 附件链接 function isPdfAttachmentLink(link) { const text = (link.textContent || '').trim().toLowerCase(); // 优先检查链接文字是否包含 .pdf if (text.includes('.pdf')) return true; // 备用:从 URL 提取扩展名(精确匹配) const ext = getFileTypeFromUrl(link.href); return ext === 'pdf'; } // 判断是否为 Excel 附件链接(支持 .xls 和 .xlsx,但后续只预览 .xlsx,.xls 会单独处理) function isExcelAttachment(link) { const text = (link.textContent || '').trim().toLowerCase(); // 优先检查链接文字是否包含 .xls if (text.includes('.xls')) return true; const ext = getFileTypeFromUrl(link.href); return ext === 'xls' || ext === 'xlsx'; } // 判断是否为 Word 附件(支持预览的 .docx) function isWordAttachment(link) { const text = (link.textContent || '').trim().toLowerCase(); // 优先检查链接文字是否包含 .docx if (text.includes('.docx')) return true; const ext = getFileTypeFromUrl(link.href); return ext === 'docx' || ext === 'doc'; // 注意:.doc 会被下面 isUnsupportedDocAttachment 优先捕获,但这里保留以防万一 } // 判断是否为旧版 Word (.doc) 附件(不支持预览) function isUnsupportedDocAttachment(link) { const text = (link.textContent || '').trim().toLowerCase(); // 优先检查链接文字:包含 .doc 且不包含 .docx if (text.includes('.doc') && !text.includes('.docx')) return true; const ext = getFileTypeFromUrl(link.href); return ext === 'doc'; } // 判断是否为 ZIP 压缩包 function isZipAttachment(link) { const text = (link.textContent || '').trim().toLowerCase(); // 优先检查链接文字是否包含 .zip if (text.includes('.zip')) return true; const ext = getFileTypeFromUrl(link.href); return ext === 'zip'; } // 从链接中获取可读的附件名称(优先取链接文字,否则从 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 正常预览 } // 链接文本为“附件”且无后缀,根据 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) { // 清除已有标记 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(); // 排除明显不是金额的"竞价"类词(如"自由竞价时间"、"延时竞价周期") const AMOUNT_HEADER_EXCLUDE = ['竞价','评价']; function isAmountHeader(text) { if (AMOUNT_HEADER_EXCLUDE.some(kw => text.includes(kw))) return false; 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]; } // 遍历所有行,跳过"全宽单 td 分节标题行"(tr-td-div 三段单一结构), // 找第一个含金额关键词的"多 td 行"作为表头 const allRows = table.querySelectorAll('tr'); for (const row of allRows) { if (row.cells.length <= 1) continue; // 分节标题行,跳过 for (const cell of row.cells) { if (isAmountHeader((cell.textContent || '').trim())) { return row; } } } return null; } */ const tables = container.querySelectorAll('table'); for (const table of tables) { // 获取所有行(包括表头和数据行) const rows = table.rows; // HTMLCollection // ===== 键值对模式检测(表头与数据位于同一行) ===== // 处理形如
PDF 加载失败,请稍后重试或检查网络。
若持续失败,请尝试右键链接另存为查看。
| ),其余为数据行( | ) const tag = index === 0 ? 'th' : 'td'; 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 表格内单元格的段落样式 */ .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-unsupported'; 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 = 'word-toolbar'; 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 = 'doc-unsupported'; contentDiv.innerHTML = '📄 此附件为旧版 Word 文档(.doc)格式,暂不支持在线预览,请点击下载按钮使用 Word 打开查看。'; block.appendChild(contentDiv); */ //在线预览 const contentFrame = document.createElement('iframe'); contentFrame.className = 'doc-frame'; //微软 //contentFrame.src = 'https://view.officeapps.live.com/op/view.aspx?src=' + encodeURIComponent(docUrl); //普通预览 //contentFrame.src = 'https://view.officeapps.live.com/op/embed.aspx?src=' + encodeURIComponent(docUrl); //嵌入预览 //谷歌 //contentFrame.src = 'https://docs.google.com/viewer?embedded=true&url=' + encodeURIComponent(docUrl); //普通预览 //contentFrame.src = 'https://docs.google.com/gview?embedded=true&url=' + encodeURIComponent(docUrl); //嵌入预览 //XDOC //contentFrame.src = 'https://view.xdocin.com/xdoc?_xdoc=' + encodeURIComponent(docUrl); //兼容预览(旧接口) contentFrame.src = 'https://view.xdocin.com/view?src=' + encodeURIComponent(docUrl); //普通预览 block.appendChild(contentFrame); container.appendChild(block); } // 金额单位 → 气泡 class 映射(按"从大到小"顺序匹配,避免"万元"被"元"抢先) const TOOLTIP_UNIT_RULES = [ { keyword: '亿元', cls: 'tooltip-yi' }, { keyword: '万元', cls: 'tooltip-wan' }, { keyword: '元', cls: 'tooltip-yuan' }, ]; function getTooltipUnitClass(str) { for (const rule of TOOLTIP_UNIT_RULES) { if (str.includes(rule.keyword)) return rule.cls; } return 'tooltip-yuan'; // 兜底 } // 一次性清理旧 class、添加新 class(避免残留) function applyTooltipUnitClass(tooltip, wanValue) { tooltip.classList.remove('tooltip-yuan', 'tooltip-wan', 'tooltip-yi'); tooltip.classList.add(getTooltipUnitClass(wanValue)); } // 创建全局气泡并绑定事件委托 // 用于记录已经绑定过气泡事件的容器,防止重复绑定导致事件堆积 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-amount]'); if (!target) { // 鼠标移到非金额区域,隐藏气泡 tooltip.style.display = 'none'; return; } const wanValue = target.getAttribute('data-amount'); if (!wanValue) return; // 更新气泡内容和位置 tooltip.textContent = wanValue; tooltip.style.display = 'block'; applyTooltipUnitClass(tooltip, wanValue); 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-amount]'); 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-amount]'); if (!target) { tooltip.style.display = 'none'; return; } const wanValue = target.getAttribute('data-amount'); applyTooltipUnitClass(tooltip, wanValue); 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() : ''; /*== 非依法必招项目 ==*/ // 获取副标题 .text_tit2 const tit2Elem = document.querySelector('.text_tit2'); const tit2Text = tit2Elem ? tit2Elem.textContent.trim() : ''; /* // *正文匹配:"无" 或 "/" 或 以"详见"开头 const firstTextNode = [...contText.childNodes].find(node => node.nodeType === Node.TEXT_NODE && node.textContent.trim() !== '' ); const firstText = firstTextNode ? firstTextNode.textContent.trim() : ''; const bodyMatches = firstText === '无' || firstText === '/' || firstText.includes('详见'); // *附件名匹配:"附件" 或 "附件1" const nameMatches = attachments.length === 1 && (attachments[0].name.trim() === '附件' || attachments[0].name.trim() === '附件1'); */ // 特殊条件 const currentUrl = window.location.href; const specialCondition = currentUrl.startsWith('https://ggzyjy.nmg.gov.cn/jyxx/index_39.html') && tit2Text === '非依法必招项目'; /*== 非依法必招项目 ==*/ // 将所有附件 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 (specialCondition) { finalTitle = docTitle; } // 若最终标题仍为空,回退到附件的原始名称 if (!finalTitle) finalTitle = att.name; 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; // 标记已插入 } // ================================================================ // 启动观察:监听页面动态内容变化,重复执行检查 // ================================================================ // 全局保存 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(); } })();