<template>
|
<el-dialog
|
:visible.sync="visible"
|
width="80%"
|
top="5vh"
|
append-to-body
|
:close-on-click-modal="false"
|
:destroy-on-close="false"
|
:fullscreen="fullscreen"
|
custom-class="file-preview-dialog"
|
@closed="handleClosed"
|
>
|
<div slot="title" class="file-preview-title">
|
<span>{{ currentFile ? currentFile.name : "文件预览" }}</span>
|
<el-button
|
class="file-preview-fullscreen-btn"
|
size="mini"
|
:icon="fullscreen ? 'el-icon-close' : 'el-icon-full-screen'"
|
@click="fullscreen = !fullscreen"
|
>{{ fullscreen ? "还原" : "全屏" }}</el-button
|
>
|
</div>
|
|
<div v-loading="loading" class="file-preview">
|
<div class="file-preview__list">
|
<el-table
|
:data="previewFiles"
|
highlight-current-row
|
size="mini"
|
height="70vh"
|
@row-click="handleSelect"
|
>
|
<el-table-column prop="name" label="文件" show-overflow-tooltip />
|
</el-table>
|
</div>
|
|
<div class="file-preview__body">
|
<template v-if="currentFile">
|
<div v-if="currentFile.error && !currentFile.previewUrl" class="file-preview__tip">
|
文件加载失败:{{ currentFile.error }}
|
</div>
|
<iframe
|
v-else-if="currentFile.type === 'pdf'"
|
:src="currentFile.previewUrl"
|
class="file-preview__pdf"
|
title="PDF预览"
|
/>
|
<div
|
v-else-if="currentFile.type === 'image'"
|
class="file-preview__image-pane"
|
>
|
<div class="file-preview__toolbar">
|
<el-button-group>
|
<el-button
|
size="mini"
|
icon="el-icon-zoom-out"
|
title="缩小"
|
@click="zoomImage(-0.2)"
|
/>
|
<el-button size="mini" class="file-preview__scale">
|
{{ Math.round(imageScale * 100) }}%
|
</el-button>
|
<el-button
|
size="mini"
|
icon="el-icon-zoom-in"
|
title="放大"
|
@click="zoomImage(0.2)"
|
/>
|
</el-button-group>
|
<el-button-group>
|
<el-button
|
size="mini"
|
icon="el-icon-refresh-left"
|
title="向左旋转"
|
@click="rotateImage(-90)"
|
/>
|
<el-button
|
size="mini"
|
icon="el-icon-refresh-right"
|
title="向右旋转"
|
@click="rotateImage(90)"
|
/>
|
</el-button-group>
|
<el-button
|
size="mini"
|
icon="el-icon-refresh"
|
@click="resetImage"
|
>重置</el-button
|
>
|
</div>
|
<div class="file-preview__image-wrap">
|
<img
|
:src="currentFile.previewUrl"
|
class="file-preview__image"
|
:style="{
|
transform:
|
'scale(' + imageScale + ') rotate(' + imageRotate + 'deg)'
|
}"
|
alt="图片预览"
|
/>
|
</div>
|
</div>
|
<div v-else class="file-preview__tip">
|
该文件类型暂不支持预览,请下载后查看
|
</div>
|
</template>
|
<div v-else class="file-preview__tip">暂无可预览文件</div>
|
</div>
|
</div>
|
|
<span slot="footer" class="dialog-footer">
|
<span class="file-preview-pager">
|
<el-button
|
v-if="currentIndex > 0"
|
size="mini"
|
icon="el-icon-arrow-left"
|
@click="prevFile"
|
>上一张</el-button
|
>
|
<span class="file-preview-pager__text">
|
{{ previewFiles.length ? currentIndex + 1 : 0 }} /
|
{{ previewFiles.length }}
|
</span>
|
<el-button
|
v-if="currentIndex < previewFiles.length - 1"
|
size="mini"
|
@click="nextFile"
|
>下一张<i class="el-icon-arrow-right el-icon--right"></i
|
></el-button>
|
</span>
|
<el-button
|
v-if="currentFile && currentFile.type === 'pdf' && currentFile.previewUrl"
|
icon="el-icon-top-right"
|
@click="openInNewWindow"
|
>新窗口打开</el-button
|
>
|
<el-button type="primary" icon="el-icon-download" @click="handleDownload"
|
>下载</el-button
|
>
|
<el-button @click="visible = false">关闭</el-button>
|
</span>
|
</el-dialog>
|
</template>
|
|
<script>
|
import { getToken } from "@/utils/auth";
|
|
const IMAGE_EXT = ["jpg", "jpeg", "png", "gif", "bmp", "webp"];
|
|
export default {
|
name: "FilePreview",
|
data() {
|
return {
|
visible: false,
|
loading: false,
|
fileList: [],
|
previewFiles: [],
|
currentIndex: 0,
|
imageScale: 1,
|
imageRotate: 0,
|
fullscreen: false
|
};
|
},
|
computed: {
|
currentFile() {
|
return this.previewFiles[this.currentIndex] || null;
|
}
|
},
|
methods: {
|
/**
|
* 打开预览
|
* files: [{ name, url }] 或单个 { name, url }
|
* index: 默认选中下标
|
*/
|
open(files, index = 0) {
|
this.revokeUrls();
|
this.resetImage();
|
this.fullscreen = true;
|
this.fileList = Array.isArray(files) ? files : files ? [files] : [];
|
this.currentIndex = Math.max(0, Number(index) || 0);
|
this.previewFiles = [];
|
this.visible = true;
|
this.loadFiles();
|
},
|
async loadFiles() {
|
if (!this.fileList.length) {
|
this.loading = false;
|
return;
|
}
|
this.loading = true;
|
const token = getToken();
|
const result = await Promise.all(
|
this.fileList.map((file, idx) => this.loadOne(file, idx, token))
|
);
|
if (!this.visible) {
|
result.forEach(item => {
|
if (item.owned && item.previewUrl) URL.revokeObjectURL(item.previewUrl);
|
});
|
return;
|
}
|
this.previewFiles = result;
|
if (this.currentIndex >= this.previewFiles.length) {
|
this.currentIndex = 0;
|
}
|
this.loading = false;
|
},
|
async loadOne(file, idx, token) {
|
const name = file.name || "未命名文件";
|
const type = this.getFileType(name);
|
const item = {
|
name,
|
type,
|
rawUrl: file.url,
|
previewUrl: "",
|
owned: false,
|
error: ""
|
};
|
|
// 其他类型不加载预览,保留下载地址即可
|
if (type === "other") {
|
return item;
|
}
|
|
try {
|
const url = this.resolveUrl(file.url);
|
let previewUrl = url;
|
|
// blob/data URL 已经是本地资源,无需再次请求
|
if (!/^(blob:|data:)/i.test(url)) {
|
const headers = token ? { Authorization: "Bearer " + token } : {};
|
const response = await fetch(url, { headers, cache: "no-store" });
|
if (!response.ok) {
|
throw new Error("HTTP " + response.status);
|
}
|
const blob = await response.blob();
|
if (type === "pdf") {
|
await this.assertPdf(blob);
|
} else if (type === "image" && blob.type && !/^image\//i.test(blob.type)) {
|
throw new Error("文件内容不是有效图片,可能返回了接口提示信息");
|
}
|
const typedBlob =
|
type === "pdf"
|
? new Blob([blob], { type: "application/pdf" })
|
: blob;
|
previewUrl = URL.createObjectURL(typedBlob);
|
item.owned = true;
|
} else if (type === "pdf" && /^blob:/i.test(url)) {
|
// 调用方传入的 PDF blob 可能没有正确的 MIME,重新包装
|
const response = await fetch(url);
|
const blob = await response.blob();
|
await this.assertPdf(blob);
|
previewUrl = URL.createObjectURL(
|
new Blob([blob], { type: "application/pdf" })
|
);
|
item.owned = true;
|
} else if (type === "image" && /^blob:/i.test(url)) {
|
// 校验调用方传入的图片 blob,避免把 JSON 错误响应当图片显示
|
const response = await fetch(url);
|
const blob = await response.blob();
|
if (blob.type && !/^image\//i.test(blob.type)) {
|
throw new Error("文件内容不是有效图片,可能返回了接口提示信息");
|
}
|
previewUrl = URL.createObjectURL(blob);
|
item.owned = true;
|
}
|
|
item.previewUrl = previewUrl;
|
} catch (e) {
|
item.error = e && e.message ? e.message : "加载失败";
|
// 内容非法时不要退回原地址,否则浏览器只会再次显示空白/“未能加载”
|
item.previewUrl =
|
type === "pdf" || /不是有效/.test(item.error)
|
? ""
|
: this.resolveUrl(file.url);
|
}
|
return item;
|
},
|
async assertPdf(blob) {
|
const head = await blob.slice(0, 5).text();
|
if (head !== "%PDF-") {
|
throw new Error(
|
"文件内容不是有效 PDF,可能是登录失效或文件地址错误(返回了接口提示信息)"
|
);
|
}
|
},
|
resolveUrl(url) {
|
if (!url) {
|
return "";
|
}
|
if (/^(blob:|data:|https?:)/i.test(url)) {
|
return url;
|
}
|
return (process.env.VUE_APP_BASE_API || "") + url;
|
},
|
getFileType(name) {
|
const ext = String(name || "")
|
.split(".")
|
.pop()
|
.toLowerCase();
|
if (ext === "pdf") {
|
return "pdf";
|
}
|
if (IMAGE_EXT.indexOf(ext) > -1) {
|
return "image";
|
}
|
return "other";
|
},
|
handleSelect(row) {
|
const index = this.previewFiles.indexOf(row);
|
if (index > -1) {
|
this.currentIndex = index;
|
this.resetImage();
|
}
|
},
|
prevFile() {
|
if (this.currentIndex > 0) {
|
this.currentIndex -= 1;
|
this.resetImage();
|
}
|
},
|
nextFile() {
|
if (this.currentIndex < this.previewFiles.length - 1) {
|
this.currentIndex += 1;
|
this.resetImage();
|
}
|
},
|
zoomImage(delta) {
|
const next = Math.round((this.imageScale + delta) * 100) / 100;
|
this.imageScale = Math.min(5, Math.max(0.2, next));
|
},
|
rotateImage(deg) {
|
this.imageRotate += deg;
|
},
|
resetImage() {
|
this.imageScale = 1;
|
this.imageRotate = 0;
|
},
|
handleDownload() {
|
const file = this.currentFile;
|
if (!file) {
|
return;
|
}
|
const href = file.previewUrl || this.resolveUrl(file.rawUrl);
|
if (!href) {
|
return;
|
}
|
const a = document.createElement("a");
|
a.href = href;
|
a.download = file.name;
|
document.body.appendChild(a);
|
a.click();
|
document.body.removeChild(a);
|
},
|
openInNewWindow() {
|
if (this.currentFile && this.currentFile.previewUrl) {
|
window.open(this.currentFile.previewUrl, "_blank");
|
}
|
},
|
handleClosed() {
|
this.revokeUrls();
|
this.previewFiles = [];
|
this.fileList = [];
|
this.currentIndex = 0;
|
this.resetImage();
|
this.fullscreen = false;
|
},
|
revokeUrls() {
|
this.previewFiles.forEach(item => {
|
if (item.owned && item.previewUrl && /^blob:/i.test(item.previewUrl)) {
|
URL.revokeObjectURL(item.previewUrl);
|
}
|
});
|
}
|
},
|
beforeDestroy() {
|
this.revokeUrls();
|
}
|
};
|
</script>
|
|
<style scoped>
|
.file-preview-title {
|
font-weight: 600;
|
font-size: 15px;
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
padding-right: 30px;
|
}
|
.file-preview-fullscreen-btn {
|
margin-left: 12px;
|
}
|
.file-preview-pager {
|
float: left;
|
display: inline-flex;
|
align-items: center;
|
gap: 8px;
|
}
|
.file-preview-pager__text {
|
color: #606266;
|
font-size: 12px;
|
}
|
.file-preview {
|
display: flex;
|
height: 70vh;
|
min-height: 420px;
|
}
|
.file-preview__list {
|
width: 240px;
|
flex: 0 0 240px;
|
border-right: 1px solid #ebeef5;
|
padding-right: 10px;
|
overflow: hidden;
|
}
|
.file-preview__body {
|
flex: 1;
|
min-width: 0;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
overflow: auto;
|
}
|
.file-preview__pdf {
|
width: 100%;
|
height: 70vh;
|
border: 0;
|
}
|
.file-preview__image {
|
max-width: 100%;
|
max-height: 100%;
|
transition: transform 0.2s ease;
|
transform-origin: center center;
|
}
|
.file-preview__image-pane {
|
width: 100%;
|
height: 100%;
|
display: flex;
|
flex-direction: column;
|
min-height: 0;
|
}
|
.file-preview__toolbar {
|
flex: 0 0 auto;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
gap: 8px;
|
padding: 4px 0 10px;
|
}
|
.file-preview__scale {
|
min-width: 64px;
|
cursor: default;
|
}
|
.file-preview__image-wrap {
|
flex: 1 1 auto;
|
min-height: 0;
|
overflow: auto;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
}
|
.file-preview__tip {
|
color: #909399;
|
font-size: 14px;
|
}
|
</style>
|