<script lang="ts" setup>
|
import type { TmsAttachmentFile } from './types';
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
import { useMessage } from '@jnpf/hooks';
|
import {
|
FullscreenExitOutlined,
|
FullscreenOutlined,
|
MenuFoldOutlined,
|
MenuUnfoldOutlined,
|
} from '@ant-design/icons-vue';
|
|
import { useAccessStore } from '@vben/stores';
|
|
import { previewFile } from '#/api/core/common';
|
import { getDocumentServerUrl } from '#/components/OnlyOfficeEditor/src/loadDocsApi';
|
import { onlineUtils } from '#/utils/jnpf';
|
|
import { normalizeAttachment, toAuthMediaUrl } from './utils';
|
|
defineOptions({ name: 'TmsAttachmentPreview' });
|
|
const props = withDefaults(
|
defineProps<{
|
files?: TmsAttachmentFile[];
|
listTitle?: string;
|
emptyListText?: string;
|
emptyPreviewText?: string;
|
/** 初始选中的附件 id */
|
modelValue?: string;
|
}>(),
|
{
|
files: () => [],
|
listTitle: '附件列表',
|
emptyListText: '暂无附件',
|
emptyPreviewText: '请选择附件预览',
|
},
|
);
|
|
const emit = defineEmits<{
|
'update:modelValue': [string | undefined];
|
}>();
|
|
const { createMessage } = useMessage();
|
const accessStore = useAccessStore();
|
|
const sideCollapsed = ref(false);
|
const previewFullscreen = ref(false);
|
const officePreviewUrl = ref('');
|
const officePreviewLoading = ref(false);
|
const officePreviewError = ref('');
|
let officePreviewSeq = 0;
|
|
type PreviewItem = TmsAttachmentFile & { _key: string };
|
|
function fileKey(file: TmsAttachmentFile, index: number) {
|
return String(file.id || file.fileId || file.url || file.name || `idx-${index}`);
|
}
|
|
const normalizedFiles = computed<PreviewItem[]>(() =>
|
(props.files || []).map((f, i) => ({
|
...normalizeAttachment(f),
|
_key: fileKey(f, i),
|
})),
|
);
|
|
/** 内部选中 key(不依赖 id,附件无 id 时也能点选) */
|
const activeKey = ref<string>();
|
|
const activeFile = computed(
|
() =>
|
normalizedFiles.value.find((f) => f._key === activeKey.value) ||
|
normalizedFiles.value[0],
|
);
|
|
const previewUrl = computed(() => toAuthMediaUrl(activeFile.value?.url));
|
const canOpenOnlyOffice = computed(
|
() =>
|
Boolean(
|
getDocumentServerUrl() &&
|
activeFile.value?.fileId &&
|
activeFile.value?.previewType === 'office',
|
),
|
);
|
|
function syncActiveFromModel() {
|
const list = normalizedFiles.value;
|
if (!list.length) {
|
activeKey.value = undefined;
|
return;
|
}
|
if (props.modelValue) {
|
const byId = list.find((f) => f.id === props.modelValue);
|
if (byId) {
|
activeKey.value = byId._key;
|
return;
|
}
|
}
|
if (!activeKey.value || !list.some((f) => f._key === activeKey.value)) {
|
activeKey.value = list[0]?._key;
|
const id = list[0]?.id;
|
if (id && id !== props.modelValue) {
|
emit('update:modelValue', id);
|
}
|
}
|
}
|
|
watch(normalizedFiles, () => syncActiveFromModel(), { immediate: true });
|
watch(
|
() => props.modelValue,
|
() => syncActiveFromModel(),
|
);
|
|
watch(
|
activeFile,
|
(file) => {
|
void loadOfficePreview(file);
|
},
|
{ immediate: true },
|
);
|
|
onMounted(() => {
|
window.addEventListener('keydown', onKeydown);
|
});
|
|
onBeforeUnmount(() => {
|
window.removeEventListener('keydown', onKeydown);
|
});
|
|
function onKeydown(e: KeyboardEvent) {
|
if (e.key === 'Escape' && previewFullscreen.value) {
|
previewFullscreen.value = false;
|
}
|
}
|
|
function toggleSide() {
|
sideCollapsed.value = !sideCollapsed.value;
|
}
|
|
function toggleFullscreen() {
|
previewFullscreen.value = !previewFullscreen.value;
|
}
|
|
function selectFile(file: PreviewItem) {
|
activeKey.value = file._key;
|
if (file.id) {
|
emit('update:modelValue', file.id);
|
}
|
}
|
|
async function loadOfficePreview(file?: TmsAttachmentFile) {
|
const seq = ++officePreviewSeq;
|
officePreviewUrl.value = '';
|
officePreviewError.value = '';
|
if (!file || file.previewType !== 'office') {
|
officePreviewLoading.value = false;
|
return;
|
}
|
if (!file.url && !file.fileId) {
|
officePreviewError.value = '缺少文件地址,无法预览';
|
officePreviewLoading.value = false;
|
return;
|
}
|
officePreviewLoading.value = true;
|
try {
|
const res = await previewFile({
|
fileDownloadUrl: file.url,
|
fileName: file.fileId,
|
fileVersionId: file.fileVersionId,
|
});
|
if (seq !== officePreviewSeq) return;
|
if (res?.data) {
|
const token = accessStore.accessToken || '';
|
officePreviewUrl.value = token ? `${res.data}&token=${token}` : String(res.data);
|
} else {
|
officePreviewError.value = '文件预览服务未返回地址';
|
}
|
} catch (e: any) {
|
if (seq !== officePreviewSeq) return;
|
officePreviewError.value = e?.message || 'Office 文档预览失败';
|
} finally {
|
if (seq === officePreviewSeq) {
|
officePreviewLoading.value = false;
|
}
|
}
|
}
|
|
function openWithOnlyOffice() {
|
const file = activeFile.value;
|
if (!file?.fileId) {
|
createMessage.warning('缺少附件 ID,无法用 OnlyOffice 打开');
|
return;
|
}
|
onlineUtils.openOfficeDocument({
|
file: {
|
fileId: file.fileId,
|
name: file.name || `file.${file.fileExt || 'docx'}`,
|
url: file.url,
|
fileExtension: file.fileExt,
|
fileVersionId: file.fileVersionId,
|
} as any,
|
mode: 'view',
|
title: file.name,
|
});
|
}
|
|
function openOriginal() {
|
const url = previewUrl.value;
|
if (!url) {
|
createMessage.info('暂无可打开附件');
|
return;
|
}
|
window.open(url, '_blank');
|
}
|
|
defineExpose({ openOriginal, openWithOnlyOffice });
|
</script>
|
|
<template>
|
<div class="tms-attach-preview" :class="{ 'is-fullscreen': previewFullscreen }">
|
<div class="tms-attach-preview__body" :class="{ 'side-collapsed': sideCollapsed }">
|
<aside v-show="!sideCollapsed" class="file-side">
|
<div class="side-head">
|
<div class="side-title">{{ listTitle }}</div>
|
<a-tooltip title="收起列表">
|
<a-button type="text" size="small" class="icon-btn" @click="toggleSide">
|
<template #icon><MenuFoldOutlined /></template>
|
</a-button>
|
</a-tooltip>
|
</div>
|
<div class="file-list">
|
<div
|
v-for="file in normalizedFiles"
|
:key="file._key"
|
class="file-row"
|
:class="{ active: file._key === activeFile?._key }"
|
@click="selectFile(file)"
|
>
|
<div class="name">{{ file.name }}</div>
|
<div class="ext">{{ (file.previewType || 'other').toUpperCase() }}</div>
|
</div>
|
<div v-if="!normalizedFiles.length" class="empty">{{ emptyListText }}</div>
|
</div>
|
</aside>
|
|
<div class="preview-side">
|
<div v-if="sideCollapsed" class="side-rail">
|
<a-tooltip title="展开列表" placement="right">
|
<a-button type="text" class="icon-btn side-rail__btn" @click="toggleSide">
|
<template #icon><MenuUnfoldOutlined /></template>
|
</a-button>
|
</a-tooltip>
|
</div>
|
<div class="preview-main">
|
<div class="preview-bar">
|
<span class="preview-bar__name" :title="activeFile?.name">{{ activeFile?.name || '预览区' }}</span>
|
<a-button v-if="previewUrl" type="link" size="small" @click="openOriginal">原文</a-button>
|
<a-tooltip :title="previewFullscreen ? '退出全屏' : '预览全屏'">
|
<a-button type="text" size="small" class="icon-btn" @click="toggleFullscreen">
|
<template #icon>
|
<FullscreenExitOutlined v-if="previewFullscreen" />
|
<FullscreenOutlined v-else />
|
</template>
|
</a-button>
|
</a-tooltip>
|
</div>
|
<div class="preview-canvas">
|
<template v-if="activeFile">
|
<img v-if="activeFile.previewType === 'image' && previewUrl" :src="previewUrl" class="preview-media" alt="" />
|
<video
|
v-else-if="activeFile.previewType === 'video' && previewUrl"
|
:src="previewUrl"
|
class="preview-media"
|
controls
|
controlslist="nodownload"
|
/>
|
<audio
|
v-else-if="activeFile.previewType === 'audio' && previewUrl"
|
:src="previewUrl"
|
class="preview-audio"
|
controls
|
/>
|
<iframe
|
v-else-if="activeFile.previewType === 'pdf' && previewUrl"
|
:src="previewUrl"
|
class="preview-frame"
|
title="pdf-preview"
|
/>
|
<template v-else-if="activeFile.previewType === 'office'">
|
<iframe
|
v-if="officePreviewUrl"
|
:src="officePreviewUrl"
|
class="preview-frame"
|
title="office-preview"
|
/>
|
<div v-else class="preview-fallback" v-loading="officePreviewLoading">
|
<div class="mb-2">
|
{{ officePreviewError || (officePreviewLoading ? '正在加载文档预览…' : '暂无法预览该文档') }}
|
</div>
|
<a-button v-if="canOpenOnlyOffice" type="primary" @click="openWithOnlyOffice">
|
用 OnlyOffice 打开
|
</a-button>
|
</div>
|
</template>
|
<div v-else class="preview-fallback">
|
<div class="mb-2">
|
<template v-if="!previewUrl">该附件缺少预览地址</template>
|
<template v-else>
|
当前文件类型({{ activeFile.fileExt || activeFile.previewType }})暂不支持页内预览。
|
</template>
|
</div>
|
<a-button v-if="canOpenOnlyOffice" type="primary" @click="openWithOnlyOffice">
|
用 OnlyOffice 打开
|
</a-button>
|
<a-button v-else-if="previewUrl" type="primary" @click="openOriginal">打开原文</a-button>
|
</div>
|
</template>
|
<div v-else class="preview-fallback">{{ emptyPreviewText }}</div>
|
</div>
|
</div>
|
</div>
|
</div>
|
</div>
|
</template>
|
|
<style scoped>
|
.tms-attach-preview {
|
height: 100%;
|
min-height: 360px;
|
display: flex;
|
flex-direction: column;
|
background: #fff;
|
box-sizing: border-box;
|
}
|
|
.tms-attach-preview.is-fullscreen {
|
position: fixed;
|
inset: 0;
|
z-index: 1000;
|
background: #fff;
|
padding: 8px;
|
box-sizing: border-box;
|
}
|
|
.tms-attach-preview__body {
|
flex: 1;
|
min-height: 0;
|
display: grid;
|
grid-template-columns: 220px minmax(0, 1fr);
|
gap: 8px;
|
}
|
|
.tms-attach-preview__body.side-collapsed {
|
grid-template-columns: minmax(0, 1fr);
|
}
|
|
.file-side {
|
min-height: 0;
|
display: flex;
|
flex-direction: column;
|
border: 1px solid #f0f0f0;
|
border-radius: 6px;
|
overflow: hidden;
|
background: #fff;
|
}
|
|
.side-head {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
gap: 8px;
|
flex-shrink: 0;
|
padding: 8px 10px;
|
border-bottom: 1px solid #f0f0f0;
|
}
|
|
.side-title {
|
font-weight: 600;
|
font-size: 13px;
|
}
|
|
.file-list {
|
flex: 1;
|
min-height: 0;
|
overflow: auto;
|
padding: 8px;
|
}
|
|
.file-row {
|
padding: 8px 10px;
|
border-radius: 6px;
|
cursor: pointer;
|
}
|
|
.file-row:hover,
|
.file-row.active {
|
background: #e6f4ff;
|
}
|
|
.file-row .name {
|
line-height: 1.4;
|
word-break: break-all;
|
font-size: 13px;
|
}
|
|
.file-row .ext {
|
margin-top: 2px;
|
color: #8c8c8c;
|
font-size: 12px;
|
}
|
|
.preview-side {
|
border: 1px solid #f0f0f0;
|
border-radius: 6px;
|
min-width: 0;
|
min-height: 0;
|
height: 100%;
|
display: flex;
|
flex-direction: row;
|
background: #fafafa;
|
overflow: hidden;
|
}
|
|
.side-rail {
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
flex-shrink: 0;
|
width: 36px;
|
border-right: 1px solid #f0f0f0;
|
background: #fff;
|
}
|
|
.side-rail__btn {
|
width: 32px;
|
height: 48px;
|
color: rgba(0, 0, 0, 0.55);
|
}
|
|
.side-rail__btn:hover {
|
color: #1677ff;
|
background: #e6f4ff;
|
}
|
|
.preview-main {
|
flex: 1;
|
min-width: 0;
|
min-height: 0;
|
display: flex;
|
flex-direction: column;
|
}
|
|
.preview-bar {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
flex-shrink: 0;
|
padding: 4px 8px 4px 12px;
|
border-bottom: 1px solid #f0f0f0;
|
background: #fff;
|
}
|
|
.preview-bar__name {
|
flex: 1;
|
min-width: 0;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
font-size: 13px;
|
color: rgba(0, 0, 0, 0.65);
|
}
|
|
.icon-btn {
|
color: rgba(0, 0, 0, 0.55);
|
}
|
|
.icon-btn:hover {
|
color: #1677ff;
|
}
|
|
.preview-canvas {
|
flex: 1;
|
min-height: 0;
|
display: flex;
|
align-items: stretch;
|
justify-content: stretch;
|
overflow: hidden;
|
}
|
|
.preview-media {
|
width: 100%;
|
height: 100%;
|
object-fit: contain;
|
background: #111;
|
}
|
|
.preview-audio {
|
align-self: center;
|
width: 80%;
|
margin: 0 auto;
|
}
|
|
.preview-frame {
|
width: 100%;
|
height: 100%;
|
border: 0;
|
background: #fff;
|
display: block;
|
}
|
|
.preview-fallback,
|
.empty {
|
color: #8c8c8c;
|
padding: 24px;
|
text-align: center;
|
margin: auto;
|
}
|
|
@media (max-width: 900px) {
|
.tms-attach-preview__body {
|
grid-template-columns: 1fr;
|
grid-template-rows: minmax(120px, 28%) minmax(0, 1fr);
|
}
|
|
.tms-attach-preview__body.side-collapsed {
|
grid-template-rows: minmax(0, 1fr);
|
}
|
}
|
</style>
|