JS之文件预览功能的实现
需求:JS实现文件预览功能,可预览图片、Word文档、Excel表格、PDF文件、TXT文件。
Word预览:
需要安装docx-preview
Excel预览:
需要安装xlsx
PDF预览:
需要安装pdfh5
npm i pdfh5 -D
package.json
"pdfh5": "1.4.2",
package-lock.json
"node_modules/pdfh5": { "version": "1.4.2", "resolved": "https://registry.npmmirror.com/pdfh5/-/pdfh5-1.4.2.tgz", "integrity": "sha512-1BL8HIx/EEZowRPBgas7/WokbGEv1gxKNRmmHSimG113178mKxIBH4pxWBc0tj6d25Sy+EwnlQwv9cUUmQa42w==" },
TXT预览:
txtToUtf8.js
export const txtToUtf8 = (file) => { return new Promise(async (resolve, reject) => { const codeType = await getUnicodeType(file); if (codeType === 'utf-8') { return resolve(file); }; let newBlob = null let render = new FileReader() render.readAsText(file, 'gb2312') render.onload = (res) => { newBlob = new Blob([res.target.result], { type: "text/plain" }) let newFile = new File([newBlob], file.name, { type: newBlob.type }) resolve(newFile) } render.onerror = (err) => { reject(err) } }) } // 获取txt文件编码类型 const getUnicodeType = (file) => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = function (e) { var v8 = new Uint8Array(e.target.result); if (isUTF8(v8)) { resolve("utf-8") } else { resolve("gbk") } }; reader.onerror = e => { reject(e); }; reader.readAsArrayBuffer(file); }) } // 判断是否为UTF8编码类型的文件 const isUTF8 = (bytes) => { var i = 0; while (i < bytes.length) { if (( // ASCII bytes[i] == 0x09 || bytes[i] == 0x0A || bytes[i] == 0x0D || (0x20 <= bytes[i] && bytes[i] <= 0x7E) )) { i += 1; continue; } if (( // non-overlong 2-byte (0xC2 <= bytes[i] && bytes[i] <= 0xDF) && (0x80 <= bytes[i + 1] && bytes[i + 1] <= 0xBF) )) { i += 2; continue; } if (( // excluding overlongs bytes[i] == 0xE0 && (0xA0 <= bytes[i + 1] && bytes[i + 1] <= 0xBF) && (0x80 <= bytes[i + 2] && bytes[i + 2] <= 0xBF) ) || ( // straight 3-byte ((0xE1 <= bytes[i] && bytes[i] <= 0xEC) || bytes[i] == 0xEE || bytes[i] == 0xEF) && (0x80 <= bytes[i + 1] && bytes[i + 1] <= 0xBF) && (0x80 <= bytes[i + 2] && bytes[i + 2] <= 0xBF) ) || ( // excluding surrogates bytes[i] == 0xED && (0x80 <= bytes[i + 1] && bytes[i + 1] <= 0x9F) && (0x80 <= bytes[i + 2] && bytes[i + 2] <= 0xBF) ) ) { i += 3; continue; } if (( // planes 1-3 bytes[i] == 0xF0 && (0x90 <= bytes[i + 1] && bytes[i + 1] <= 0xBF) && (0x80 <= bytes[i + 2] && bytes[i + 2] <= 0xBF) && (0x80 <= bytes[i + 3] && bytes[i + 3] <= 0xBF) ) || ( // planes 4-15 (0xF1 <= bytes[i] && bytes[i] <= 0xF3) && (0x80 <= bytes[i + 1] && bytes[i + 1] <= 0xBF) && (0x80 <= bytes[i + 2] && bytes[i + 2] <= 0xBF) && (0x80 <= bytes[i + 3] && bytes[i + 3] <= 0xBF) ) || ( // plane 16 bytes[i] == 0xF4 && (0x80 <= bytes[i + 1] && bytes[i + 1] <= 0x8F) && (0x80 <= bytes[i + 2] && bytes[i + 2] <= 0xBF) && (0x80 <= bytes[i + 3] && bytes[i + 3] <= 0xBF) ) ) { i += 4; continue; } return false; } return true; }
预览相关的全部代码示例如下:
UploadFile.vue
<template> <div :class="subFormItem ? 'sub-comp' : 'custom-comp'"> <van-field :label="item.label" :required="item.isConfigRequired ?? item.isRequired" placeholder="点击上传" readonly > <template #input> <div class="uploader"> <van-row> <van-col v-for="(file, index) in formData[item.prop]" :key="file.objectName" span="24" > <div class="doc-file-item"> <img class="file-icon" src="@/assets/file/png-ext.png" v-if="getFileType(file.fileName) === 'png'" /> <img class="file-icon" src="@/assets/file/jpg-ext.png" v-if=" getFileType(file.fileName) === 'jpg' || getFileType(file.fileName) === 'jpeg' " /> <img class="file-icon" src="@/assets/file/gif-ext.png" v-if="getFileType(file.fileName) === 'gif'" /> <img class="file-icon" src="@/assets/file/ppt-ext.png" v-if=" getFileType(file.fileName) === 'ppt' || getFileType(file.fileName) === 'pptx' " /> <img class="file-icon" src="@/assets/file/pdf-ext.png" v-if="getFileType(file.fileName) === 'pdf'" /> <img class="file-icon" src="@/assets/file/word-ext.png" v-if=" getFileType(file.fileName) === 'docx' || getFileType(file.fileName) === 'doc' " /> <img class="file-icon" src="@/assets/file/excel-ext.png" v-if=" getFileType(file.fileName) === 'xlsx' || getFileType(file.fileName) === 'xls' " /> <img class="file-icon" src="@/assets/file/zip-ext.png" v-if="getFileType(file.fileName) === 'zip'" /> <img class="file-icon" src="@/assets/file/txt-ext.png" v-if="getFileType(file.fileName) === 'txt'" /> <img class="file-icon" src="@/assets/file/unknown-ext.png" v-if="getFileType(file.fileName) === ''" /> <span @click="previewFile(file)">{{ file.fileName }}</span> <div class="file-delete" @click="deleteFile(formData, index, item)" v-show="!isReadOnly" > <van-icon name="cross" size="12" /> </div> </div> </van-col> <div v-if="!formData[item.prop] && isReadOnly">无附件</div> </van-row> <van-uploader :accept="acceptFileTypes.join(',')" :multiple="true" :preview-image="false" :after-read="(file) => uploaderAfter(file, 'file')" v-show="!isReadOnly" > <van-button icon="plus" type="primary">点击上传</van-button> </van-uploader> </div> </template> </van-field> </div> <UploadProgress :loading="uploadLoading" :rate="uploadRate" :show-progress="showProgress" /> <van-popup v-model:show="previewVisible" round closeable position="bottom" :style="{ height: '90%', padding: '16px' }" > <FilePreview :file="currentFile" /> </van-popup> </template> <script setup> import { onMounted, ref, toRefs, defineAsyncComponent, computed, watch, } from "vue"; import { Toast, Dialog } from "vant"; import ActivitiApi from "@/api/activiti"; import { checkMobileModel } from "@/utils/androidOrIos"; import { hasOptionsComp, isUploaderComp, PROCESS_ORDER_STATUS, acceptFileTypes, deleteFile, sensitiveInfoSalt, reportUsageRecord, } from "@/utils/common"; import compressorImage from "@/utils/compressor"; import dayjs from "dayjs"; import UploadProgress from "@/components/uploadProgress/uploadProgress.vue"; import { useRoute, useRouter } from "vue-router"; import { txtToUtf8 } from "@/utils/txtToUtf8"; const FilePreview = defineAsyncComponent(() => import("@/components/filePreview/FilePreview.vue") ); const props = defineProps({ formData: Object, item: Object, isReadOnly: Object, compList: Array, reportType: String, wholeFormData: Object, subFormItem: Object, subFormIdx: Number, }); const { formData, item, isReadOnly, compList, reportType, wholeFormData, subFormItem, subFormIdx, } = toRefs(props); watch( () => formData.value[item.value.prop], (val) => { if (wholeFormData.value) { const resArr = JSON.parse( wholeFormData.value[subFormItem.value.prop] ?? "[]" ); if (!resArr[subFormIdx.value]) { resArr[subFormIdx.value] = {}; } resArr[subFormIdx.value][item.value.prop] = val; wholeFormData.value[subFormItem.value.prop] = JSON.stringify(resArr); sessionStorage.formState = JSON.stringify(wholeFormData.value); } } ); const router = useRouter(); const route = useRoute(); const previewVisible = ref(false); const currentFile = ref(); const previewFile = (file) => { if ( getFileType(file.fileName) == "ppt" || getFileType(file.fileName) == "pptx" ) { Toast.fail("ppt文件暂不支持预览"); } else { reportDownLoad(); const params = { env: "internet", fileName: file.objectName, }; ActivitiApi.getBatchFileUrl(params) .then((res) => { const data = res.data; if (data.code === 200) { previewVisible.value = true; file.fileUrl = data.data; currentFile.value = file; } else { throw new Error(data.message); } }) .catch((err) => { if (err.message) { Toast.fail(err.message); } else { Toast.fail("上传失败"); } }); } }; const reportDownLoad = () => { const data = { landingPageClass: "unknown", landingPageSubclass: "FileDownload", landingPageRelateName: route.query.workConfCode ?? sessionStorage.workConfCode, quantity: 1, accessSource: route.query.workConfCode ? "sso" : "frontEndRoute", }; if (reportType.value) { data.landingPageClass = reportType.value; } if (sessionStorage.eAppInfo) { data.landingPageClass = "eApp"; data.landingPageRelateName = JSON.parse( sessionStorage.eAppInfo ?? "{}" ).code; } reportUsageRecord(data); }; const uploadLoading = ref(false); const uploadRate = ref(0); const showProgress = ref(false); const uploaderAfter = async (file, type) => { if (!file) { return; } uploadLoading.value = true; const uploadFormData = new FormData(); if (file instanceof Array) { let fileList = file; if (type === "image") { fileList = await compressorImage(fileList); fileList.forEach((item) => { uploadFormData.append("fileList", item.file); }); } else { const txts = []; const tasks = []; file.forEach((item) => { if (getFileType(item.file.name) === "txt") { txts.push(item.file); } else { uploadFormData.append("fileList", item.file); } }); txts.forEach((txt) => { tasks.push(txtToUtf8(txt)); }); await Promise.all(tasks) .then((txtList) => { txtList.forEach((txt) => { uploadFormData.append("fileList", txt); }); }) .catch((err) => { txts.forEach((txt) => { uploadFormData.append("fileList", txt); }); }); } } if (file.constructor === Object) { if (type === "image") { let fileObj = file; fileObj = await compressorImage(fileObj); uploadFormData.append("fileList", fileObj.file); } else { if (getFileType(file.file.name) === "txt") { await txtToUtf8(file.file) .then((txt) => { uploadFormData.append("fileList", txt); }) .catch((err) => { uploadFormData.append("fileList", file.file); }); } else { uploadFormData.append("fileList", file.file); } } } showProgress.value = true; uploadFile(uploadFormData) .then((res) => { if (formData.value[item.value.prop]) { formData.value[item.value.prop] = formData.value[item.value.prop].concat(res); } else { formData.value[item.value.prop] = res; } }) .finally(() => { uploadRate.value = 0; uploadLoading.value = false; showProgress.value = false; }); }; const uploadFile = (data) => { return new Promise((resolve, reject) => { ActivitiApi.uploadBatchFile(data, (progress) => { uploadRate.value = Math.round((progress.loaded / progress.total) * 100); }) .then((res) => { const data = res.data; if (data.code === 200) { Toast.success("上传成功"); resolve(data.data); } else { throw new Error(data.message); } }) .catch((err) => { if (err.message) { Toast.fail(err.message); } else { Toast.fail("上传失败"); } reject(err); }); }); }; const uploadImage = (data) => { return new Promise((resolve, reject) => { ActivitiApi.uploadBatchImage(data, (progress) => { uploadRate.value = Math.round((progress.loaded / progress.total) * 100); }) .then((res) => { const data = res.data; if (data.code === 200) { Toast.success("上传成功"); resolve(data.data); } else { throw new Error(data.message); } }) .catch((err) => { if (err.message) { Toast.fail(err.message); } else { Toast.fail("上传失败"); } reject(err); }); }); }; const openUpload = (event, data) => { if (data.waterMarkConfigComps?.length) { const result = data.waterMarkConfigComps.find((prop) => { return !formData.value[prop]; }); const target = compList.value.find((comp) => { return result === comp.prop; }); if (target) { if (target.type !== "title" && target.type !== "noticeBar") { Toast.fail(target.label + "不能为空"); event.preventDefault(); } } } }; const onOversize = (file) => { Toast.fail("文件大小不能超过50MB"); }; const getFileType = (fileName) => { if (!fileName) { return ""; } let flag = fileName.split("."); return flag[flag.length - 1]; }; </script> <style scoped lang="less"> .custom-comp { margin: 0px 20px; width: 90%; } .component { .custom-comp { margin: 0; width: 100%; } } .uploader { width: 100%; display: flex; flex-direction: column; } .doc-file-item { width: 100%; margin-bottom: 18px; display: flex; flex-shrink: 0; align-items: center; } .doc-file-item span { width: 100%; margin: 0 8px; font-size: 12px; line-height: 18px; word-break: break-all; } .file-delete { width: 16px; height: 16px; display: flex; flex-shrink: 0; align-items: center; justify-content: center; border-radius: 50%; color: #ffffff; background: rgba(0, 0, 0, 0.6); } .file-icon { width: 30px; height: auto; } </style>
FilePreview.vue
<script setup> import { ref, onMounted, getCurrentInstance, reactive, toRefs, watch, nextTick, } from "vue"; import * as docx from "docx-preview"; import * as XLSX from "xlsx"; import Pdfh5 from "pdfh5"; import "pdfh5/css/pdfh5.css"; import request from "../../utils/request"; import { Toast } from "vant"; const { proxy } = getCurrentInstance(); const typeName = ref(""); const imgUrl = ref(""); const srcList = ref(); const loading = ref(false); const pdfUrl = ref(""); const pdfh5 = ref(null); const pptUrl = ref(""); const txtUrl = ref(""); const docFile = ref(null); const txtText = ref(""); const emits = defineEmits(); const emptyTips = ref("暂无内容"); const fullscreen = ref(false); const data = reactive({ excel: { // 数据 workbook: {}, // 表名称集合 sheetNames: [], // 激活项 sheetNameActive: "", // 当前激活表格 SheetActiveTable: "", }, }); const props = defineProps({ showTime: { type: Boolean, default: false, }, file: { type: Object, default: {}, }, clientHeight: { type: Number, default: 600, }, }); const { excel } = toRefs(data); const wordUrl = ref(""); onMounted(() => { loading.value = true; let fileType = props.file.fileName.split("."); fileType = fileType[fileType.length - 1]; init(fileType); }); // 前一个页面调用的init 我在前一个页面根据文件名字后缀已经判断是什么类型的文件了 const init = (type) => { typeName.value = type; if ( type.startsWith("JPG") || type.startsWith("JPEG") || type.startsWith("PNG") || type.startsWith("jpg") || type.startsWith("jpeg") || type.startsWith("png") ) { request({ method: "GET", url: props.file.fileUrl, responseType: "blob", //告诉服务器想到的响应格式 headers: { Accept: "application/octet-stream", }, }) .then((res) => { if (res) { let blob = new Blob([res.data], { type: "image/jpg" }); const imageUrl = URL.createObjectURL(blob); imgUrl.value = imageUrl; (srcList.value = [imageUrl]), (loading.value = false); } else { loading.value = false; } }) .catch(function (error) { console.log(error); loading.value = false; }); } else if (type == "pdf") { request({ method: "GET", url: props.file.fileUrl, responseType: "blob", //告诉服务器想到的响应格式 headers: { "Content-Type": "application/octet-stream", }, }) .then((res) => { if (res) { let blob = new Blob([res.data], { type: "application/pdf" }); const url = URL.createObjectURL(blob); loading.value = false; pdfUrl.value = url; pdfh5.value = new Pdfh5("#pdf-preview", { pdfurl: pdfUrl.value, }); } else { loading.value = false; } }) .catch(function (error) { console.log(error); loading.value = false; }); } else if (type == "ppt" || type == "pptx") { pptUrl.value = "https://view.xdocin.com/view?src=" + props.file.fileUrl; } else if (type == "xlsx" || type == "xls") { //表格 request({ method: "GET", url: props.file.fileUrl, responseType: "arraybuffer", //告诉服务器想到的响应格式 headers: { "Content-Type": "application/vnd.ms-excel;application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }, }) .then((res) => { console.log(res, "xls"); loading.value = false; if (res) { const workbook = XLSX.read(new Uint8Array(res.data), { type: "array", }); const sheetNames = workbook.SheetNames; // 工作表名称集合 excel.value.workbook = workbook; excel.value.sheetNames = sheetNames; excel.value.sheetNameActive = sheetNames[0]; console.log("excel", excel); getSheetNameTable(sheetNames[0]); } }) .catch(function (error) { console.log(error); loading.value = false; }); } else if (type == "docx" || type == "doc") { request({ method: "GET", url: props.file.fileUrl, responseType: "blob", //告诉服务器想到的响应格式 }) .then((res) => { console.log("DOC", res); loading.value = false; if (res) { // let docx = require("docx-preview"); docx.renderAsync(res.data, docFile.value, null, { className: "docx", // 默认和文档样式类的类名/前缀 inWrapper: true, // 启用围绕文档内容渲染包装器 ignoreWidth: false, // 禁止页面渲染宽度 ignoreHeight: false, // 禁止页面渲染高度 ignoreFonts: false, // 禁止字体渲染 breakPages: true, // 在分页符上启用分页 ignoreLastRenderedPageBreak: true, //禁用lastRenderedPageBreak元素的分页 experimental: false, //启用实验性功能(制表符停止计算) trimXmlDeclaration: true, //如果为真,xml声明将在解析之前从xml文档中删除 debug: false, // 启用额外的日志记录 }); // mammoth.convertToHtml({ arrayBuffer: new Uint8Array(xhr.response) }).then((resultObject) => { // nextTick(() => { // wordText.value = resultObject.value; // });p // }); } }) .catch(function (error) { loading.value = false; }); } else if (type === "txt") { request({ method: "GET", url: props.file.fileUrl, }) .then((res) => { txtText.value = res.data; loading.value = false; }) .catch(function (error) { console.log(error); loading.value = false; }); } else { Toast.fail("暂不支持预览该文件类型"); } }; const getSheetNameTable = (sheetName) => { try { // 获取当前工作表的数据 const worksheet = excel.value.workbook.Sheets[sheetName]; // 转换为数据 1.json数据有些问题,2.如果是html那么样式需修改 let htmlData = XLSX.utils.sheet_to_html(worksheet, { header: "", footer: "", }); htmlData = htmlData === "" ? htmlData : htmlData.replace( /<table/, '<table class="default-table" border="1px solid #ccc" cellpadding="0" cellspacing="0"' ); // 第一行进行改颜色 htmlData = htmlData === "" ? htmlData : htmlData.replace(/<tr/, '<tr style="background:#b4c9e8"'); excel.value.SheetActiveTable = htmlData; } catch (e) { // 如果工作表没有数据则到这里来处理 excel.value.SheetActiveTable = '<h4 style="text-align: center">' + emptyTips.value + "</h4>"; } }; watch( () => props.file, (newVal, oldVal) => { loading.value = true; let fileType = props.file.fileName.split("."); fileType = fileType[fileType.length - 1]; console.log(props.file, "watch"); imgUrl.value = ""; pdfUrl.value = ""; init(fileType); } ); defineExpose({ init, }); </script> <template> <div class="viewItemFile"> <van-loading v-if="typeName !== 'pdf' && loading" /> <div class="image" v-if=" typeName.startsWith('JPG') || typeName.startsWith('jpg') || typeName.startsWith('JPEG') || typeName.startsWith('jpeg') || typeName.startsWith('PNG') || typeName.startsWith('png') " > <div> <img style="display: block; max-width: 100%; margin: 24px auto" :src="imgUrl" alt="" /> </div> </div> <div class="docWrap" v-if="typeName == 'docx' || typeName == 'doc'"> <!-- 预览文件的地方(用于渲染) --> <div ref="docFile" class="docPre"></div> </div> <div class="xlxs-pre" v-if="typeName == 'xlsx' || typeName == 'xls'"> <div class="tab"> <a-radio-group size="small" v-model:value="excel.sheetNameActive" @change="getSheetNameTable(excel.sheetNameActive)" > <a-radio-button v-for="(item, index) in excel.sheetNames" :key="index" :value="item" >{{ item }}</a-radio-button > </a-radio-group> </div> <div style=" margin-top: 5px; border: 1px solid #a0a0a0; overflow-x: auto; overflow-y: scroll; " > <div v-html="excel.SheetActiveTable" style="padding: 10px 15px"></div> </div> </div> <div v-if="typeName == 'pdf'" style="height: 100%"> <div id="pdf-preview" /> </div> <div v-if="typeName == 'ppt' || typeName == 'pptx'" style="height: 100%"> <iframe id="ppt-preview" width="100%" height="auto" :src="pptUrl" /> </div> <div class="text-plain" v-if="typeName === 'txt'"> <textarea readonly :value="txtText"></textarea> </div> </div> </template> <style lang="less" scoped> .viewItemFile { height: 100%; .image { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; div { height: 600px; width: 600px; } } .divContent { display: flex; align-items: center; justify-content: center; } } .viewItemFile { #pdf-preview .pinch-zoom-container { height: 100% !important; } .xlxs-pre { height: calc(100vh - 40px); padding: 20px; .table-html-wrap :deep(table) { border-right: 1px solid #e8eaec; border-bottom: 1px solid #e8eaec; border-collapse: collapse; margin: auto; } .table-html-wrap :deep(table td) { border-left: 1px solid #e8eaec; border-top: 1px solid #e8eaec; white-space: wrap; text-align: left; min-width: 100px; padding: 4px; } table { border-top: 1px solid #ebeef5; border-left: 1px solid #ebeef5; width: 100%; overflow: auto; tr { height: 44px; } td { min-width: 200px; max-width: 400px; padding: 4px 8px; border-right: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5; } } } :deep(table) { width: 100% !important; border-collapse: collapse !important; border-spacing: 0 !important; text-align: center !important; border: 0px !important; overflow-x: auto !important; overflow-y: scroll !important; } :deep(table tr td) { /* border: 1px solid gray !important; */ border-right: 1px solid gray !important; border-bottom: 1px solid gray !important; width: 300px !important; height: 33px !important; } /**整体样式 */ :deep(.excel-view-container) { background-color: #ffffff; } /**标题样式 */ :deep(.class4Title) { font-size: 22px !important; font-weight: bold !important; padding: 10px !important; } /**表格表头样式 */ :deep(.class4TableTh) { /* font-size: 14px !important; */ font-weight: bold !important; padding: 2px !important; background-color: #ccc !important; } } .docWrap { width: 100%; :deep(.docx-wrapper) { background: #ffffff !important; :deep(.docx-wrapper section) { padding: 8pt 16pt; width: 100%; } :deep(.docx-wrapper > section.docx) { box-shadow: 0; } } } html body { width: 100%; height: 100vh; margin: 0; } .text-plain { width: 100%; height: 100%; padding-top: 30px; textarea { width: 100%; height: 100%; border: none; } .visible-content { height: 100%; border: none; } } </style> <style scoped> .docWrap { width: 100%; } :deep(.docWrap .docx-wrapper) { background: #ffffff !important; padding: 16px !important; } :deep(.docWrap .docx-wrapper > section.docx) { padding: 8pt 16pt !important; width: 100% !important; overflow: scroll !important; } :deep(#pdf-preview .pinch-zoom-container) { height: auto !important; } </style>
即可。