ant-design-vue & Formily 图片上传并预览组件

292 阅读2分钟

前言

在中后台系统中,图片、pdf等其他文件上传并预览是非常常见的功能。一般都有通用的组件进行封装,使用时直接复用即可。在互联网发展到2025年这个节点上,理论上不应该有这种文章,对这种需求的封装应该很成熟才对。

但因为一些原因我还是被迫参与到这种古老组件的封装中。事实上无论是vue还是react都已经支持了非常完善的功能,只需简单改造即可,但我们也确实不知道为什么代码里能写的面目全非。

简单需求

  1. 在formily中接入
  2. 支持单选、多选等其他基本功能
  3. 支持上传图片和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>