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