前言
在中后台系统中,图片、pdf等其他文件上传并预览是非常常见的功能。一般都有通用的组件进行封装,使用时直接复用即可。在互联网发展到2025年这个节点上,理论上不应该有这种文章,对这种需求的封装应该很成熟才对。
但因为一些原因我还是被迫参与到这种古老组件的封装中。事实上无论是vue还是react都已经支持了非常完善的功能,只需简单改造即可,但我们也确实不知道为什么代码里能写的面目全非。
简单需求
- 在formily中接入
- 支持单选、多选等其他基本功能
- 支持上传图片和pdf的预览
代码
<template>
<div class="clearfix">
<Upload
:id="id"
:accept="accept"
:multiple="multiple"
:disabled="disabled"
:list-type="listType"
:file-list="fileList"
:before-upload="beforeUpload"
:custom-request="customRequest"
:remove="handleRemove"
@preview="handlePreview"
@change="handleChange"
>
<div v-if="fileList.length < maxLength">
<Icon type="plus" />
</div>
</Upload>
<AModal :visible="previewVisible" :footer="null" @cancel="handleCancel">
<img style="width: 100%" :src="previewImage" />
</AModal>
</div>
</template>
<script>
import { Upload, Icon, message as Message, Modal as AModal } from 'ant-design-vue'
import { useField } from '@formily/vue'
import { fileUploadFullPath } from '@/api/upload'
import _ from 'lodash'
const COLLEGE_UPLOAD = 'NewsImage'
function getBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result)
reader.onerror = error => reject(error)
})
}
export default {
components: {
Icon,
Upload,
AModal
},
props: {
id: {
type: String,
default: 'cdn-uploader-static'
},
disabled: {
type: Boolean,
default: false
},
multiple: {
type: Boolean,
default: false
},
accept: {
type: String,
default: '.gif,.jpg,.jpeg,.png,.svg'
},
listType: {
type: String,
default: '.text'
},
maxLength: {
type: Number,
default: 100
},
fileName: {
type: String,
default: ''
}
},
setup() {
const fieldRef = useField()
return {
fieldRef
}
},
data() {
return {
fileList: [],
loading: false,
previewVisible: false,
previewImage: ''
}
},
computed: {
field() {
return this.fieldRef
}
},
watch: {
field: {
handler(val) {
if (val) {
const serviceResList = _.get(val, 'value', [])
const fileList = serviceResList.map((item, index) => {
const { fileName, cdnUrl, src } = item
const fileType = this.getFileTypeByUrl(cdnUrl || src)
return {
uid: `-${index + 1}`,
name: fileName || item[this.fileName],
url: cdnUrl || src,
status: 'done',
type: fileType
}
})
this.fileList = fileList
}
},
immediate: true
}
},
methods: {
setFieldValue(val) {
this.field?.setValue(val)
},
isPdf(file) {
return file.type?.toLocaleLowerCase().includes('pdf')
},
beforeUpload(file) {
if (file.size > 10485760) {
Message.error('图片大小不能超过10M,请压缩后重新上传')
return false
}
return true
},
async customRequest(params) {
const { file, onSuccess, onError } = params
const fd = new FormData()
fd.append('file', file)
fd.append('attachmentType', COLLEGE_UPLOAD)
this.loading = true
fileUploadFullPath(fd)
.then(data => {
const baseValue = { ...data, src: data.cdnUrl }
if (this.fileName) baseValue[this.fileName] = data.fileName
const previousValue = this.field.value ?? []
const fieldValue = this.multiple ? [...previousValue, baseValue] : [baseValue]
this.setFieldValue(fieldValue)
// 这里onSuccess要传入data和file,不然file的response属性上拿不到
onSuccess(data, file)
})
.catch(_ => {
onError()
})
.finally(() => {
this.loading = false
})
},
handleChange({ file, fileList }) {
// 上传pdf的预览按钮默认是置灰的,因为他的thumbUrl为空,因此这里针对pdf重新赋值
fileList = fileList.map(item => {
if (this.isPdf(item)) {
item.thumbUrl = item.response ? item.response.cdnUrl : file.thumbUrl
}
return item
})
this.fileList = fileList
},
async handlePreview(file) {
if (this.isPdf(file)) {
window.open(file.thumbUrl || file.url, '_blank')
} else {
if (!file.url && !file.preview) {
file.preview = await getBase64(file.originFileObj)
}
this.previewImage = file.url || file.preview
this.previewVisible = true
}
},
handleCancel() {
this.previewVisible = false
},
handleRemove(file) {
const previousValue = this.field.value ?? []
/**
* 兼容老上传数据 & 新上传数据 & 上传又删除数据
* cdnUrl - 上传接口返回的默认url名称
* src - 提交时要求的字段名称
* url - Upload回显要求的字段名称
*/
const fileAfterRemove = previousValue.filter(previousFile => {
const previousUrl = previousFile.src || previousFile.url || previousFile.response?.cdnUrl
const currentUrl = file.src || file.url || file.response?.cdnUrl
return previousUrl !== currentUrl
})
this.setFieldValue(fileAfterRemove)
},
getFileTypeByUrl(fileUrl) {
const parts = fileUrl.split('.')
const fileType = parts[parts.length - 1]
return fileType
}
}
}
</script>
<style>
.ant-upload-select-picture-card i {
font-size: 32px;
color: #999;
}
.ant-upload-select-picture-card .ant-upload-text {
margin-top: 8px;
color: #666;
}
</style>