商会系统里有个功能是会员上传资料:营业执照、资质证书、合同扫描件、财务报表。会员传上来,秘书处在后台要看。
一开始我们的做法很简单------后台列表里给个下载链接,点一下下载到本地,用本机的软件打开。这个做法能用,但有几个麻烦:一是干事每次看一份文件都要下载,桌面上堆一堆;二是下载下来的文件散在各个人的电脑里,版本对不上;三是有些文件涉及会员企业的敏感信息,下载到本地不好管控。
后来改成在线预览。这篇把三种格式的预览方案和踩过的坑记录一下。
先说需求边界
我们的场景有三个特点。第一,格式集中在三种:PDF、Word、Excel。第二,文件不大,单个通常几 MB,但也有几十 MB 的财务报表。第三,预览要控权限------不是谁都能看,而且链接不能长期有效。
基于这三条,我们排除了几个方案。Office Online 和 Google Docs Viewer 这类在线服务需要文件能被公网访问,会员资料不能放公网,排除。纯前端用 iframe 套 PDF 的方式,PDF 可以,Word 和 Excel 不行,覆盖不全,也排除。
还有一个方案是后端统一转成 PDF 再预览,用 LibreOffice 之类的工具做无头转换。这个方案的优点是前端只需要处理 PDF 一种格式,逻辑简单。缺点是转换耗时长,一个几十页的 Word 转 PDF 要几秒到十几秒,会员传完文件不能立刻看,得等。而且这类工具对复杂排版的还原度一般,转换前后版式会有差异。
我们最终没选这个方案,主要卡在"等"这件事上------会员上传完资料,秘书处马上就要审核,让他等十几秒再看的体验不好。
最后的方案是分格式处理:PDF 用 pdf.js,Word 用 mammoth.js 转 HTML,Excel 用 SheetJS 转 HTML 表格。三种都在前端完成转换,后端只负责文件类型校验和临时链接签发。
后端:文件类型校验与临时链接
第一步是校验。上传时不能只看扩展名,要看文件头。
```javascript
const fileType = require('file-type');
async function validateFile(buffer, declaredExt) {
const type = await fileType.fromBuffer(buffer);
if (!type) {
throw new Error('无法识别的文件类型');
}
const allowed = {
pdf: 'application/pdf',
docx: [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
],
xlsx: [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
]
};
if (!alloweddeclaredExt || !alloweddeclaredExt.includes(type.mime)) {
throw new Error('文件实际类型与声明类型不符');
}
return type;
}
```
第二步是签临时链接。预览链接不能永久有效,我们签一个 10 分钟的有效期。
```javascript
const crypto = require('crypto');
function signPreviewUrl(fileId, userId, ttlSeconds = 600) {
const expireAt = Math.floor(Date.now() / 1000) + ttlSeconds;
const raw = `{fileId}:{userId}:${expireAt}`;
const sig = crypto
.createHmac('sha256', process.env.PREVIEW_SECRET)
.update(raw)
.digest('hex')
.slice(0, 16);
return `/api/preview/{fileId}?uid={userId}&exp={expireAt}\&sig={sig}`;
}
function verifyPreviewUrl(fileId, userId, exp, sig) {
if (Math.floor(Date.now() / 1000) > Number(exp)) return false;
const raw = `{fileId}:{userId}:${exp}`;
const expect = crypto
.createHmac('sha256', process.env.PREVIEW_SECRET)
.update(raw)
.digest('hex')
.slice(0, 16);
return expect === sig;
}
```
前端:三种格式分别处理
PDF 用 pdf.js,做成滚动翻页,一次只渲染当前页和前后各一页。
```javascript
import * as pdfjsLib from 'pdfjs-dist';
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js';
async function renderPdf(url, container) {
const pdf = await pdfjsLib.getDocument(url).promise;
const pages = \[\];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: 1.5 });
const canvas = document.createElement('canvas');
canvas.height = viewport.height;
canvas.width = viewport.width;
container.appendChild(canvas);
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
pages.push(canvas);
}
return pages;
}
```
Word 用 mammoth.js 转成 HTML 直接塞进容器。
```javascript
import mammoth from 'mammoth';
async function renderDocx(arrayBuffer, container) {
const result = await mammoth.convertToHtml({ arrayBuffer });
container.innerHTML = result.value;
// result.messages 里是转换告警,样式丢失会在这里提示
if (result.messages.length) {
console.warn('docx 转换告警:', result.messages);
}
}
```
Excel 用 SheetJS 读成 HTML 表格,多个 sheet 就渲染成多个表格加 tab 切换。
```javascript
import * as XLSX from 'xlsx';
function renderXlsx(arrayBuffer, container) {
const wb = XLSX.read(arrayBuffer, { type: 'array' });
container.innerHTML = '';
wb.SheetNames.forEach((name) => {
const ws = wb.Sheetsname;
const html = XLSX.utils.sheet_to_html(ws);
const wrap = document.createElement('div');
wrap.innerHTML = `<h4>{name}\{html}`;
container.appendChild(wrap);
});
}
```
踩过的坑
第一个是中文乱码。Word 转 HTML 时,如果文档里用了特殊字体,转换后中文会变成方块。解决办法是在容器上显式指定中文字体族,并且提醒会员上传时用常见字体。
第二个是大文件卡顿。一个几十页的 PDF,一次性渲染所有页会卡住。改成滚动到哪一页渲染哪一页,只保留前后各一页的 canvas,内存占用降下来不少。
第三个是 mammoth 转 HTML 会丢失复杂样式。表格合并单元格、页眉页脚这些内容转过去会变形。我们的做法是接受这个损失------预览的目的是看内容,不是还原排版,真要看排版还是得下载原文件。
第四个是权限链接被转发。临时链接虽然有时效,但在有效期内被转发出去,别人还是能打开。加上签发时的 userId 校验之后,链接换个人就失效了。
第五个是移动端适配。秘书处的干事经常用手机审核资料,手机上预览 PDF 时,canvas 渲染出来的页面在小屏上看不清。做法是默认按容器宽度缩放,再加一个双指缩放的手势支持,不做这个优化的话手机端基本没法用。
什么时候不需要这套方案
如果会员上传的文件只是给秘书处自己看,不要求在线预览,那下载到本地就够了,不用折腾转换。如果文件量很小、格式很单一(比如全是 PDF),那直接用 iframe 套原生预览更简单。这套方案的价值在于三种格式都要覆盖、而且要做权限控制的场景。
我们这边在用的商会管理系统叫未来漫城·商会互联平台,上面这套预览现在跑在会员资料审核的环节,干事在后台点开就能看营业执照和资质证书,不用下载。