| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463 |
- <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>
|