|
|
@@ -0,0 +1,306 @@
|
|
|
+<template>
|
|
|
+ <el-dialog
|
|
|
+ v-model="visible"
|
|
|
+ :title="title"
|
|
|
+ :width="width"
|
|
|
+ append-to-body
|
|
|
+ destroy-on-close
|
|
|
+ class="file-preview-dialog"
|
|
|
+ >
|
|
|
+ <div class="toolbar">
|
|
|
+ <div class="left">
|
|
|
+ <el-select
|
|
|
+ v-if="normalizedUrls.length > 1"
|
|
|
+ v-model="activeUrl"
|
|
|
+ filterable
|
|
|
+ class="file-select"
|
|
|
+ placeholder="选择文件"
|
|
|
+ >
|
|
|
+ <el-option
|
|
|
+ v-for="(u, idx) in normalizedUrls"
|
|
|
+ :key="u + idx"
|
|
|
+ :label="guessNameFromUrl(u)"
|
|
|
+ :value="u"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ <div v-else class="file-name" :title="activeName">
|
|
|
+ {{ activeName }}
|
|
|
+ </div>
|
|
|
+ <el-tag size="small" type="info" class="file-type">{{ activeExt || 'file' }}</el-tag>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="right">
|
|
|
+ <el-button :disabled="!activeUrl" type="primary" plain @click="download">
|
|
|
+ <Icon icon="ep:download" class="mr-5px" />
|
|
|
+ 下载
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="content" :style="{ height }">
|
|
|
+ <el-empty v-if="!activeUrl" description="暂无可预览文件" />
|
|
|
+
|
|
|
+ <template v-else>
|
|
|
+ <el-alert
|
|
|
+ v-if="previewHint"
|
|
|
+ :title="previewHint"
|
|
|
+ type="warning"
|
|
|
+ show-icon
|
|
|
+ :closable="false"
|
|
|
+ class="hint"
|
|
|
+ />
|
|
|
+
|
|
|
+ <!-- 图片 -->
|
|
|
+ <div v-if="activeKind === 'image'" class="stage">
|
|
|
+ <el-image
|
|
|
+ :src="activeUrl"
|
|
|
+ fit="contain"
|
|
|
+ :preview-src-list="[activeUrl]"
|
|
|
+ preview-teleported
|
|
|
+ class="img"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- PDF -->
|
|
|
+ <div v-else-if="activeKind === 'pdf'" class="stage">
|
|
|
+ <iframe class="frame" :src="pdfIframeSrc" title="PDF Preview" frameborder="0"></iframe>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Word -->
|
|
|
+ <div v-else-if="activeKind === 'word'" class="stage">
|
|
|
+ <iframe
|
|
|
+ v-if="officeIframeSrc"
|
|
|
+ class="frame"
|
|
|
+ :src="officeIframeSrc"
|
|
|
+ title="Office Preview"
|
|
|
+ frameborder="0"
|
|
|
+ ></iframe>
|
|
|
+ <el-empty v-else description="该文件无法直接预览" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 其它 -->
|
|
|
+ <div v-else class="stage">
|
|
|
+ <el-empty description="暂不支持该格式预览" />
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { computed, ref, watch } from 'vue'
|
|
|
+
|
|
|
+defineOptions({ name: 'FilePreviewDialog' })
|
|
|
+
|
|
|
+type OfficeViewer = 'microsoft'
|
|
|
+
|
|
|
+const props = withDefaults(
|
|
|
+ defineProps<{
|
|
|
+ modelValue: boolean
|
|
|
+ title?: string
|
|
|
+ width?: string | number
|
|
|
+ height?: string
|
|
|
+ url?: string
|
|
|
+ urls?: string[]
|
|
|
+ name?: string
|
|
|
+ useOfficeViewer?: boolean
|
|
|
+ officeViewer?: OfficeViewer
|
|
|
+ }>(),
|
|
|
+ {
|
|
|
+ title: '文件预览',
|
|
|
+ width: '1100px',
|
|
|
+ height: '72vh',
|
|
|
+ url: '',
|
|
|
+ urls: () => [],
|
|
|
+ name: '',
|
|
|
+ useOfficeViewer: true,
|
|
|
+ officeViewer: 'microsoft'
|
|
|
+ }
|
|
|
+)
|
|
|
+
|
|
|
+const emit = defineEmits<{
|
|
|
+ (e: 'update:modelValue', v: boolean): void
|
|
|
+}>()
|
|
|
+
|
|
|
+const visible = computed({
|
|
|
+ get: () => props.modelValue,
|
|
|
+ set: (v: boolean) => emit('update:modelValue', v)
|
|
|
+})
|
|
|
+
|
|
|
+const normalizedUrls = computed(() => {
|
|
|
+ const list = [...(props.urls || []), props.url].filter(Boolean) as string[]
|
|
|
+ const uniq: string[] = []
|
|
|
+ for (const u of list) {
|
|
|
+ if (!uniq.includes(u)) uniq.push(u)
|
|
|
+ }
|
|
|
+ return uniq
|
|
|
+})
|
|
|
+
|
|
|
+const activeUrl = ref<string>('')
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => normalizedUrls.value,
|
|
|
+ (list) => {
|
|
|
+ activeUrl.value = list[0] || ''
|
|
|
+ },
|
|
|
+ { immediate: true }
|
|
|
+)
|
|
|
+
|
|
|
+const activeName = computed(() => {
|
|
|
+ if (!activeUrl.value) return ''
|
|
|
+ if (props.name) return props.name
|
|
|
+ return guessNameFromUrl(activeUrl.value)
|
|
|
+})
|
|
|
+
|
|
|
+const activeExt = computed(() => getFileExt(activeName.value || activeUrl.value))
|
|
|
+
|
|
|
+type Kind = 'image' | 'pdf' | 'word' | 'other'
|
|
|
+const activeKind = computed<Kind>(() => {
|
|
|
+ const ext = activeExt.value
|
|
|
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return 'image'
|
|
|
+ if (ext === 'pdf') return 'pdf'
|
|
|
+ if (ext === 'doc' || ext === 'docx') return 'word'
|
|
|
+ return 'other'
|
|
|
+})
|
|
|
+
|
|
|
+const isHttpUrl = (u: string) => /^https?:\/\//i.test(u)
|
|
|
+
|
|
|
+const officeIframeSrc = computed(() => {
|
|
|
+ if (!props.useOfficeViewer) return ''
|
|
|
+ if (!activeUrl.value) return ''
|
|
|
+ if (!isHttpUrl(activeUrl.value)) return ''
|
|
|
+ if (props.officeViewer !== 'microsoft') return ''
|
|
|
+ return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(activeUrl.value)}`
|
|
|
+})
|
|
|
+
|
|
|
+const pdfIframeSrc = computed(() => {
|
|
|
+ if (!activeUrl.value) return ''
|
|
|
+ // Chrome 内置 PDF 预览支持 #toolbar=0
|
|
|
+ return `${activeUrl.value}#toolbar=0&navpanes=0&scrollbar=0`
|
|
|
+})
|
|
|
+
|
|
|
+const previewHint = computed(() => {
|
|
|
+ if (!activeUrl.value) return ''
|
|
|
+ if (activeKind.value === 'word' && !officeIframeSrc.value) {
|
|
|
+ return 'Word 预览需要可被 Office 在线服务访问的完整 URL(通常要求公网/无鉴权),否则建议后端转 PDF 再预览。'
|
|
|
+ }
|
|
|
+ return ''
|
|
|
+})
|
|
|
+
|
|
|
+const openNewTab = () => {
|
|
|
+ if (!activeUrl.value) return
|
|
|
+ window.open(activeUrl.value, '_blank', 'noopener,noreferrer')
|
|
|
+}
|
|
|
+
|
|
|
+const download = () => {
|
|
|
+ if (!activeUrl.value) return
|
|
|
+ const a = document.createElement('a')
|
|
|
+ a.href = activeUrl.value
|
|
|
+ a.download = activeName.value || ''
|
|
|
+ a.target = '_blank'
|
|
|
+ a.rel = 'noopener noreferrer'
|
|
|
+ document.body.appendChild(a)
|
|
|
+ a.click()
|
|
|
+ a.remove()
|
|
|
+}
|
|
|
+
|
|
|
+function getFileExt(input: string) {
|
|
|
+ const clean = input.split('?')[0].split('#')[0]
|
|
|
+ const dot = clean.lastIndexOf('.')
|
|
|
+ if (dot < 0) return ''
|
|
|
+ return clean.substring(dot + 1).toLowerCase()
|
|
|
+}
|
|
|
+
|
|
|
+function guessNameFromUrl(u: string) {
|
|
|
+ try {
|
|
|
+ const url = new URL(u, window.location.origin)
|
|
|
+ const name = url.pathname.split('/').filter(Boolean).pop() || ''
|
|
|
+ return decodeURIComponent(name) || u
|
|
|
+ } catch {
|
|
|
+ const clean = u.split('?')[0].split('#')[0]
|
|
|
+ const name = clean.split('/').filter(Boolean).pop() || clean
|
|
|
+ try {
|
|
|
+ return decodeURIComponent(name)
|
|
|
+ } catch {
|
|
|
+ return name
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.file-preview-dialog {
|
|
|
+ :deep(.el-dialog__body) {
|
|
|
+ padding-top: 10px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 12px;
|
|
|
+ padding: 0 0 10px;
|
|
|
+ border-bottom: 1px solid var(--el-border-color-lighter);
|
|
|
+}
|
|
|
+
|
|
|
+.left {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.file-select {
|
|
|
+ width: min(520px, 48vw);
|
|
|
+}
|
|
|
+
|
|
|
+.file-name {
|
|
|
+ max-width: min(520px, 48vw);
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ color: var(--el-text-color-primary);
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.file-type {
|
|
|
+ flex: none;
|
|
|
+}
|
|
|
+
|
|
|
+.right {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ flex: none;
|
|
|
+}
|
|
|
+
|
|
|
+.content {
|
|
|
+ margin-top: 12px;
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+.hint {
|
|
|
+ margin-bottom: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.stage {
|
|
|
+ height: 100%;
|
|
|
+ border: 1px solid var(--el-border-color-lighter);
|
|
|
+ border-radius: 10px;
|
|
|
+ overflow: hidden;
|
|
|
+ background: linear-gradient(180deg, rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0.01));
|
|
|
+}
|
|
|
+
|
|
|
+.img {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.frame {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ border: 0;
|
|
|
+ background: #fff;
|
|
|
+}
|
|
|
+</style>
|