458 lines
13 KiB
Vue
458 lines
13 KiB
Vue
<template>
|
||
<el-dialog
|
||
:model-value="visibleLocal"
|
||
:title="currentFile?.name || '文件预览'"
|
||
:close-on-click-modal="false"
|
||
:destroy-on-close="false"
|
||
width="90%"
|
||
:fullscreen="isFullscreen"
|
||
@close="handleClose"
|
||
>
|
||
<div class="file-preview-toolbar">
|
||
<div class="left">
|
||
<!-- <el-button-group>
|
||
<el-button size="small" @click="prevFile" :disabled="!hasPrev">上一页</el-button>
|
||
<el-button size="small" @click="nextFile" :disabled="!hasNext">下一页</el-button>
|
||
</el-button-group>
|
||
|
||
<el-divider direction="vertical" />
|
||
|
||
<el-button size="small" @click="zoomOut">-</el-button>
|
||
<el-button size="small" @click="resetZoom">重置</el-button>
|
||
<el-button size="small" @click="zoomIn">+</el-button>
|
||
|
||
<el-divider direction="vertical" /> -->
|
||
|
||
<!-- <el-button size="small" @click="rotateLeft">⟲</el-button> -->
|
||
<!-- <el-button size="small" @click="rotateRight">⟳</el-button> -->
|
||
|
||
<!-- <el-divider direction="vertical" />
|
||
|
||
<el-button size="small" @click="toggleFullscreen">{{
|
||
isFullscreen ? '退出全屏' : '全屏'
|
||
}}</el-button> -->
|
||
</div>
|
||
|
||
<div class="right">
|
||
<!-- <el-button size="small" type="primary" :href="currentFile?.url" target="_blank"
|
||
>在新标签打开</el-button
|
||
> -->
|
||
<!-- <el-button size="small" @click="downloadCurrent">下载</el-button> -->
|
||
<el-button size="small" type="danger" @click="handleClose">关闭</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="file-preview-body">
|
||
<template v-if="!currentFile">
|
||
<div class="no-file">无要预览的文件</div>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<!-- IMAGE -->
|
||
<div v-if="fileType === 'image'" class="viewer-wrap" :key="`image-${currentFile.url}`">
|
||
<div class="viewer" :style="viewerStyle">
|
||
<img
|
||
:src="currentFile.url"
|
||
:key="currentFile.url"
|
||
@error="onError"
|
||
@load="onLoadImage"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PDF -->
|
||
<div v-else-if="fileType === 'pdf'" class="viewer-wrap" :key="`pdf-${currentFile.url}`">
|
||
<div class="viewer" :style="viewerStyle">
|
||
<!-- 使用 object / iframe 作为预览,支持大多数现代浏览器 -->
|
||
<object
|
||
:key="`pdf-obj-${currentFile.url}`"
|
||
:data="appendPdfParams(currentFile.url)"
|
||
type="application/pdf"
|
||
width="100%"
|
||
height="100%"
|
||
>
|
||
<iframe
|
||
:key="`pdf-iframe-${currentFile.url}`"
|
||
:src="appendPdfParams(currentFile.url)"
|
||
width="100%"
|
||
height="100%"
|
||
></iframe>
|
||
<div class="fallback"
|
||
>无法在浏览器内预览 PDF。<a :href="currentFile.url" target="_blank"
|
||
>点击打开</a
|
||
></div
|
||
>
|
||
</object>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- OFFICE (Word, Excel, PPT) 使用微软 Office 在线查看器,仅当文件对外可访问时有效 -->
|
||
<div
|
||
v-else-if="fileType === 'office'"
|
||
class="viewer-wrap"
|
||
:key="`office-${currentFile.url}`"
|
||
>
|
||
<div class="viewer" :style="viewerStyle">
|
||
<template v-if="isHttpUrl(currentFile.url)">
|
||
<iframe
|
||
:key="`office-iframe-${currentFile.url}`"
|
||
:src="officeViewerUrl(currentFile.url)"
|
||
width="100%"
|
||
height="100%"
|
||
frameborder="0"
|
||
></iframe>
|
||
</template>
|
||
<template v-else>
|
||
<div class="fallback"
|
||
>Office 文件必须是可通过公网访问的 URL 才能使用在线预览。<a
|
||
:href="currentFile.url"
|
||
target="_blank"
|
||
>点击打开或下载</a
|
||
></div
|
||
>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 文本 / 代码 -->
|
||
<div v-else-if="fileType === 'text'" class="viewer-wrap" :key="`text-${currentFile.url}`">
|
||
<div class="viewer text-viewer" :style="viewerStyle">
|
||
<pre v-if="textContent !== null">{{ textContent }}</pre>
|
||
<div v-else class="loading">正在加载文本内容...</div>
|
||
<div v-if="textError" class="error">加载文本失败:{{ textError }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 未知 -->
|
||
<div v-else class="viewer-wrap unknown-viewer" :key="`unknown-${currentFile.url}`">
|
||
<div class="viewer" :style="viewerStyle">
|
||
<div class="unknown">
|
||
<p>无法在线预览此类型文件。</p>
|
||
<p><a :href="currentFile.url" target="_blank">点击打开或下载文件</a></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<div class="footer-info">{{ currentFile?.name || '' }}</div>
|
||
</template>
|
||
</el-dialog>
|
||
</template>
|
||
|
||
<script lang="ts" setup name="FilesPreviews">
|
||
import { ref, computed, watch, onMounted } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
// --- Props ---
|
||
interface FileItem {
|
||
file_name: any
|
||
name?: string
|
||
url: string
|
||
mime?: string // 可选,优先使用
|
||
}
|
||
|
||
const props = defineProps<{
|
||
modelValue: boolean
|
||
file?: FileItem | null // 单文件
|
||
files?: FileItem[] | null // 多文件
|
||
startIndex?: number
|
||
enableFullscreen?: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'update:modelValue', v: boolean): void
|
||
(e: 'close'): void
|
||
}>()
|
||
|
||
// --- Local state ---
|
||
const visibleLocal = ref<boolean>(!!props.modelValue)
|
||
const currentIndex = ref<number>(props.startIndex ?? 0)
|
||
const isFullscreen = ref<boolean>(!!props.enableFullscreen)
|
||
const zoom = ref<number>(1)
|
||
const rotate = ref<number>(0) // deg
|
||
const textContent = ref<string | null>(null)
|
||
const textError = ref<string | null>(null)
|
||
|
||
// --- Computed ---
|
||
const currentFile = computed((): FileItem | null => {
|
||
if (props.files && props.files.length > 0) {
|
||
const idx = Math.min(Math.max(0, currentIndex.value), props.files.length - 1)
|
||
return props.files[idx]
|
||
}
|
||
return props.file ?? null
|
||
})
|
||
|
||
function resetViewState(clearText = true) {
|
||
zoom.value = 1
|
||
rotate.value = 0
|
||
if (clearText) {
|
||
textContent.value = null
|
||
textError.value = null
|
||
}
|
||
}
|
||
|
||
// --- Watchers ---
|
||
watch(
|
||
() => props.modelValue,
|
||
(v) => (visibleLocal.value = v)
|
||
)
|
||
watch(visibleLocal, (v) => emit('update:modelValue', v))
|
||
|
||
watch(
|
||
() => props.file,
|
||
() => {
|
||
currentIndex.value = 0
|
||
resetViewState()
|
||
loadTextIfNeeded()
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => props.files,
|
||
() => {
|
||
currentIndex.value = props.startIndex ?? 0
|
||
resetViewState()
|
||
loadTextIfNeeded()
|
||
}
|
||
)
|
||
|
||
watch(currentIndex, () => {
|
||
resetViewState(false)
|
||
loadTextIfNeeded()
|
||
})
|
||
|
||
// 监听当前文件变化,确保文件切换时正确更新
|
||
watch(
|
||
() => currentFile.value?.url,
|
||
(newUrl, oldUrl) => {
|
||
if (newUrl !== oldUrl && newUrl) {
|
||
resetViewState()
|
||
loadTextIfNeeded()
|
||
}
|
||
}
|
||
)
|
||
|
||
const hasNext = computed(
|
||
() => !!(props.files && currentIndex.value < (props.files?.length ?? 0) - 1)
|
||
)
|
||
const hasPrev = computed(() => !!(props.files && currentIndex.value > 0))
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
function prevFile() {
|
||
if (hasPrev.value) currentIndex.value -= 1
|
||
}
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
function nextFile() {
|
||
if (hasNext.value) currentIndex.value += 1
|
||
}
|
||
|
||
function handleClose() {
|
||
visibleLocal.value = false
|
||
emit('close')
|
||
}
|
||
|
||
// --- Viewer helpers ---
|
||
function getExtension(name = '') {
|
||
const m = name.split('.')
|
||
return (m.length > 1 ? m[m.length - 1].toLowerCase() : '').trim()
|
||
}
|
||
|
||
function mimeFromName(name = '') {
|
||
const ext = getExtension(name)
|
||
const map: Record<string, string> = {
|
||
pdf: 'application/pdf',
|
||
png: 'image/png',
|
||
jpg: 'image/jpeg',
|
||
jpeg: 'image/jpeg',
|
||
gif: 'image/gif',
|
||
bmp: 'image/bmp',
|
||
webp: 'image/webp',
|
||
txt: 'text/plain',
|
||
md: 'text/markdown',
|
||
csv: 'text/csv',
|
||
doc: 'application/msword',
|
||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||
xls: 'application/vnd.ms-excel',
|
||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
ppt: 'application/vnd.ms-powerpoint',
|
||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||
}
|
||
return map[ext] ?? ''
|
||
}
|
||
|
||
const fileType = computed(() => {
|
||
const f = currentFile.value
|
||
if (!f) return 'unknown'
|
||
let names = f.name
|
||
if (names == '') {
|
||
names = f.file_name
|
||
}
|
||
const mime = (f.mime || mimeFromName(names) || '').toLowerCase()
|
||
const ext = getExtension(names)
|
||
|
||
if (mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].includes(ext))
|
||
return 'image'
|
||
if (mime === 'application/pdf' || ext === 'pdf') return 'pdf'
|
||
if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)) return 'office'
|
||
if (mime.startsWith('text/') || ['txt', 'md', 'csv', 'log', 'json', 'xml'].includes(ext))
|
||
return 'text'
|
||
return 'unknown'
|
||
})
|
||
|
||
function isHttpUrl(u?: string) {
|
||
return !!u && /^https?:\/\//i.test(u)
|
||
}
|
||
|
||
function officeViewerUrl(url: string) {
|
||
// 使用微软 Office 在线查看器
|
||
return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(url)}`
|
||
}
|
||
|
||
function appendPdfParams(url: string) {
|
||
// 某些浏览器可以在 pdf 后加参数调整行为,例如 #toolbar=0
|
||
return url
|
||
}
|
||
|
||
// --- transform style for viewer ---
|
||
const viewerStyle = computed(() => {
|
||
return {
|
||
transform: `scale(${zoom.value}) rotate(${rotate.value}deg)`,
|
||
transformOrigin: 'center top'
|
||
} as Record<string, string>
|
||
})
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
function zoomIn() {
|
||
zoom.value = Math.min(zoom.value + 0.25, 4)
|
||
}
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
function zoomOut() {
|
||
zoom.value = Math.max(zoom.value - 0.25, 0.25)
|
||
}
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
function resetZoom() {
|
||
zoom.value = 1
|
||
rotate.value = 0
|
||
}
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
function rotateLeft() {
|
||
rotate.value = (rotate.value - 90) % 360
|
||
}
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
function rotateRight() {
|
||
rotate.value = (rotate.value + 90) % 360
|
||
}
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
function toggleFullscreen() {
|
||
isFullscreen.value = !isFullscreen.value
|
||
}
|
||
|
||
// --- text loading ---
|
||
async function loadTextIfNeeded() {
|
||
if (!currentFile.value) return
|
||
if (fileType.value !== 'text') return
|
||
|
||
textContent.value = null
|
||
textError.value = null
|
||
|
||
try {
|
||
const resp = await fetch(currentFile.value.url)
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||
const txt = await resp.text()
|
||
textContent.value = txt
|
||
} catch (err: any) {
|
||
textError.value = String(err?.message ?? err)
|
||
}
|
||
}
|
||
|
||
// --- image handlers ---
|
||
function onError() {
|
||
ElMessage.warning('图片加载失败')
|
||
}
|
||
function onLoadImage() {
|
||
// 可以在这里获取自然尺寸进行布局优化
|
||
}
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
function downloadCurrent() {
|
||
if (!currentFile.value) return
|
||
// 打开下载链接(浏览器通常会下载)
|
||
const a = document.createElement('a')
|
||
a.href = currentFile.value.url
|
||
a.download = currentFile.value.name || ''
|
||
a.target = '_blank'
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
}
|
||
|
||
// expose some controls externally if needed
|
||
onMounted(() => {
|
||
if (props.modelValue) visibleLocal.value = true
|
||
if (fileType.value === 'text') loadTextIfNeeded()
|
||
})
|
||
// 导出组件
|
||
defineExpose({})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.file-preview-toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
.file-preview-body {
|
||
height: calc(80vh - 80px);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.viewer-wrap {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: auto;
|
||
}
|
||
.viewer {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.viewer img {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
display: block;
|
||
}
|
||
.text-viewer pre {
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
text-align: left;
|
||
padding: 12px;
|
||
height: 100%;
|
||
overflow: auto;
|
||
}
|
||
.unknown-viewer .unknown {
|
||
text-align: center;
|
||
}
|
||
.no-file {
|
||
color: #999;
|
||
}
|
||
.footer-info {
|
||
color: #666;
|
||
}
|
||
.loading {
|
||
color: #999;
|
||
}
|
||
.error {
|
||
color: #f56c6c;
|
||
}
|
||
</style>
|