// ==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 加载失败,请稍后重试或检查网络。
若持续失败,请尝试右键链接另存为查看。
| ),其余为数据行( | ) 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.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(); } })();