图片批量上传的功能实现

前往原站点查看

2025-06-14 19:28:13

    因为个人玩游戏、看番、看电影有着疯狂截图的习惯,并且想要永久珍藏下体验的过程,所以时常会把这些截图上传到云端。

    目前这些内容我还是主要在QQ空间上传,但是,QQ空间有着一个让人不是很满意的地方,就是普通用户上传的图片都会被压缩。身为一个技术,能自己实现的东西,怎么能会去充QQ会员呢!更何况自己的这些数据在别人手上,总有种患得患失的感觉,害怕某天QQ空间的图片服务暂停了,自己多年的记忆岂不是也都灰飞烟灭?

    在今天之前,我的个人博客其实已经包含了图片/视频上传的功能,只是每次上传都要一张一张上传。然后可以预先设置名字和描述(当然也可以上传结束再设置)。



    这样的功能不能说不能用,只能说偶尔上传一两张图片还有点用。每次都要精挑细选哪些需要上传,然后还要本地打开这张图片,人眼辨别这张图片是否之前上传过了,就很麻烦。从而导致了一个结果,每次新奇体验后的截图,想要上传到个人博客的兴趣不是很高,缺乏了动力。所以也是时候来支持一下批量上传功能了。

    基本的需求就是:

    1. 能够批量上传图片/视频

     2. 批量上传的图片/视频要支持预览

     3. 批量上传结束后,要得到每张图片的上传结果,并且直观的展示出来

     4. 对于那些上传失败的图片/视频,支持重新上传

   未来的优化项:

     1. 支持大图预览图片/视频

     2. 可以删除掉上传队列的部分图片/视频


    

    因为博客已经有了文件上传图床的封装接口,所以本文在 已有上传单图接口的基础上进行编写。


后端实现

    因为要记录每个图片的上传状态,所以对于返回的结果,需要记录下该图片是否上传成功,然后每个图片需要编配编号,来唯一对应每个上传的文件,以供前端同步状态。

    返回实体类见下方代码。其中index即为当前文件的唯一编码;code表示上传的状态,如果是0表示上传成功,-1表示失败;msg是状态补充码(可以理解为失败的具体原因码),当失败时,可能还有细分原因,比如我这边的图片存储策略可能的原因有 -1 本地存储失败 -2 本地读取失败 -3 存储数据库失败 -4 其他情况。

package top.dreamcenter.dreamcenter.ret;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 基础信息
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class InfoResult {

    private String index;

    /**
     * 状态
     */
    private Integer code;

    /**
     * 消息
     */
    private String msg;

    /**
     * 成功
     * @return code返回0, 无msg
     */
    public static InfoResult success(String index) {
        return new InfoResult(index,0, null);
    }

    /**
     * 失败
     * @param msg 失败原因
     * @return
     */
    public static InfoResult fail(String index, Object msg) {
        return new InfoResult(index, -1, msg.toString());
    }

}

    对于我这边现有的单文件上传接口是 public DogeResult uploadFile(MultipartFile file, Image image),参数file即为要上传的图片,参数2表示额外要修改的图片信息,如文件名称、描述等。所以服务端这边,本质上要做的操作就是,对每个文件单独调用该接口,如果有异常就捕获,并且记录异常原因,最终封装返回即可。

    下方函数即为我封装的批量上传接口,其中idList表示的是files文件列表每个文件对应的唯一编码顺序序列,毕竟MultipartFile[]数组并不存储这个唯一编码,只能自己额外补充传递,当然一开始也想到用文件名称添加前缀来解决,但是最终感觉还是不合适,因为我们要的只是纯粹的文件上传功能,不能偏离原始诉求。对于每个文件,如果有统一修改的文件名,则修改显示名字为统一名字,如果没有,显示名字为文件原始名字。在最终返回的结果中,判断是否都成功,都成功,返回状态码200,失败返回-1,但是无论成功还是失败,都要返回本批次处理的结果。

    public RetResult<List<InfoResult>> batchUpload(MultipartFile[] files , Image image, String idList) {
        List<InfoResult> infoResultList = new ArrayList<>();

        int errCount = files.length;

        String[] idArr = idList.split(",");

        if (idArr.length != files.length) {
            return new RetResult<>(ResultCode.FAIL_CODE, "上传队列不匹配!", infoResultList);
        }

        for (int i = 0; i < files.length; i++) {
            MultipartFile current = files[i];
            String idIndex = idArr[i];
            System.out.println(current.getOriginalFilename());

            try {
                // 设置名称
                if(image.getName().trim().equals("")) {
                    image.setName(current.getOriginalFilename());
                }

                // 上传
                DogeResult dogeResult = uploadFile(current, image);
                Integer errno = dogeResult.getErrno();

                // 成功
                if (errno == 0) {
                    infoResultList.add(InfoResult.success(idIndex));
                    errCount--;
                }
                // 失败
                else {
                    infoResultList.add(InfoResult.fail(idIndex, errno));
                }
            } catch (Exception e) {
                // 异常,属于失败情况
                infoResultList.add(InfoResult.fail(idIndex, e.getMessage()));
            }
        }


        if (errCount == 0) return RetResult.success(infoResultList);
        return new RetResult<>(ResultCode.FAIL_CODE, "存在失败上传", infoResultList);
    }

    对于controller层,简单返回调用即可。

@PostMapping("/batchUpload")
    public RetResult<List<InfoResult>> batchUpload(@RequestPart MultipartFile[] files , Image image, String idList) {
        return imageService.batchUpload(files, image, idList);
    }


前端实现

    在本次批量上传中,逻辑比较复杂的反而是前端页面。在早先的博客设计上,缺乏了组件化思想建设,而最近才开始一点一点的封装组件。我这边前端使用的是Vue。

    目前该模块用了两个封装组件,一个左右布局组件,一个是消息提示组件。布局组件如下图所示的左边灰色区域,右边黑色区,当变成移动端时,右边栏自动切换到下边。当然本文重点不是讲述这个布局如何实现,而是批量上传如何实现,所以这部分不多赘述,只是批量上传在此组件基础上搭建的,组件名称 screenShow 。



    我们依然见上图,整体的功能区域还是很简单的,左边展示即将上传的图片,右边则是额外操作面板。先直接贴批量上传的源码部分。(貌似vue代码没有成功染色[[疑惑]]凑活着看吧)

<template>
  <div>
    <ScreenShow>
      <div slot="left" class="leftBatch">
        <input ref="files" type="file" multiple="multiple" @change="showFiles"/>
        <div class="albumView">
          <div v-for="item in fileList" :key="item.id" class="item">
            <img :src="item.src" width="100" height="100">
            <p v-if="item.uploadRes == ''" class="uploadRes resEmpty">{{ item.uploadRes }}</p>
            <p v-else-if="item.uploadRes == '成功'" class="uploadRes resSuccess">{{ item.uploadRes }}</p>
            <p v-else class="uploadRes resFail">{{ item.uploadRes }}</p>
            <p class="fileName">{{ item.raw.name }}</p>
          </div>
        </div>
      </div>
      <div slot="right" class="rightBatch">
        <div class="closeImg" @click="closeBatchUpload">x</div>
        <label name="newFileName">新文件名</label>
        <br/>
        <input type="text" id="newFileName" v-model="tempName"/>
        <br/>
        <label name="describe">描述</label>
        <br/>
        <input type="text" id="describe" v-model="describe"/>
        <br/><br/>
        <button @click="uploadAllFiles">上传</button>
      </div>
    </ScreenShow>
    <Message @closeMessage="closeMessage" v-if="showMsg">
        <p>{{ msgContent }}</p>
    </Message>
  </div>
</template>

<script>
import axios from 'axios'
import screenShow from '../components/frame/screenShow'
import message from '../components/message.vue'

export default {
  data () {
    return {
      tempName: '',
      describe: '',
      fileList: [],
      msgContent: '',
      showMsg: false
    }
  },
  props: ['aid'],
  components: {
    ScreenShow: screenShow,
    Message: message
  },
  methods: {
    showFiles (event) {
      var that = this
      that.fileList = []
      var files = event.target.files
      for (let i = 0; i < files.length; i++) {
        const file = files[i]

        var reader = new FileReader()
        reader.readAsDataURL(file)
        reader.onload = function (e) {
          that.fileList.push({
            id: i,
            raw: file,
            src: this.result,
            uploadRes: ''
          })
        }
      }
    },
    uploadAllFiles () {
      const req = new FormData()
      req.append('aid', this.aid)
      req.append('name', this.tempName)
      req.append('time', this.$time())
      req.append('describe', this.describe)

      // 将所有未成功的图片上传
      var idList = []
      for (let i = 0; i < this.fileList.length; i++) {
        const tmp = this.fileList[i]
        if (tmp.uploadRes !== '成功') {
          req.append('files', tmp.raw)
          idList.push(tmp.id)
        }
      }
      req.append('idList', idList)

      // 说明全部已上传,无需上传
      if (idList.length === 0) {
        this.msgContent = '已全部上传,无需上传'
        this.showMsg = true
        return
      }

      // 上传
      axios.post('/api/image/batchUpload', req, {
        headers: {
          'Content-Type': 'multipart/form-data'
        }
      }).then(res => {
        console.log(res.data)
        // 染色
        var data = res.data.data
        data.forEach(item => {
          var idIndex = item.index
          var code = item.code
          var msg = item.msg
          // 依据idIndex找到对应文件
          this.fileList.forEach(file => {
            if (file.id === Number.parseInt(idIndex)) {
              if (code === 0) {
                file.uploadRes = '成功'
              } else {
                file.uploadRes = msg
              }
            }
          })
        })
        console.log(data)
      }).catch(err => {
        this.msgContent = '插入图片出现问题,请检查控制台'
        this.showMsg = true
        console.log(err)
      })
    },
    closeBatchUpload () {
      console.log('click close!')
      this.$emit('closeBatchUpload')
    },
    closeMessage () {
      this.showMsg = false
    }
  }
}
</script>

<style lang="scss" scoped>
.leftBatch{
  box-sizing: border-box;
  .albumView{
    display: flex;
    flex-direction: row;
    text-align: center;
    .item{
      box-sizing: border-box;
      width: 106px;
      height: 126px;
      border: 1px solid rgba(73, 73, 73, 0.185);
      margin: 10px;
      overflow: hidden;
      padding: 2px;
      position: relative;
      .fileName{
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
        font-size: 12px;
      }
      .uploadRes{
        box-sizing: border-box;
        position: absolute;
        top: 0;
        width: 100px;
        color: rgb(71, 71, 71);
      }
      .resEmpty {

      }
      .resSuccess {
        background-color: rgb(87, 228, 117);
      }
      .resFail {
        background-color: rgb(238, 86, 81);
      }
    }
  }
}
.rightBatch{
  .closeImg{
    font-size: 40px;
    vertical-align: middle;
    // width: 40px;
    height: 40px;
    border: 1px solid rgb(202, 202, 202);
    color: rgb(218, 218, 218);
    text-align: center;
    line-height: 36px;
    border-radius: 20px;
    box-sizing: border-box;
    margin-bottom: 20px;

    &:hover{
      border: 2px solid ghostwhite;
      color: ghostwhite;
    }
  }
}
</style>

    其中该批量上传组件需要一个参数aid,我在props中定义了aid变量,表示的是图片所属专辑,因为我的每个图片都有其所属的专辑,在这里进行了设置,表示其归属于哪个相册。对于数据fileList即为某个图片的基础数据,封装结构为:

{
    id: i,
    raw: file,
    src: this.result,
    uploadRes: ''
}

    这个id即为图片唯一编码,后续的状态更新全部依托于该参数;raw即为图片原始对象;src表示即将显示的图片资源,用base64表示;uploadRes表示上传状态,默认为空字符串,表示待上传,如果上传成功,需要更新为“成功”,失败则返回对应的子错误码。图片的预览数据装载使用FileReader类,当装载成功后才加入到集合中。当我们选择了图片并且确认后,就会触发装载渲染图片函数showFiles。效果如下图所示。


    上传函数uploadAllFiles,需要自己封装一个FormData,当调用后端上传接口成功后,则开始进行染色(重渲染组件),唯一对于编号后,更新该图片的状态。上传结束后效果如下图所示。我后端控制了第一张图返回异常,发现全部染色成功了。



    后续仍然可以再次点击上传,重新上传也只会上传非成功的图片,我后端去除了异常抛出语句后,最终上传效果如下!



    可见非常的完美,后续如果想要继续上传重新选择文件即可,该页面不会立即退出。当点击右上角的关闭按钮后,重新渲染列表,即可在该专辑下成功预览到图片啦!




    整体其实并没有什么技术难点,原来真正阻碍我的,不是技术,而是对新需求的接受阻碍罢了。当我们坦然面对,冷静分析清除需求后,世上也就没有解决不了的问题啦!

    鸡汤灌完,结束!


    [[爽啊]]

    应该不算水文吧QAQ



上一篇: vs切分支书签失效问题解决