|
|
@@ -0,0 +1,463 @@
|
|
|
+<template>
|
|
|
+ <div class="file-preview">
|
|
|
+ <!-- 加载中 -->
|
|
|
+ <div v-if="loading" class="fp-status">
|
|
|
+ <i class="el-icon-loading" /> 附件加载中...
|
|
|
+ </div>
|
|
|
+ <!-- 加载失败 -->
|
|
|
+ <div v-else-if="errorMsg" class="fp-status">{{ errorMsg }}</div>
|
|
|
+ <!-- 无附件 -->
|
|
|
+ <div v-else-if="!fileList.length" class="fp-status">暂无附件</div>
|
|
|
+
|
|
|
+ <div v-else class="fp-body">
|
|
|
+ <!-- 多个附件时的文件切换列表 -->
|
|
|
+ <div v-if="fileList.length > 1" class="fp-list">
|
|
|
+ <div
|
|
|
+ v-for="(item, index) in fileList"
|
|
|
+ :key="item.key"
|
|
|
+ class="fp-list-item"
|
|
|
+ :class="{ 'is-active': index === currentIndex }"
|
|
|
+ :title="item.name"
|
|
|
+ @click="handleSelect(index)"
|
|
|
+ >
|
|
|
+ <i :class="item.icon" />
|
|
|
+ <span class="fp-list-name">{{ item.name }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 文件预览区 -->
|
|
|
+ <div class="fp-main">
|
|
|
+ <div class="fp-toolbar">
|
|
|
+ <i :class="currentFile.icon" />
|
|
|
+ <span class="fp-title" :title="currentFile.name">{{
|
|
|
+ currentFile.name
|
|
|
+ }}</span>
|
|
|
+ <el-button
|
|
|
+ v-if="currentFile.url"
|
|
|
+ type="text"
|
|
|
+ icon="el-icon-view"
|
|
|
+ @click="handleOpenWindow"
|
|
|
+ >新窗口打开</el-button
|
|
|
+ >
|
|
|
+ <el-button
|
|
|
+ type="text"
|
|
|
+ icon="el-icon-download"
|
|
|
+ :disabled="!currentFile.storePath"
|
|
|
+ @click="handleDownload"
|
|
|
+ >下载</el-button
|
|
|
+ >
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="fp-viewer">
|
|
|
+ <!-- 视频:原生播放器,铺满预览区 -->
|
|
|
+ <video
|
|
|
+ v-if="currentFile.category === 'video'"
|
|
|
+ class="fp-video"
|
|
|
+ :src="currentFile.url"
|
|
|
+ controls
|
|
|
+ preload="metadata"
|
|
|
+ ></video>
|
|
|
+
|
|
|
+ <!-- 图片:支持点击放大 -->
|
|
|
+ <el-image
|
|
|
+ v-else-if="currentFile.category === 'image'"
|
|
|
+ class="fp-media"
|
|
|
+ :src="currentFile.url"
|
|
|
+ :preview-src-list="previewList"
|
|
|
+ fit="contain"
|
|
|
+ >
|
|
|
+ <div slot="placeholder" class="fp-status">
|
|
|
+ <i class="el-icon-picture-outline" />
|
|
|
+ </div>
|
|
|
+ <div slot="error" class="fp-status">图片加载失败</div>
|
|
|
+ </el-image>
|
|
|
+
|
|
|
+ <!-- pdf / 文本:按 blob 渲染,不会触发浏览器下载 -->
|
|
|
+ <div v-else-if="isNativePreview" class="fp-doc">
|
|
|
+ <div v-if="blobLoading" class="fp-status">
|
|
|
+ <i class="el-icon-loading" /> 文件加载中...
|
|
|
+ </div>
|
|
|
+ <iframe
|
|
|
+ v-else
|
|
|
+ class="fp-iframe"
|
|
|
+ :src="blobUrl || currentFile.url"
|
|
|
+ frameborder="0"
|
|
|
+ ></iframe>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- word / excel 等:kkFileView 转换后预览 -->
|
|
|
+ <iframe
|
|
|
+ v-else-if="currentFile.previewUrl"
|
|
|
+ class="fp-iframe"
|
|
|
+ :src="currentFile.previewUrl"
|
|
|
+ frameborder="0"
|
|
|
+ ></iframe>
|
|
|
+
|
|
|
+ <!-- 其它格式:仅支持下载 -->
|
|
|
+ <div v-else class="fp-status">
|
|
|
+ <i class="el-icon-document" />
|
|
|
+ <p>该格式暂不支持在线预览,请下载后查看</p>
|
|
|
+ <el-button type="primary" size="small" @click="handleDownload"
|
|
|
+ >下载文件</el-button
|
|
|
+ >
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+ import { queryIds } from '@/api/file/index.js';
|
|
|
+ import { getFile } from '@/api/system/file/index.js';
|
|
|
+ import {
|
|
|
+ getFileCategory,
|
|
|
+ getFileUrl,
|
|
|
+ getKkFileUrl,
|
|
|
+ getFileApiUrls
|
|
|
+ } from '@/utils/file';
|
|
|
+ import request from '@/utils/request';
|
|
|
+
|
|
|
+ // 各类文件的图标
|
|
|
+ const ICON_MAP = {
|
|
|
+ video: 'el-icon-video-camera',
|
|
|
+ image: 'el-icon-picture-outline',
|
|
|
+ pdf: 'el-icon-document',
|
|
|
+ text: 'el-icon-tickets',
|
|
|
+ office: 'el-icon-notebook-2',
|
|
|
+ other: 'el-icon-tickets'
|
|
|
+ };
|
|
|
+
|
|
|
+ // 浏览器可直接展示的类型(pdf、文本),按 blob 渲染
|
|
|
+ const isNativeCategory = (category) =>
|
|
|
+ category === 'pdf' || category === 'text';
|
|
|
+
|
|
|
+ export default {
|
|
|
+ name: 'FilePreview',
|
|
|
+ props: {
|
|
|
+ // 文件id集合(数字库附件id)
|
|
|
+ ids: {
|
|
|
+ type: [Array, String],
|
|
|
+ default: () => []
|
|
|
+ }
|
|
|
+ },
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ loading: false,
|
|
|
+ errorMsg: '',
|
|
|
+ fileList: [],
|
|
|
+ currentIndex: 0,
|
|
|
+ // 文档 blob 地址
|
|
|
+ blobUrl: '',
|
|
|
+ blobLoading: false,
|
|
|
+ // storePath -> blob 地址缓存
|
|
|
+ blobCache: {}
|
|
|
+ };
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ // 当前展示的文件
|
|
|
+ currentFile() {
|
|
|
+ return this.fileList[this.currentIndex] || {};
|
|
|
+ },
|
|
|
+ // 图片预览列表
|
|
|
+ previewList() {
|
|
|
+ return this.fileList
|
|
|
+ .filter((item) => item.category === 'image')
|
|
|
+ .map((item) => item.url);
|
|
|
+ },
|
|
|
+ // 浏览器可直接展示的类型(pdf、文本)按 blob 渲染
|
|
|
+ isNativePreview() {
|
|
|
+ return isNativeCategory(this.currentFile.category);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ watch: {
|
|
|
+ ids: {
|
|
|
+ immediate: true,
|
|
|
+ handler() {
|
|
|
+ this.load();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ beforeDestroy() {
|
|
|
+ this.revokeBlob();
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ // 加载附件信息
|
|
|
+ async load() {
|
|
|
+ const ids = this.normalizeIds(this.ids);
|
|
|
+ this.currentIndex = 0;
|
|
|
+ this.fileList = [];
|
|
|
+ this.errorMsg = '';
|
|
|
+ this.blobUrl = '';
|
|
|
+ if (!ids.length) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.loading = true;
|
|
|
+ try {
|
|
|
+ const list = await queryIds(ids);
|
|
|
+ this.fileList = (list || []).map((item, index) =>
|
|
|
+ this.mapFile(item, index)
|
|
|
+ );
|
|
|
+ this.loadBlob(this.currentFile);
|
|
|
+ } catch (error) {
|
|
|
+ this.errorMsg = error.message || '附件加载失败';
|
|
|
+ } finally {
|
|
|
+ this.loading = false;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ // 附件id标准化,兼容数组和逗号分隔字符串
|
|
|
+ normalizeIds(ids) {
|
|
|
+ const list = Array.isArray(ids) ? ids : String(ids || '').split(',');
|
|
|
+ return list
|
|
|
+ .map((item) => String(item || '').trim())
|
|
|
+ .filter((item) => item);
|
|
|
+ },
|
|
|
+ // 文件信息转换
|
|
|
+ mapFile(item, index) {
|
|
|
+ const storage = (item.storagePath && item.storagePath[0]) || {};
|
|
|
+ const fileName = storage.name || item.name || '';
|
|
|
+ const category = getFileCategory(fileName);
|
|
|
+ return {
|
|
|
+ key: item.id || `${index}-${fileName}`,
|
|
|
+ id: item.id,
|
|
|
+ name: fileName,
|
|
|
+ storage,
|
|
|
+ storePath: storage.storePath || '',
|
|
|
+ category,
|
|
|
+ icon: ICON_MAP[category] || ICON_MAP.other,
|
|
|
+ url: getFileUrl(storage),
|
|
|
+ previewUrl:
|
|
|
+ category === 'office' ? getKkFileUrl(storage, fileName) : ''
|
|
|
+ };
|
|
|
+ },
|
|
|
+ // 切换文件
|
|
|
+ handleSelect(index) {
|
|
|
+ this.currentIndex = index;
|
|
|
+ this.loadBlob(this.currentFile);
|
|
|
+ },
|
|
|
+ // pdf / 文本按 blob 渲染:带 token 鉴权,且浏览器不会直接下载
|
|
|
+ async loadBlob(file) {
|
|
|
+ this.blobUrl = '';
|
|
|
+ if (!file || !isNativeCategory(file.category)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const cacheKey = file.storePath || file.url;
|
|
|
+ if (!cacheKey) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const cached = this.blobCache[cacheKey];
|
|
|
+ if (cached) {
|
|
|
+ this.blobUrl = cached;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.blobLoading = true;
|
|
|
+ try {
|
|
|
+ const url = await this.fetchBlob(file.storage);
|
|
|
+ if (url) {
|
|
|
+ this.blobCache[cacheKey] = url;
|
|
|
+ this.blobUrl = url;
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ this.blobLoading = false;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ // 依次尝试候选地址拉取文件二进制
|
|
|
+ async fetchBlob(storage) {
|
|
|
+ const urls = getFileApiUrls(storage);
|
|
|
+ for (const url of urls) {
|
|
|
+ try {
|
|
|
+ const res = await request.get(url, {
|
|
|
+ responseType: 'blob'
|
|
|
+ });
|
|
|
+ if (res.data && res.data.size) {
|
|
|
+ return URL.createObjectURL(res.data);
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ // 当前地址不可用,继续尝试下一个
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return '';
|
|
|
+ },
|
|
|
+ // 释放 blob 地址
|
|
|
+ revokeBlob() {
|
|
|
+ Object.keys(this.blobCache).forEach((key) => {
|
|
|
+ URL.revokeObjectURL(this.blobCache[key]);
|
|
|
+ });
|
|
|
+ this.blobCache = {};
|
|
|
+ this.blobUrl = '';
|
|
|
+ },
|
|
|
+ // 新窗口打开文件
|
|
|
+ handleOpenWindow() {
|
|
|
+ const file = this.currentFile;
|
|
|
+ if (!file) {
|
|
|
+ this.$message.warning('文件地址不存在');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // pdf / 文本优先用 blob 地址,避免新窗口直接触发下载
|
|
|
+ // word / excel 等走 kkFileView 转换预览
|
|
|
+ const url =
|
|
|
+ (isNativeCategory(file.category) && this.blobUrl) ||
|
|
|
+ file.previewUrl ||
|
|
|
+ file.url;
|
|
|
+ if (!url) {
|
|
|
+ this.$message.warning('文件地址不存在');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ window.open(url, '_blank');
|
|
|
+ },
|
|
|
+ // 使用 kkFile 预览(word、excel 等需要转换的格式)
|
|
|
+ handleKkPreview() {
|
|
|
+ const file = this.currentFile;
|
|
|
+ if (!file || !file.previewUrl) {
|
|
|
+ this.$message.warning('预览地址不存在');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ window.open(file.previewUrl, '_blank');
|
|
|
+ },
|
|
|
+ // 下载文件
|
|
|
+ handleDownload() {
|
|
|
+ const file = this.currentFile;
|
|
|
+ if (!file) {
|
|
|
+ this.$message.warning('文件地址不存在,无法下载');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (file.storePath) {
|
|
|
+ getFile({ objectName: file.storePath }, file.name);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (file.url) {
|
|
|
+ window.open(file.url, '_blank');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.$message.warning('文件地址不存在,无法下载');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+ .file-preview {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ overflow: hidden;
|
|
|
+
|
|
|
+ .fp-status {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 10px;
|
|
|
+ color: #909399;
|
|
|
+ font-size: 13px;
|
|
|
+
|
|
|
+ i {
|
|
|
+ font-size: 26px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .fp-body {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ }
|
|
|
+
|
|
|
+ .fp-list {
|
|
|
+ width: 200px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ border-right: 1px solid #ebeef5;
|
|
|
+ background-color: #fff;
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 6px 0;
|
|
|
+
|
|
|
+ .fp-list-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 8px 12px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #606266;
|
|
|
+ cursor: pointer;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background-color: #f5f7fa;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-active {
|
|
|
+ background-color: #ecf5ff;
|
|
|
+ color: #409eff;
|
|
|
+ }
|
|
|
+
|
|
|
+ .fp-list-name {
|
|
|
+ margin-left: 6px;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .fp-main {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+
|
|
|
+ .fp-toolbar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 6px 10px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #606266;
|
|
|
+ border-bottom: 1px solid #ebeef5;
|
|
|
+ background-color: #fff;
|
|
|
+
|
|
|
+ .fp-title {
|
|
|
+ flex: 1;
|
|
|
+ margin-left: 6px;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .fp-viewer {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background-color: #eef5f9;
|
|
|
+ overflow: hidden;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 图片:完整展示,不裁剪
|
|
|
+ .fp-media {
|
|
|
+ max-width: 100%;
|
|
|
+ max-height: 100%;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 视频:铺满预览区
|
|
|
+ .fp-video {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: cover;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 文档:撑满预览区
|
|
|
+ .fp-doc {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ .fp-iframe {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ border: none;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+</style>
|