feat(ui): add reusable file preview

This commit is contained in:
caoqianming 2026-08-03 11:31:37 +08:00
parent 44a3ea1028
commit 699e78f20b
9 changed files with 304 additions and 27 deletions

View File

@ -26,6 +26,11 @@
</template> </template>
</el-upload> </el-upload>
<span style="display:none!important"><el-input v-model="value"></el-input></span> <span style="display:none!important"><el-input v-model="value"></el-input></span>
<xt-file-preview
v-model="previewVisible"
:files="defaultFileList"
:initial-index="previewIndex"
/>
</div> </div>
</template> </template>
@ -53,7 +58,9 @@
data() { data() {
return { return {
value: "", value: "",
defaultFileList: [] defaultFileList: [],
previewVisible: false,
previewIndex: 0,
} }
}, },
watch:{ watch:{
@ -151,7 +158,9 @@
this.$message.warning(`当前设置最多上传 ${this.limit} 个文件,请移除后上传!`) this.$message.warning(`当前设置最多上传 ${this.limit} 个文件,请移除后上传!`)
}, },
handlePreview(uploadFile){ handlePreview(uploadFile){
window.open(uploadFile.url) const index = this.defaultFileList.indexOf(uploadFile)
this.previewIndex = index >= 0 ? index : 0
this.previewVisible = true
}, },
request(param){ request(param){
var apiObj = config.apiObjFile; var apiObj = config.apiObjFile;

View File

@ -25,6 +25,11 @@
<div v-if="tip" class="el-upload__tip">{{tip}}</div> <div v-if="tip" class="el-upload__tip">{{tip}}</div>
</template> </template>
</el-upload> </el-upload>
<xt-file-preview
v-model="previewVisible"
:files="defaultFileList"
:initial-index="previewIndex"
/>
</div> </div>
</template> </template>
@ -54,6 +59,8 @@
return { return {
defaultFileList: [], defaultFileList: [],
uploadingCount: 0, uploadingCount: 0,
previewVisible: false,
previewIndex: 0,
} }
}, },
watch:{ watch:{
@ -104,6 +111,9 @@
id: item.id, id: item.id,
name: item.name, name: item.name,
url: item.path || item.url, url: item.path || item.url,
path: item.path || item.url,
mime: item.mime,
type: item.type,
status: "success", status: "success",
}) })
} }
@ -128,7 +138,10 @@
} }
file.name = res.name file.name = res.name
file.url = res.path file.url = res.path
file.path = res.path
file.id = res.id file.id = res.id
file.mime = res.mime
file.type = res.type
}, },
error(err){ error(err){
this.$notify.error({ this.$notify.error({
@ -149,7 +162,9 @@
this.$message.warning(`当前设置最多上传 ${this.limit} 个文件,请移除后上传!`) this.$message.warning(`当前设置最多上传 ${this.limit} 个文件,请移除后上传!`)
}, },
handlePreview(uploadFile){ handlePreview(uploadFile){
window.open(uploadFile.url) const index = this.defaultFileList.indexOf(uploadFile)
this.previewIndex = index >= 0 ? index : 0
this.previewVisible = true
}, },
request(param){ request(param){
var apiObj = config.apiObjFile; var apiObj = config.apiObjFile;

View File

@ -25,7 +25,7 @@
</template> </template>
<template #file="{ file }"> <template #file="{ file }">
<div class="sc-upload-list-item"> <div class="sc-upload-list-item">
<el-image class="el-upload-list__item-thumbnail" :src="file.url" fit="cover" :preview-src-list="preview" :initial-index="preview.findIndex(n=>n==file.url)" hide-on-click-modal append-to-body :z-index="9999"> <el-image class="el-upload-list__item-thumbnail" :src="file.url" fit="cover" @click="handlePreview(file)">
<template #placeholder> <template #placeholder>
<div class="sc-upload-multiple-image-slot"> <div class="sc-upload-multiple-image-slot">
Loading... Loading...
@ -42,6 +42,11 @@
</template> </template>
</el-upload> </el-upload>
<span style="display:none!important"><el-input v-model="value"></el-input></span> <span style="display:none!important"><el-input v-model="value"></el-input></span>
<xt-file-preview
v-model="previewVisible"
:files="defaultFileList"
:initial-index="previewIndex"
/>
</div> </div>
</template> </template>
@ -69,7 +74,9 @@
data(){ data(){
return { return {
value: "", value: "",
defaultFileList: [] defaultFileList: [],
previewVisible: false,
previewIndex: 0,
} }
}, },
watch:{ watch:{
@ -235,7 +242,9 @@
this.$message.warning(`当前设置最多上传 ${this.limit} 个文件,请移除后上传!`) this.$message.warning(`当前设置最多上传 ${this.limit} 个文件,请移除后上传!`)
}, },
handlePreview(uploadFile){ handlePreview(uploadFile){
window.open(uploadFile.url) const index = this.defaultFileList.indexOf(uploadFile)
this.previewIndex = index >= 0 ? index : 0
this.previewVisible = true
}, },
request(param){ request(param){
var apiObj = config.apiObj; var apiObj = config.apiObj;

View File

@ -0,0 +1,228 @@
<template>
<span class="xt-file-preview-trigger">
<slot name="trigger" :open="open"></slot>
<el-dialog
v-model="visible"
:title="currentFile.name || '文件预览'"
width="90%"
top="5vh"
append-to-body
destroy-on-close
:close-on-click-modal="false"
>
<div class="xt-file-preview">
<div v-if="!currentFile.url" class="xt-file-preview__empty">
文件地址不存在
</div>
<img
v-else-if="previewType === 'image'"
class="xt-file-preview__image"
:src="currentFile.url"
:alt="currentFile.name"
/>
<video
v-else-if="previewType === 'video'"
class="xt-file-preview__media"
:src="currentFile.url"
controls
preload="metadata"
></video>
<audio
v-else-if="previewType === 'audio'"
class="xt-file-preview__audio"
:src="currentFile.url"
controls
preload="metadata"
></audio>
<iframe
v-else-if="previewType === 'pdf'"
class="xt-file-preview__pdf"
:src="currentFile.url"
:title="currentFile.name"
></iframe>
<div v-else class="xt-file-preview__unsupported">
<div class="xt-file-preview__file-name">{{ currentFile.name }}</div>
<div>该文件格式不支持浏览器内预览请下载源文件查看</div>
</div>
</div>
<template #footer>
<div class="xt-file-preview__footer">
<div>
<el-button v-if="fileList.length > 1" :disabled="currentIndex <= 0" @click="previous">
上一个
</el-button>
<span v-if="fileList.length > 1" class="xt-file-preview__counter">
{{ currentIndex + 1 }} / {{ fileList.length }}
</span>
<el-button v-if="fileList.length > 1" :disabled="currentIndex >= fileList.length - 1" @click="next">
下一个
</el-button>
</div>
<div>
<el-button :disabled="!currentFile.url" @click="download">下载源文件</el-button>
<el-button type="primary" @click="visible = false">关闭</el-button>
</div>
</div>
</template>
</el-dialog>
</span>
</template>
<script>
const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"];
const VIDEO_EXTENSIONS = ["mp4", "webm", "ogg", "mov", "m4v"];
const AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "m4a", "aac", "flac"];
export default {
name: "XtFilePreview",
props: {
modelValue: { type: Boolean, default: undefined },
files: { type: Array, default: () => [] },
initialIndex: { type: Number, default: 0 },
},
emits: ["update:modelValue"],
data() {
return {
currentIndex: 0,
internalVisible: false,
};
},
computed: {
visible: {
get() {
return this.modelValue === undefined ? this.internalVisible : this.modelValue;
},
set(value) {
this.internalVisible = value;
this.$emit("update:modelValue", value);
},
},
fileList() {
return this.files.map((file) => this.normalizeFile(file));
},
currentFile() {
return this.fileList[this.currentIndex] || {};
},
previewType() {
const mime = (this.currentFile.mime || "").toLowerCase();
const extension = this.extensionOf(this.currentFile.name || this.currentFile.url || "");
if (mime.startsWith("image/") || IMAGE_EXTENSIONS.includes(extension)) return "image";
if (mime.startsWith("video/") || VIDEO_EXTENSIONS.includes(extension)) return "video";
if (mime.startsWith("audio/") || AUDIO_EXTENSIONS.includes(extension)) return "audio";
if (mime === "application/pdf" || extension === "pdf") return "pdf";
return "unsupported";
},
},
watch: {
modelValue(value) {
if (value) this.resetIndex();
},
initialIndex() {
if (this.modelValue) this.resetIndex();
},
files() {
this.resetIndex(this.currentIndex);
},
},
methods: {
normalizeFile(file) {
const response = file?.response || {};
return {
id: file?.id || response.id,
name: file?.name || response.name || "未命名文件",
url: file?.path || file?.url || response.path || response.url || "",
mime: file?.mime || file?.raw?.type || response.mime || "",
};
},
extensionOf(value) {
const cleanValue = value.split(/[?#]/)[0];
const dotIndex = cleanValue.lastIndexOf(".");
return dotIndex >= 0 ? cleanValue.slice(dotIndex + 1).toLowerCase() : "";
},
resetIndex(index = this.initialIndex) {
const maxIndex = Math.max(0, this.files.length - 1);
this.currentIndex = Math.min(Math.max(0, index), maxIndex);
},
open(index = this.initialIndex) {
this.resetIndex(index);
this.visible = true;
},
previous() {
if (this.currentIndex > 0) this.currentIndex -= 1;
},
next() {
if (this.currentIndex < this.fileList.length - 1) this.currentIndex += 1;
},
download() {
if (!this.currentFile.url) return;
const link = document.createElement("a");
link.href = this.currentFile.url;
link.download = this.currentFile.name || "download";
link.target = "_blank";
link.rel = "noopener";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
},
};
</script>
<style scoped>
.xt-file-preview {
display: flex;
align-items: center;
justify-content: center;
min-height: 420px;
height: 70vh;
background: var(--el-fill-color-light);
overflow: hidden;
}
.xt-file-preview__image,
.xt-file-preview__media {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.xt-file-preview__audio {
width: min(600px, 90%);
}
.xt-file-preview__pdf {
width: 100%;
height: 100%;
border: 0;
background: #fff;
}
.xt-file-preview__empty,
.xt-file-preview__unsupported {
padding: 32px;
text-align: center;
color: var(--el-text-color-secondary);
}
.xt-file-preview__file-name {
margin-bottom: 12px;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
word-break: break-all;
}
.xt-file-preview__footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.xt-file-preview__counter {
display: inline-block;
min-width: 70px;
text-align: center;
color: var(--el-text-color-secondary);
}
</style>

View File

@ -30,17 +30,23 @@
<el-table-column label="物料类别" prop="material_cate" width="200" show-overflow-tooltip></el-table-column> <el-table-column label="物料类别" prop="material_cate" width="200" show-overflow-tooltip></el-table-column>
<el-table-column label="调查表" prop="survery_form" width="100" show-overflow-tooltip> <el-table-column label="调查表" prop="survery_form" width="100" show-overflow-tooltip>
<template #default="scope"> <template #default="scope">
<el-link v-if="scope.row.survery_form_" type="text" :href="scope.row.survery_form_.path" target="_blank" style="color:blue" @click.stop="">下载</el-link> <xt-file-preview v-if="scope.row.survery_form_" :files="[scope.row.survery_form_]">
<template #trigger="{ open }"><el-link type="primary" @click.stop="open()">预览</el-link></template>
</xt-file-preview>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="营业执照" prop="business_license" width="100" show-overflow-tooltip> <el-table-column label="营业执照" prop="business_license" width="100" show-overflow-tooltip>
<template #default="scope"> <template #default="scope">
<el-link v-if="scope.row.business_license_" type="text" :href="scope.row.business_license_.path" target="_blank" style="color:blue" @click.stop="">下载</el-link> <xt-file-preview v-if="scope.row.business_license_" :files="[scope.row.business_license_]">
<template #trigger="{ open }"><el-link type="primary" @click.stop="open()">预览</el-link></template>
</xt-file-preview>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="质量证书" prop="quality_certificate" show-overflow-tooltip> <el-table-column label="质量证书" prop="quality_certificate" show-overflow-tooltip>
<template #default="scope"> <template #default="scope">
<el-link v-if="scope.row.quality_certificate_" type="text" :href="scope.row.quality_certificate_.path" target="_blank" style="color:blue" @click.stop="">下载</el-link> <xt-file-preview v-if="scope.row.quality_certificate_" :files="[scope.row.quality_certificate_]">
<template #trigger="{ open }"><el-link type="primary" @click.stop="open()">预览</el-link></template>
</xt-file-preview>
</template> </template>
</el-table-column> </el-table-column>
</scTable> </scTable>

View File

@ -22,9 +22,13 @@
</el-table-column> </el-table-column>
<el-table-column label="文件" prop="files" > <el-table-column label="文件" prop="files" >
<template #default="scope"> <template #default="scope">
<div v-for="item in scope.row.files_" :key="item.id"> <xt-file-preview :files="scope.row.files_">
<el-link style="font-size:12px" type="primary" :href="item.path" target="_blank">{{item.name}}</el-link> <template #trigger="{ open }">
</div> <div v-for="(item, index) in scope.row.files_" :key="item.id">
<el-link style="font-size:12px" type="primary" @click.stop="open(index)">{{item.name}}</el-link>
</div>
</template>
</xt-file-preview>
</template> </template>
</el-table-column> </el-table-column>
<!-- <el-table-column label="操作" fixed="right" align="left" width="170"> <!-- <el-table-column label="操作" fixed="right" align="left" width="170">

View File

@ -29,10 +29,14 @@
</el-table-column> </el-table-column>
<el-table-column label="文件资料" prop="name"> <el-table-column label="文件资料" prop="name">
<template #default="scope"> <template #default="scope">
<div v-for="item in scope.row.files_" :key="item.id"> <xt-file-preview :files="scope.row.files_">
<el-link style="font-size:12px" type="primary" :href="item.path" target="_blank"> <template #trigger="{ open }">
{{ item.name }}</el-link> <div v-for="(item, index) in scope.row.files_" :key="item.id">
</div> <el-link style="font-size:12px" type="primary" @click.stop="open(index)">
{{ item.name }}</el-link>
</div>
</template>
</xt-file-preview>
</template> </template>
</el-table-column> </el-table-column>
</scTable> </scTable>

View File

@ -146,15 +146,15 @@
</el-table-column> </el-table-column>
<el-table-column label="文件资料" prop="name"> <el-table-column label="文件资料" prop="name">
<template #default="scope"> <template #default="scope">
<div v-for="item in scope.row.files_" :key="item.id"> <xt-file-preview :files="scope.row.files_">
<el-link <template #trigger="{ open }">
style="font-size: 12px" <div v-for="(item, index) in scope.row.files_" :key="item.id">
type="primary" <el-link style="font-size: 12px" type="primary" @click.stop="open(index)">
:href="item.path" {{ item.name }}
target="_blank" </el-link>
>{{ item.name }}</el-link </div>
> </template>
</div> </xt-file-preview>
</template> </template>
</el-table-column> </el-table-column>

View File

@ -4,6 +4,7 @@ import ehsSelect from './components/ehsSelect/select'
import ehsTableSelect from './components/ehsSelect/tableSelect' import ehsTableSelect from './components/ehsSelect/tableSelect'
import xtSelect from './components/xtSelect/index.vue' import xtSelect from './components/xtSelect/index.vue'
import xtScanInput from './components/xtScanInput/index.vue' import xtScanInput from './components/xtScanInput/index.vue'
import xtFilePreview from './components/xtFilePreview/index.vue'
export default { export default {
install(app) { install(app) {
app.component('ehsUserSelect', ehsUserSelect); app.component('ehsUserSelect', ehsUserSelect);
@ -12,5 +13,6 @@ export default {
app.component('ehsTableSelect', ehsTableSelect); app.component('ehsTableSelect', ehsTableSelect);
app.component('xtSelect', xtSelect); app.component('xtSelect', xtSelect);
app.component('xtScanInput', xtScanInput); app.component('xtScanInput', xtScanInput);
app.component('xtFilePreview', xtFilePreview);
} }
} }