<!-- components/Preview.vue -->
<template>
<div v-if="visible" class="preview-overlay" @click.self="close">
<div class="preview-container">
<button class="close-btn" @click="close">×</button>
<div class="preview-content-wrapper">
<!-- loading 状态 -->
<div v-if="loading" class="preview-loading">
<div class="spinner"></div>
<p>加载文件中...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="errorMessage" class="preview-error">
<p>{{ errorMessage }}</p>
<button @click="close" class="error-close-btn">关闭</button>
</div>
<!-- 正常预览内容 -->
<template v-else>
<!-- 图片 -->
<img
v-if="type === 'image'"
:src="fileUrl"
class="preview-image"
/>
<!-- PDF -->
<iframe
v-else-if="type === 'pdf'"
:src="fileUrl"
class="preview-media"
frameborder="0"
></iframe>
<!-- 视频 -->
<video
v-else-if="type === 'video'"
controls
class="preview-media"
>
<source :src="fileUrl" :type="fileType" />
您的浏览器不支持视频播放。
</video>
<!-- 音频 -->
<audio
v-else-if="type === 'audio'"
controls
class="preview-audio"
>
<source :src="fileUrl" :type="fileType" />
</audio>
<!-- 文本文件 -->
<div v-else-if="type === 'text'" class="preview-text">
<pre>{{ textContent }}</pre>
</div>
<!-- Office 文档 -->
<div v-else-if="type === 'office'" class="office-preview">
<div v-if="officePreviewUrl" class="iframe-wrapper">
<iframe
:src="officePreviewUrl"
frameborder="0"
class="preview-media"
@load="onOfficeLoad"
></iframe>
</div>
<div v-else class="download-prompt">
<p>无法在线预览此文档,请下载后查看。</p>
<a
:href="fileUrl"
:download="fileName"
class="download-btn"
>下载文件</a
>
</div>
</div>
<!-- 不支持的类型 -->
<div v-else class="unsupported">
暂不支持预览该文件类型 ({{ fileType || "未知类型" }})
</div>
</template>
</div>
</div>
</div>
</template>
<script>
export default {
name: "FilePreview",
data() {
return {
visible: false,
loading: false,
fileUrl: "",
fileType: "",
fileName: "",
textContent: "",
officePreviewUrl: "",
blobData: null,
errorMessage: "",
officeLoadTimer: null,
};
},
computed: {
type() {
if (!this.fileType && this.fileUrl) {
return this.guessTypeFromUrl(this.fileUrl);
}
if (this.fileType.startsWith("image/")) return "image";
if (this.fileType === "application/pdf") return "pdf";
if (this.fileType.startsWith("video/")) return "video";
if (this.fileType.startsWith("audio/")) return "audio";
if (this.fileType.startsWith("text/")) return "text";
const officeMimes = [
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
];
if (officeMimes.includes(this.fileType)) return "office";
return "unknown";
},
},
methods: {
guessTypeFromUrl(url) {
try {
const urlObj = new URL(url);
const path = urlObj.pathname;
const ext = path.split(".").pop().toLowerCase();
const imageExts = [
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"webp",
"svg",
];
const videoExts = ["mp4", "webm", "ogg", "mov", "avi"];
const audioExts = ["mp3", "wav", "ogg", "flac"];
const textExts = [
"txt",
"js",
"css",
"json",
"xml",
"html",
"htm",
"md",
];
const officeExts = [
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
];
if (imageExts.includes(ext)) return "image";
if (ext === "pdf") return "pdf";
if (videoExts.includes(ext)) return "video";
if (audioExts.includes(ext)) return "audio";
if (textExts.includes(ext)) return "text";
if (officeExts.includes(ext)) return "office";
return "unknown";
} catch {
const ext = url.split(".").pop().toLowerCase().split("?")[0];
const officeExts = [
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
];
if (officeExts.includes(ext)) return "office";
return "unknown";
}
},
async open(file, options = {}) {
this.close(true);
this.visible = false;
this.textContent = "";
this.officePreviewUrl = "";
this.errorMessage = "";
this.loading = true;
this.visible = true;
try {
let rawUrl = "";
let rawType = "";
let rawName = "";
let blobData = null;
if (file instanceof File || file instanceof Blob) {
blobData = file;
rawUrl = URL.createObjectURL(file);
rawType = file.type;
rawName = file.name || "";
this.loading = false;
} else if (typeof file === "string") {
let fileName = "";
let ext = "";
try {
const urlObj = new URL(file);
const path = urlObj.pathname;
fileName = path.split("/").pop() || "";
ext = fileName.includes(".")
? fileName.split(".").pop().toLowerCase()
: "";
} catch {
const parts = file.split("/");
fileName = parts[parts.length - 1].split("?")[0];
ext = fileName.includes(".")
? fileName.split(".").pop().toLowerCase()
: "";
}
const officeExts = [
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
];
const isOffice = officeExts.includes(ext);
if (isOffice) {
rawUrl = file;
rawType = options.type || "";
rawName = options.name || fileName;
// 注意:此时不设置 loading = false,将在 iframe load 或超时后关闭
} else {
try {
const response = await fetch(file, {});
if (!response.ok)
throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
blobData = blob;
rawUrl = URL.createObjectURL(blob);
rawType = blob.type;
rawName = options.name || fileName;
} catch (err) {
console.error("获取文件失败", err);
rawUrl = file;
rawType = options.type || "";
rawName = options.name || fileName;
this.errorMessage = `文件加载失败:${err.message}`;
} finally {
this.loading = false;
}
}
} else if (file && typeof file === "object") {
rawUrl = file.url;
rawType = file.type || "";
rawName = file.name || "";
this.loading = false;
} else {
throw new Error("无效的文件参数");
}
if (!this.visible) {
if (rawUrl && rawUrl.startsWith("blob:"))
URL.revokeObjectURL(rawUrl);
return;
}
this.fileUrl = rawUrl;
this.fileType = rawType;
this.fileName = rawName;
this.blobData = blobData;
if (
this.type === "text" &&
!blobData &&
rawUrl &&
!rawUrl.startsWith("blob:")
) {
await this.loadTextContent();
}
if (this.type === "office") {
const isPublicHttp =
rawUrl &&
(rawUrl.startsWith("http://") ||
rawUrl.startsWith("https://")) &&
!rawUrl.startsWith("blob:");
if (isPublicHttp) {
this.officePreviewUrl =
this.getOfficePreviewUrl(rawUrl);
// 保持 loading 为 true,等待 iframe 加载或超时
this.loading = true;
// 超时保护:10秒后自动关闭 loading
this.officeLoadTimer = setTimeout(() => {
this.loading = false;
this.officeLoadTimer = null;
}, 10000);
} else {
this.officePreviewUrl = "";
this.loading = false;
}
}
} catch (err) {
console.error("预览出错", err);
this.errorMessage = err.message || "预览失败";
this.loading = false;
}
},
onOfficeLoad() {
if (this.officeLoadTimer) {
clearTimeout(this.officeLoadTimer);
this.officeLoadTimer = null;
}
this.loading = false;
},
async loadTextContent() {
if (!this.fileUrl) return;
try {
const response = await fetch(this.fileUrl);
this.textContent = await response.text();
} catch (error) {
console.error("加载文本文件失败", error);
this.textContent = "无法加载文件内容";
}
},
getOfficePreviewUrl(fileUrl) {
const encodedUrl = encodeURIComponent(fileUrl);
return `https://view.officeapps.live.com/op/embed.aspx?src=${encodedUrl}`;
},
close(skipCleanup = false) {
if (this.officeLoadTimer) {
clearTimeout(this.officeLoadTimer);
this.officeLoadTimer = null;
}
this.visible = false;
this.loading = false;
if (
!skipCleanup &&
this.fileUrl &&
this.fileUrl.startsWith("blob:")
) {
URL.revokeObjectURL(this.fileUrl);
}
this.blobData = null;
this.fileUrl = "";
this.fileType = "";
this.fileName = "";
this.textContent = "";
this.officePreviewUrl = "";
this.errorMessage = "";
},
},
};
</script>
<style scoped>
.download-btn {
color: #3498db;
cursor: pointer;
}
.preview-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #666;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.preview-error {
text-align: center;
color: #e74c3c;
}
.error-close-btn {
margin-top: 16px;
padding: 6px 12px;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.preview-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
}
.preview-container {
position: relative;
width: 95vw;
height: 90vh;
max-width: 1400px;
background: #fff;
display: flex;
flex-direction: column;
overflow: hidden;
}
.close-btn {
position: absolute;
top: 12px;
right: 20px;
font-size: 28px;
color: #333;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
.preview-content-wrapper {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
padding: 16px;
}
/* 媒体元素填满容器 */
.preview-media {
width: 90vw;
height: 85vh;
border-radius: 4px;
border: none;
}
/* 图片保持原始比例 */
.preview-image {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
border-radius: 4px;
}
.preview-audio {
width: 80%;
min-width: 300px;
}
.preview-text {
width: 100%;
height: 100%;
overflow: auto;
background: #f5f5f5;
padding: 16px;
border-radius: 8px;
}
.preview-text pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
font-family: "Courier New", monospace;
font-size: 14px;
}
.unsupported {
text-align: center;
color: #666;
font-size: 18px;
padding: 40px;
}
</style>