Element UI el-upload 二次封装图片上传组件(可预览、可批量,含手动上传)

先说明几点:1. 使用Vue 2.x。2. 这几个例子是比较适合我自己项目场景的方案,主要为了记录下,仅供参考。样式有引入部分覆盖element-ui的公共样式,因此光用我组件里scoped的样式显示效果不会完全一样。然后单图上传是模拟了element-ui 两种文件列表的样子,实际上属性设置为:show-file-list="false",移除功能也是额外定义而非传值实现的。需要留下心。

一、单图上传(父子组件图片地址双向绑定)

我们先看功能和效果。大致分点击按钮上传拖拽上传,上传后都可以预览(预览弹窗宽度可传参dialogWidth: String自定义)

1. 点击按钮上传 (不传 drag 参数)
没有图片数据时
有图片数据时
移到按钮右侧出现 x 图标,可用来删除上传的图片
点击 x 弹出确认框
大图预览效果,可自定义预览弹窗宽度
2. 拖拽/点击上传 (传 :drag = "true")

注:拖拽无法限制拖入多张图的操作,但我们设置了:multiple="false",因此只有一张能上传成功。

没有图片数据时

有图片数据时
鼠标移到小预览图上后有两个功能,右边垃圾桶图标可移除图片
点击放大镜大图预览
3. 组件代码

SingleUpload.vue

<!--单图上传组件/按钮-->
<template>
  <div class="upload-container">
    <el-upload
      :action="uploadUrl"
      name="avatar"
      :multiple="false"
      :show-file-list="false"
      :before-upload="beforeUpload"
      :on-success="handleUploadSuccess"
      :on-error="handleUploadError"
      :drag="drag"
      with-credentials
      :class="uploadClass"
    >
      <div v-if="!drag">
        <el-button v-if="imageUrl" size="small" type="success">已上传,可点击修改</el-button>
        <el-button size="small" type="primary" v-else><i class="el-icon-upload el-icon--left"></i>点击上传</el-button>
      </div>
      <div v-if="drag">
        <i class="el-icon-upload" />
        <div class="el-upload__text">将单张图片拖到<br />此处,或<em>点击上传</em></div>
      </div>
    </el-upload>
    <el-dialog :visible.sync="dialogVisible" :append-to-body="true" :modal-append-to-body="false" :width="dialogWidth" class="preview-dialog">
      <img width="100%" :src="imageUrl" alt="" />
    </el-dialog>
    <div v-if="!drag && imageUrl.length > 0" class="el-upload-list el-upload-list--text">
      <div class="el-upload-list__item is-success">
        <a class="el-upload-list__item-name" @click="handlePreview"> <i class="el-icon-picture"></i>点此处预览</a>
        <label class="el-upload-list__item-status-label">
          <i class="el-icon-upload-success el-icon-circle-check"></i>
        </label>
        <i class="el-icon-close" @click="removeImage"></i>
      </div>
    </div>
    <div v-if="drag && imageUrl.length > 0" class="el-upload-list el-upload-list--picture-card">
      <div class="el-upload-list__item is-success">
        <img :src="imageUrl" alt="" class="el-upload-list__item-thumbnail" />
        <label class="el-upload-list__item-status-label">
          <i class="el-icon-upload-success el-icon-check"></i>
        </label>
        <i class="el-icon-close"></i>
        <i class="el-icon-close-tip">按 delete 键可删除</i>
        <span class="el-upload-list__item-actions">
          <span class="el-upload-list__item-preview">
            <i class="el-icon-zoom-in" @click="handlePreview"></i>
          </span>
          <span class="el-upload-list__item-delete">
            <i class="el-icon-delete" @click="removeImage"></i>
          </span>
        </span>
      </div>
    </div>
    <div class="el-upload__tip" slot="tip">只能上传一张图片,格式限jpg/png/gif,大小不超过2M</div>
  </div>
</template>

<script>
export default {
  name: 'SingleUpload',
  props: {
    value: {
      type: String,
      default: ''
    },
    drag: {
      type: Boolean,
      default: false
    },
    dialogWidth: {
      type: String,
      default: '35%'
    }
  },
  data() {
    return {
      dialogVisible: false
    }
  },
  computed: {
    uploadUrl() {
      const url = process.env.VUE_APP_BASE_API + '/admin/single/upload'
      return url
    },
    imageUrl() {
      return this.value ? process.env.VUE_APP_BASE_API + this.value : ''
    },
    uploadClass() {
      return this.drag ? 'image-uploader' : ''
    }
  },
  methods: {
    removeImage() {
      this.$confirm(`确定移除已上传的图片?`, '提示').then(() => {
        this.emitInput('')
      })
    },
    emitInput(val) {
      this.$emit('input', val)
    },
    handleUploadSuccess(response) {
      this.emitInput(this.commonApi.uploadSuccess(response))
    },
    handleUploadError(err) {
      this.$message.error(err.message)
    },
    handleUploadRemove() {
      this.emitInput('')
    },
    beforeUpload(file) {
      const typeList = ['image/jpeg', 'image/png', 'image/gif']
      const isTypeValid = typeList.includes(file.type)
      const isLt2M = file.size / 1024 / 1024 < 2
      if (!isTypeValid) {
        this.$message.error('图片格式只能是 JPG/PNG/GIF!')
      }
      if (!isLt2M) {
        this.$message.error('图片大小不能超过 2MB!')
      }
      return isTypeValid && isLt2M
    },
    handlePreview() {
      this.dialogVisible = true
    }
  }
}
</script>

<style lang="scss" scoped>
@import '~@/styles/mixin.scss';
.upload-container {
  width: 100%;
  position: relative;
  display: flex;
  flex-direction: row;
  @include clearfix;
  .image-uploader {
    width: 150px;
    margin-right: 20px;
    .el-icon-upload {
      margin: 20px 0 16px;
      font-size: 60px;
    }
    .el-upload__text {
      line-height: 20px;
      font-size: 13px;
    }
  }
  .el-upload-list--text {
    margin: 6px 0 0 10px;
  }
  .image-uploader /deep/ .el-upload-dragger {
    width: 150px;
    height: 150px;
  }
  .el-upload__tip {
    margin: 0 0 0 15px;
  }
  .el-upload-list__item:first-child {
    margin: 0;
  }
}
.preview-dialog /deep/ .el-dialog__header {
  padding: 0;
}
.preview-dialog /deep/ .el-dialog__body {
  padding: 20px;
}
</style>

4. 使用

前提是我已经全局注册upload-single组件,若单独引用别忘了在当前组件导入和注册。然后一般情况同个项目上传地址都是一致的,就写死在上传组件里了。用起来基本只需关注数据字段,代码非常简洁。

<template>
  <div class="app-container">
    <!---- * 简单示例,省略其余外层代码 * ---->
    <!---- 1. 点击上传 ---->
    <el-col :span="24">
      <el-form-item label="照片" prop="photoSrc">
        <upload-single v-model="newPlant.photoSrc" />
      </el-form-item>
    </el-col>
    <!---- 2. 拖拽上传,同时定义了预览弹窗宽度,和element-ui官方api一致值是百分比 ---->
    <el-col :span="24">
      <el-form-item label="照片" prop="photoSrc">
        <upload-single v-model="newPlant.photoSrc" dialogWidth="30%" drag />
      </el-form-item>
    </el-col>
  </div>
</template>
export default {
  name: 'Plant',
  data() {
    return {
      newPlant: {
        // ...
        photoSrc: ''    
    }
  }
}

一、批量上传

设置 multiple: true 可以让我们同时选取多个文件,然而上传到action地址的时候,则是选取几个文件便调用几次接口,on-success也会触发相应的次数。处理上传成功返回数据的时候要特别注意。

手动上传相关参数

除此之外,el-upload也支持手动上传。(这在多图的时候很有必要,对用户来说可以在上传前确认一遍选择的资源是否正确,对服务端来说可以节省不必要的开销)
在组件设置:auto-upload="false"ref="upload":http-request="handleUpload"之后,执行this.$refs.upload.submit(),便会触发http-request钩子。如果我们在http-request函数中发起上传请求,则仍是fileList有几个文件就调用几次接口,依旧无法实现在一次请求中上传多个文件。例子如下:

<template>
  <el-upload
    ref="upload"
    name="photoSrc"
    :file-list="uploadList"
    :auto-upload="false"
    :http-request="handleUpload"
    multiple
  >
    <el-button slot="trigger" size="small" type="primary">选取文件</el-button>
    <el-button style="margin-left: 10px;" size="small" type="success" @click="submitUpload">上传到服务器</el-button>
  </el-upload>
</template>
export default {
  name: 'MultiUpload',
  methods: {
    submitUpload() {
      this.$refs.upload.submit()
    },
    handleUpload(params) {
      // 如果在这里写上传业务,有多少张图片就会触发多少次上传请求
      console.log(params)
    },
  }
}
界面效果一
选取两张图片,再点击上传到服务器后http-request钩子打印的参数

如果我们想在选取图片后手动上传,并且提交请求时一次性上传多个文件,就选择放弃http-request钩子,也不要再调用this.$refs.upload.submit()。同时on-successon-error也都无用武之地了。
文件状态改变时的钩子on-change也可以帮助我们方便地获取文件数据。on-change事件触发的次数也是根据选取图片的张数来的,我们可以在此处理验证文件格式等,代替自动上传时before-upload进行的逻辑。
action是el-upload的必选属性,但手动上传的情况这个值其实无意义了,我们可以设置为空字符串或者任意字符 :action="''"
再然后,让用户自己选取图片再确认上传这个功能似乎不太合理(头像上传这种单图的情况相对适用)。一方面直观简洁的操作体验更好,还有可能造成部分场景逻辑不连贯:比如图片上传只是表单的一项而已,最终提交表单的时候,用户选取了文件但没有点击上传,是他/她放弃传还是忘记了呢?所以这种情况我们把上传事件放在提交表单里。

说了这么多,你是不是想说,don't bb,show me the code


具体封装好的MultiUpload组件代码如下:

<!--批量上传组件-->
<template>
  <div class="upload-container">
    <el-upload
      ref="upload"
      :action="`${apiUrl}/admin/upload`"
      name="photoSrc"
      with-credentials
      :show-file-list="true"
      :file-list="fileList"
      :on-change="fileListChange"
      :on-preview="handlePreview"
      :on-remove="handleRemove"
      :auto-upload="false"
      :drag="drag"
      multiple
      :class="uploadClass"
    >
      <el-button v-if="!drag" slot="trigger" size="small" type="primary">选取文件</el-button>
      <div v-if="drag">
        <i class="el-icon-upload" />
        <div class="el-upload__text">将单张图片拖到<br />此处,或<em>点击上传</em></div>
      </div>
      <!-- <el-button style="margin-left: 10px;" size="small" type="success" @click="handleUpload">上传到服务器</el-button> -->
      <div slot="tip" class="el-upload__tip">
        <span style="color: #f56c6c;">只能上传jpg/png/gif文件,且不超过2M。</span><br />
        图片列表中已经上传成功的图片(<span style="color: #67c23a;">有绿色✓</span>),点击图片名称可以预览大图
      </div>
    </el-upload>
    <el-dialog :visible.sync="dialogVisible" :append-to-body="true" :modal-append-to-body="false" :width="dialogWidth" class="preview-dialog">
      <img width="100%" :src="previewUrl" alt="" />
    </el-dialog>
  </div>
</template>

<script>
import { uploadMult } from '@/api/upload'
export default {
  name: 'MultiUpload',
  props: {
    list: {
      type: Array,
      default: []
    },
    drag: {
      type: Boolean,
      default: false
    },
    dialogWidth: {
      type: String,
      default: '35%'
    }
  },
  data() {
    return {
      value: '',
      fileList: [], // 由于不是简单数据,不能直接用props.list值作为初始值
      dialogVisible: false
    }
  },
  computed: {
    previewUrl() {
      return this.value ? process.env.VUE_APP_BASE_API + this.value : ''
    },
    uploadClass() {
      return this.drag ? 'image-uploader' : ''
    }
  },
  watch: {
    list: {
      handler(newVal) {
        // 深拷贝,项目里推荐lodash
        // this.fileList = _.cloneDeep(this.list)
        this.fileList = this.list.map(obj => ({ ...obj }))
      },
      deep: true
    }
  },
  methods: {
    fileListChange(file, fileList) {
      // 添加文件、上传成功和上传失败时都会被调用
      const typeList = ['image/jpeg', 'image/png', 'image/gif']
      const isTypeValid = typeList.includes(file.raw.type)
      const isLt2M = file.size / 1024 / 1024 < 2
      if (!isTypeValid) return this.$message.error('图片格式只能是 JPG/PNG/GIF!')
      if (!isLt2M) return this.$message.error('图片大小不能超过 2MB!')
      this.fileList.push(file) // 能响应式更改数组
      // this.$set(this.fileList, this.fileList.length, file) // 能响应式更改数组
    },
    filterFileList(uploaded) {
      // 传 uploaded 筛选已上传的图片列表,不传筛选未上传的
      return uploaded ? this.fileList.filter(item => !item.hasOwnProperty('raw')) : this.fileList.filter(item => item.hasOwnProperty('raw'))
    },
    uploadMultiSuccess(files) {
      let photoList = []
      files.forEach((pic, i) => {
        const src = pic.path.replace('/public', '')
        const uid = Date.parse(new Date()) / 1000 + i
        photoList.push({ name: pic.name, src, uid, status: 'success' })
      })
      return photoList
    },
    async getUploadedList() {
      /* 逻辑梳理:
       1. 点击父组件表单提交按钮触发,这时说明组件已无其他数据操作
       2. 父组件需要的数据是fileList中所有不含raw字段的数据,不含的只有list传过来的新成功上传后经过处理的两种可嫩
       3. 首先判断有没有含有raw的,如果没有,直接return fileList, 因为fileList的值就等于传过来的list
       4. 如果有去批量上传,请求的返回值追加到this.list中,再返给父组件
      */
      const toUploadList = this.filterFileList()
      if (toUploadList.length == 0) return this.fileList
      let formData = new FormData()
      toUploadList.forEach(file => formData.append('photoSrc', file.raw, file.name))
      const { data } = await uploadMult(formData)
      return this.filterFileList(true).concat(this.uploadMultiSuccess(data))
    },
    handlePreview(file) {
      if (file.src) {
        this.value = file.src
        this.dialogVisible = true
      }
    },
    handleRemove(file, fileList) {
      this.fileList = fileList.map(obj => ({ ...obj }))
    }
  }
}
</script>

<style lang="scss" scoped>
@import '~@/styles/mixin.scss';
.upload-container {
  width: 100%;
  position: relative;
  display: flex;
  flex-direction: row;
  flex-flow: wrap;
  .image-uploader {
    width: 100%;
    display: flex;
    flex-direction: row;
    .el-icon-upload {
      margin: 20px 0 16px;
      font-size: 60px;
    }
    .el-upload__text {
      line-height: 20px;
      font-size: 13px;
    }
    .el-upload__tip {
      width: 300px;
      margin: 0 20px;
      line-height: 20px;
    }
  }
  .image-uploader /deep/ .el-upload-list__item:first-child {
    margin-top: 0px;
  }
  .image-uploader /deep/ .el-upload-dragger {
    width: 150px;
    height: 150px;
  }
}
</style>

父组件使用

<template>
  <div class="app-container">
    <!---- * 简单示例,省略其余外层代码 * ---->
    <!---- 1. 点击上传 ---->
    <el-col :span="24">
      <el-form-item label="照片" prop="photoSrc">
        <upload-multi ref="upload" :list="newClothing.photoSrc" />
      </el-form-item>
    </el-col>
    <!---- 2. 拖拽上传,同时定义了预览弹窗宽度,和element-ui官方api一致值是百分比 ---->
    <el-col :span="24">
      <el-form-item label="照片" prop="photoSrc">
        <upload-multi v-model="newClothing.photoSrc" dialogWidth="40%" drag />
      </el-form-item>
    </el-col>
  </div>
</template>
export default {
  name: 'Clothing',
  data() {
    return {
     newClothing: {
        // ...
        photoSrc: ''    
    }
  },
  method: 
}
左边是我们上传处理后的格式,右边是未上传到服务器的图片格式
批量上传默认样式
批量拖拽上传
大图预览

丑丑的样式就先这样了,懒得再改了,心lui了
在使用sass-loader预处理器的情况下,覆盖element-ui样式的时候遇到了坑,指路:[Vue] scoped覆盖第三方或子组件样式无效

可以看出这两个组件和elementUI大部分是借鉴UI的关系,主要功能大多自行实现,因此稍微修改下就可以应用到大部分库(如vuetify、iview)。
以上均为个人拙见,功能/业务逻辑大家觉得有不合理之处或者更好的处理方法,欢迎留言指点。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,482评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,377评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,762评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,273评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,289评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,046评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,351评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,988评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,476评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,948评论 2 324
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,064评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,712评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,261评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,264评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,486评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,511评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,802评论 2 345

推荐阅读更多精彩内容