前端文件预览整合(一)

在 Web 应用中,文件预览功能是一个常见且实用的需求。
最下方有源码,大佬可以跳过简单介绍可以直接拉到下方查看。

方案 1 - kkFileView 服务

kkFile 文件预览是一款开源的文档在线预览项目。项目使用流行的 spring boot 搭建,易上手和部署,基本支持主流办公文档的在线预览,如 doc,docx,Excel,pdf,txt,zip,rar,图片等等。。

  1. 无需前端代码支持:kkFileView 是一个后端服务解决方案,不需要前端大量代码文件支持。
  2. 支持多种文件类型:kkFileView 支持 图片.docx.xlsx.pdf 等常见文件类型。

方案 2 - 前端预览插件(文后附 vue 代码

前端预览插件是一个更轻量级的解决方案,它不需要服务端支持,只需在浏览器中加载文件预览组件即可。目前仅支持docx、xlsx、xls、pdf、jpg、jpeg、png、txt、MP4、MP3格式,其他后缀可继续扩展

  1. 无需后端支持:前端预览插件不需要服务端支持,只需在浏览器中加载文件预览组件即可。
  2. 轻量:前端预览插件仅依赖前端框架,不依赖于任何后端服务。
  3. 灵活:前端预览插件支持各种文件类型,包括图片、文档、音视频等。

方案 2 的实现方式

1. 组件结构

首先,我们定义一个 Vue 组件,用于容纳文件列表和预览对话框。

<template>
  <div class="filepreview-container">
    <div class="file-list">
      <div
        v-for="(item, index) in fileList"
        :key="index"
        class="file-item"
        @click="handleShowFile(item)"
      >
        {{ item.src }}
      </div>
    </div>
    <div v-if="showFile" class="file-preview-dialog" @click="handleClose">
      <!-- 这里空白点击事件是为了阻止触发父元素点击事件,避免关闭预览框  -->
      <div class="file-preview-content" @click.stop="()=>{}">
        <div class="file-box">
          <div
            v-if="!supportedTypes.includes(extractFileType(targetFileSrc))"
            class="empty-box"
          >
            在线预览服务当前仅支持docx/xlsx/xls/pdf/jpg/jpeg/png/txt/mp4/mp3
          </div>
          <div
            v-else-if="extractFileType(targetFileSrc) === 'xls'"
            class="file-content xls-content"
          >
            <div id="tabs-container">
              <div class="tabs-header">
                <!-- 动态生成sheet标签页 -->
                <div
                  :class="['tab-item', { active: activeSheetName === itemSheetName }]"
                  v-for="(item, itemSheetName) in xlsData"
                  :key="itemSheetName"
                  @click="activeSheetName = itemSheetName"
                >
                  {{ itemSheetName }}
                </div>
              </div>
              <div class="tabs-content">
                <!-- 显示当前选中的表格 -->
                <template v-if="activeSheetName && xlsData[activeSheetName]">
                  <table
                    v-if="xlsData[activeSheetName].length > 0"
                    class="data-table"
                  >
                    <thead>
                      <tr>
                        <!-- 表头 -->
                        <th
                          v-for="(item, key) in xlsData[activeSheetName][0]"
                          :key="key"
                        >
                          {{ key }}
                        </th>
                      </tr>
                    </thead>
                    <tbody>
                      <!-- 表格内容 -->
                      <tr v-for="row in xlsData[activeSheetName]" :key="row.id">
                        <td v-for="(cell, key) in row" :key="key">
                          {{ cell }}
                        </td>
                      </tr>
                    </tbody>
                  </table>
                  <div v-else>当前Sheet页无数据</div>
                </template>
                <div v-else>请选择一个Sheet页</div>
              </div>
            </div>
          </div>
          <VueOfficeDocx
            v-else-if="extractFileType(targetFileSrc) === 'docx'"
            :src="targetFileSrc"
            class="file-content docx-content"
          />
          <VueOfficeExcel
            v-else-if="extractFileType(targetFileSrc) === 'xlsx'"
            :src="targetFileSrc"
            class="file-content xlsx-content"
          />
          <VueOfficePdf
            v-else-if="extractFileType(targetFileSrc) === 'pdf'"
            :src="targetFileSrc"
            class="file-content pdf-content"
          />
          <div
            v-else-if="['jpeg', 'png', 'jpg'].includes(extractFileType(targetFileSrc))"
            class="file-content img-content"
          >
            <img :src="targetFileSrc" :alt="targetFileSrc" />
          </div>
          <div
            v-else-if="extractFileType(targetFileSrc) === 'txt'"
            class="file-content txt-content"
          >
            <pre><code>{{ txtData }}</code></pre>
          </div>
          <div
            v-else-if="extractFileType(targetFileSrc) === 'mp3'"
            class="file-content audio-content"
          >
            <!-- 音频文件 -->
            <audio :src="targetFileSrc" controls width="100%" height="30px">
              您的浏览器不支持 audio 标签。
            </audio>
          </div>
          <div
            v-else-if="extractFileType(targetFileSrc) === 'mp4'"
            class="file-content video-content"
          >
            <!-- 视频文件 -->
            <video controls width="auto" height="100%">
              <source :src="targetFileSrc" type="video/mp4" />
              您的浏览器不支持 Video 标签。
            </video>
          </div>
        </div>
        <div class="file-box-btns">
          <div @click="downLoadFile">下载</div>
          <div @click="handleClose">取消</div>
        </div>
      </div>
    </div>
  </div>
</template>

2. 使用第三方库

为了处理不同类型的文件预览,我们将使用@vue-officexlsx-js-style

安装依赖
npm install @vue-office/docx @vue-office/excel @vue-office/pdf xlsx-js-style
引入并注册组件

在 Vue 组件中,我们需要引入并注册这些第三方库提供的组件。


<script setup name="FilePreview">
import { ref, computed, defineProps } from "vue"
import { getToken } from "@/utils/auth"
import VueOfficeDocx from "@vue-office/docx"
import "@vue-office/docx/lib/index.css"
import VueOfficeExcel from "@vue-office/excel"
import "@vue-office/excel/lib/index.css"
import VueOfficePdf from "@vue-office/pdf"
import * as XLSX from "xlsx-js-style"
// 支持的文件类型数组
const supportedTypes = ["docx", "xlsx", "xls", "pdf", "jpg", "jpeg", "png", "txt", "mp4", "mp3"]

// 封装一个fetch方法,通过文件路径获取二进制文件内容
const getFileArrayBufferBySrc = async (src, useLocalToken = false) => {
  const headers = new Headers()
  if (useLocalToken) {
    headers.set("Authorization", `Bearer ${getToken()}`)
  }

  const res = await fetch(src, { headers })
  if (!res.ok) {
    throw new Error("请求失败")
  }
  return await res.arrayBuffer()
}

// 提取文件类型函数,通过文件路径获取文件扩展名
const extractFileType = path => {
  const dotIndex = path.lastIndexOf(".")
  return dotIndex !== -1 ? path.substring(dotIndex + 1) : "无文件扩展名"
}

// 验证URL是否有效的函数
const isValidUrl = url => {
   const pattern = new RegExp('^(https?:\\/\\/)?'+ // 协议
    '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|'+ // 域名
    '((\\d{1,3}\\.){3}\\d{1,3}))'+ // IP
    '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // 端口和路径
    '(\\?[;&a-z\\d%_.~+=-]*)?'+ // 查询字符串
    '(\\#[-a-z\\d_]*)?$','i'); // 锚点
  return !!pattern.test(url);
}

// 定义组件属性
// list可以是字符串数组[''],也可以是object数组[{name: '', src: ''}]
const props = defineProps({
  list: {
    type: Array,
    default: () => []
  }
})

// 文件预览对话框显示状态
const showFile = ref(false)
// 当前预览文件的源路径
const targetFileSrc = ref("")
// 当前活动的Sheet名称,用于Excel文件预览
const activeSheetName = ref(undefined)
// Excel文件数据
const xlsData = ref({})
// 文本文件数据
const txtData = ref(undefined)

// 处理文件列表,根据URL或对象生成统一的文件列表格式
const fileList = computed(() => {
  return props.list.map(item => {
    if (typeof item === "string") {
      const lastSlashIndex = item.lastIndexOf("/")
      const dotIndex = item.lastIndexOf(".")
      if (lastSlashIndex !== -1 && dotIndex !== -1 && lastSlashIndex < dotIndex) {
        const fileName = item.substring(lastSlashIndex + 1, dotIndex)
        const fileType = item.substring(dotIndex + 1)
        return { name: fileName, src: item, filetype: fileType }
      } else {
        return { name: "未知文件类型", src: item, filetype: "unknown" }
      }
    } else if (typeof item === "object" && item !== null && "src" in item) {
      const fileName = "name" in item ? item.name : "unknown"
      const fileType = extractFileType(item.src)
      return { name: fileName, src: item.src, filetype: fileType }
    } else {
      return { name: "unknown", src: "unknown", filetype: "unknown" }
    }
  })
})

// 处理文件预览
const handleShowFile = async item => {
  showFile.value = true
  targetFileSrc.value = item.src

  if (item.filetype === "xls") {
    const arrayBuffer = await getFileArrayBufferBySrc(item.src, false)
    const data = new Uint8Array(arrayBuffer)
    const workbook = XLSX.read(data, { type: "buffer" })
    activeSheetName.value = workbook.SheetNames[0]
    workbook.SheetNames.forEach(sheetName => {
      const worksheet = workbook.Sheets[sheetName]
      xlsData.value[sheetName] = XLSX.utils.sheet_to_json(worksheet)
    })
  } else if (item.filetype === "txt") {
    const arrayBuffer = await getFileArrayBufferBySrc(item.src, false)
    const decoder = new TextDecoder("utf-8")
    txtData.value = decoder.decode(arrayBuffer)
  }
}

// 处理文件下载
const downLoadFile = () => {
  if (!isValidUrl(targetFileSrc.value)) {
    console.error("下载路径不正确.")
    return
  }

  fetch(targetFileSrc.value, {
    method: "GET",
    headers: {
      Authorization: `Bearer ${getToken()}`,
      "Content-Type": "application/x-www-form-urlencoded"
    }
  })
    .then(response => {
      if (!response.ok) {
        throw new Error("网络连接失败")
      }
      // 将 Response 转换为 Blob
      return response.blob()
    })
    .then(data => {
      const reader = new FileReader()
      // 读取 blob 数据
      reader.readAsDataURL(data)
      // 读取 blob 数据为 Data URL
      reader.onloadend = function () {
        const a = document.createElement("a")
        a.href = reader.result
         // 设置为'_blank'以在新窗口或标签页中打开
        a.target = "_blank"
        // 安全问题
        a.rel = "noopenner noreferrer"
        const lastSlashIndex = targetFileSrc.value.lastIndexOf("/")
        const fileName = targetFileSrc.value.substring(lastSlashIndex + 1)
        a.download = fileName // 设置文件名
        a.click() // 触发下载
        handleClose()
      }
    })
    .catch(error => {
      console.error("下载失败:", error)
    })
}

// 关闭文件预览对话框
const handleClose = () => {
  showFile.value = false
  targetFileSrc.value = undefined
  activeSheetName.value = undefined
  xlsData.value = {}
  txtData.value = undefined
}
</script>

3. 样式

最后,我们添加一些 CSS 样式来美化我们的文件预览插件。


<style lang="scss" scoped>
$border-color: #e4e7ed;
$hover-bg: #f5f7fa;
$hover-color: #69b4ff;
$active-color: #409eff;

$file-preview-dialog-width: 100vw;
$file-preview-dialog-height: 100vh;

.filepreview-container {
  width: 100%;
  max-width: 1000px;
  overflow: auto;
  .file-list {
    .file-item {
      border: 1px solid $border-color;
      line-height: 2;
      margin-bottom: 10px;
      position: relative;
      // 省略号
      overflow: hidden;
      white-space: nowrap;
      text-overflow: ellipsis;
      padding: 0 10px;
      // hover
      transition: background-color 0.3s, color 0.3s;
      &:hover {
        background-color: $hover-bg;
        cursor: pointer;
        color: $hover-color;
      }
    }
  }
  .file-preview-dialog {
    width: $file-preview-dialog-width;
    height: $file-preview-dialog-height;
    background-color: rgba(0, 0, 0, 0.4);
    overflow: hidden;
    position: fixed;
    left: 0;
    top: 0;
    z-index: 999;
    display: flex;
    justify-content: center;
    align-items: center;
    .file-preview-content {
      width: 80vw;
      height: 90vh;
      background-color: #fff;
      border-radius: 5px;
      padding: 30px;
      display: flex;
      flex-flow: column;
      .file-box {
        flex: 1;
        height: calc(90vh - 80px - 60px);
        .file-content {
          width: 100%;
          height: 100%;
          display: flex;
          flex-flow: column;
          overflow: auto;
          background-color: #808080;
        }
        .xls-content {
          // 表格table的基础样式
          #tabs-container {
            width: 100%;
            height: 100%;
            background-color: #fff;
            display: flex;
            flex-direction: column;
            border: 1px solid $border-color;
            box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
            .tabs-header {
              width: 100%;
              display: flex;
              gap: 5px;
              background-color: #f2f2f2;
              padding: 5px 5px 0;
              .tab-item {
                padding: 8px 16px;
                cursor: pointer;
                border: 1px solid $border-color;
                border-bottom: none;
                color: #606266;
                font-size: 14px;
                line-height: 1.5;
                transition: all 0.3s;
                &.active {
                  background-color: white;
                  position: relative;
                  top: -1px; /* 下沉效果 */
                  border-bottom-color: transparent;
                  color: $active-color;
                  font-weight: bold;
                }
              }
            }
            .tabs-content {
              width: 100%;
              height: calc(100% - 36px); /* 减去头部高度 */
              padding: 10px;
              overflow: auto;
              border: 1px solid $border-color;
              border-top: none;
              .data-table {
                width: 100%;
                border-collapse: collapse;
                overflow: auto;
                font-size: 14px;
                color: #606266;
                th,
                td {
                  border: 1px solid $border-color;
                  padding: 8px;
                  text-align: left;
                  min-width: 100px;
                  min-height: 30px;
                  background-color: #fff;
                  vertical-align: middle;
                }
                th {
                  background-color: #f2f2f2;
                  color: #606266;
                  font-weight: bold;
                }
                tr:hover {
                  background-color: $hover-bg;
                }
              }
            }
          }
        }
        .img-content {
          > img {
            width: 100%;
            height: auto;
          }
        }
        .txt-content {
          font-size: 15px;
          line-height: 1.5;
          background-color: #fff;
        }
        .audio-content {
          display: flex;
          justify-content: center;
          align-items: center;
        }
        .empty-box {
          color: #aaaaaa;
          fill: currentColor;
          width: 100%;
          height: 100%;
          vertical-align: top;
          display: flex;
          flex-direction: column;
          justify-content: center;
          align-items: center;
        }
      }
      .file-box-btns {
        display: flex;
        flex-flow: row;
        justify-content: center;
        margin: 20px 0;
        > div {
          margin: 0 20px;
          background-color: $active-color;
          color: #fff;
          line-height: 40px;
          padding: 0 20px;
          border-radius: 4px;
          cursor: pointer;
          transition: background-color 0.3s;
          &:hover {
            background-color: lighten($active-color, 10%);
            color: #fff;
          }
        }
      }
    }
  }
}

// 响应式设计
@media (max-width: 768px) {
  .filepreview-container {
    max-width: 100%;
  }
  .file-preview-dialog {
    width: 100%;
    height: 100%;
    .file-preview-content {
      width: 90%;
      height: 80%;
      padding: 20px;
      .file-box {
        height: calc(80% - 40px - 40px);
        .file-content {
          height: 100%;
        }
      }
    }
  }
}
</style>

通过以上步骤,我们实现了一个功能齐全的前端文件预览插件。用户可以通过点击文件列表中的文件来查看其内容,并且可以下载文件。这个插件支持.docx.xlsx.pdf文件类型,并且可以根据需要轻松扩展以支持更多文件类型。

文件列表
20230926164408
word文档
20230926164408
excel预览(xlsx和xls不一致,xls无空白行数据及样式)
20230926164408 20230926164408
txt预览
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值