| | |
| | | <template> |
| | | <div> |
| | | <div v-if="isImage"> |
| | | <img :src="imgUrl" alt="Image Preview" /> |
| | | <!-- 图片 --> |
| | | <div v-if="isImage" style="text-align: center"> |
| | | <img :src="imgUrl" alt="图片预览" /> |
| | | </div> |
| | | <div v-if="isPdf" style="height: 80vh;"> |
| | | <!-- 视频 --> |
| | | <div v-else-if="isVideo" style="text-align: center"> |
| | | <video :src="fileUrl" controls style="max-width: 100%; max-height: 85vh"></video> |
| | | </div> |
| | | <!-- 音频 --> |
| | | <div v-else-if="isAudio" style="text-align: center; padding-top: 10vh"> |
| | | <audio :src="fileUrl" controls style="width: 60%"></audio> |
| | | </div> |
| | | <!-- PDF:传了 OnlyOffice 配置走文档服务,否则用浏览器内置阅读器 --> |
| | | <div v-else-if="isPdf" style="height: 80vh;"> |
| | | <onlyoffice v-if="useOnlyOffice" ref="onlyoffice" :options="option" style="width: 100%;height: 100%;" /> |
| | | <iframe v-else-if="pdfUrl" :src="pdfUrl" title="PDF 预览" style="width: 100%;height: 100%;border: none;"></iframe> |
| | | <p v-else-if="pdfError">PDF 预览失败,<a :href="fileUrl" style="color: var(--el-color-primary);" target="_blank">点此下载查看</a></p> |
| | | <p v-else>正在加载 PDF…</p> |
| | | </div> |
| | | <div v-if="isDoc"> |
| | | <p v-if="!isDocShow">文档无法直接预览,请下载查看。</p> |
| | | <a :href="fileUrl" v-if="!isDocShow">下载文件</a> |
| | | <vue-office-docx v-else :src="fileUrl" style="height: 100vh;" @rendered="renderedHandler" @error="errorHandler" /> |
| | | </div> |
| | | <div v-if="isXls"> |
| | | <p v-if="!isDocShow">文档无法直接预览,请下载查看。</p> |
| | | <a :href="fileUrl" v-if="!isDocShow">下载文件</a> |
| | | <vue-office-excel v-else :src="fileUrl" :options="excelOptions" style="height: 100vh;" @rendered="renderedHandler" |
| | | <!-- Word --> |
| | | <div v-else-if="isDoc"> |
| | | <vue-office-docx v-if="isDocShow" :key="fileUrl" :src="fileUrl" style="height: 100vh;" @rendered="renderedHandler" |
| | | @error="errorHandler" /> |
| | | <div v-else class="preview-fallback"> |
| | | <p>Word 文档无法直接预览,请下载查看。</p> |
| | | <el-button type="primary" @click="downloadFile">下载文件</el-button> |
| | | </div> |
| | | </div> |
| | | <div v-if="isZipOrRar"> |
| | | <p>压缩文件无法直接预览,请下载查看。</p> |
| | | <a :href="fileUrl">下载文件</a> |
| | | <!-- Excel --> |
| | | <div v-else-if="isXls"> |
| | | <vue-office-excel v-if="isDocShow" :key="fileUrl" :src="fileUrl" :options="excelOptions" style="height: 100vh;" |
| | | @rendered="renderedHandler" @error="errorHandler" /> |
| | | <div v-else class="preview-fallback"> |
| | | <p>Excel 文档无法直接预览,请下载查看。</p> |
| | | <el-button type="primary" @click="downloadFile">下载文件</el-button> |
| | | </div> |
| | | </div> |
| | | <div v-if="isCsv"> |
| | | <p v-if="csvList.length == 0">CSV 文件无法直接预览,请下载查看。</p> |
| | | <a :href="fileUrl" v-if="csvList.length == 0">下载文件</a> |
| | | <el-tabs type="border-card" v-if="csvList.length > 0" tab-position="bottom"> |
| | | <el-tab-pane :label="item.sheetName" v-for="(item, index) in csvList" :key="index"> |
| | | <el-table :data="item.tableData" height="75vh"> |
| | | <el-table-column :label="m.label" :prop="m.prop" v-for="(m, i) in item.column" :key="i" min-width="120px" |
| | | show-overflow-tooltip> |
| | | <template #header="scope"> |
| | | <div> |
| | | <el-tooltip :content="m.label" placement="top"> |
| | | <div class="oneLine"> |
| | | <span>{{ m.label }}</span> |
| | | </div> |
| | | </el-tooltip> |
| | | </div> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | </el-tab-pane> |
| | | </el-tabs> |
| | | <!-- CSV 表格预览 --> |
| | | <div v-else-if="isCsv"> |
| | | <el-table v-if="csvRows.length" :data="csvRows" height="75vh"> |
| | | <el-table-column :label="col" :prop="col" v-for="(col, i) in csvColumns" :key="i" min-width="120px" |
| | | show-overflow-tooltip /> |
| | | </el-table> |
| | | <div v-else-if="csvError" class="preview-fallback"> |
| | | <p>CSV 文件解析失败,请下载查看。</p> |
| | | <el-button type="primary" @click="downloadFile">下载文件</el-button> |
| | | </div> |
| | | <p v-else>正在加载…</p> |
| | | </div> |
| | | <div v-if="!isSupported"> |
| | | <p>不支持的文件格式</p> |
| | | <!-- 文本文件 --> |
| | | <div v-else-if="isText"> |
| | | <pre v-if="textLoaded" class="text-preview">{{ textContent }}</pre> |
| | | <div v-else-if="textError" class="preview-fallback"> |
| | | <p>文本文件读取失败,请下载查看。</p> |
| | | <el-button type="primary" @click="downloadFile">下载文件</el-button> |
| | | </div> |
| | | <p v-else>正在加载…</p> |
| | | </div> |
| | | <!-- 兜底:压缩包、程序文件等浏览器无法直接显示的格式,保证也可下载 --> |
| | | <div v-else class="preview-fallback"> |
| | | <p>该类型文件({{ fileExt }})浏览器无法在线显示,请下载后查看。</p> |
| | | <el-button type="primary" @click="downloadFile">下载文件</el-button> |
| | | </div> |
| | | </div> |
| | | </template> |
| | |
| | | data() { |
| | | return { |
| | | isDocShow: true, |
| | | csvList: [], //csv文件数据 |
| | | csvColumns: [], |
| | | csvRows: [], |
| | | csvError: false, |
| | | textContent: '', |
| | | textLoaded: false, |
| | | textError: false, |
| | | pdfUrl: '', //pdf本地预览地址 |
| | | pdfError: false |
| | | } |
| | | }, |
| | | computed: { |
| | | // 原实现把 isImage / isXls / isCsv 写成了「读文件后缀 + 顺手改别的响应式状态」的 computed: |
| | | // isImage 里写 imgUrl、isXls 里写 options.xls、isCsv 里直接发请求。 |
| | | // computed 里写状态属于副作用,在 Vue3 中会在渲染期触发状态变更,很容易演变成递归更新。 |
| | | // 这里拆成纯计算属性,副作用统一交给下面那个 watch。 |
| | | isImage() { |
| | | return /\.(jpg|jpeg|png|gif)$/i.test(this.fileUrl) |
| | | }, |
| | | imgUrl() { |
| | | // 原 isImage 无论真假都会写 imgUrl = fileUrl(命中图片时把 word 换成 img) |
| | | // 图片存在 /img 静态路径下,调用方传的可能是 /word 地址,这里统一换成 /img |
| | | return this.isImage ? this.fileUrl.replaceAll('word', 'img') : '' |
| | | }, |
| | | isVideo() { |
| | | return /\.(mp4|webm|ogv|ogg)$/i.test(this.fileUrl) |
| | | }, |
| | | isAudio() { |
| | | return /\.(mp3|wav|m4a|aac|flac|oga)$/i.test(this.fileUrl) |
| | | }, |
| | | isPdf() { |
| | | return /\.pdf$/i.test(this.fileUrl) |
| | |
| | | }, |
| | | isXls() { |
| | | return /\.(xls|xlsx)$/i.test(this.fileUrl) |
| | | }, |
| | | isCsv() { |
| | | return /\.csv$/i.test(this.fileUrl) |
| | | }, |
| | | isText() { |
| | | return /\.(txt|log|json|xml|md|ini|conf|cfg|yml|yaml|sql|properties)$/i.test(this.fileUrl) |
| | | }, |
| | | // vue-office 的 excel 组件用 xls 标志区分 xlsx / xls |
| | | excelOptions() { |
| | |
| | | heightOffset: 10, //在默认渲染的列表高度上再加 Npx高 |
| | | beforeTransformData: workbookData => { |
| | | return workbookData |
| | | }, //底层通过exceljs获取excel文件内容,通过该钩子函数,可以对获取的excel文件内容进行修改,比如某个单元格的数据显示不正确,可以在此自行修改每个单元格的value值。 |
| | | }, //底层通过exceljs获取excel文件内容,通过该钩子函数,可以对获取到的excel文件内容进行修改,比如某个单元格的数据显示不正确,可以在此自行修改每个单元格的value值。 |
| | | transformData: workbookData => { |
| | | return workbookData |
| | | } //将获取到的excel数据进行处理之后且渲染到页面之前,可通过transformData对即将渲染的数据及样式进行修改,此时每个单元格的text值就是即将渲染到页面上的内容 |
| | | } |
| | | }, |
| | | isZipOrRar() { |
| | | return /\.(zip|rar)$/i.test(this.fileUrl) |
| | | }, |
| | | isCsv() { |
| | | return /\.csv$/i.test(this.fileUrl) |
| | | }, |
| | | isSupported() { |
| | | return this.isImage || this.isPdf || this.isDoc || this.isZipOrRar || this.isCsv || this.isXls |
| | | fileExt() { |
| | | const match = this.fileUrl.match(/\.([a-zA-Z0-9]+)$/) |
| | | return match ? match[1].toUpperCase() : '未知' |
| | | }, |
| | | // 调用方传了 OnlyOffice 的配置才走文档服务,否则用浏览器内置阅读器 |
| | | useOnlyOffice() { |
| | |
| | | watch: { |
| | | fileUrl: { |
| | | handler() { |
| | | this.resetState() |
| | | this.loadPdf() |
| | | if (this.isCsv) { |
| | | this.loadCSVData() |
| | | } |
| | | this.loadTextContent() |
| | | }, |
| | | immediate: true |
| | | } |
| | |
| | | this.releasePdfUrl() |
| | | }, |
| | | methods: { |
| | | // 切换文件时清掉上一个文件的预览状态,避免沿用旧结果(比如上次 Word 渲染失败,这次还被卡在下载兜底页) |
| | | resetState() { |
| | | this.isDocShow = true |
| | | this.csvColumns = [] |
| | | this.csvRows = [] |
| | | this.csvError = false |
| | | this.textContent = '' |
| | | this.textLoaded = false |
| | | this.textError = false |
| | | }, |
| | | releasePdfUrl() { |
| | | if (this.pdfUrl) { |
| | | URL.revokeObjectURL(this.pdfUrl) |
| | |
| | | this.pdfError = true |
| | | } |
| | | }, |
| | | renderedHandler() { |
| | | console.log('渲染完成') |
| | | this.isDocShow = true |
| | | this.resetStyle() |
| | | }, |
| | | errorHandler() { |
| | | console.log('渲染失败') |
| | | this.isDocShow = false |
| | | }, |
| | | async loadCSVData() { |
| | | // 原实现依赖 this.$axios / this.$api,而这两个全局变量在项目里从未注册过 |
| | | // (main.js 没有挂载,也没有 api 聚合对象),一旦进入 CSV 分支就抛 TypeError。 |
| | | // 在拿到真实接口之前先显式降级为「下载查看」,与用户实际看到的结果一致。 |
| | | if (!this.$axios || !this.$api?.insOrderPlan?.preview) { |
| | | console.warn('[filePreview] 未配置 $axios/$api,CSV 转表格不可用,已降级为下载查看') |
| | | this.csvList = [] |
| | | // CSV 解析成表格、文本文件直接展示;内容常见为 GBK 编码,UTF-8 解出乱码时换 GBK 再解一次 |
| | | async loadTextContent() { |
| | | if (!this.isCsv && !this.isText) { |
| | | return |
| | | } |
| | | this.$axios |
| | | .post(this.$api.insOrderPlan.preview, { |
| | | id: this.currentFile.id |
| | | }) |
| | | .then(res => { |
| | | let arr = res.data |
| | | arr = arr.map(m => { |
| | | const obj = { |
| | | sheetName: m.sheetName, |
| | | tableData: [], |
| | | column: [] |
| | | } |
| | | obj.tableData = this.formatCSVToTable(m.content.replaceAll('null', ' ')) |
| | | // .replaceAll('MIN','=MIN').replaceAll('MAX','=MAX').replaceAll('AVERAGE','=AVERAGE') |
| | | for (const item in obj.tableData[0]) { |
| | | obj.column.push({ |
| | | label: item, |
| | | prop: item |
| | | }) |
| | | } |
| | | return obj |
| | | }) |
| | | this.csvList = arr |
| | | }) |
| | | .catch(err => { |
| | | console.log(err) |
| | | }) |
| | | try { |
| | | const { data } = await axios.get(this.fileUrl, { responseType: 'arraybuffer' }) |
| | | let text = new TextDecoder('utf-8').decode(data) |
| | | // 解码结果里出现 U+FFFD 替换符,说明原文不是 UTF-8,常见是 GBK 编码的中文文件 |
| | | if (text.indexOf(String.fromCharCode(0xFFFD)) !== -1) { |
| | | try { |
| | | text = new TextDecoder('gbk').decode(data) |
| | | } catch (e) { |
| | | // 个别浏览器不支持 gbk 解码,保留 UTF-8 结果 |
| | | } |
| | | } |
| | | if (this.isCsv) { |
| | | const rows = this.formatCSVToTable(text.replaceAll('\r\n', '\n').replaceAll('\r', '\n')) |
| | | if (rows.length) { |
| | | this.csvColumns = Object.keys(rows[0]) |
| | | this.csvRows = rows.slice(0, 5000) |
| | | } else { |
| | | this.csvError = true |
| | | } |
| | | } else { |
| | | this.textContent = text.length > 1000000 |
| | | ? text.slice(0, 1000000) + '\n\n……(文件过大,仅预览前一百万字,完整内容请下载查看)' |
| | | : (text || '(文件内容为空)') |
| | | this.textLoaded = true |
| | | } |
| | | } catch (error) { |
| | | console.error(error) |
| | | if (this.isCsv) { |
| | | this.csvError = true |
| | | } else { |
| | | this.textError = true |
| | | } |
| | | } |
| | | }, |
| | | formatCSVToTable(str) { |
| | | const result = [] |
| | |
| | | } else { |
| | | const obj = {} |
| | | for (let index = 0; index < arrHeader.length; index++) { |
| | | // 组装表格数据 |
| | | const name = String(arrHeader[index]) |
| | | const name = String(arrHeader[index]).trim() |
| | | if (!arrHeader[index]) continue |
| | | if (!obj[name]) { |
| | | try { |
| | | if (kv[index]) { |
| | | obj[name] = String(kv[index]) |
| | | } else { |
| | | obj[name] = '' |
| | | } |
| | | } catch (err) { |
| | | obj[name] = '' |
| | | } |
| | | obj[name] = kv[index] ? String(kv[index]) : '' |
| | | } |
| | | } |
| | | result.push(obj) |
| | |
| | | } |
| | | } |
| | | return result |
| | | }, |
| | | downloadFile() { |
| | | const name = this.currentFile && this.currentFile.fileName |
| | | ? this.currentFile.fileName |
| | | : this.fileUrl.split('/').pop() |
| | | this.$download.saveAs(this.fileUrl, name) |
| | | }, |
| | | renderedHandler() { |
| | | console.log('渲染完成') |
| | | this.isDocShow = true |
| | | this.resetStyle() |
| | | }, |
| | | errorHandler() { |
| | | console.log('渲染失败') |
| | | this.isDocShow = false |
| | | }, |
| | | resetStyle() { |
| | | // 注:原实现是 style.replace(/pt/g, 'px'),会把样式里任意位置的 pt 都换掉 |
| | |
| | | max-width: 100%; |
| | | } |
| | | |
| | | .oneLine { |
| | | overflow: hidden; |
| | | white-space: nowrap; |
| | | text-overflow: ellipsis; |
| | | .text-preview { |
| | | white-space: pre-wrap; |
| | | word-break: break-all; |
| | | max-height: 85vh; |
| | | overflow: auto; |
| | | padding: 12px; |
| | | margin: 0; |
| | | background: #f8f9fa; |
| | | font-family: Consolas, Monaco, monospace; |
| | | font-size: 13px; |
| | | line-height: 1.6; |
| | | } |
| | | |
| | | .preview-fallback { |
| | | text-align: center; |
| | | padding-top: 15vh; |
| | | color: #606266; |
| | | } |
| | | |
| | | .preview-fallback p { |
| | | margin-bottom: 16px; |
| | | } |
| | | </style> |