vue-office:一站式Vue文件预览解决方案,轻松集成docx、xlsx与pdf

vue 使用vue-office预览word、excel,pdf同理 在此,我只使用了docx和excel, pdf我直接使用的iframe进行的展示就不作赘述了。 阅读详情

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>

把这个组件在你的主页面中引入,就能看到效果了。我第一次运行这个代码的时候,真的被惊艳到了——原来实现一个专业的文档预览功能,只需要这么几行代码。

这里有几个实用的细节我想特别说明一下:

  1. 样式设置:我给组件加了height: 600px,这是因为如果不设置高度,组件会默认取父元素的高度,最小300px。根据你的布局需求,可以灵活调整这个值。

  2. 事件处理:@rendered事件在文档渲染完成时触发,@error事件在出错时触发。这两个事件非常有用,你可以在这里添加加载状态管理、错误提示等逻辑。

  3. 文件地址: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表格,这里分享几个处理技巧:

  1. 大数据量优化:如果表格数据量很大(几万行),可以考虑分页加载。虽然vue-office做了性能优化,但浏览器渲染大量DOM节点仍然有压力。

  2. 公式支持:vue-office能显示公式计算的结果,但注意它不会重新计算公式。如果公式引用了其他单元格,确保这些数据都在表格中。

  3. 样式兼容:大部分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。这里有几个优化建议:

  1. 启用文本层:如果PDF包含文本层,启用文本选择功能会让用户体验更好。

  2. 懒加载:对于多页PDF,可以考虑只加载当前查看的页面,其他页面在需要时再加载。

  3. 缓存策略:如果用户经常查看同一个PDF,可以考虑在本地缓存渲染结果。

  4. 内存管理: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>

这个组件考虑了用户体验的很多细节:

  1. 拖拽上传支持:可以通过添加@drop事件监听来实现拖拽功能
  2. 文件验证:包括大小限制、类型验证
  3. 加载状态:预览加载时有明确的提示
  4. 错误处理:各种错误情况都有相应的提示
  5. 响应式设计:适应不同屏幕尺寸

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>

这个文件管理器实现了以下功能:

  1. 文件列表管理:左侧显示所有文件,支持选择、下载、删除
  2. 实时预览:右侧预览选中的文件
  3. 批量操作:可以同时管理多个文件
  4. 分页导航:PDF文件支持缩略图导航
  5. 全屏模式:更好的阅读体验
  6. 响应式设计:适应不同屏幕尺寸

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
vue-office 支持多种文件(docx、excel、pdf)预览的vue组件库 如果后端给的不是CDN地址,而是一些POST接口,该接口返回二进制流,则可以调用接口获取文件的ArrayBuffer数据,传递给src属性。通过网络地址预览示例如下,通过文件ArrayBuffer预览和上面docx的使用方式一致。通过网络地址预览示例如下,通过文件ArrayBuffer预览和上面docx的使用方式一致。读取文件的ArrayBuffer。 阅读详情

相关推荐

使用vue-office插件在线预览docx、.xlsx、pdf文件

vue-office是一个支持多种文件(docx、.xlsx、pdf)预览的vue组件库,支持vue2和vue3。

smallqiang的专栏 4539

Vue-Office:优雅实现Office文件预览的Vue组件

在现代Web应用中,Office文件(Word、Excel、PPT等)的预览是一个常见的需求。传统的解决方案往往需要依赖后端转换或第三方服务,不仅增加了系统复杂性,还可能带来性能和安全问题。今天我要介绍的vue-office正是一个优雅解决这一痛点的前端解决方案。vue-office是一套基于Vue.js的Office:Word文档预览组件:Excel文档预览组件:PDF文档预览组件这些组件能够直接在浏览器中解析和渲染Office文件,无需后端参与,极大简化了文件预览功能的实现。

李赛赛的专栏 1万+

糖尿病预测(深度学习pytorch实现-最新)

本次做的项目是糖尿病的预测,也是来自kaggle上面的一个小项目。根据一个人的怀孕次数、血糖、血压、皮肤厚度、胰岛素指标、身体质量指数、糖尿病谱系指数、年龄等身体参数来预测一个人是否患有糖尿病。

NangoGreen的博客 5360

Vue vue-office 预览docx、excel、pdf文件

在Vue中,我们可以使用一些第三方的插件或者组件来实现文件预览功能。比如,我们可以使用vue-office插件来预览PDF文件,文中所给的例子中使用了vue-office+element-ui。

qq_44747728的博客 1万+

单细胞RNA测序数据分析与可视化:从基础原理到高级应用

单细胞RNA测序(scRNA-seq)技术通过解析单个细胞的基因表达谱,显著提升了我们对细胞异质性的理解。本文详细介绍了scRNA-seq数据分析的完整流程,包括数据预处理、质量控制、降维、聚类、差异表达分析、细胞类型注释、细胞周期分析、轨迹分析、RNA速率分析、基因集富集分析、细胞通讯分析、批次效应校正以及空间转录组学整合。文章还展示了scrna_toolkit包在简化分析流程和增强数据可视化方面的应用。通过这一工具,研究者可以更高效地处理单细胞数据,揭示复杂的生物学信息。未来,该工具将进一步整合多组学分

2301_80338712的博客 4133

COMSOL仿真教程—激光烧蚀

本例使用二维模型,将来自激光的入射热通量模拟为金属表面上空间分布的热源,从而得到金属的烧蚀过程和瞬态温度分布。

qq_40977332的博客 2万+

vue-office:一站式Vue2/Vue3文件预览解决方案,轻松集成docx、xlsx与pdf

vue-office是一款专为Vue2/Vue3设计的一站式文件预览解决方案,支持docx、xlsx和pdf格式,简化了开发者的集成流程。通过统一的API和组件化设计,vue-office解决了多格式文件预览的兼容性问题,提升开发效率。文章详细介绍了安装、基础使用、高级场景及性能优化技巧,帮助开发者快速上手并应用于实际项目。

r2s3t4的博客 196

Oracle恢复学习实战小结

     学习oracle10g 043课程有段时间了,前一两个星期学习了backup/restore相关的内容,刚好公司有要求要为host服务器做一个备用机以防止数据意外丢失。于是,实战开始了。     首先介绍host服务器架构。host以oracle数据库作为底层数据存储,以公司总部开发的Santorin作为应用,主要提供给测试台架数据存储、数据访问、数据导入导出、用户权限管理和数据自动备份

kseawolfk的专栏 1035

vue实现本地预览word(docx)、excel(xlsx)、pdf文件

vue实现本地浏览器预览word(docx)、excel(xlsx)、pdf文件

qq_45158026的博客 2万+

微信小程序消息推送实战:从模板消息到订阅消息的进阶指南

本文详细解析了微信小程序消息推送的核心机制,重点对比了模板消息与订阅消息的差异与应用场景。模板消息适用于支付成功等强即时性业务回执,而订阅消息则需用户授权,更适合物流提醒等可预期通知。文章提供了从授权、配置到发送的完整实战指南,并分享了常见的避坑技巧与性能优化建议,帮助开发者构建高效、合规的用户触达体系。

weixin_29231027的博客 596

GStreamer —— 2.1、Windows搭建GStreamer环境,Qt加载GStreamer库后运行 - “教程1:Hello world!“(附:完整源码)

GStreamer —— 2、Windows搭建GStreamer环境,Qt加载GStreamer库后运行"教程1:Hello world!"

798

vue-office文档预览插件

在项目中,可能会有录入信息时预览上传的文件,或者在详情页查看文件等在线预览附件的需求场景,但是,一般除pdf外,其余类型文件并不支持直接客户端打开。vue-office是一个支持多种文件(docx、.xlsx、pdf)预览的vue组件库,能够很好的实现在项目中各种在线预览附件的功能需求使用简单,只需提供文档的src(网络地址)即可完成文档预览支持docx、pdf、excel多种文档的在线预览方案用户体验和性能比较好,可以选择每个文档的最佳预览方案。

钱多多的博客 6190

152:vue+leaflet 判断一个点是否在一个电子围栏内

本文介绍了一个基于Vue+Leaflet的示例,演示如何判断点是否在电子围栏内。作者大剑师兰特(优快云知名博主)展示了使用polygon.getBounds().contains(latlng)方法检测点与多边形边界的关系。代码仅需80行,通过点击地图触发判断逻辑,结果显示提示信息。该技术可广泛应用于地理围栏、位置服务等场景,需要安装turf依赖。文章提供了完整源码和配置说明,适合GIS开发者学习参考。

大剑师兰特的GIS世界 758

vue项目基于vue-office实现docx、xlsx、pdf文件的在线预览

vue项目基于vue-office实现docx、xlsx、pdf文件的在线预览

weixin_44161385的博客 1533

win10 跳过U8环境检测的IE WebControls.rar

win10 安装u8的时候 提示IE WebControls 不通过,这个工具可以欺骗u8的检查。 跳过U8环境检测的IE WebControls。提示导入失败后,重新检测通过。

假人有限元模型

假人模型,车辆碰撞假人有限元模型,适合做乘员安全的研究者利用

Vue-Office实现PPTX在线预览:前端文档处理的完整解决方案

在现代Web开发中,文档在线预览已成为提升用户体验的关键环节。面对复杂的PPTX文件格式,传统解决方案往往需要后端服务支持,而Vue-Office项目通过纯前端实现,为开发者提供了开箱即用的PPTX在线预览功能。 ## 快速上手:5分钟集成PPTX预览 ### 环境准备与安装 首先克隆项目仓库: ```bash git clone https://gitcode.com/gh_mirror

gitblog_00575的博客 869

efm32 入门.pdf

本PDF共计22页,介绍的是操作驱动API,下载与否自己斟酌。

Vue-Office 5分钟快速上手:一站式文档预览终极指南

Vue-Office是一个支持Word(.docx)、Excel(.xlsx,.xls)、PDF、PPTX等多种Office文件预览的Vue组件库,为开发者提供了一站式Office文件预览解决方案。无论是Vue2还是Vue3项目,甚至是React等非Vue框架,都能轻松集成这个强大的文档预览工具。🚀 ## 🌟 为什么选择Vue-Office? 在当今的Web应用中,文档预览功能已经成为许多

gitblog_00714的博客 542

ssm框架搭建

这个是一个在idea上成功跑起来的ssm框架项目,保存在这里方便以后使用直接下载

Vue-Office终极指南:快速实现Word、Excel、PDF、PPTX文档在线预览

在现代Web开发中,文档在线预览已成为提升用户体验的关键环节。Vue-Office作为一款专业的Vue组件库,为开发者提供了开箱即用的文档预览解决方案,让文档处理变得前所未有的简单。 ## 为什么选择Vue-Office进行文档预览开发? ### 一站式多格式文档处理能力 Vue-Office集成了完整的文档预览功能,支持Word(.docx)、Excel(.xlsx, .xls)、PDF、

gitblog_00247的博客 929

3分钟搞定!Vue Office文档预览组件让你的项目轻松支持Word、Excel、PDF预览

还在为项目中Office文档预览功能发愁吗?vue-office文档预览组件库让这个难题迎刃而解!无论你是Vue 2还是Vue 3开发者,这个强大的工具都能帮你快速实现Word、Excel、PDF等多种格式文档的在线预览功能,彻底告别繁琐的集成过程。想象一下,你的用户可以直接在浏览器中查看合同、报表、PPT,无需下载安装任何软件,体验就像在本地应用一样流畅! ## 为什么你需要vue-offic

gitblog_00115的博客 466

ECC 接入 Trae 完全指南:单命令部署 Everything Claude Code 工作流与安全卸载机制解析

本文面向希望在 Trae IDE 中复刻 Claude Code / Codex / Opencode 工作流的开发者,讲解如何借助开源仓库 ev/ECC 的 Trae 安装器,把项目沉淀的 90+ 自定义命令、60+ 智能体、280+ 技能与跨语言规则一键部署进 `.trae/`(或 CN 环境下的 `.trae-cn/`)目录。你将掌握本地与全局四种安装方式、`TRAE_ENV` 环境切换、基

gitblog_00484的博客 398

Vue-Office:一站式文档预览解决方案,让Word、Excel、PDF在线查看变得如此简单!

还在为如何在Web应用中实现文档预览而烦恼吗?🤔 面对Word、Excel、PDF等多种格式的办公文档,传统方案往往需要复杂的配置和繁琐的集成过程。但现在,有了Vue-Office,这一切都将变得前所未有的简单! ## 痛点分析:为什么文档预览如此棘手? 在开发过程中,你是否遇到过这些问题? - **格式兼容性差**:不同文档格式需要不同的处理库,集成成本高 - **用户体验不佳**:加载

gitblog_00183的博客 388

Vue-Office实现PPTX在线预览:5分钟快速集成的完整指南

在现代Web开发中,文档在线预览已成为提升用户体验的关键环节。Vue-Office项目通过纯前端实现,为开发者提供了开箱即用的PPTX在线预览功能,无需后端服务支持即可实现专业级的文档处理效果。本文将带你快速掌握如何集成和使用这一强大工具。 ## 项目概述与核心价值 Vue-Office是一个专为Vue.js应用设计的文档预览组件库,支持包括PPTX、DOCX、XLSX和PDF在内的多种格式。

gitblog_00313的博客 684

解锁vue-office组件潜能:从集成到定制的实战指南

vue-office是一款专为Vue开发者打造的文档预览组件库,能够轻松实现在线预览Word、Excel和PDF文件。本指南将带你从基础集成到高级定制,全面掌握vue-office的使用技巧,让文档预览功能成为你项目中的亮点。 ## 📋 快速了解vue-office核心功能 vue-office提供了三大核心组件,满足不同文档类型的预览需求: - **VueOfficeDocx**:Wor

gitblog_00479的博客 924

Vue-Office终极指南:5分钟实现专业文档预览解决方案

Vue-Office是一个功能强大的Vue组件库,专为前端开发者提供一站式Office文档预览解决方案。这个开源项目支持Word(.docx)、Excel(.xlsx/.xls)、PDF和PPTX等多种格式的文档预览,无论是Vue2还是Vue3项目都能完美兼容,甚至非Vue框架如React也能轻松使用。对于需要在Web应用中集成文档预览功能的开发者来说,Vue-Office提供了简单高效的实现方案

gitblog_00903的博客 435

在Vue项目中轻松集成文档预览功能的实战指南

还在为Vue项目中的文档预览功能发愁吗?面对Word、Excel、PDF等格式的文件,传统方案要么依赖第三方软件,要么实现复杂。现在,通过vue-office组件库,你可以在几分钟内为项目添加强大的文档预览能力。 ## 为什么选择vue-office解决文档预览难题 想象这样一个场景:你的OA系统需要在线查看合同文档,教育平台要展示课件内容,或者管理系统要预览报表文件。传统的做法是下载到本地用

gitblog_00110的博客 372

零门槛集成vue-office文档预览:从依赖安装到多场景实战指南

在现代Web应用开发中,文档在线预览已成为企业级应用的核心功能需求。vue-office文档预览组件库凭借其跨Vue版本兼容、多格式支持和极简API设计,成为开发者实现Office文档在线预览的首选方案。本文将从核心优势解析、多场景实战指南到性能调优策略,全方位展示如何零门槛集成这一强大工具,让你的应用轻松具备专业级文档处理能力。 ## 核心优势解析:为什么选择vue-office ### 1

gitblog_01175的博客 439
上一篇: 利用K8S的ingress-nginx动态生成CSP Nonce值防御XSS攻击
下一篇: Vue3实战:基于vue3-slide-verify打造高安全性离线滑块验证组件
生活碎片
博客等级 码龄2年 51粉丝 1780原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值