xuanweiH / Project-issue

记录项目遇到一些问题与封装
2 stars 0 forks source link

图片上传排序预览组件 #6

Open xuanweiH opened 4 years ago

xuanweiH commented 4 years ago

实现一个 图片上传组件,支持上传时拖拽排序, 点击预览大图

思路大概是:

  1. 没有图片文件展示时, 给一个模板展示
  2. 有图片文件且没有超出时, 对应显示相关配置以及进度条显示 同时用focusing变量表示选中的图片 注册点击预览事件handlePreview 点击弹出el-dialog 以及 el-carouse
    handlePreview (file) {
      if (this.isPreview) {
        this.dialogVisible = true
        setTimeout(() => {
          this.$refs.carouselBox.setActiveItem(this.fileList.indexOf(file))
        }, 100)
      }
      if ('on-preview' in this.$attrs) {
        this.$attrs['on-preview'](file)
      }
    },

    点击更换图片 主要是通过 创造一个 type为file的input标签, input,onchange 把旧的文件放进新文件属性里面,再移除. 调用 handleStart(file) 再调用上传

接着就是基于el-upload完成图片上传的操作 注册一些相关的时间beforeUpload 如果用element的上传直接把路径写action里面,如果要自己自定义httprequest也可以另外写. 同时这个组件还支持上传多张时 拖拽排序 原理也是基于sortablejs 用sortable.create中的onend事件里, 通过

const currRow = this.fileList.splice(oldIndex, 1)[0]
this.fileList.splice(newIndex, 0, currRow)

props 暴露出去的属性大致分为, value 文件属性 type/ image 是否可以上传 canUpload 是否可以预览 一些文件限制 limit 对象 以及输出类型outputType 输出类型主要是用于看后端需要什么值:

      if (this.outputType === 'string') {
        this.$emit('input', files.length === 0 ? '' : files[0].picUrl ? files[0].picUrl : '')
      } else if (this.outputType === 'object') {
        this.$emit('input', files.length === 0 ? {} : files[0])
      } else if (this.outputType === 'array') {
        this.$emit('input', files)
      }

封装代码:

<template>
  <div class="img-upload-preview">
    <template v-if="fileList.length === 0 && type == 'image' && !canUpload">
      <div
        class="no-image"
        :class="{
          'big': size === 'big',
          'medium': size === 'medium',
          'small': size === 'small',
          'mini': size === 'mini',
        }"
      >
      </div>
    </template>
    <template v-if="fileList.length > 0 && type == 'image' && limit.maxNumber >= fileList.length" class="upload-type-image">
      <ul class="el-upload-list el-upload-list--picture-card">
        <li
          v-for="(file, index) in fileList"
          :key="`${file.url}-${index}`"
          class="el-upload-list__item single-image-box"
          :class="{
            'big': size === 'big',
            'medium': size === 'medium',
            'small': size === 'small',
            'mini': size === 'mini',
            'focusing' : focusing === index
          }"
          @mouseenter="focusing = index"
          @mouseleave="focusing = -1"
          @click="focusing = index"

        >
          <el-progress
            class="single-image-progress"
            v-if="file.status === 'uploading'"
            type="circle"
            :width="size === 'big' ? 120 : size === 'medium' ? 90 : size === 'small' ? 60 : 120"
            :stroke-width="size === 'big' ? 6 : size === 'medium' ? 4 : size === 'small' ? 3 : 6"
            :percentage="file.percentage">
          </el-progress>
          <span class="single-image-box__actions">
            <span v-if="isPreview" class="single-image-box__preview" @click="handlePreview(file)">
              <i class="el-icon-zoom-in"></i>
            </span>
            <span v-if="canUpload && size === 'mini'" class="single-image-box__change" @click="handleChangeImg(file)">
              <i class="el-icon-refresh"></i>
            </span>
            <span v-if="canUpload" class="single-image-box__change" @click="handleDelete(file)">
              <i class="el-icon-delete"></i>
            </span>
          </span>
          <span v-if="canUpload && size !== 'mini'" class="single-image-box__changes" @click="handleChangeImg(file)">
            点击更换
          </span>
          <img v-if="file.status === 'success'" :src="file.url">
        </li>
      </ul>
    </template>
    <div class="omp-upload-img" :class="{'no_upload': (isPreview && !canUpload) || fileList.length >= limit.maxNumber, 'is_preview': isPreview}">
      <input v-model="value" placeholder="请输入内容" @input="value = $event.target.value" v-show="false"/>
      <el-upload
        class="upload-box"
        :class="{
          'big': type === 'image' && size === 'big',
          'medium': type === 'image' && size === 'medium',
          'small': type === 'image' && size === 'small',
          'mini': type === 'image' && size === 'mini'
        }"
        ref="upload"
        v-bind="$attrs"
        v-on="$listeners"
        :list-type="listType"
        :file-list="fileList"
        :auto-upload="true"
        :action="uploadUrl"
        :data="data"
        :limit="limit.maxNumber"
        :show-file-list="type !== 'image'"
        :before-upload="beforeUpload"
        :on-success="handleSuccess"
        :on-exceed="handleExceed"
        :on-change="handleChange"
        :on-remove="handleRemove"
        :on-preview="handlePreview"
        :on-progress="handleProgress"
        :disabled="!canUpload"
      >
        <template v-if="['text', 'picture'].indexOf(listType) !== -1">
          <slot name="trigger" slot="trigger">
            <el-button slot="trigger" :size="size" type="primary" :disabled="!canUpload">上传<i class="el-icon-upload el-icon--right"></i></el-button>
          </slot>
          <slot name="tip" slot="tip">
            <div slot="tip" class="el-upload__tip">只能上传{{this.limit.format.join(',')}}文件</div>
          </slot>
          <slot></slot>
        </template>
        <template v-else>
          <slot>
            <div v-if="size === 'big' || size === 'medium'" style="display: flex; justify-content: center; align-items: center; flex-direction: column; height: 100%;">
              <i class="el-icon-upload"></i>
              <label v-if="placeholder !== ''" style="line-height: 20px; font-size: 12px;">{{placeholder}}</label>
            </div>
            <i v-else-if="size === 'small'" class="el-icon-upload" style="font-size: 24px;"></i>
            <i v-else class="el-icon-plus" style="font-size: 18px;"></i>
          </slot>
        </template>
      </el-upload>
      <el-dialog :visible.sync="dialogVisible" top="10vh" title="图片预览" append-to-body custom-class="omp-upload-preview">
        <el-carousel :autoplay="false" height="620px" class="preview-img" ref="carouselBox" :arrow="fileList.length > 1 ? 'hover' : 'never'" :indicator-position="fileList.length > 1 ? 'outside' : 'none'">
          <el-carousel-item v-for="(file, key) in fileList" :key="key" class="text-center">
            <img height="100%" :src="file.url" :alt="file.name">
          </el-carousel-item>
        </el-carousel>
      </el-dialog>
    </div>
  </div>
</template>

<script>
import helper from '@/utils/helper'
import Sortable from 'sortablejs'

let listTypeObj = {
  'text': 'text',
  'button': 'picture',
  'image': 'picture-card'
}
export default {
  name: 'imgUploadPreview',
  components: {
  },
  data () {
    return {
      focusing: -1,
      data: {
        fileName: '',
        fileSize: 0
      },
      listType: listTypeObj[this.type],
      input: '',
      fileList: [],
      dialogVisible: false,
      baseUrl: this.$api.common.imageUrl,
      uploadUrl: `${this.$api.common.imageUrl}upload`
    }
  },
  computed: {
  },
  props: {
    value: {
      default: () => {
        return []
      },
      type: [Array, String, Object]
    },
    type: {
      default: 'image',
      type: String
    },
    size: {
      default: 'medium',
      type: String
    },
    canUpload: {
      default: false,
      type: Boolean
    },
    isPreview: {
      default: true,
      type: Boolean
    },
    limit: {
      default: () => {
        return {
          width: undefined,
          height: undefined,
          format: ['jpeg', 'png', 'gif'],
          size: undefined,
          maxNumber: 1,
          isStrictSize: false,
          isSquare: false,
          overLimitMsg: '上传文件数量超过限制'
        }
      },
      type: Object
    },
    placeholder: {
      default: '',
      type: String
    },
    outputType: {
      default: 'string',
      type: String
    }
  },
  methods: {
    handleChange (file, fileList) {
      // 判断是否为替换文件
      if (file.raw.oldFile && file.status === 'ready') {
        const oldFile = file.raw.oldFile
        fileList.splice(fileList.indexOf(oldFile), 1, file)
        fileList.pop()
      }
      this.changeCommon(file, fileList)
      if ('on-change' in this.$attrs) {
        this.$attrs['on-change'](file, fileList)
      }
    },
    handleRemove (file, fileList) {
      this.changeCommon(file, fileList)
      if ('on-remove' in this.$attrs) {
        this.$attrs['on-remove'](file, fileList)
      }
    },
    changeCommon (file, fileList) {
      // 输出上传结果
      this.fileList = fileList
      this.dragTable()
      this.handleOutput()
    },
    // 文件上传成功status = 200 的回调
    handleSuccess (response, file, fileList) {
      if (response && response.errorCode === 0) {
        let resData = response.data
        let { groupName, remoteFileName } = resData
        let { width, height } = file.raw
        file.picUrl = `/${groupName}/${remoteFileName}?width=${width}&height=${height}`
        file.url = file.picUrl
        file.remoteFileName = resData.remoteFileName
        file.groupName = resData.groupName
      } else {
        this.$message.error(`上传失败!${response.errorMsg}`)
        // 判断是否为替换,如果是替换,还原旧文件
        if (file.raw.oldFile) {
          const oldFile = file.raw.oldFile
          fileList.splice(fileList.indexOf(file), 1, oldFile)
        } else {
          fileList.splice(fileList.indexOf(file), 1)
        }
      }
      if ('on-success' in this.$attrs) {
        this.$attrs['on-success'](response, file, fileList)
      }
    },
    // 上传前判断图片类型, 尺寸,宽高
    beforeUpload (file) {
      // debugger
      // 校验
      let fileSize = file.size / 1024
      if (this.limit.format) {
        const typeList = this.limit.format.map(item => {
          item = `image/${item}`
          return item
        })
        if (!typeList.includes(file.type)) {
          this.$message.error(`上传失败:图片格式只能为${this.limit.format.join(',')}!`)
          return false
        }
      }
      if (this.limit.size && fileSize > this.limit.size) {
        this.$message.error(`上传失败:图片大小需小于${this.limit.size}k,请重新上传!`)
        return false
      }
      const isCheck = new Promise((resolve, reject) => {
        let _URL = window.URL || window.webkitURL
        let img = new Image()
        img.onload = () => {
          file.width = img.width
          file.height = img.height
          let inValid, message
          if (this.limit.isStrictSize) {
            // 严格模式下存在单边严格,所以要分成三种情况分析
            // 只有宽度严格
            if (this.limit.width && !this.limit.height) {
              inValid = img.width !== this.limit.width
              message = `图片需严格遵守 ${this.limit.width} * 高度不限制`
            } else if (!this.limit.width && this.limit.height) {
              // 只有高度严格
              inValid = img.height !== this.limit.height
              message = `图片需严格遵守 宽度不限制 * ${this.limit.height}`
            } else {
              // 全严格
              inValid = img.width !== this.limit.width || img.height !== this.limit.height
              message = `图片需严格遵守 ${this.limit.width} * ${this.limit.height}`
            }
          } else {
            // 只有宽度
            if (this.limit.width && !this.limit.height) {
              inValid = img.width > this.limit.width
              message = `图片需遵守小于 ${this.limit.width} * 高度不限制`
            } else if (!this.limit.width && this.limit.height) {
              // 只有高度
              inValid = img.height > this.limit.height
              message = `图片需遵守小于 宽度不限制 * ${this.limit.height}`
            } else {
              inValid = img.width > this.limit.width || img.height > this.limit.height
              message = `图片需遵守小于 ${this.limit.width} * ${this.limit.height}`
            }
          }
          if (this.limit.isSquare) {
            inValid = img.width !== img.height
            message = `图片需遵守长宽1:1的正方形切图`
          }
          inValid ? reject(message) : resolve()
        }
        img.src = _URL.createObjectURL(file)
      }).then(() => {
        // 构造额外请求参数
        this.data = {
          fileName: file.name,
          fileSize: file.size
        }
        return file
      }, (message) => {
        this.$message.error(`上传失败:${message}`)
        return Promise.reject(new Error('something bad happened'))
      })
      return isCheck
    },
    handlePreview (file) {
      if (this.isPreview) {
        this.dialogVisible = true
        setTimeout(() => {
          this.$refs.carouselBox.setActiveItem(this.fileList.indexOf(file))
        }, 100)
      }
      if ('on-preview' in this.$attrs) {
        this.$attrs['on-preview'](file)
      }
    },
    handleExceed (files, fileList) {
      if ('on-exceed' in this.$attrs) {
        this.$attrs['on-exceed'](files, fileList)
      } else {
        this.$message.error(`上传失败:${this.limit.overLimitMsg}`)
      }
    },
    handleChangeImg (oldFile) {
      this.focusing = false
      // 创建一个承载器
      const newInput = document.createElement('input')
      newInput.type = 'file'
      newInput.onchange = (e) => {
        let file = e.target.files[0]
        // 将旧文件信息加入新文件属性里
        file.oldFile = oldFile
        // this.clearFiles(false)
        newInput.remove()
        setTimeout(() => {
          // 加入上传文件
          this.$refs.upload.handleStart(file)
          // 开始上传
          this.$refs.upload.submit()
        }, 100)
      }
      newInput.click()
    },
    handleProgress (event, file, fileList) {
      event.percent = 0
      if (file.percentage === undefined) {
        file.percentage = 0
      }
      return this.changeProgress(file)
    },
    changeProgress (file) {
      setTimeout(() => {
        file.percentage = file.percentage + 10
        if (file.percentage < 100) {
          this.changeProgress(file)
        } else {
          file.percentage = 100
        }
        return file
      }, (file.size / 1024) / 10)
    },
    handleOutput () {
      const fileList = helper.objDeepClone(this.fileList)
      const files = []
      // 如果是正在上传的图片, 不传值
      if (fileList.length !== 0 && fileList.filter(item => item.status === 'success').length !== fileList.length) return
      fileList.forEach(item => {
        files.push({
          groupName: item.groupName,
          picUrl: item.picUrl,
          remoteFileName: item.remoteFileName,
          size: item.size,
          width: item.raw ? item.raw.width : item.width,
          height: item.raw ? item.raw.height : item.height
        })
      })
      if (this.outputType === 'string') {
        this.$emit('input', files.length === 0 ? '' : files[0].picUrl ? files[0].picUrl : '')
      } else if (this.outputType === 'object') {
        this.$emit('input', files.length === 0 ? {} : files[0])
      } else if (this.outputType === 'array') {
        this.$emit('input', files)
      }
    },
    handleDelete (file) {
      this.fileList.splice(this.fileList.indexOf(file), 1)
      this.handleRemove(file, this.fileList)
    },
    clearFiles (boolean = true) {
      this.fileList = []
      this.$refs.upload.clearFiles()
      if (boolean) {
        this.handleOutput()
      }
    },
    abort (file) {
      this.$refs.upload.abort(file)
    },
    submit () {
      this.$refs.upload.submit()
    },
    // 拖拽
    dragTable () {
      if (this.$el) {
        const el = this.$el.querySelector('.el-upload-list.el-upload-list--picture-card')
        if (el !== null) {
          Sortable.create(el, {
            animation: 180,
            delay: 0,
            onEnd: ({ newIndex, oldIndex }) => {
              if (newIndex !== oldIndex) {
                const currRow = this.fileList.splice(oldIndex, 1)[0]
                this.fileList.splice(newIndex, 0, currRow)
                this.handleOutput()
              }
            }
          })
        }
      }
    }
  },
  created () {
    // this.baseUrl = this.$api.common.imageUrl
    // this.uploadUrl = this.baseUrl + 'upload'
  },
  mounted () {
    // 开启排序功能
    if (this.canUpload) {
      setTimeout(() => {
        this.dragTable()
      }, 500)
    }
  },
  watch: {
    value: {
      handler (newName, oldName) {
        this.fileList = []
        if (helper.isString(this.value) && this.value.length !== 0) {
          this.fileList = [{
            picUrl: this.value,
            url: this.baseUrl + this.value,
            status: 'success'
          }]
        } else if (helper.isObject(this.value) && JSON.stringify(this.value) !== '{}') {
          const object = helper.objDeepClone(this.value)
          object.url = this.baseUrl + this.value.picUrl
          object.status = 'success'
          this.fileList = [object]
        } else if (helper.isArray(this.value) && this.value.length !== 0) {
          const array = helper.objDeepClone(this.value)
          this.fileList = array.map(item => {
            item.url = this.baseUrl + item.picUrl
            item.status = 'success'
            return item
          })
        }
      },
      immediate: true,
      deep: true
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="stylus">
.img-upload-preview
  position relative
  .no-image
    position relative
    background-color #fbfdff
    border 1px dashed #c0ccda
    border-radius 6px
    vertical-align top
    display inline-block
    &.big
      width 148px
      height 148px
      line-height 148px
    &.medium
      width 120px
      height 120px
      line-height 120px
    &.small
      width 86px
      height 86px
      line-height 86px
    &.mini
      width 53px
      height 53px
      line-height 53px
  .single-image-box
    position relative
    margin 5px !important
    background rgba(0, 0, 0, 0.3) !important
    border 1px dashed #c0ccda
    border-radius 6px
    -webkit-box-sizing border-box
    box-sizing border-box
    cursor pointer
    vertical-align top
    overflow hidden
    &.focusing
      .single-image-box__actions
        opacity 1
    &.big
      width 148px
      height 148px
      line-height 148px
    &.medium
      width 120px
      height 120px
      line-height 120px
    &.small
      width 86px
      height 86px
      line-height 86px
    &.mini
      width 53px
      height 53px
      line-height 53px
      .single-image-box__actions
        font-size 14px
    .single-image-progress
      display flex
      justify-content center
      align-items center
      position absolute
      top 50%
      left 50%
      transform translate(-50%, -50%)
    .single-image-box__actions
      position absolute
      width 100%
      height 100%
      left 0
      top 0
      cursor default
      text-align center
      color #fff
      opacity 0;
      font-size 20px
      background-color rgba(0, 0, 0, 0.5)
      transition opacity .3s
      z-index 10
    .single-image-box__changes
      font-size 12px
      height 30px
      line-height 30px
      display block
      position absolute
      bottom 0
      width 100%
      text-align center
      background rgba(0, 0, 0, 0.65)
      color #fff
      cursor pointer
      z-index 99
    img
      width 100%
      height 100%
      position relative
      object-fit contain
      object-position center
      vertical-align middle
      border-style none
      background rgba(0, 0, 0, 0.3)
      margin-top -4px
  .omp-upload-img
    display inline-block
    margin 5px
    .upload-box
      &.big
        .el-upload--picture-card
          width 148px
          height 148px
          line-height 148px
        .el-upload-list__item
          width 148px
          height 148px
      &.medium
        .el-upload--picture-card
          width 120px
          height 120px
          line-height 120px
        .el-upload-list__item
          width 120px
          height 120px
      &.small
        .el-upload--picture-card
          width 86px
          height 86px
          line-height 86px
        .el-upload-list__item
          width 86px
          height 86px
      &.mini
        .el-upload--picture-card
          width 53px
          height 53px
          line-height 53px
        .el-upload-list__item
          width 53px
          height 53px
          .el-upload-list__item-preview, .el-upload-list__item-delete
            font-size 16px
            margin 0 2px
          .el-icon-close-tip
            display none
    /* 屏蔽上传功能 */
    &.no_upload
      margin 0
      .el-upload--picture-card
        display none
    /* 屏蔽上传成功标签 */
    &.is_preview
      .el-upload-list__item
        &:hover
          .el-upload-list__item-status-label
            display none
        .el-upload-list__item-status-label
            display none
.omp-upload-preview
  .preview-img
    overflow hidden
    img
      width 100%
      height 560px
      position relative
      z-index 10
      object-fit contain
      object-position center
      vertical-align middle
      border-style none
      background rgba(0, 0, 0, 0.3)
</style>

组件使用

<img-upload-preview
    style="text-align: center;"
    type="image"
    size="medium"
    :canUpload="true"
    :isPreview="true"
    v-model="form.skuHeadPic"
    :limit="{
      width: undefined,
      height: undefined,
      size: undefined,
      maxNumber: 1,
      format: ['jpeg', 'png','gif'],
      isStrictSize: false,
      isSquare: false,
      overLimitMsg: '上传文件数量超过限制'
    }"
    placeholder="请上传商品头图"
    outputType="object"
    >
 </img-upload-preview>