前言
近期我正在开发一个前后端分离项目,使用了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框架文件预览