Files
u-ppt/src/components/common/FilePreviewDrawer.vue
T

278 lines
8.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- =====================================================================
FilePreviewDrawer.vue 附件右侧抽屉预览
图片/视频直接 ObjectURL 预览PDF/DOCX 提取文本MD renderMd
其余类型展示元数据和下载双击内容或点击放大按钮可占满窗口
===================================================================== -->
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { attachmentState, closePreview, downloadAttachment, getAttachment, loadPreviewText } from '../../core/attachments'
import { renderMd } from '../../core/markdown'
import Icon from './Icon.vue'
const activeAttachment = computed(() => getAttachment(attachmentState.activeId.value))
const renderedMarkdown = computed(() => renderMd(activeAttachment.value?.text || ''))
/* 媒体 ObjectURL:每个附件只创建一份,切换/关闭/卸载时统一回收,防泄漏 */
const mediaUrl = ref('')
function revokeMedia() {
if (mediaUrl.value) { URL.revokeObjectURL(mediaUrl.value); mediaUrl.value = '' }
}
watch(() => attachmentState.activeId.value, () => {
revokeMedia()
const att = activeAttachment.value
if (att && (att.kind === 'image' || att.kind === 'video')) {
mediaUrl.value = URL.createObjectURL(att.file)
}
void loadPreviewText(att)
}, { immediate: true })
onBeforeUnmount(revokeMedia)
function toggleMaximize() {
attachmentState.maximized.value = !attachmentState.maximized.value
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (attachmentState.maximized.value) attachmentState.maximized.value = false
else closePreview()
}
}
onMounted(() => document.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
</script>
<template>
<Teleport to="body">
<div
v-if="activeAttachment"
class="file-preview-mask"
:class="{ maximized: attachmentState.maximized.value }"
>
<aside class="file-preview-drawer" role="dialog" aria-modal="true" :aria-label="`${activeAttachment.name} 预览`">
<header class="file-preview-head">
<div class="file-preview-title" :title="activeAttachment.name">
<span class="file-preview-title-icon"><Icon name="paperclip" :size="14" /></span>
<span>{{ activeAttachment.name }}</span>
</div>
<div class="file-preview-actions">
<button class="file-preview-btn" :title="attachmentState.maximized.value ? '退出放大' : '放大预览'" @click="toggleMaximize">
{{ attachmentState.maximized.value ? '↙' : '↗' }}
</button>
<button class="file-preview-btn close" title="关闭预览" @click="closePreview">×</button>
</div>
</header>
<main class="file-preview-body" @dblclick="toggleMaximize">
<!-- 图片 -->
<img
v-if="activeAttachment.kind === 'image'"
class="file-preview-image"
:src="mediaUrl"
:alt="activeAttachment.name"
/>
<!-- 视频 -->
<video
v-else-if="activeAttachment.kind === 'video'"
class="file-preview-video"
:src="mediaUrl"
controls
>你的浏览器不支持视频预览</video>
<!-- Markdown -->
<article
v-else-if="activeAttachment.kind === 'markdown' && activeAttachment.textState === 'done'"
class="file-preview-markdown"
v-html="renderedMarkdown"
/>
<!-- PDF / DOC / TXT统一纯文本阅读 -->
<pre
v-else-if="(activeAttachment.kind === 'pdf' || activeAttachment.kind === 'doc' || activeAttachment.kind === 'text') && activeAttachment.textState === 'done'"
class="file-preview-text"
>{{ activeAttachment.text }}</pre>
<!-- 文本加载 / 错误 -->
<div v-else-if="activeAttachment.textState === 'loading'" class="file-preview-status">正在提取文件内容</div>
<div v-else-if="activeAttachment.textState === 'error'" class="file-preview-status error">
预览读取失败{{ activeAttachment.textError }}
</div>
<!-- 无结构化预览的文件 -->
<div v-else class="file-preview-meta">
<span class="file-preview-meta-icon"><Icon name="paperclip" :size="12" /></span>
<strong>{{ activeAttachment.name }}</strong>
<span>{{ activeAttachment.file.type || '未知文件类型' }}</span>
<span>{{ activeAttachment.size.toLocaleString() }} B</span>
<button class="file-preview-download" @click="downloadAttachment(activeAttachment)">下载文件</button>
</div>
</main>
<footer class="file-preview-foot">
<span>{{ activeAttachment.file.type || '未知类型' }}</span>
<button class="file-preview-download link" @click="downloadAttachment(activeAttachment)">下载</button>
</footer>
</aside>
</div>
</Teleport>
</template>
<style scoped>
.file-preview-mask {
position: fixed;
inset: 0;
z-index: 80;
pointer-events: none;
}
.file-preview-drawer {
position: absolute;
top: 0;
right: 0;
width: min(480px, 100vw);
height: 100%;
display: flex;
flex-direction: column;
background: var(--ui-panel);
border-left: 1px solid var(--ui-border);
box-shadow: var(--shadow-lg);
pointer-events: auto;
animation: drawer-enter .18s ease-out;
}
.file-preview-mask.maximized .file-preview-drawer {
width: 100vw;
border-left: none;
}
@keyframes drawer-enter {
from { transform: translateX(28px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.file-preview-head,
.file-preview-foot {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
padding: 10px 12px;
border-bottom: 1px solid var(--ui-border);
}
.file-preview-title {
display: flex;
align-items: center;
min-width: 0;
gap: 7px;
flex: 1;
color: var(--ui-text);
font-size: 14px;
font-weight: 600;
}
.file-preview-title span:last-child {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.file-preview-title-icon { font-size: 17px; }
.file-preview-actions { display: flex; gap: 4px; }
.file-preview-btn {
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--ui-muted);
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.file-preview-btn:hover { background: var(--ui-hover); color: var(--ui-text); }
.file-preview-btn.close:hover { color: var(--ui-danger); }
.file-preview-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px;
background: var(--ui-bg);
}
.file-preview-image,
.file-preview-video {
display: block;
width: 100%;
max-height: 100%;
object-fit: contain;
margin: auto;
}
.file-preview-image { min-height: 200px; }
.file-preview-text {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--ui-text);
font: 13px/1.7 var(--mono);
}
.file-preview-markdown {
color: var(--ui-text);
font-size: 14px;
line-height: 1.7;
overflow-wrap: anywhere;
}
.file-preview-markdown :deep(h1),
.file-preview-markdown :deep(h2),
.file-preview-markdown :deep(h3) { margin: 1.1em 0 .5em; }
.file-preview-markdown :deep(p),
.file-preview-markdown :deep(ul),
.file-preview-markdown :deep(ol) { margin: .6em 0; }
.file-preview-markdown :deep(pre) {
padding: 10px;
overflow: auto;
background: #0f172a;
color: #e2e8f0;
border-radius: var(--radius-sm);
}
.file-preview-markdown :deep(code) { font-family: var(--mono); }
.file-preview-status {
display: grid;
place-items: center;
height: 100%;
color: var(--ui-muted);
font-size: 14px;
text-align: center;
}
.file-preview-status.error { color: var(--ui-danger); }
.file-preview-meta {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 100%;
color: var(--ui-muted);
font-size: 13px;
text-align: center;
}
.file-preview-meta strong { color: var(--ui-text); overflow-wrap: anywhere; }
.file-preview-meta-icon { font-size: 44px; }
.file-preview-download {
padding: 7px 12px;
border: 1px solid var(--ui-primary);
border-radius: var(--radius-sm);
background: var(--ui-primary);
color: #fff;
font-size: 13px;
cursor: pointer;
}
.file-preview-download:hover { filter: brightness(.94); }
.file-preview-foot {
justify-content: space-between;
border-top: 1px solid var(--ui-border);
border-bottom: none;
color: var(--ui-muted);
font-size: 12px;
}
.file-preview-download.link {
padding: 3px 6px;
border: none;
background: transparent;
color: var(--ui-primary);
}
</style>