前言
近期我正在开发一个前后端分离项目,使用了Spring Boot 和 Vue2,借助了国内优秀的框架 jeecg,前端UI库则选择了 ant-design-vue。在项目中,需要实现文件上传功能,同时还要能够在线预览和下载图片和PDF文件,甚至需要在页面上直接打印PDF文件。尽管框架自带了 vue-print-nb-jeecg
组件,但它相对较为简陋,只支持单页打印,无法实现多页打印。经过仔细的权衡和比较后,最终决定采用 vue-pdf
和 print-js
组件来满足需求。
一、先来展示一下最终效果
前端上传文件列表:
点击PDF文件后展示预览:
点击打印按钮后效果:
二、实现步骤及代码
vue-pdf
可以用于在线预览,而 print-js
则提供了更强大的打印功能,支持多种文档类型,包括PDF、HTML、IMAGE和JSON,而且默认情况下是PDF。其实vue-pdf
也可以实现打印功能,但是跟前述的vue-print-nb
一样,只能打印页面显示的第一页内容(预览展示没问题)。
Print.js官网👉点我直达
1. 在vue中安装vue-pdf
和Print.js
yarn add vue-pdf ... yarn add print-js
复制
2. 可以全局引入,也可以在需要的文件中引入
import pdf from 'vue-pdf' import printJS from 'print-js'
复制
3.主要代码
<a-modal :visible="previewVisibleForAll" :footer="null" @cancel="handleCancelAll" :width="800" :maskClosable="maskClosable" :keyboard="keyboard"> <img alt="example" style="width: 100%;margin-top:20px" :src="previewFileSrc" v-if="isImage"/> <div v-if="isPdf" style="overflow-y: auto;overflow-x: hidden;"> <a-button shape="round" icon="file-pdf" @click="handlePrint(printData)" size="small">打印</a-button> <div id="printFrom"> <pdf ref="pdf" v-for="item in pageTotal" :src="previewFileSrc" :key="item" :page="item"></pdf> </div> </div> </a-modal>
复制
打印按钮执行的方法
// data参数 printData: { printable: 'printFrom', header: '', ignore: ['no-print'] }, // 执行方法 handlePrint(params) { printJS({ printable: params.printable, // 'printFrom', // 标签元素id type: params.type || 'html', header: params.header, // '表单', targetStyles: ['*'], style: '@page {margin:0 10mm};', // 可选-打印时去掉眉页眉尾 ignoreElements: params.ignore || [], // ['no-print'] properties: params.properties || null }) }
复制
不同组件,如果文件是图片就预览图片,PDF就预览PDF。
4. 全部代码:
<template> <div :id="containerId" style="position: relative"> <!-- ---------------------------- begin 图片左右换位置 ------------------------------------- --> <div class="movety-container" :style="{top:top+'px',left:left+'px',display:moveDisplay}" style="padding:0 8px;position: absolute;z-index: 91;height: 32px;width: 104px;text-align: center;"> <div :id="containerId+'-mover'" :class="showMoverTask?'uploadty-mover-mask':'movety-opt'" style="margin-top: 12px"> <a @click="moveLast" style="margin: 0 5px;"><a-icon type="arrow-left" style="color: #fff;font-size: 16px"/></a> <a @click="moveNext" style="margin: 0 5px;"><a-icon type="arrow-right" style="color: #fff;font-size: 16px"/></a> </div> </div> <!-- ---------------------------- end 图片左右换位置 ------------------------------------- --> <a-upload name="file" :multiple="multiple" :action="uploadAction" :headers="headers" :data="{'biz':bizPath}" :fileList="fileList" :beforeUpload="doBeforeUpload" @change="handleChange" :disabled="disabled" :returnUrl="returnUrl" :listType="complistType" @preview="handlePreview1" :showUploadList="{ showRemoveIcon: true, showDownloadIcon: true }" :class="{'uploadty-disabled':disabled}"> <template> <div v-if="isImageComp"> <a-icon type="plus" /> <div class="ant-upload-text">{{ text }}</div> </div> <a-button v-else-if="buttonVisible"> <a-icon type="upload" />{{ text }} </a-button> </template> </a-upload> <a-modal :visible="previewVisible" :footer="null" @cancel="handleCancel"> <img alt="example" style="width: 100%" :src="previewImage" /> </a-modal> <a-modal :visible="previewVisibleForAll" :footer="null" @cancel="handleCancelAll" :width="800" :maskClosable="maskClosable" :keyboard="keyboard"> <img alt="example" style="width: 100%;margin-top:20px" :src="previewFileSrc" v-if="isImage"/> <div v-if="isPdf" style="overflow-y: auto;overflow-x: hidden;"> <a-button shape="round" icon="file-pdf" @click="handlePrint(printData)" size="small">打印</a-button> <div id="printFrom"> <pdf ref="pdf" v-for="item in pageTotal" :src="previewFileSrc" :key="item" :page="item"></pdf> </div> </div> </a-modal> </div> </template> <script> import Vue from 'vue' import { ACCESS_TOKEN } from "@/store/mutation-types" import { getFileAccessHttpUrl } from '@/api/manage'; import pdf from 'vue-pdf' import printJS from 'print-js' const FILE_TYPE_ALL = "all" const FILE_TYPE_IMG = "image" const FILE_TYPE_TXT = "file" const uidGenerator=()=>{ return '-'+parseInt(Math.random()*10000+1,10); } const getFileName=(path)=>{ if(path.lastIndexOf("\\")>=0){ let reg=new RegExp("\\\\","g"); path = path.replace(reg,"/"); } return path.substring(path.lastIndexOf("/")+1); } export default { name: 'JUpload', components: { pdf }, data(){ return { printData: { printable: 'printFrom', header: '', ignore: ['no-print'] }, uploadAction:window._CONFIG['domianURL']+"/sys/common/upload", headers:{}, fileList: [], newFileList: [], uploadGoOn:true, previewVisible: false, //---------------------------- begin 图片左右换位置 ------------------------------------- previewImage: '', containerId:'', top:'', left:'', moveDisplay:'none', showMoverTask:false, moverHold:false, currentImg:'', //---------------------------- end 图片左右换位置 ------------------------------------- previewVisibleForAll:false, pageTotal: null, previewFileSrc:'', isImage:false, isExcel:false, isPdf:false, } }, props:{ text:{ type:String, required:false, default:"点击上传" }, fileType:{ type:String, required:false, default:FILE_TYPE_ALL }, /*这个属性用于控制文件上传的业务路径*/ bizPath:{ type:String, required:false, default:"temp" }, value:{ type:[String,Array], required:false }, // update-begin- --- author:wangshuai ------ date:20190929 ---- for:Jupload组件增加是否能够点击 disabled:{ type:Boolean, required:false, default: false }, // update-end- --- author:wangshuai ------ date:20190929 ---- for:Jupload组件增加是否能够点击 //此属性被废弃了 triggerChange:{ type: Boolean, required: false, default: false }, /** * update -- author:lvdandan -- date:20190219 -- for:Jupload组件增加是否返回url, * true:仅返回url * false:返回fileName filePath fileSize */ returnUrl:{ type:Boolean, required:false, default: true }, number:{ type:Number, required:false, default: 0 }, buttonVisible:{ type:Boolean, required:false, default: true }, multiple: { type: Boolean, default: true }, beforeUpload: { type: Function }, maskClosable: { type: Boolean, default:true, }, keyboard: { type: Boolean, default:true, }, }, watch:{ value:{ immediate: true, handler() { let val = this.value if (val instanceof Array) { if(this.returnUrl){ this.initFileList(val.join(',')) }else{ this.initFileListArr(val); } } else { this.initFileList(val) } } } }, computed:{ isImageComp(){ return this.fileType === FILE_TYPE_IMG }, complistType(){ return this.fileType === FILE_TYPE_IMG?'picture-card':'text' } }, created(){ const token = Vue.ls.get(ACCESS_TOKEN); //---------------------------- begin 图片左右换位置 ------------------------------------- this.headers = {"X-Access-Token":token}; this.containerId = 'container-ty-'+new Date().getTime(); //---------------------------- end 图片左右换位置 ------------------------------------- }, methods:{ handlePrint(params) { printJS({ printable: params.printable, // 'printFrom', // 标签元素id type: params.type || 'html', header: params.header, // '表单', targetStyles: ['*'], style: '@page {margin:0 10mm};', // 可选-打印时去掉眉页眉尾 ignoreElements: params.ignore || [], // ['no-print'] properties: params.properties || null }) }, printPdf() { this.$refs.pdf.print() // window.print() }, initFileListArr(val){ console.log(val) if(!val || val.length==0){ this.fileList = []; return; } let fileList = []; for(var a=0;a<val.length;a++){ let url = getFileAccessHttpUrl(val[a].filePath); fileList.push({ uid:uidGenerator(), name:val[a].fileName, status: 'done', url: url, response:{ status:"history", message:val[a].filePath } }) } this.fileList = fileList console.log(this.fileList) }, initFileList(paths){ if(!paths || paths.length==0){ //return []; // update-begin- --- author:os_chengtgen ------ date:20190729 ---- for:issues:326,Jupload组件初始化bug this.fileList = []; return; // update-end- --- author:os_chengtgen ------ date:20190729 ---- for:issues:326,Jupload组件初始化bug } let fileList = []; let arr = paths.split(",") for(var a=0;a<arr.length;a++){ let url = getFileAccessHttpUrl(arr[a]); fileList.push({ uid:uidGenerator(), name:getFileName(arr[a]), status: 'done', url: url, response:{ status:"history", message:arr[a] } }) } this.fileList = fileList }, handlePathChange(){ let uploadFiles = this.fileList let path = '' if(!uploadFiles || uploadFiles.length==0){ path = '' } let arr = []; for(var a=0;a<uploadFiles.length;a++){ // update-begin-author:lvdandan date:20200603 for:【TESTA-514】【开源issue】多个文件同时上传时,控制台报错 if(uploadFiles[a].status === 'done' ) { arr.push(uploadFiles[a].response.message) }else{ return; } // update-end-author:lvdandan date:20200603 for:【TESTA-514】【开源issue】多个文件同时上传时,控制台报错 } if(arr.length>0){ path = arr.join(",") } this.$emit('change', path); }, doBeforeUpload(file){ this.uploadGoOn=true var fileType = file.type; if(this.fileType===FILE_TYPE_IMG){ if(fileType.indexOf('image')<0){ this.$message.warning('请上传图片'); this.uploadGoOn=false return false; } } // 文件大小限定在600K以下 const isLt2M = file.size / 1024 / 1024 < 10; if (!isLt2M){ this.$message.warning('请确保上传的文件小于10MB!'); this.fileList = [] this.uploadGoOn=false return false; } // 扩展 beforeUpload 验证 if (typeof this.beforeUpload === 'function') { return this.beforeUpload(file) } return true }, handleChange(info) { console.log("--文件列表改变--") if(!info.file.status && this.uploadGoOn === false){ info.fileList.pop(); } let fileList = info.fileList if(info.file.status==='done'){ if(this.number>0){ fileList = fileList.slice(-this.number); } if(info.file.response.success){ fileList = fileList.map((file) => { if (file.response) { let reUrl = file.response.message; file.url = getFileAccessHttpUrl(reUrl); } return file; }); } //this.$message.success(`${info.file.name} 上传成功!`); }else if (info.file.status === 'error') { this.$message.error(`${info.file.name} 上传失败.`); }else if(info.file.status === 'removed'){ this.handleDelete(info.file) } this.fileList = fileList if(info.file.status==='done' || info.file.status === 'removed'){ //returnUrl为true时仅返回文件路径 if(this.returnUrl){ this.handlePathChange() }else{ //returnUrl为false时返回文件名称、文件路径及文件大小 this.newFileList = []; for(var a=0;a<fileList.length;a++){ // update-begin-author:lvdandan date:20200603 for:【TESTA-514】【开源issue】多个文件同时上传时,控制台报错 if(fileList[a].status === 'done' ) { var fileJson = { fileName:fileList[a].name, filePath:fileList[a].response.message, fileSize:fileList[a].size }; this.newFileList.push(fileJson); }else{ return; } // update-end-author:lvdandan date:20200603 for:【TESTA-514】【开源issue】多个文件同时上传时,控制台报错 } this.$emit('change', this.newFileList); } } }, handleDelete(file){ //如有需要新增 删除逻辑 console.log(file) }, // handlePreview(file){ // console.log('file') // console.log(file) // if(this.fileType === FILE_TYPE_IMG){ // this.previewImage = file.url || file.thumbUrl; // this.previewVisible = true; // }else{ // if(file.name.endsWith('pdf') || file.name.endsWith('PDF')) { // let viewPath = window._CONFIG['domianURL'].replace('9999', '15550').replace('/jeecg-boot', '') + '/' + (file.url.replace(window._CONFIG['staticDomainURL'] + "/", '')) // console.log(viewPath) // window.open(viewPath,"_blank") // this.isPdf = true // this.previewFileSrc = file.url // } // // else {//TODO:重新打开页面 // // location.href=file.url // // } // } // }, // 获取pdf总页数 getTotal() { // 多页pdf的src中不能直接使用后端获取的pdf地址 否则会按页数请求多次数据 // 需要使用下述方法的返回值作为url this.previewFileSrc = pdf.createLoadingTask(this.previewFileSrc) // 获取页码 this.previewFileSrc.promise.then(pdf => this.pageTotal = pdf.numPages).catch(error => { }) }, handlePreview1(file){ if(this.fileType === FILE_TYPE_IMG){ this.previewImage = file.url || file.thumbUrl; this.previewVisible = true; }else{ // 判断当前文件类型 this.previewFileSrc = file.url || file.thumbUrl; // "http://localhost:9999/sys/common/static/orderPaymentInfo/网约区域复习题_1694585302732.pdf" let fileTypeLocal = this.matchFileType(file.name); this.isImage = false; this.isPdf = false; if(fileTypeLocal == 'image') { this.previewVisibleForAll = true; this.isImage = true; } else if(fileTypeLocal == 'pdf') { this.previewVisibleForAll = true; this.isPdf = true; this.getTotal() } else { location.href=file.url } } }, matchFileType(fileName) { // 后缀获取 let suffix = ''; // 获取类型结果 let result = ''; if (!fileName) return false; try { // 截取文件后缀 suffix = fileName.substr(fileName.lastIndexOf('.') + 1, fileName.length) // 文件后缀转小写,方便匹配 suffix = suffix.toLowerCase() } catch (err) { suffix = ''; } // fileName无后缀返回 false if (!suffix) { result = false; return result; } let fileTypeList = [ // 图片类型 {'typeName': 'image', 'types': ['png', 'jpg', 'jpeg', 'bmp', 'gif']}, // 文本类型 {'typeName': 'txt', 'types': ['txt']}, // excel类型 {'typeName': 'excel', 'types': ['xls', 'xlsx']}, {'typeName': 'word', 'types': ['doc', 'docx']}, {'typeName': 'pdf', 'types': ['pdf']}, {'typeName': 'ppt', 'types': ['ppt']}, // 视频类型 {'typeName': 'video', 'types': ['mp4', 'm2v', 'mkv']}, // 音频 {'typeName': 'radio', 'types': ['mp3', 'wav', 'wmv']} ] // let fileTypeList = ['image', 'txt', 'excel', 'word', 'pdf', 'video', 'radio'] for (let i = 0; i < fileTypeList.length; i++) { const fileTypeItem = fileTypeList[i] const typeName = fileTypeItem.typeName const types = fileTypeItem.types console.log(fileTypeItem); result = types.some(function (item) { return item === suffix; }); if (result) { return typeName } } return 'other' }, handleCancel(){ this.previewVisible = false; }, handleCancelAll(){ this.previewVisibleForAll = false; this.isImage = false; this.isPdf = false; }, //---------------------------- begin 图片左右换位置 ------------------------------------- moveLast(){ //console.log(ev) //console.log(this.fileList) //console.log(this.currentImg) let index = this.getIndexByUrl(); if(index==0){ this.$message.warn('未知的操作') }else{ let curr = this.fileList[index].url; let last = this.fileList[index-1].url; let arr =[] for(let i=0;i<this.fileList.length;i++){ if(i==index-1){ arr.push(curr) }else if(i==index){ arr.push(last) }else{ arr.push(this.fileList[i].url) } } this.currentImg = last this.$emit('change',arr.join(',')) } }, moveNext(){ let index = this.getIndexByUrl(); if(index==this.fileList.length-1){ this.$message.warn('已到最后~') }else{ let curr = this.fileList[index].url; let next = this.fileList[index+1].url; let arr =[] for(let i=0;i<this.fileList.length;i++){ if(i==index+1){ arr.push(curr) }else if(i==index){ arr.push(next) }else{ arr.push(this.fileList[i].url) } } this.currentImg = next this.$emit('change',arr.join(',')) } }, getIndexByUrl(){ for(let i=0;i<this.fileList.length;i++){ if(this.fileList[i].url === this.currentImg || encodeURI(this.fileList[i].url) === this.currentImg){ return i; } } return -1; } }, mounted(){ const moverObj = document.getElementById(this.containerId+'-mover'); if(moverObj){ moverObj.addEventListener('mouseover',()=>{ this.moverHold = true this.moveDisplay = 'block'; }); moverObj.addEventListener('mouseout',()=>{ this.moverHold = false this.moveDisplay = 'none'; }); } let picList = document.getElementById(this.containerId)?document.getElementById(this.containerId).getElementsByClassName('ant-upload-list-picture-card'):[]; if(picList && picList.length>0){ picList[0].addEventListener('mouseover',(ev)=>{ ev = ev || window.event; let target = ev.target || ev.srcElement; if('ant-upload-list-item-info' == target.className){ this.showMoverTask=false let item = target.parentElement this.left = item.offsetLeft this.top=item.offsetTop+item.offsetHeight-50; this.moveDisplay = 'block'; this.currentImg = target.getElementsByTagName('img')[0].src } }); picList[0].addEventListener('mouseout',(ev)=>{ ev = ev || window.event; let target = ev.target || ev.srcElement; //console.log('移除',target) if('ant-upload-list-item-info' == target.className){ this.showMoverTask=true setTimeout(()=>{ if(this.moverHold === false) this.moveDisplay = 'none'; },100) } if('ant-upload-list-item ant-upload-list-item-done' == target.className || 'ant-upload-list ant-upload-list-picture-card'== target.className){ this.moveDisplay = 'none'; } }) //---------------------------- end 图片左右换位置 ------------------------------------- } }, model: { prop: 'value', event: 'change' } } </script> <style lang="less"> .uploadty-disabled{ .ant-upload-list-item { .anticon-close{ display: none; } .anticon-delete{ display: none; } } } //---------------------------- begin 图片左右换位置 ------------------------------------- .uploadty-mover-mask{ background-color: rgba(0, 0, 0, 0.5); opacity: .8; color: #fff; height: 28px; line-height: 28px; } //---------------------------- end 图片左右换位置 ------------------------------------- </style>
复制
总结
除了以上两个组件库1+1的方式,还有百度前端大神开发的vue-office组件库,而且优点也很明显:
- 使用简单,对新手友好,只传递一个文件地址,就可实现预览。
- 提供多种文件的一站式预览解决方案,解决常见的docx、excel、pdf三种文件的预览。
- 预览效果也好,不只是对内容预览,也要支持样式。
预览的效果确实超级棒,可惜的是不支持打印功能,不能满足需求,可惜了。
有需要的可以去看vue-office的演示效果:
Vue框架演示效果
非Vue框架文件预览