1. 为什么你需要vue-office?一个真实开发者的痛点分享
做前端开发这么多年,我处理过各种各样的文件预览需求。记得几年前做一个企业内部文档管理系统,产品经理跑过来说:“咱们这个系统要能在线预览Word、Excel和PDF,用户不想下载就能直接看内容。”我当时心里咯噔一下,这可不是个小活儿。
我最初的想法很简单,不就是预览文件嘛,找个库不就行了?结果一搜才发现,事情没那么简单。Word预览得用docx-preview,Excel预览得用x-data-spreadsheet,PDF预览得用pdfjs。每个库的API都不一样,集成方式也千差万别。更头疼的是,这些库的版本兼容性、样式冲突、性能问题,让我那段时间几乎天天加班到深夜。
最让我崩溃的是,用户上传了一个复杂的Excel表格,里面有很多合并单元格和公式,预览出来的样式完全乱了套。产品经理拿着手机拍着屏幕问我:“这数据都对不上,用户怎么用?”我只能硬着头皮说:“我再调调,可能是样式兼容性问题。”
后来我发现了vue-office,说实话,第一次用的时候我都有点不敢相信。原来文件预览可以这么简单?只需要安装对应的组件,传入文件地址,剩下的什么都不用管了。那种感觉就像是你一直在手动挡开车,突然换成了自动挡——太顺畅了。
现在我做任何需要文件预览的项目,第一反应就是vue-office。它把三种最常见的办公文档格式(docx、xlsx、pdf)的预览都封装好了,而且对Vue2和Vue3都支持。你不用再为不同格式找不同的解决方案,也不用担心版本兼容性问题,更不用自己处理那些复杂的渲染逻辑。
2. 5分钟快速上手:从零开始集成vue-office
2.1 环境准备与安装
咱们先从最基础的开始。无论你是用Vue2还是Vue3,vue-office都能很好地支持。我建议你先创建一个新的Vue项目,或者在你现有的项目中直接安装。
如果你用的是Vue3,直接安装就行。如果是Vue2,特别是Vue 2.6或更早的版本,需要额外安装一个@vue/composition-api。这个库让Vue2也能使用Vue3的Composition API,vue-office内部用到了这个特性。
# 创建一个新的Vue3项目(如果你还没有项目)
npm create vue@latest my-office-app
cd my-office-app
npm install
# 安装vue-office的各个组件
# 如果你只需要预览Word文档
npm install @vue-office/docx vue-demi
# 如果你只需要预览Excel
npm install @vue-office/excel vue-demi
# 如果你只需要预览PDF
npm install @vue-office/pdf vue-demi
# 如果你三种格式都需要,可以一次性安装
npm install @vue-office/docx @vue-office/excel @vue-office/pdf vue-demi
# 如果是Vue 2.6或以下版本,还需要安装这个
npm install @vue/composition-api
这里有个小细节需要注意:vue-demi是一个很聪明的库,它能自动检测你项目中使用的是Vue2还是Vue3,然后提供对应的API。这就是为什么vue-office能同时支持两个大版本的原因。
安装完成后,你可以在package.json里看到这些依赖。我建议你检查一下版本,确保没有冲突。有时候不同的UI库可能会有版本要求,但vue-office在这方面做得比较友好,一般不会出现兼容性问题。
2.2 你的第一个文件预览组件
安装好了,咱们来写第一个预览组件。我建议从最简单的Word文档预览开始,因为这是最常见的需求。
创建一个新的Vue组件,比如叫DocumentPreview.vue:
<template>
<div class="preview-container">
<h2>Word文档预览</h2>
<vue-office-docx
:src="docxUrl"
style="height: 600px; border: 1px solid #e8e8e8; border-radius: 8px;"
@rendered="handleRendered"
@error="handleError"
/>
</div>
</template>
<script>
// 引入VueOfficeDocx组件
import VueOfficeDocx from '@vue-office/docx'
// 引入相关样式
import '@vue-office/docx/lib/index.css'
export default {
name: 'DocumentPreview',
components: {
VueOfficeDocx
},
data() {
return {
// 这里可以是网络地址,也可以是本地相对路径
docxUrl: 'https://example.com/your-document.docx'
}
},
methods: {
handleRendered() {
console.log('文档渲染完成!')
// 这里可以添加一些渲染完成后的逻辑
// 比如隐藏加载动画,或者更新页面状态
},
handleError(error) {
console.error('文档渲染失败:', error)
// 这里可以处理错误,比如显示错误提示
this.$message.error('文档加载失败,请检查网络或文件地址')
}
}
}
</script>
<style scoped>
.preview-container {
padding: 20px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
h2 {
margin-bottom: 20px;
color: #333;
font-size: 24px;
}
</style>
把这个组件在你的主页面中引入,就能看到效果了。我第一次运行这个代码的时候,真的被惊艳到了——原来实现一个专业的文档预览功能,只需要这么几行代码。
这里有几个实用的细节我想特别说明一下:
-
样式设置:我给组件加了
height: 600px,这是因为如果不设置高度,组件会默认取父元素的高度,最小300px。根据你的布局需求,可以灵活调整这个值。 -
事件处理:
@rendered事件在文档渲染完成时触发,@error事件在出错时触发。这两个事件非常有用,你可以在这里添加加载状态管理、错误提示等逻辑。 -
文件地址:
src属性支持多种格式,除了网络URL,还支持相对路径、ArrayBuffer、Blob等。这个灵活性在实际项目中特别有用。
3. 三种文件格式的深度使用指南
3.1 Word文档预览的实战技巧
Word文档预览可能是最常用的功能了。@vue-office/docx基于docx-preview库实现,这个选择很明智,因为它能很好地保留原文档的格式和样式。
在实际项目中,你可能会遇到各种情况。比如,用户上传的文档可能有复杂的表格、图片、列表样式等。我测试过很多复杂的文档,vue-office都能很好地处理。
处理大文档的性能优化
如果你要预览的Word文档特别大(比如几十页),可能会遇到性能问题。这时候可以采取一些优化措施:
<template>
<div>
<!-- 添加加载状态提示 -->
<div v-if="loading" class="loading">文档加载中...</div>
<vue-office-docx
v-else
:src="docxData"
:options="docxOptions"
style="height: 800px;"
@rendered="handleRendered"
/>
</div>
</template>
<script>
import VueOfficeDocx from '@vue-office/docx'
import '@vue-office/docx/lib/index.css'
export default {
components: { VueOfficeDocx },
data() {
return {
loading: true,
docxData: null,
docxOptions: {
// 可以传入一些配置选项
// 比如设置默认字体大小
style: `
.docx-wrapper {
font-size: 14px;
line-height: 1.6;
}
`
}
}
},
async mounted() {
// 对于大文件,可以考虑分片加载
await this.loadDocument()
},
methods: {
async loadDocument() {
try {
// 这里可以是API请求
const response = await fetch('https://api.example.com/large-document.docx')
const arrayBuffer = await response.arrayBuffer()
this.docxData = arrayBuffer
} catch (error) {
console.error('加载文档失败:', error)
} finally {
this.loading = false
}
},
handleRendered() {
console.log('大文档渲染完成')
// 可以在这里添加一些后处理逻辑
}
}
}
</script>
样式自定义与覆盖
有时候你可能需要调整预览的样式,比如修改默认字体、调整行间距等。vue-office允许你通过CSS覆盖默认样式:
/* 在你的全局或组件样式中 */
.docx-wrapper {
font-family: 'Microsoft YaHei', 'Segoe UI', sans-serif;
line-height: 1.8;
}
.docx-wrapper table {
border-collapse: collapse;
width: 100%;
}
.docx-wrapper img {
max-width: 100%;
height: auto;
}
3.2 Excel表格预览的专业处理
Excel预览的需求在企业应用中特别常见。@vue-office/excel基于exceljs和x-data-spreadsheet实现,这意味着它不仅能显示数据,还能保留公式、样式、合并单元格等高级特性。
基本使用示例
<template>
<div class="excel-preview">
<div class="preview-header">
<h3>Excel表格预览</h3>
<div class="tools">
<button @click="zoomIn">放大</button>
<button @click="zoomOut">缩小</button>
<button @click="download">下载</button>
</div>
</div>
<vue-office-excel
ref="excelViewer"
:src="excelUrl"
:style="excelStyle"
@rendered="handleExcelRendered"
@error="handleExcelError"
/>
</div>
</template>
<script>
import VueOfficeExcel from '@vue-office/excel'
import '@vue-office/excel/lib/index.css'
export default {
components: { VueOfficeExcel },
data() {
return {
excelUrl: 'https://example.com/data.xlsx',
zoomLevel: 1,
excelStyle: {
height: '700px',
width: '100%',
transform: 'scale(1)',
transformOrigin: '0 0'
}
}
},
methods: {
handleExcelRendered() {
console.log('Excel渲染完成')
// 可以在这里获取表格数据
const viewer = this.$refs.excelViewer
if (viewer && viewer.getData) {
const sheetData = viewer.getData()
console.log('表格数据:', sheetData)
}
},
zoomIn() {
this.zoomLevel += 0.1
this.excelStyle.transform = `scale(${this.zoomLevel})`
},
zoomOut() {
if (this.zoomLevel > 0.5) {
this.zoomLevel -= 0.1
this.excelStyle.transform = `scale(${this.zoomLevel})`
}
},
async download() {
// 实现下载功能
const response = await fetch(this.excelUrl)
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'document.xlsx'
a.click()
}
}
}
</script>
处理复杂表格的注意事项
我在实际项目中遇到过一些复杂的Excel表格,这里分享几个处理技巧:
-
大数据量优化:如果表格数据量很大(几万行),可以考虑分页加载。虽然
vue-office做了性能优化,但浏览器渲染大量DOM节点仍然有压力。 -
公式支持:
vue-office能显示公式计算的结果,但注意它不会重新计算公式。如果公式引用了其他单元格,确保这些数据都在表格中。 -
样式兼容:大部分Excel样式都能正确显示,但某些特殊的单元格格式(如条件格式、数据验证)可能无法完全还原。
3.3 PDF文档预览的高级配置
PDF预览可能是最稳定的,因为@vue-office/pdf基于成熟的pdfjs库实现。这个库是Mozilla维护的,质量很有保障。
基础PDF预览
<template>
<div class="pdf-viewer">
<vue-office-pdf
:src="pdfUrl"
:request-options="pdfOptions"
style="height: 800px;"
@rendered="handlePdfRendered"
@error="handlePdfError"
@page-change="handlePageChange"
/>
<div class="pdf-controls">
<button @click="prevPage">上一页</button>
<span>第 {{ currentPage }} 页 / 共 {{ totalPages }} 页</span>
<button @click="nextPage">下一页</button>
<input
type="range"
min="1"
:max="totalPages"
v-model="currentPage"
@change="goToPage"
/>
</div>
</div>
</template>
<script>
import VueOfficePdf from '@vue-office/pdf'
export default {
components: { VueOfficePdf },
data() {
return {
pdfUrl: 'https://example.com/document.pdf',
currentPage: 1,
totalPages: 0,
pdfOptions: {
// 可以配置请求头,比如添加认证token
headers: {
'Authorization': 'Bearer your-token-here'
}
}
}
},
methods: {
handlePdfRendered(pdfInfo) {
console.log('PDF渲染完成,文档信息:', pdfInfo)
this.totalPages = pdfInfo.numPages || 0
},
handlePageChange(pageNum) {
this.currentPage = pageNum
},
prevPage() {
if (this.currentPage > 1) {
this.currentPage--
this.goToPage()
}
},
nextPage() {
if (this.currentPage < this.totalPages) {
this.currentPage++
this.goToPage()
}
},
goToPage() {
// 这里可以通过ref调用组件的方法跳转页面
const viewer = this.$refs.pdfViewer
if (viewer && viewer.goToPage) {
viewer.goToPage(this.currentPage)
}
}
}
}
</script>
PDF性能优化建议
PDF文件通常比较大,特别是扫描版的PDF。这里有几个优化建议:
-
启用文本层:如果PDF包含文本层,启用文本选择功能会让用户体验更好。
-
懒加载:对于多页PDF,可以考虑只加载当前查看的页面,其他页面在需要时再加载。
-
缓存策略:如果用户经常查看同一个PDF,可以考虑在本地缓存渲染结果。
-
内存管理:PDF.js会占用较多内存,特别是大文件。确保在组件销毁时清理资源:
export default {
// ...
beforeDestroy() {
// 清理PDF资源
if (this.pdfViewer) {
this.pdfViewer.destroy()
}
}
}
4. 实际项目中的高级应用场景
4.1 文件上传与实时预览
在实际项目中,用户上传文件后立即预览是最常见的需求。这个功能看似简单,但要做好用户体验,需要考虑很多细节。
完整的文件上传预览组件
我设计过一个比较完善的上传预览组件,这里分享给大家:
<template>
<div class="file-upload-preview">
<!-- 上传区域 -->
<div class="upload-area" @click="triggerUpload">
<input
ref="fileInput"
type="file"
:accept="acceptTypes"
@change="handleFileChange"
style="display: none;"
/>
<div v-if="!currentFile" class="upload-placeholder">
<svg-icon name="upload" size="48" />
<p>点击上传文件,或拖拽文件到这里</p>
<p class="hint">支持 {{ acceptTypes }} 格式</p>
</div>
<div v-else class="file-info">
<div class="file-header">
<span class="file-name">{{ currentFile.name }}</span>
<span class="file-size">{{ formatFileSize(currentFile.size) }}</span>
<button @click.stop="removeFile" class="remove-btn">×</button>
</div>
<!-- 预览区域 -->
<div class="preview-area">
<div v-if="previewLoading" class="loading">
<div class="spinner"></div>
<p>正在加载预览...</p>
</div>
<!-- Word预览 -->
<vue-office-docx
v-else-if="isDocx && docxData"
:src="docxData"
style="height: 500px;"
@rendered="handlePreviewReady"
@error="handlePreviewError"
/>
<!-- Excel预览 -->
<vue-office-excel
v-else-if="isExcel && excelData"
:src="excelData"
style="height: 500px;"
@rendered="handlePreviewReady"
@error="handlePreviewError"
/>
<!-- PDF预览 -->
<vue-office-pdf
v-else-if="isPdf && pdfData"
:src="pdfData"
style="height: 500px;"
@rendered="handlePreviewReady"
@error="handlePreviewError"
/>
<!-- 不支持的类型 -->
<div v-else class="unsupported">
<p>暂不支持预览此文件类型</p>
<button @click="downloadFile">下载文件</button>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<button @click="uploadFile" :disabled="uploading">
{{ uploading ? '上传中...' : '确认上传' }}
</button>
<button @click="cancelUpload" class="cancel-btn">取消</button>
</div>
</div>
</div>
<!-- 错误提示 -->
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
</div>
</template>
<script>
import VueOfficeDocx from '@vue-office/docx'
import VueOfficeExcel from '@vue-office/excel'
import VueOfficePdf from '@vue-office/pdf'
import '@vue-office/docx/lib/index.css'
import '@vue-office/excel/lib/index.css'
export default {
name: 'FileUploadPreview',
components: {
VueOfficeDocx,
VueOfficeExcel,
VueOfficePdf
},
props: {
// 支持的文件类型
acceptTypes: {
type: String,
default: '.docx,.xlsx,.pdf'
},
// 最大文件大小(MB)
maxSize: {
type: Number,
default: 10
}
},
data() {
return {
currentFile: null,
previewLoading: false,
uploading: false,
errorMessage: '',
docxData: null,
excelData: null,
pdfData: null
}
},
computed: {
isDocx() {
return this.currentFile?.name.toLowerCase().endsWith('.docx')
},
isExcel() {
const name = this.currentFile?.name.toLowerCase()
return name.endsWith('.xlsx') || name.endsWith('.xls')
},
isPdf() {
return this.currentFile?.name.toLowerCase().endsWith('.pdf')
}
},
methods: {
triggerUpload() {
if (!this.currentFile) {
this.$refs.fileInput.click()
}
},
async handleFileChange(event) {
const file = event.target.files[0]
if (!file) return
// 验证文件大小
const maxSizeBytes = this.maxSize * 1024 * 1024
if (file.size > maxSizeBytes) {
this.errorMessage = `文件大小不能超过 ${this.maxSize}MB`
return
}
// 验证文件类型
const fileExt = file.name.split('.').pop().toLowerCase()
const allowedExts = this.acceptTypes.split(',').map(ext => ext.replace('.', ''))
if (!allowedExts.includes(fileExt)) {
this.errorMessage = `不支持的文件类型,请上传 ${this.acceptTypes} 格式的文件`
return
}
this.currentFile = file
this.errorMessage = ''
this.previewLoading = true
try {
// 读取文件内容
const arrayBuffer = await this.readFileAsArrayBuffer(file)
// 根据文件类型设置对应的数据
if (this.isDocx) {
this.docxData = arrayBuffer
} else if (this.isExcel) {
this.excelData = arrayBuffer
} else if (this.isPdf) {
this.pdfData = arrayBuffer
}
} catch (error) {
console.error('读取文件失败:', error)
this.errorMessage = '文件读取失败,请重试'
this.currentFile = null
}
},
readFileAsArrayBuffer(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (event) => {
resolve(event.target.result)
}
reader.onerror = reject
reader.readAsArrayBuffer(file)
})
},
handlePreviewReady() {
this.previewLoading = false
console.log('预览加载完成')
},
handlePreviewError(error) {
this.previewLoading = false
this.errorMessage = '文件预览失败,可能文件已损坏'
console.error('预览错误:', error)
},
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
},
removeFile() {
this.currentFile = null
this.docxData = null
this.excelData = null
this.pdfData = null
this.errorMessage = ''
this.$refs.fileInput.value = ''
},
async uploadFile() {
if (!this.currentFile) return
this.uploading = true
this.errorMessage = ''
try {
const formData = new FormData()
formData.append('file', this.currentFile)
// 这里调用你的上传API
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error('上传失败')
}
const result = await response.json()
this.$emit('upload-success', result)
this.removeFile()
} catch (error) {
this.errorMessage = '文件上传失败,请重试'
console.error('上传错误:', error)
} finally {
this.uploading = false
}
},
cancelUpload() {
this.removeFile()
this.$emit('cancel')
},
downloadFile() {
if (!this.currentFile) return
const url = URL.createObjectURL(this.currentFile)
const a = document.createElement('a')
a.href = url
a.download = this.currentFile.name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
}
}
</script>
<style scoped>
.file-upload-preview {
width: 100%;
max-width: 800px;
margin: 0 auto;
}
.upload-area {
border: 2px dashed #d9d9d9;
border-radius: 8px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: border-color 0.3s;
background: #fafafa;
}
.upload-area:hover {
border-color: #1890ff;
}
.upload-placeholder {
color: #999;
}
.upload-placeholder .hint {
font-size: 12px;
color: #ccc;
margin-top: 8px;
}
.file-info {
text-align: left;
}
.file-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
}
.file-name {
font-weight: 500;
color: #333;
}
.file-size {
color: #666;
font-size: 14px;
}
.remove-btn {
background: none;
border: none;
font-size: 20px;
color: #999;
cursor: pointer;
padding: 0 8px;
}
.remove-btn:hover {
color: #ff4d4f;
}
.preview-area {
border: 1px solid #f0f0f0;
border-radius: 4px;
overflow: hidden;
margin-bottom: 20px;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 500px;
color: #999;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #f3f3f3;
border-top: 3px solid #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.unsupported {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 500px;
color: #666;
}
.action-buttons {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.action-buttons button {
padding: 8px 24px;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 14px;
}
.action-buttons button:first-child {
background: #1890ff;
color: white;
}
.action-buttons button:first-child:disabled {
background: #bae7ff;
cursor: not-allowed;
}
.action-buttons .cancel-btn {
background: #f5f5f5;
color: #666;
}
.error-message {
margin-top: 12px;
padding: 12px;
background: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 4px;
color: #ff4d4f;
font-size: 14px;
}
</style>
这个组件考虑了用户体验的很多细节:
- 拖拽上传支持:可以通过添加
@drop事件监听来实现拖拽功能 - 文件验证:包括大小限制、类型验证
- 加载状态:预览加载时有明确的提示
- 错误处理:各种错误情况都有相应的提示
- 响应式设计:适应不同屏幕尺寸
4.2 与后端API的深度集成
在实际企业应用中,文件通常不会直接使用公开的URL,而是需要通过后端API获取。这时候就需要处理认证、授权、分片加载等问题。
带认证的文件预览
<template>
<div class="secure-preview">
<vue-office-docx
v-if="fileType === 'docx'"
:src="fileData"
:request-options="requestOptions"
style="height: 700px;"
@rendered="handleRendered"
@error="handleError"
/>
<vue-office-excel
v-else-if="fileType === 'excel'"
:src="fileData"
:request-options="requestOptions"
style="height: 700px;"
@rendered="handleRendered"
@error="handleError"
/>
<vue-office-pdf
v-else-if="fileType === 'pdf'"
:src="fileData"
:request-options="requestOptions"
style="height: 700px;"
@rendered="handleRendered"
@error="handleError"
/>
</div>
</template>
<script>
import VueOfficeDocx from '@vue-office/docx'
import VueOfficeExcel from '@vue-office/excel'
import VueOfficePdf from '@vue-office/pdf'
import '@vue-office/docx/lib/index.css'
import '@vue-office/excel/lib/index.css'
export default {
components: {
VueOfficeDocx,
VueOfficeExcel,
VueOfficePdf
},
props: {
fileId: {
type: String,
required: true
},
fileType: {
type: String,
required: true,
validator: value => ['docx', 'excel', 'pdf'].includes(value)
}
},
data() {
return {
fileData: null,
loading: true,
error: null,
requestOptions: {
headers: {
'Authorization': `Bearer ${this.getAuthToken()}`,
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'include',
mode: 'cors'
}
}
},
watch: {
fileId: {
immediate: true,
handler: 'loadFile'
}
},
methods: {
getAuthToken() {
// 从localStorage、cookie或状态管理获取token
return localStorage.getItem('auth_token') || ''
},
async loadFile() {
this.loading = true
this.error = null
try {
// 方案1:直接使用API URL
// this.fileData = `/api/files/${this.fileId}/content`
// 方案2:通过API获取ArrayBuffer
const response = await fetch(`/api/files/${this.fileId}/content`, {
headers: {
'Authorization': `Bearer ${this.getAuthToken()}`
}
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const arrayBuffer = await response.arrayBuffer()
this.fileData = arrayBuffer
} catch (err) {
console.error('加载文件失败:', err)
this.error = err.message
this.$emit('error', err)
} finally {
this.loading = false
}
},
handleRendered() {
console.log('安全文件渲染完成')
this.$emit('rendered')
},
handleError(error) {
console.error('安全文件渲染失败:', error)
this.error = error.message
this.$emit('error', error)
},
// 重新加载文件
reload() {
this.loadFile()
},
// 下载文件
async download() {
try {
const response = await fetch(`/api/files/${this.fileId}/download`, {
headers: {
'Authorization': `Bearer ${this.getAuthToken()}`
}
})
if (!response.ok) {
throw new Error('下载失败')
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `file.${this.fileType}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
} catch (err) {
console.error('下载失败:', err)
this.$message.error('文件下载失败')
}
}
}
}
</script>
<style scoped>
.secure-preview {
position: relative;
min-height: 400px;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.error-message {
padding: 40px;
text-align: center;
color: #ff4d4f;
background: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 4px;
}
.controls {
position: absolute;
top: 20px;
right: 20px;
z-index: 5;
display: flex;
gap: 8px;
}
.controls button {
padding: 6px 12px;
background: white;
border: 1px solid #d9d9d9;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.controls button:hover {
border-color: #1890ff;
color: #1890ff;
}
</style>
处理大文件的分片加载
对于特别大的文件,可以考虑实现分片加载:
async loadLargeFile(fileId) {
const chunkSize = 1024 * 1024 // 1MB
const totalSize = await this.getFileSize(fileId)
const chunks = Math.ceil(totalSize / chunkSize)
const chunkPromises = []
for (let i = 0; i < chunks; i++) {
const start = i * chunkSize
const end = Math.min(start + chunkSize, totalSize)
chunkPromises.push(
this.loadFileChunk(fileId, start, end)
)
}
// 并行加载所有分片
const chunksData = await Promise.all(chunkPromises)
// 合并分片数据
const totalLength = chunksData.reduce((acc, chunk) => acc + chunk.byteLength, 0)
const mergedArray = new Uint8Array(totalLength)
let offset = 0
chunksData.forEach(chunk => {
mergedArray.set(new Uint8Array(chunk), offset)
offset += chunk.byteLength
})
this.fileData = mergedArray.buffer
}
async loadFileChunk(fileId, start, end) {
const response = await fetch(`/api/files/${fileId}/chunk`, {
headers: {
'Authorization': `Bearer ${this.getAuthToken()}`,
'Range': `bytes=${start}-${end-1}`
}
})
return response.arrayBuffer()
}
async getFileSize(fileId) {
const response = await fetch(`/api/files/${fileId}/metadata`, {
headers: {
'Authorization': `Bearer ${this.getAuthToken()}`
}
})
const metadata = await response.json()
return metadata.size
}
4.3 多文件批量预览与管理
在文档管理系统、知识库等应用中,经常需要批量预览多个文件。这里我设计了一个文件管理器的示例:
<template>
<div class="file-manager">
<!-- 侧边栏:文件列表 -->
<div class="sidebar">
<div class="sidebar-header">
<h3>文档列表</h3>
<button @click="addNewFile" class="add-btn">+ 添加</button>
</div>
<div class="file-list">
<div
v-for="file in files"
:key="file.id"
:class="['file-item', { active: activeFileId === file.id }]"
@click="selectFile(file)"
>
<div class="file-icon">
<FileIcon :type="file.type" />
</div>
<div class="file-info">
<div class="file-name">{{ file.name }}</div>
<div class="file-meta">
<span class="file-size">{{ formatSize(file.size) }}</span>
<span class="file-date">{{ formatDate(file.updatedAt) }}</span>
</div>
</div>
<div class="file-actions">
<button @click.stop="downloadFile(file)" title="下载">
↓
</button>
<button @click.stop="deleteFile(file)" title="删除">
×
</button>
</div>
</div>
</div>
</div>
<!-- 主区域:文件预览 -->
<div class="main-content">
<div v-if="activeFile" class="preview-container">
<div class="preview-header">
<h2>{{ activeFile.name }}</h2>
<div class="header-actions">
<button @click="downloadFile(activeFile)">下载</button>
<button @click="printFile">打印</button>
<button @click="fullscreen">全屏</button>
</div>
</div>
<div class="preview-wrapper">
<!-- Word文档预览 -->
<vue-office-docx
v-if="activeFile.type === 'docx'"
:key="activeFile.id"
:src="activeFile.url"
style="height: 100%;"
@rendered="handleFileRendered"
@error="handleFileError"
/>
<!-- Excel预览 -->
<vue-office-excel
v-else-if="activeFile.type === 'xlsx' || activeFile.type === 'xls'"
:key="activeFile.id"
:src="activeFile.url"
style="height: 100%;"
@rendered="handleFileRendered"
@error="handleFileError"
/>
<!-- PDF预览 -->
<vue-office-pdf
v-else-if="activeFile.type === 'pdf'"
:key="activeFile.id"
:src="activeFile.url"
style="height: 100%;"
@rendered="handleFileRendered"
@error="handleFileError"
/>
<!-- 不支持的类型 -->
<div v-else class="unsupported-type">
<p>不支持预览此文件类型</p>
<button @click="downloadFile(activeFile)">下载文件</button>
</div>
</div>
<!-- 缩略图导航(仅PDF) -->
<div v-if="activeFile.type === 'pdf'" class="thumbnail-nav">
<div class="thumbnails">
<div
v-for="page in pdfPages"
:key="page"
:class="['thumbnail', { active: currentPage === page }]"
@click="goToPage(page)"
>
<div class="page-number">{{ page }}</div>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<div class="empty-icon">📁</div>
<h3>暂无文件</h3>
<p>点击左侧"添加"按钮上传文件,或从列表中选择文件进行预览</p>
</div>
</div>
<!-- 上传对话框 -->
<FileUploadDialog
v-if="showUploadDialog"
@upload-success="handleUploadSuccess"
@cancel="showUploadDialog = false"
/>
</div>
</template>
<script>
import VueOfficeDocx from '@vue-office/docx'
import VueOfficeExcel from '@vue-office/excel'
import VueOfficePdf from '@vue-office/pdf'
import '@vue-office/docx/lib/index.css'
import '@vue-office/excel/lib/index.css'
import FileUploadDialog from './FileUploadDialog.vue'
import FileIcon from './FileIcon.vue'
export default {
name: 'FileManager',
components: {
VueOfficeDocx,
VueOfficeExcel,
VueOfficePdf,
FileUploadDialog,
FileIcon
},
data() {
return {
files: [
{
id: '1',
name: '项目计划书.docx',
type: 'docx',
size: 1024 * 512, // 512KB
url: 'https://example.com/files/1',
updatedAt: '2024-01-15'
},
{
id: '2',
name: '财务报告.xlsx',
type: 'xlsx',
size: 1024 * 1024 * 2, // 2MB
url: 'https://example.com/files/2',
updatedAt: '2024-01-14'
},
{
id: '3',
name: '产品手册.pdf',
type: 'pdf',
size: 1024 * 1024 * 5, // 5MB
url: 'https://example.com/files/3',
updatedAt: '2024-01-13'
}
],
activeFileId: null,
currentPage: 1,
pdfPages: 10,
showUploadDialog: false,
loading: false
}
},
computed: {
activeFile() {
return this.files.find(file => file.id === this.activeFileId)
}
},
methods: {
selectFile(file) {
this.activeFileId = file.id
this.currentPage = 1
// 记录最近访问
this.$store.dispatch('files/recordAccess', file.id)
},
async downloadFile(file) {
try {
const response = await fetch(file.url, {
headers: {
'Authorization': `Bearer ${this.getAuthToken()}`
}
})
if (!response.ok) {
throw new Error('下载失败')
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = file.name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
this.$message.success('文件下载成功')
} catch (error) {
console.error('下载失败:', error)
this.$message.error('文件下载失败')
}
},
async deleteFile(file) {
const confirmed = await this.$confirm(
`确定要删除"${file.name}"吗?`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
if (confirmed) {
try {
// 调用删除API
await this.$api.delete(`/files/${file.id}`)
// 从列表中移除
this.files = this.files.filter(f => f.id !== file.id)
// 如果删除的是当前选中的文件,清空选中状态
if (this.activeFileId === file.id) {
this.activeFileId = null
}
this.$message.success('文件删除成功')
} catch (error) {
console.error('删除失败:', error)
this.$message.error('文件删除失败')
}
}
},
addNewFile() {
this.showUploadDialog = true
},
handleUploadSuccess(newFile) {
this.files.unshift(newFile)
this.showUploadDialog = false
this.selectFile(newFile)
this.$message.success('文件上传成功')
},
handleFileRendered() {
console.log('文件渲染完成')
this.loading = false
},
handleFileError(error) {
console.error('文件渲染失败:', error)
this.loading = false
this.$message.error('文件预览失败')
},
printFile() {
window.print()
},
fullscreen() {
const elem = this.$el.querySelector('.preview-wrapper')
if (!document.fullscreenElement) {
elem.requestFullscreen?.() ||
elem.webkitRequestFullscreen?.() ||
elem.msRequestFullscreen?.()
} else {
document.exitFullscreen?.() ||
document.webkitExitFullscreen?.() ||
document.msExitFullscreen?.()
}
},
goToPage(page) {
this.currentPage = page
// 这里可以通过ref调用PDF组件的方法跳转页面
},
formatSize(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
},
formatDate(dateString) {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN')
},
getAuthToken() {
return localStorage.getItem('auth_token') || ''
}
},
mounted() {
// 加载文件列表
this.loadFileList()
}
}
</script>
<style scoped>
.file-manager {
display: flex;
height: 100vh;
background: #f5f5f5;
}
.sidebar {
width: 300px;
background: white;
border-right: 1px solid #e8e8e8;
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid #e8e8e8;
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-header h3 {
margin: 0;
font-size: 16px;
color: #333;
}
.add-btn {
padding: 6px 12px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.add-btn:hover {
background: #40a9ff;
}
.file-list {
flex: 1;
overflow-y: auto;
}
.file-item {
display: flex;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.3s;
}
.file-item:hover {
background: #fafafa;
}
.file-item.active {
background: #e6f7ff;
border-left: 3px solid #1890ff;
}
.file-icon {
margin-right: 12px;
font-size: 24px;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-weight: 500;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta {
display: flex;
gap: 12px;
margin-top: 4px;
font-size: 12px;
color: #999;
}
.file-actions {
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.3s;
}
.file-item:hover .file-actions {
opacity: 1;
}
.file-actions button {
background: none;
border: none;
color: #999;
cursor: pointer;
font-size: 16px;
padding: 4px;
}
.file-actions button:hover {
color: #1890ff;
}
.main-content {
flex: 1;
padding: 20px;
overflow: auto;
}
.preview-container {
height: 100%;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
}
.preview-header {
padding: 20px;
border-bottom: 1px solid #e8e8e8;
display: flex;
justify-content: space-between;
align-items: center;
}
.preview-header h2 {
margin: 0;
font-size: 18px;
color: #333;
}
.header-actions {
display: flex;
gap: 12px;
}
.header-actions button {
padding: 8px 16px;
background: #f5f5f5;
border: 1px solid #d9d9d9;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.header-actions button:hover {
border-color: #1890ff;
color: #1890ff;
}
.preview-wrapper {
flex: 1;
padding: 20px;
overflow: auto;
}
.unsupported-type {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
}
.unsupported-type button {
margin-top: 16px;
padding: 8px 24px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.thumbnail-nav {
padding: 20px;
border-top: 1px solid #e8e8e8;
background: #fafafa;
}
.thumbnails {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 8px 0;
}
.thumbnail {
width: 60px;
height: 80px;
background: white;
border: 2px solid #d9d9d9;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.3s;
}
.thumbnail:hover {
border-color: #1890ff;
}
.thumbnail.active {
border-color: #1890ff;
background: #e6f7ff;
}
.page-number {
font-size: 12px;
color: #666;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
}
.empty-icon {
font-size: 64px;
margin-bottom: 20px;
}
.empty-state h3 {
margin: 0 0 12px 0;
font-size: 20px;
color: #666;
}
.empty-state p {
margin: 0;
font-size: 14px;
}
</style>
这个文件管理器实现了以下功能:
- 文件列表管理:左侧显示所有文件,支持选择、下载、删除
- 实时预览:右侧预览选中的文件
- 批量操作:可以同时管理多个文件
- 分页导航:PDF文件支持缩略图导航
- 全屏模式:更好的阅读体验
- 响应式设计:适应不同屏幕尺寸
5. 性能优化与最佳实践
5.1 懒加载与虚拟滚动
当需要预览大量文件或大文件时,性能优化就变得非常重要。我在这方面踩过不少坑,总结了一些实用的优化技巧。
组件懒加载
对于大型应用,可以考虑按需加载vue-office组件:
// 在路由配置或组件中动态导入
const VueOfficeDocx = () => import('@vue-office/docx')
const VueOfficeExcel = () => import('@vue-office/excel')
const VueOfficePdf = () => import('@vue-office/pdf')
// 或者使用Vue 3的defineAsyncComponent
import { defineAsyncComponent } from 'vue'
const AsyncDocx = defineAsyncComponent(() =>
import('@vue-office/docx').then(module => module.default)
)
const AsyncExcel = defineAsyncComponent(() =>
import('@vue-office/excel').then(module => module.default)
)
const AsyncPdf = defineAsyncComponent(() =>
import('@vue-office/pdf').then(module => module.default)
)
大文件分片加载
对于特别大的文件,可以分片加载和渲染:
<template>
<div class="large-file-viewer">
<!-- 加载进度 -->
<div v-if="loadingProgress < 100" class="loading-progress">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: `${loadingProgress}%` }"
></div>
</div>
<div class="progress-text">
加载中... {{ loadingProgress }}%
</div>
</div>
<!-- 分页控制 -->
<div v-if="totalPages > 1" class="pagination-controls">
<button @click="prevPage" :disabled="currentPage === 1">上一页</button>
<span>第 {{ currentPage }} 页 / 共 {{ totalPages }} 页</span>
<button @click="nextPage" :disabled="currentPage === totalPages">下一页</button>
</div>
<!-- 文档预览 -->
<div class="document-container">
<vue-office-pdf
v-if="fileType === 'pdf'"
:src="currentChunk"
:key="currentPage"
@rendered="handlePageRendered"
@error="handlePageError"
/>
</div>
<!-- 页面缩略图 -->
<div v-if="fileType === 'pdf'" class="page-thumbnails">
<div
v-for="page in visiblePages"
:key="page"
:class="['thumbnail', { active: currentPage === page }]"
@click="goToPage(page)"
>
<div class="page-number">{{ page }}</div>
</div>
</div>
</div>
</template>
<script>
import VueOfficePdf from '@vue-office/pdf'
export default {
components: { VueOfficePdf },
props: {
fileUrl: {
type: String,
required: true
},
fileType: {
type: String,
default: 'pdf'
},
chunkSize: {
type: Number,
default: 1024 * 1024 // 1MB
}
},
data() {
return {
loadingProgress: 0,
currentPage: 1,
totalPages: 0,
currentChunk: null,
pageChunks: new Map(),
visiblePages: [],
pageSize: 10 // 每页显示的数量
}
},
watch: {
fileUrl: {
immediate: true,
handler: 'loadFileMetadata'
},
currentPage: {
handler: 'loadPage'
}
},
methods: {
async loadFileMetadata() {
try {
// 获取文件总大小
const headResponse = await fetch(this.fileUrl, { method: 'HEAD' })
const totalSize = parseInt(headResponse.headers.get('content-length') || '0')
// 计算总页数(假设每页约100KB)
this.totalPages = Math.ceil(totalSize / (100 * 1024))
// 初始化可见页面范围
this.updateVisiblePages()
// 加载第一页
await this.loadPage(1)
} catch (error) {
console.error('加载文件元数据失败:', error)
this.$emit('error', error)
}
},
async loadPage(pageNum) {
if (this.pageChunks.has(pageNum)) {
// 如果已经加载过,直接使用缓存
this.currentChunk = this.pageChunks.get(pageNum)
return
}
this.loadingProgress = 0
try {
// 计算该页的字节范围
const startByte = (pageNum - 1) * this.chunkSize
const endByte = pageNum * this.chunkSize - 1
const response = await fetch(this.fileUrl, {
headers: {
'Range': `bytes=${startByte}-${endByte}`
}
})
if (!response.ok) {
throw new Error(`加载第${pageNum}页失败`)
}
const arrayBuffer = await response.arrayBuffer()
// 缓存该页数据
this.pageChunks.set(pageNum, arrayBuffer)
this.currentChunk = arrayBuffer
// 更新加载进度
this.loadingProgress = 100
// 更新可见页面范围
this.updateVisiblePages()
} catch (error) {
console.error(`加载第${pageNum}页失败:`, error)
this.$emit('error', error)
}
},
updateVisiblePages() {
const start = Math.max(1, this.currentPage - Math.floor(this.pageSize / 2))
const end = Math.min(this.totalPages, start + this.pageSize - 1)
this.visiblePages = []
for (let i = start; i <= end; i++) {
this.visiblePages.push(i)
}
},
handlePageRendered() {
console.log(`第${this.currentPage}页渲染完成`)
this.$emit('page-rendered', this.currentPage)
},
handlePageError(error) {
console.error(`第${this.currentPage}页渲染失败:`, error)
this.$emit('page-error', { page: this.currentPage, error })
},
prevPage() {
if (this.currentPage > 1) {
this.currentPage--
}
},
nextPage() {
if (this.currentPage < this.totalPages) {
this.currentPage++
}
},
goToPage(pageNum) {
if (pageNum >= 1 && pageNum <= this.totalPages) {
this.currentPage = pageNum
}
},
// 预加载相邻页面
prefetchAdjacentPages() {
const pagesToPrefetch = [
this.currentPage - 1,
this.currentPage + 1
].filter(page =>
page >= 1 &&
page <= this.totalPages &&
!this.pageChunks.has(page)
)
pagesToPrefetch.forEach(page => {
this.loadPage(page).catch(console.error)
})
}
}
}
</script>
<style scoped>
.large-file-viewer {
height: 100%;
display: flex;
flex-direction: column;
}
.loading-progress {
padding: 20px;
background: #f5f5f5;
border-bottom: 1px solid #e8e8e8;
}
.progress-bar {
height: 8px;
background: #e8e8e8;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-fill {
height: 100%;
background: #1890ff;
transition: width 0.3s ease;
}
.progress-text {
text-align: center;
color: #666;
font-size: 14px;
}
.pagination-controls {
padding: 16px 20px;
background: white;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
}
.pagination-controls button {
padding: 8px 16px;
background: #f5f5f5;
border: 1px solid #d9d9d9;
border-radius: 4px;
cursor: pointer;
}
.pagination-controls button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-controls button:not(:disabled):hover {
border-color: #1890ff;
color: #1890ff;
}
.document-container {
flex: 1;
overflow: auto;
background: white;
}
.page-thumbnails {
padding: 20px;
background: #fafafa;
border-top: 1px solid #e8e8e8;
display: flex;
gap: 12px;
overflow-x: auto;
}
.thumbnail {
width: 80px;
height: 100px;
background: white;
border: 2px solid #d9d9d9;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: all 0.3s;
}
.thumbnail:hover {
border-color: #1890ff;
transform: translateY(-2px);
}
.thumbnail.active {
border-color: #1890ff;
background: #e6f7ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.2);
}
.page-number {
font-size: 14px;
color: #666;
font-weight: 500;
}
</style>
5.2 错误处理与用户体验
良好的错误处理能显著提升用户体验。下面是我总结的一些错误处理最佳实践:
统一的错误处理组件
<template>
<div class="document-viewer">
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<p>{{ loadingText }}</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-state">
<div class="error-icon">⚠️</div>
<h3>{{ errorTitle }}</h3>
<p>{{ errorMessage }}</p>
<div class="error-actions">
<button @click="retry" class="retry-btn">重试</button>
<button @click="download" class="download-btn">下载文件</button>
<button @click="report" class="report-btn">报告问题</button>
</div>
</div>
<!-- 空状态 -->
<div v-else-if="!hasContent" class="empty-state">
<div class="empty-icon">📄</div>
<h3>暂无文档</h3>
<p>请选择或上传一个文档进行预览</p>
</div>
<!-- 正常预览 -->
<div v-else class="preview-content">
<component
:is="previewComponent"
:src="fileData"
:request-options="requestOptions"
:style="previewStyle"
@rendered="handleRendered"
@error="handleError"
/>
</div>
<!-- 水印(可选) -->
<div v-if="showWatermark" class="watermark">
{{ watermarkText }}
</div>
</div>
</template>
<script>
import VueOfficeDocx from '@vue-office/docx'
import VueOfficeExcel from '@vue-office/excel'
import VueOfficePdf from '@vue-office/pdf'
import '@vue-office/docx/lib/index.css'
import '@vue-office/excel/lib/index.css'
export default {
name: 'DocumentViewer',
components: {
VueOfficeDocx,
VueOfficeExcel,
VueOfficePdf
},
props: {
fileUrl: {
type: [String, ArrayBuffer, Blob],
default: null
},
fileType: {
type: String,
validator: value => ['docx', 'xlsx', 'xls', 'pdf'].includes(value)
},
showWatermark: {
type: Boolean,
default: false
},
watermarkText: {
type: String,
default: '内部使用,请勿外传'
}
},
data() {
return {
loading: false,
error: null,
fileData: null,
retryCount: 0,
maxRetries: 3
}
},
computed: {
previewComponent() {
const map = {
docx: 'VueOfficeDocx',
xlsx: 'VueOfficeExcel',
xls: 'VueOfficeExcel',
pdf: 'VueOfficePdf'
}
return map[this.fileType] || null
},
hasContent() {
return this.fileData !== null
},
loadingText() {
const texts = [
'正在加载文档...',
'解析文件内容...',
'渲染文档中...',
'即将完成...'
]
return texts[this.retryCount % texts.length]
},
errorTitle() {
if (this.error?.type === 'network') {
return '网络错误'
} else if (this.error?.type === 'format') {
return '格式错误'
} else if (this.error?.type === 'size') {
return '文件过大'
} else {
return '加载失败'
}
},
errorMessage() {
if (this.error?.type === 'network') {
return '无法连接到服务器,请检查网络连接后重试。'
} else if (this.error?.type === 'format') {
return '文件格式不支持或文件已损坏,请确认文件格式是否正确。'
} else if (this.error?.type === 'size') {
return `文件大小超过限制(${this.error.maxSize}),请压缩文件后重试。`
} else {
return this.error?.message || '文档加载失败,请重试。'
}
},
requestOptions() {
return {
headers: {
'Authorization': `Bearer ${this.getAuthToken()}`
},
timeout: 30000, // 30秒超时
retry: {
retries: 3,
delay: 1000
}
}
},
previewStyle() {
return {
height: '100%',
width: '100%',
backgroundColor: 'white'
}
}
},
watch: {
fileUrl: {
immediate: true,
handler: 'loadDocument'
}
},
methods: {
async loadDocument() {
if (!this.fileUrl) {
this.fileData = null
return
}
this.loading = true
this.error = null
try {
// 如果是字符串URL,直接使用
if (typeof this.fileUrl === 'string') {
// 验证URL格式
if (!this.isValidUrl(this.fileUrl)) {
throw new Error('无效的文件地址')
}
// 检查文件大小(通过HEAD请求)
const size = await this.getFileSize(this.fileUrl)
if (size > 50 * 1024 * 1024) { // 50MB限制
throw {
type: 'size',
message: '文件过大',
maxSize: '50MB'
}
}
this.fileData = this.fileUrl
} else if (this.fileUrl instanceof ArrayBuffer || this.fileUrl instanceof Blob) {
// 如果是ArrayBuffer或Blob,直接使用
this.fileData = this.fileUrl
} else {
throw new Error('不支持的文件格式')
}
this.retryCount = 0
} catch (err) {
console.error('加载文档失败:', err)
this.error = err
// 自动重试
if (this.retryCount < this.maxRetries) {
this.retryCount++
setTimeout(() => {
this.loadDocument()
}, 1000 * this.retryCount) // 指数退避
}
} finally {
this.loading = false
}
},
async getFileSize(url) {
try {
const response = await fetch(url, { method: 'HEAD' })
const size = response.headers.get('content-length')
return size ? parseInt(size) : 0
} catch {
return 0
}
},
isValidUrl(url) {
try {
new URL(url)
return true
} catch {
return false
}
},
handleRendered() {
console.log('文档渲染完成')
this.$emit('rendered')
},
handleError(error) {
console.error('文档渲染错误:', error)
// 分类处理错误
let errorType = 'unknown'
let errorMessage = error?.message || '未知错误'
if (error.message?.includes('network') || error.message?.includes('fetch')) {
errorType = 'network'
} else if (error.message?.includes('format') || error.message?.includes('corrupt')) {
errorType = 'format'
}
this.error = {
type: errorType,
message: errorMessage,
originalError: error
}
this.$emit('error', this.error)
},
retry() {
this.retryCount = 0
this.loadDocument()
},
async download() {
try {
let blob
let filename = 'document'
if (typeof this.fileUrl === 'string') {
const response = await fetch(this.fileUrl)
blob = await response.blob()
// 从URL中提取文件名
const url = new URL(this.fileUrl)
filename = url.pathname.split('/').pop() || 'document'
} else if (this.fileUrl instanceof Blob) {
blob = this.fileUrl
filename = 'document'
} else if (this.fileUrl instanceof ArrayBuffer) {
blob = new Blob([this.fileUrl])
filename = 'document'
}
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
this.$emit('download-success')
} catch (error) {
console.error('下载失败:', error)
this.$emit('download-error', error)
}
},
report() {
const errorInfo = {
url: typeof this.fileUrl === 'string' ? this.fileUrl : 'binary-data',
type: this.fileType,
error: this.error,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent
}
console.log('错误报告:', errorInfo)
this.$emit('report-error', errorInfo)
// 这里可以发送错误报告到服务器
// this.sendErrorReport(errorInfo)
},
getAuthToken() {
return localStorage.getItem('auth_token') || ''
}
}
}
</script>
<style scoped>
.document-viewer {
position: relative;
height: 100%;
width: 100%;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
color: #666;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #f3f3f3;
border-top: 3px solid #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
padding: 40px;
text-align: center;
background
4539



被折叠的 条评论
为什么被折叠?



