<template>
|
<div>
|
<!-- 图片 -->
|
<div v-if="isImage" style="text-align: center">
|
<img :src="imgUrl" alt="图片预览" />
|
</div>
|
<!-- 视频 -->
|
<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>
|
<!-- 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>
|
<!-- 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>
|
<!-- 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-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>
|
|
<script>
|
import axios from 'axios'
|
import VueOfficeDocx from '@vue-office/docx'
|
//引入相关样式
|
import '@vue-office/docx/lib/index.css'
|
import VueOfficeExcel from '@vue-office/excel'
|
//引入相关样式
|
import '@vue-office/excel/lib/index.css'
|
import onlyoffice from '@/components/Onlyoffice/onlyoffice.vue'
|
|
export default {
|
components: {
|
onlyoffice,
|
VueOfficeDocx,
|
VueOfficeExcel
|
},
|
props: {
|
fileUrl: {
|
type: String,
|
required: true
|
},
|
currentFile: {
|
type: Object,
|
required: true
|
},
|
option: {
|
type: Object,
|
default: () => ({})
|
}
|
},
|
data() {
|
return {
|
isDocShow: true,
|
csvColumns: [],
|
csvRows: [],
|
csvError: false,
|
textContent: '',
|
textLoaded: false,
|
textError: false,
|
pdfUrl: '', //pdf本地预览地址
|
pdfError: false
|
}
|
},
|
computed: {
|
isImage() {
|
return /\.(jpg|jpeg|png|gif)$/i.test(this.fileUrl)
|
},
|
imgUrl() {
|
// 图片存在 /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)
|
},
|
isDoc() {
|
return /\.(doc|docx)$/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() {
|
return {
|
xls: !/\.xlsx$/i.test(this.fileUrl), //预览xlsx文件设为false;预览xls文件设为true
|
minColLength: 0, // excel最少渲染多少列,如果想实现xlsx文件内容有几列,就渲染几列,可以将此值设置为0.
|
minRowLength: 0, // excel最少渲染多少行,如果想实现根据xlsx实际函数渲染,可以将此值设置为0.
|
widthOffset: 10, //如果渲染出来的结果感觉单元格宽度不够,可以在默认渲染的列表宽度上再加 Npx宽
|
heightOffset: 10, //在默认渲染的列表高度上再加 Npx高
|
beforeTransformData: workbookData => {
|
return workbookData
|
}, //底层通过exceljs获取excel文件内容,通过该钩子函数,可以对获取到的excel文件内容进行修改,比如某个单元格的数据显示不正确,可以在此自行修改每个单元格的value值。
|
transformData: workbookData => {
|
return workbookData
|
} //将获取到的excel数据进行处理之后且渲染到页面之前,可通过transformData对即将渲染的数据及样式进行修改,此时每个单元格的text值就是即将渲染到页面上的内容
|
}
|
},
|
fileExt() {
|
const match = this.fileUrl.match(/\.([a-zA-Z0-9]+)$/)
|
return match ? match[1].toUpperCase() : '未知'
|
},
|
// 调用方传了 OnlyOffice 的配置才走文档服务,否则用浏览器内置阅读器
|
useOnlyOffice() {
|
return !!(this.option && this.option.url)
|
}
|
},
|
watch: {
|
fileUrl: {
|
handler() {
|
this.resetState()
|
this.loadPdf()
|
this.loadTextContent()
|
},
|
immediate: true
|
}
|
},
|
beforeUnmount() {
|
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.pdfUrl = ''
|
}
|
},
|
async loadPdf() {
|
this.releasePdfUrl()
|
this.pdfError = false
|
if (!this.isPdf || this.useOnlyOffice) {
|
return
|
}
|
try {
|
const { data } = await axios.get(this.fileUrl, { responseType: 'blob' })
|
// 后端返回的 Content-Type 不一定带 application/pdf,这里强制指定,
|
// 浏览器才会用内置阅读器打开而不是直接下载
|
this.pdfUrl = URL.createObjectURL(new Blob([data], { type: 'application/pdf' }))
|
} catch (error) {
|
console.error(error)
|
this.pdfError = true
|
}
|
},
|
// CSV 解析成表格、文本文件直接展示;内容常见为 GBK 编码,UTF-8 解出乱码时换 GBK 再解一次
|
async loadTextContent() {
|
if (!this.isCsv && !this.isText) {
|
return
|
}
|
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 = []
|
const jsonObj = str.split('\n')
|
let arrHeader = []
|
for (const i in jsonObj) {
|
if (typeof jsonObj[i] === 'string' && jsonObj[i].length > 0) {
|
const row = `${jsonObj[i]}`
|
if (row.trim().length > 0) {
|
const kv = jsonObj[i].split(',')
|
if (i == 0) {
|
// 获取column表头
|
arrHeader = kv
|
} else {
|
const obj = {}
|
for (let index = 0; index < arrHeader.length; index++) {
|
const name = String(arrHeader[index]).trim()
|
if (!arrHeader[index]) continue
|
if (!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 都换掉
|
// (例如 font-family 里含 "pt" 的字体名也会被改坏)。因为无法在此验证 vue-office
|
// 的实际输出,这里保持原样不改,仅留记号。
|
const elements = document.querySelectorAll('[style*="pt"]')
|
for (const element of elements) {
|
const style = element.getAttribute('style')
|
if (style) {
|
element.setAttribute('style', style.replace(/pt/g, 'px'))
|
}
|
}
|
}
|
}
|
}
|
</script>
|
|
<style scoped>
|
img {
|
max-width: 100%;
|
}
|
|
.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>
|