vue-office实现文件预览

81 阅读2分钟

我们可能需要对上传的文件进行预览,这里抽出了一个文件预览的组件。我们引入FilePreviewModal和useFilePreview后,可以很简单的实现word和excel的预览

xlsx文件的预览效果 多个sheet页可以正常渲染,单元格的样式也可以渲染

image.png

使用时的demo

文件列表用tag展示,上传成功和失败以及正在上传有不同的状态,点击文件,将弹出文件预览框。

<template>
// 其他代码
<Tag
      v-for="file in fileList"
      :key="file.name"
      :closable="file.status !=='uploading'"
      @close="handleRemove(file)"
      :color="file.status == 'uploading' ? 'processing' : file.status"
    >
      <template #icon>
        <sync-outlined v-if="file.status == 'uploading'" :spin="true" />
        <check-circle-outlined v-else-if="file.status === 'success'" />
        <close-circle-outlined v-else-if="file.status === 'error'" />
      </template>
      <span @click.stop="handleAssitantPreview(file)" style="cursor: pointer;">{{ file.name }}</span>
    </Tag>
    
    <!-- 预览模态框 -->
     <FilePreviewModal
      :preview-state="previewState"
      :handle-preview-cancel="handlePreviewCancel"
      :rendered="rendered"
      :error-handler="errorHandler"
    />
</template>
    
    
    // 导入组合式函数和组件
import { useFilePreview } from '@/views/components/common/useFilePreview'
import FilePreviewModal from '@/views/components/common/FilePreviewModal.vue'

// 使用文件预览功能
const {
  previewState,
  readFileAsArrayBuffer,
  parseCSV,
  handlePreview: handleFilePreview,
  handlePreviewCancel,
  rendered,
  errorHandler
} = useFilePreview()


// 处理预览点击
const handleAssitantPreview = async (file: any) => {
    // 从后端获取文件的二进制流,即data,
   const { data } = await axiosGetFile(file.id)
   file.arrayBuffer = data
  await handleFilePreview(file)
}


FilePreviewModal.vue

<!-- components/FilePreviewModal.vue -->
<template>
  <Modal
    :visible="previewState.previewVisible"
    :title="previewState.previewTitle"
    :footer="null"
    @cancel="handlePreviewCancel"
    width="80%"
  >
    <!-- Word 预览 -->
    <VueOfficeDocx
      v-if="previewState.previewType === 'word' && previewState.fileSrc"
      :src="previewState.fileSrc"
      @rendered="rendered"
      @error="errorHandler"
      class="file-preview"
    />
    <!-- Excel 预览 -->
    <VueOfficeExcel
      v-else-if="previewState.previewType === 'excel' && previewState.fileSrc"
      :src="previewState.fileSrc"
      @rendered="rendered"
      @error="errorHandler"
      class="file-preview"
    />
    <!-- CSV/Text 预览 -->
    <div v-else-if="previewState.previewType === 'csv' && previewState.fileSrc" class="file-preview">
      <Table
        :dataSource="previewState.tableData"
        :columns="previewState.tableColumns"
        :pagination="false"
        bordered
        size="small"
      >
        <template #bodyCell="{ column, text }">
          <span :title="text">{{ text }}</span>
        </template>
      </Table>
    </div>
    <div v-else>
      <p>暂不支持该文件的预览</p>
    </div>
  </Modal>
</template>

<script setup lang="ts">
import { Modal, Table } from 'ant-design-vue'
import VueOfficeExcel from '@vue-office/excel'
import VueOfficeDocx from '@vue-office/docx'

interface Props {
  previewState: any
  handlePreviewCancel: () => void
  rendered: () => void
  errorHandler: (error: any) => void
}

defineProps<Props>()
</script>

<style scoped>
.file-preview {
  max-height: 70vh;
  overflow: auto;
}
</style>

useFilePreview.ts

// composables/useFilePreview.ts
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import * as XLSX from 'xlsx'

export interface FilePreviewState {
  previewVisible: boolean
  previewTitle: string
  previewType: string
  fileSrc: string | ArrayBuffer | null
  tableData: any[]
  tableColumns: any[]
}

export function useFilePreview() {
  const previewState = ref<FilePreviewState>({
    previewVisible: false,
    previewTitle: '',
    previewType: '',
    fileSrc: null,
    tableData: [],
    tableColumns: []
  })

  // 将文件读取为 ArrayBuffer
  const readFileAsArrayBuffer = (file: File): Promise<ArrayBuffer> => {
    return new Promise((resolve, reject) => {
      const reader = new FileReader()
      reader.onload = (e) => {
        if (e.target?.result) {
          resolve(e.target.result as ArrayBuffer)
        } else {
          reject(new Error('文件读取失败'))
        }
      }
      reader.onerror = reject
      reader.readAsArrayBuffer(file)
    })
  }

  // 解析 CSV 文件
  const parseCSV = (arrayBuffer: ArrayBuffer): any[] => {
    try {
      const textDecoder = new TextDecoder('utf-8')
      let csvString = textDecoder.decode(arrayBuffer)
      
      if (csvString.includes('�')) {
        try {
          const gbkDecoder = new TextDecoder('gbk')
          csvString = gbkDecoder.decode(arrayBuffer)
        } catch (e) {
          console.warn('GBK 解码失败,使用 UTF-8 结果')
        }
      }

      const workbook = XLSX.read(csvString, { 
        type: 'string', 
        codepage: 65001,
        cellDates: true,
        cellText: false
      })
      
      const firstSheet = workbook.Sheets[workbook.SheetNames[0]]
      const jsonData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 })
      
      return jsonData
    } catch (error) {
      console.error('CSV 解析错误:', error)
      return parseCSVSimple(arrayBuffer)
    }
  }

  // 简单的 CSV 解析(备用方案)
  const parseCSVSimple = (arrayBuffer: ArrayBuffer): any[] => {
    const textDecoder = new TextDecoder('utf-8')
    let csvString = textDecoder.decode(arrayBuffer)
    
    const lines = csvString.split('\n')
    return lines.map(line => {
      const result = []
      let current = ''
      let inQuotes = false
      
      for (let i = 0; i < line.length; i++) {
        const char = line[i]
        
        if (char === '"') {
          inQuotes = !inQuotes
        } else if (char === ',' && !inQuotes) {
          result.push(current)
          current = ''
        } else {
          current += char
        }
      }
      
      result.push(current)
      return result
    })
  }

  // 设置 CSV 表格数据
  const setupCSVTable = (csvData: any[]) => {
    if (!csvData || csvData.length === 0) {
      previewState.value.tableData = []
      previewState.value.tableColumns = []
      return
    }

    const headers = csvData[0]
    previewState.value.tableColumns = headers.map((header: any, index: number) => ({
      title: header || `列${index + 1}`,
      dataIndex: index.toString(),
      key: index.toString(),
      ellipsis: true,
      width: 150
    }))

    previewState.value.tableData = csvData.slice(1).map((row, rowIndex) => {
      const record: any = { key: rowIndex }
      row.forEach((cell: any, colIndex: number) => {
        record[colIndex.toString()] = cell
      })
      return record
    })
  }

  // 处理预览点击
  const handlePreview = async (file: any) => {
    try {
      previewState.value.previewTitle = `文件预览 - ${file.name}`
      previewState.value.previewType = file.name.endsWith('xlsx') ? 'excel' :
        file.name.endsWith('docx') ? 'word' :
        file.name.endsWith('csv') ? 'csv' : ''
      
      if (previewState.value.previewType === 'csv' && file.csvData) {
        previewState.value.fileSrc = file.arrayBuffer
        setupCSVTable(file.csvData)
      } else {
        previewState.value.fileSrc = file.arrayBuffer || null
      }
      
      previewState.value.previewVisible = true
      
    } catch (error) {
      console.error('预览失败:', error)
      message.error('文件预览失败,请重试')
    }
  }

  // 关闭预览模态框
  const handlePreviewCancel = () => {
    previewState.value.previewVisible = false
    previewState.value.previewTitle = ''
    previewState.value.previewType = ''
    previewState.value.fileSrc = null
    previewState.value.tableData = []
    previewState.value.tableColumns = []
  }

  // 预览组件渲染完成回调
  const rendered = () => {
    console.log("预览渲染完成")
  }

  // 预览组件错误处理
  const errorHandler = (error: any) => {
    console.error("预览加载失败:", error)
    message.error('文件预览失败,可能文件已损坏或不支持。')
  }

  return {
    previewState,
    readFileAsArrayBuffer,
    parseCSV,
    handlePreview,
    handlePreviewCancel,
    rendered,
    errorHandler
  }
}