一 背景

公司制作在线文件预览库,可接受不能预览doc、ppt;

1)图片采用:element-plus的 el-image-viewer
2)vue-office 系列组件可预览 :pdf、docx、excel(xls、xlsx、csv)、pptx
3)bestofdview:预览ofd
4)dplayer 预览音频:mp4,这别后台会校验mp4 前 200 KB 内 有 moov(可边加载边播放)
5)txt:直接读取内容:放入网页显示,但使用 chardet 识别前10k 判断编码模式
其他介绍
html:不推荐预览,加载script 可能会攻击你的网站
若url路径包含文件名:文件名不能包含 [、]、+ 或 %

这里档案库禁止 doc、ppt 上传,除了上面能预览的档案外,档案库还允许上传安全的压缩包类型;

注:为了保证文件的防止传播性,这边后台会为每次预览生成一个临时token,且预览地址会携带该参数;后台会校验token 合法、短时间有效、甚至是单次有效,防止通过预览地址下载到原文件

1.1 预览效果

1 图片:注:不一定要点击图片显示,也可点击按钮直接渲染:el-image-viewer 组件
地址中图片预览 ,https://element-plus.org/zh-CN/component/image
2 pdf、office系列
地址中的:演示效果,https://github.com/501351981/vue-office
3 ofd
地址中的:在线预览地址,https://github.com/besthqs/bestofdview
4 媒体
地址中的媒体:https://github.com/DIYgod/DPlayer
5 文本预览
使用 vue3组件 chardet 进行编码识别,然后用浏览器内置的 TextDecoder 做编码转换:
https://github.com/runk/node-chardet

1.2 安装依赖

# 预览图片 
pnpm install element-plus

# 预览pdf
pnpm install  @vue-office/pdf
# 预览 docx
pnpm install  @vue-office/docx
# 预览 xls、xlsx
pnpm install  @vue-office/excel
# 预览ppt
pnpm install  @vue-office/pptx

# 预览 ofd
pnpm install  bestofdview

# 预览mp4
pnpm install dplayer

# 预览 文件编码识别
pnpm install chardet

二 实现介绍

2.1 图片

element-plus 版本 2.9.9 ,组件名imageDialog
效果,地址中的:图片预览 ,https://element-plus.org/zh-CN/component/image
注:不一定要点击图片显示,也可点击按钮直接渲染:el-image-viewer 组件

<template>
<el-watermark v-if="imgViewerVisible" :content="watermarkContent" :font="watermarkFont " :z-index="3100"
                style="position: fixed; inset: 0; z-index: 3000">
    <el-image-viewer
        :url-list="[fileUrl]"
        :initial-index="0"
        :z-index="3000"
        @close="closeDialog"
    />
  </el-watermark>
</template>
<script setup>
const
// 图片预览是否显示
const imgViewerVisible = ref(false)
// 文件url 
const fileUrl = ref()

const watermarkContent = ref('水印')
const watermarkFont  = ref({
      color: 'rgba(0, 0, 0, 0.2)',
      fontSize: 16,
      fontWeight: 'bold',
      fontFamily: 'sans-serif'
 })
 
// 关闭窗口
const closeDialog = () => {
  dialogVisible.value = false;
  imgViewerVisible.value = false;

  fileUrl.value = '';
}

const open = async (url ) => {
fileUrl.value = url
imgViewerVisible.value = true
})

2.2 pdf预览

PdfView.vue 官方样例:https://github.com/501351981/vue-office
注:options 传空即可,这里禁用多线程加载是为了保护服务器

<template>
  <div class="pdf-wrapper" v-loading="loading" element-loading-text="加载中...">
    <vue-office-pdf v-if="show" :src="currentUrl" :options="options" @rendered="onRendered" @error="handleError"/>
  </div>
</template>
<script setup>
import VueOfficePdf from '@vue-office/pdf'
import {ref, nextTick, watch} from 'vue'

const emit = defineEmits(['error'])

const props = defineProps({
  fileUrl: {
    type: String,
    default: undefined
  }
})

const loading = ref(false)
const show = ref(false)
const currentUrl = ref('')

const options = {
  disableRange: true,
  disableStream: true,
  disableAutoFetch: true,
}

const onRendered = () => {
  loading.value = false
}

const handleError = (error) => {
  loading.value = false
  emit('error', error)
}

// 加载文件显示
const renderFile = (url) => {
  show.value = false
  nextTick(() => {
    loading.value = true
    currentUrl.value = url
    show.value = true
  })
}

watch(() => props.fileUrl, (newUrl) => {
  if (newUrl) {
    renderFile(newUrl)
  }
}, {immediate: true})
</script>
<style lang="scss" scoped>
.pdf-wrapper {
  height: 100%;
  overflow: auto;

  /* 必加 ——— pdf.js observer 需要稳定布局 */
  display: flex;
  flex-direction: column;
  min-height: 0;
}
</style>

2.3 docx预览

DocView.vue 官方样例:https://github.com/501351981/vue-office
添加了:宽度渲染和不渲染切换效果

<template>
  <div :class="[{ 'docx-wrapper-fix-width': viewMode === 'adaptive' }]" v-loading="loading"
       element-loading-text="加载中...">
    <div class="docx-header">
      <el-radio-group v-model="viewMode" @change="handleViewModeChange" size="small">
        <el-radio-button label="standard">标准</el-radio-button>
        <el-radio-button label="adaptive">自适应</el-radio-button>
      </el-radio-group>
    </div>
    <vue-office-docx
        v-if="show"
        :src="currentUrl"
        :options="options"
        @rendered="onRendered"
        @error="handleError"
    />
  </div>
</template>

<script setup>
import VueOfficeDocx from '@vue-office/docx'
import '@vue-office/docx/lib/index.css'
import {nextTick, ref, watch} from 'vue'

const emit = defineEmits(['error'])

const props = defineProps({
  fileUrl: {
    type: String,
    default: undefined
  }
})

const loading = ref(false)
const show = ref(false)
const currentUrl = ref()
const viewMode = ref('standard')

const options = ref({
  ignoreWidth: false
})

const onRendered = () => {
  loading.value = false
}

const handleError = (error) => {
  loading.value = false
  emit('error', error)
}

const handleViewModeChange = () => {
  options.value.ignoreWidth = viewMode.value === 'adaptive'

  reloadView()
}

const reloadView = async () => {
  let url =  ... 获取新的预览地址
  renderFile(url)
}

// 加载文件显示
const renderFile = (url) => {
  show.value = false
  nextTick(() => {
    loading.value = true
    currentUrl.value = url
    show.value = true
  })
}

watch(() => props.fileUrl, (newUrl) => {
  if (newUrl) {
    renderFile(newUrl)
  }
}, {immediate: true})
</script>
<style lang="scss" scoped>
.docx-wrapper-fix-width {
  position: relative;
  padding: 0 0;
  height: 100%;
  width: 100%;
  overflow: hidden;

  :deep(.vue-office-docx-wrapper) {
    width: 100% !important;
    min-width: fit-content;
    max-width: 100%;
    overflow: hidden !important;
  }
}

.docx-header {
  position: fixed;
  top: 15px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 100;
}
</style>

2.4 xlxs、csv 预览

ExcelView.vue 官方样例:https://github.com/501351981/vue-office
添加了:xls判断,以及不显示 隐藏sheet页签

<template>
  <div class="excel-wrapper" v-loading="loading" element-loading-text="加载中...">
    <vue-office-excel
        v-if="show"
        :src="currentUrl"
        :options="excelOptions"
        @rendered="onRendered" @error="handleError"
    />
  </div>
</template>
<script setup>
import VueOfficeExcel from '@vue-office/excel'
import '@vue-office/excel/lib/index.css'
import {ref, nextTick, watch} from 'vue'

const emit = defineEmits(['error'])

const props = defineProps({
  fileUrl: {
    type: String,
    default: undefined
  },
  // 是否是xls
  xls: {
    type: Boolean,
    default: false
  }
})

const loading = ref(false)
const show = ref(false)
const currentUrl = ref()

const excelOptions = ref({
  xls: props.xls,
  beforeTransformData(data) {
    if (!data._worksheets || data._worksheets.length === 0) {
      return data
    }
    console.log(data._worksheets)
    const result = []
    for (let i = 0; i < data._worksheets.length; i++) {
      const sheet = data._worksheets[i]
      console.log(sheet)
      // 保留 空 sheet 或 state 为 visible 的 sheet
      if (!sheet || sheet.state === 'visible') {
        result.push(sheet)
      }
    }
    data._worksheets = result
    return data
  }
})

const onRendered = () => {
  loading.value = false
  console.log("渲染完成")
}

const handleError = (error) => {
  loading.value = false
  emit('error', error)
}

// 加载文件显示
const renderFile = (url) => {
  show.value = false
  nextTick(() => {
    loading.value = true
    currentUrl.value = url
    show.value = true
  })
}

watch(() => props.fileUrl, (newUrl) => {
  if (newUrl) {
    renderFile(newUrl)
  }
}, {immediate: true})
</script>
<style lang="scss" scoped>
.excel-wrapper {
  height: 100%;
}
</style>

2.5 pptx 预览

PptxView.vue 官方样例:https://github.com/501351981/vue-office

<template>
  <div class="pptx-wrapper" v-loading="loading" element-loading-text="加载中...">
    <vue-office-pptx
        v-if="show"
        :src="currentUrl"
        @rendered="onRendered"
        @error="handleError"
    />
  </div>
</template>
<script setup>
import VueOfficePptx from '@vue-office/pptx'
import {ref, nextTick, watch} from 'vue'

const emit = defineEmits(['error'])

const props = defineProps({
  fileUrl: {
    type: String,
    default: undefined
  }
})

const loading = ref(false)
const show = ref(false)
const currentUrl = ref()

const onRendered = () => {
  loading.value = false
}

const handleError = (error) => {
  loading.value = false
  emit('error', error)
}

// 加载文件显示
const renderFile = (url) => {
  show.value = false
  nextTick(() => {
    loading.value = true
    currentUrl.value = url
    show.value = true
  })
}

watch(() => props.fileUrl, (newUrl) => {
  if (newUrl) {
    renderFile(newUrl)
  }
}, {immediate: true})
</script>
<style lang="scss" scoped>
.pptx-wrapper {
  height: 100%;
  min-width: 350px;
  width: 45vw;
  margin: 0 auto;

  :deep(.pptx-preview-wrapper) {
    width: 100% !important;
    height: 100% !important;
  }
}
</style>

2.6 ofd 预览

OfdView.vue 官方样例:https://github.com/besthqs/bestofdview

<template>
  <div class="ofd-view-container" v-loading="loading" element-loading-text="加载中...">
    <OfdView
        :showOpenFileButton="true"
        :ofdLink="currentUrl"
    ></OfdView>
  </div>
</template>
<script setup>
import {OfdView} from "bestofdview";
import "bestofdview/dist/style.css";
import {nextTick, ref, watch} from "vue";

const props = defineProps({
  fileUrl: {
    type: String,
    default: undefined
  }
})

const loading = ref(false)
const show = ref(false)
const currentUrl = ref()

// 加载文件显示
const renderFile = (url) => {
  show.value = false
  nextTick(() => {
    loading.value = true
    currentUrl.value = url
    show.value = true
  })
}

watch(() => props.fileUrl, (newUrl) => {
  if (newUrl) {
    renderFile(newUrl)
  }
}, {immediate: true})
</script>
<style scoped>
.ofd-view-container {
  width: 100%;
  height: 80vh;
}
</style>

2.7 dplayer 预览

VideoPlay.vue 官方样例:https://github.com/DIYgod/DPlayer
添加组件销毁和重新渲染

<template>
  <div class="video-wrapper" v-loading="loading" element-loading-text="加载中...">
    <div id="dplayer" style="height: 80vh"></div>
  </div>
</template>
<script setup>
import {nextTick, onUnmounted, ref, watch} from "vue";
import DPlayer from 'dplayer';

const props = defineProps({
  fileUrl: {
    type: String,
    default: undefined
  }
})

const loading = ref(false)
const currentUrl = ref()
let dp = null

const renderFile = (url) => {
  nextTick(() => {
    // 销毁旧的播放器
    if (dp) {
      dp.destroy();
      dp = null;
    }

    dp = new DPlayer({
      container: document.getElementById('dplayer'),
      screenshot: true,
      video: {
        url: url,
      }
    });
  })
}

watch(() => props.fileUrl, (newUrl) => {
  if (newUrl) {
    renderFile(newUrl)
  }
}, {immediate: true})

onUnmounted(() => {
  if (dp) {
    dp.destroy();
    dp = null;
  }
})
</script>
<style lang="scss" scoped>
.video-wrapper {
  height: 100%;
}
</style>

2.8 文本预览

  1. 使用 chardet.detect(Buffer) 识别前10k的文件编码 2)使用浏览器内置的 TextDecoder 做编码转换
<template>
  <div class="txt-view">
    <div class="txt-toolbar">
      <el-select v-model="selectedEncoding" placeholder="选择编码" size="small" style="width: 160px">
        <el-option :label="`自动识别${detectedEncoding ? ':' + detectedEncoding : ''}`" value="auto"/>
        <el-option v-for="enc in encodingOptions" :key="enc" :label="enc" :value="enc"/>
      </el-select>
    </div>
    <el-scrollbar>
      <pre class="txt-content">{{ textContent }}</pre>
    </el-scrollbar>
  </div>
</template>

<script setup>
import {ref, watch, onMounted} from 'vue'
import axios from 'axios'
import chardet from 'chardet'

const props = defineProps({
  fileUrl: {
    type: String,
    required: true
  }
})

const emit = defineEmits(['error'])

const textContent = ref('')
const selectedEncoding = ref('auto')
const detectedEncoding = ref('')
const encodingOptions = ['UTF-8', 'GBK', 'GB2312', 'GB18030', 'BIG5', 'UTF-16']

// chardet 返回的编码名称映射到 TextDecoder 支持的名称
const encodingMap = {
  'windows-1252': 'WINDOWS-1252',
  'ISO-8859-1': 'ISO-8859-1',
  'ascii': 'UTF-8'
}

// 检测编码(只检测前 10KB)
const detectEncoding = (arrayBuffer) => {
  return chardet.detect(new Uint8Array(arrayBuffer.slice(0, 10240)))
}

// 加载文本内容
const loadText = async () => {
  try {
    const response = await axios.get(props.fileUrl, {
      responseType: 'arraybuffer'
    })

    let enc = selectedEncoding.value
    if (enc === 'auto') {
      enc = detectEncoding(response.data)
      detectedEncoding.value = enc
      enc = encodingMap[enc] || enc
    }

    textContent.value = new TextDecoder(enc).decode(response.data)
  } catch (error) {
    console.error('加载文本文件时出错:', error)
    textContent.value = '无法加载文件,请稍后再试。'
    emit('error', error)
  }
}

// 监听编码变化
watch(selectedEncoding, (newEncoding) => {
  if (newEncoding && textContent.value) {
    loadText()
  }
})

// 监听文件URL变化
watch(() => props.fileUrl, () => {
  selectedEncoding.value = 'auto'
  loadText()
})

onMounted(() => {
  loadText()
})
</script>

<style scoped>
.txt-view {
  height: 100%;
  display: flex;
  flex-direction: column;
}

.txt-toolbar {
  padding: 8px 16px;
  border-bottom: 1px solid var(--el-border-color-lighter);
  display: flex;
  justify-content: flex-end;
}

.txt-content {
  padding: 0 20px 0 20px;
  margin: 0;
  white-space: pre-wrap;
  word-wrap: break-word;
  overflow-x: auto;
  font-family: inherit;
  font-size: 14px;
  font-weight: 500;
  line-height: 1.3;
  letter-spacing: 1px;
  color: #000;
  background: #faf9f7;
}
</style>

三 防止通过预览下载的媒体的方式

# 1 前端改进:
地址是传递的方法:每次预览器访问地址会生成1个新token 预览地址

# 2 后端校验
1)后台对资源预览禁用缓存:防止用户直接新页签打开走缓存直接保存文件
        response.setHeader("Cache-Control", "no-store");
        response.setHeader("Pragma", "no-cache");
        response.setHeader("Expires", "0");
2)校验是通过安全域名,访问本文件
String referer = request.getHeader("Referer"); 
referer.contains("网站域名")

3)token 合法校验
token 放入 redis,校验存在,访问1次就删除;且校验token用户在登录用户,且token未过期

4)每次访问记录日志,记录访问的ip,用于追溯
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐