文件分片上传(模拟网盘效果)

文件分片上传(模拟网盘效果)

    • 文章说明
    • 简单模拟拖拽文件夹和选择文件的进度条效果
    • 效果展示
    • 结合后端实现文件上传
    • 效果展示
    • 加上分片的效果
    • 效果展示
    • 加上MD5的校验,实现秒传和分片的效果
    • 后续开发说明
    • 源码下载

文章说明

文章主要为了学习文件上传,以及分片上传的一些简单操作;更多的学习一些前端相关的文件操作的知识,包括拖拽文件函数和打开文件函数

参考资料1:window.showOpenFilePicker方法的使用

简单模拟拖拽文件夹和选择文件的进度条效果

代码如下(仿照element的样式书写,进度条也是仿照element的样式写的)

App.vue(目前还没有结合后台上传逻辑,然后也只是简单的写了一个界面效果)

<template>
  <link rel="stylesheet" href="/style/css/iconfont.css">

  <div class="drop-area" @drop="getDropItems" @click="showFilePicker">
    <div>
      <i class="iconfont icon-upload"/>
      <div class="tip-text">
        Drop file here or
        <em>click to uploadem>
      div>
    div>
  div>

  <div class="file-list">
    <div v-for="(item, index) in data.fileList" :key="index" class="single-file">
      <MyProgress :percentage="item.percentage" :content="item.name"/>
    div>
  div>
template>

<script>
import {onBeforeMount, reactive} from "vue";
import MyProgress from "@/MyProgress.vue";
import {message} from "@/util";

export default {
  name: "App",
  components: {MyProgress},
  setup() {
    const data = reactive({
      fileList: [],
      isUploading: false,
    });
    onBeforeMount(() => {
      onload = function () {
        document.addEventListener("drop", function (e) {
          //拖离
          e.preventDefault();
        });
        document.addEventListener("dragleave", function (e) {
          //拖后放
          e.preventDefault();
        });
        document.addEventListener("dragenter", function (e) {
          //拖进
          e.preventDefault();
        });
        document.addEventListener("dragover", function (e) {
          //拖来拖去
          e.preventDefault();
        });
      };
    });

    function getFileFromEntryRecursively(entry) {
      if (entry.isFile) {
        data.fileList.push({
          name: entry.fullPath.substring(entry.fullPath.lastIndexOf("/") + 1, entry.fullPath.length),
          percentage: 0
        });
      } else {
        let reader = entry.createReader();
        reader.readEntries((entries) => {
          entries.forEach((entry) => {
            getFileFromEntryRecursively(entry);
          });
        });
      }
    }

    function getDropItems(event) {
      if (data.isUploading) {
        message("正在上传...", "info");
        return;
      }
      data.fileList = [];
      data.isUploading = true;
      const items = event.dataTransfer.items;
      for (let i = 0; i <= items.length - 1; i++) {
        const item = items[i];
        if (item.kind === "file") {
          const reader = new FileReader();
          reader.readAsArrayBuffer(item.getAsFile());
          console.log(reader)

          const entry = item.webkitGetAsEntry();
          getFileFromEntryRecursively(entry);
        }
      }
      const timer = setInterval(() => {
        upload();
        closeTimer(timer);
      }, 100);
    }

    function upload() {
      for (let i = 0; i < data.fileList.length; i++) {
        data.fileList[i].percentage += 1;
      }
    }

    function closeTimer(timer) {
      let isOver = true;
      for (let i = 0; i < data.fileList.length; i++) {
        if (data.fileList[i].percentage !== 100) {
          isOver = false;
          break;
        }
      }
      if (isOver) {
        clearInterval(timer);
        data.isUploading = false;
      }
    }

    const pickerOpts = {
      excludeAcceptAllOption: false,
      multiple: true,
    };

    async function showFilePicker() {
      if (data.isUploading) {
        message("正在上传...", "info");
        return;
      }

      let fileHandle;
      try {
        fileHandle = await window.showOpenFilePicker(pickerOpts);
        data.fileList = [];
        data.isUploading = true;
      } catch (e) {
        if (e.name === 'AbortError' && e.message === 'The user aborted a request.') {
          message("用户没有选择文件", "info");
          return;
        } else {
          throw e;
        }
      }

      for (let i = 0; i < fileHandle.length; i++) {
        data.fileList.push({
          name: fileHandle[i].name,
          percentage: 0
        });
        const arrayBuffer = (await fileHandle[i].getFile()).arrayBuffer();
        console.log(arrayBuffer)

        let formData = new FormData();
        formData.append("file", arrayBuffer);
        console.log(formData)
      }

      const timer = setInterval(() => {
        upload();
        closeTimer(timer);
      }, 100);
    }

    return {
      data,
      getDropItems,
      showFilePicker,
    };
  },
};
script>

<style>
* {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}

.drop-area {
  margin: 100px auto 0;
  width: 800px;
  height: 180px;
  border: 1px dashed #dcdfe6;
  display: flex;
  align-items: center;
  justify-content: center;
}

.drop-area:hover {
  border-color: #409eff;
  cursor: pointer;
}

.icon-upload::before {
  display: flex;
  justify-content: center;
  font-size: 40px;
  margin: 10px 0;
}

.tip-text {
  color: #606266;
  font-size: 14px;
  text-align: center;
}

.tip-text em {
  color: #409eff;
  font-style: normal;
}

.file-list {
  margin: 0 auto;
  width: 800px;
}

.single-file {
  margin: 10px 0;
}
style>

MyProgress.vue

<template>
  <link rel="stylesheet" href="/style/css/iconfont.css">

  <div class="progress-container">
    <div class="bar">
      <div class="percentage" :style="{'width': props.percentage + '%'}">
        <span class="text-inside">{{ props.content + " " + props.percentage + "%" }}span>
      div>
    div>
    <div class="tip-content">
      <span v-show="props.percentage !== 100">{{ props.percentage + "%" }}span>
      <i class="iconfont icon-over" v-show="props.percentage === 100"/>
    div>
  div>
template>

<script>
export default {
  props: ["percentage", "content"],
  setup(props) {
    return {
      props
    }
  }
}
script>

<style scoped>
.progress-container {
  display: flex;
  height: 30px;
  cursor: pointer;
  border: 1px dashed #dcdfe6;
  padding: 0 10px;
}

.bar {
  color: white;
  font-weight: 500;
  line-height: 30px;
  font-size: 14px;
  flex: 1;
}

.percentage {
  border-radius: 30px;
  background-color: #67c23a;
  white-space: nowrap;
  word-break: break-all;
  overflow: hidden;
  transition: width 0.2s linear;
}

.text-inside {
  padding-right: 10px;
  padding-left: 15px;
  float: right;
}

.tip-content {
  padding: 0 10px;
  font-size: 16px;
  line-height: 30px;
  width: 40px;
}

.icon-over::before {
  font-size: 24px;
  color: #67c23a;
}
style>

效果展示

简单演示了选择文件和拖拽文件、拖拽文件夹的效果

结合后端实现文件上传

后端采用SpringBoot简单写了一个接收文件的小demo

package com.boot.controller;

import com.boot.entity.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

/**
 * 

* 前端控制器 *

* * @author bbyh * @since 2023-12-27 */
@Slf4j @RestController @RequestMapping("/fragment-info") public class FragmentInfoController { @PostMapping("/upload") public Result upload(@RequestBody MultipartFile file) { log.info(file.getOriginalFilename()); return Result.success("文件上传成功", null); } }

此时前端需要一些变化,将拖拽的文件列表和选择的文件列表都放入列表中,这里主要考察前端相关的文件操作;我找了一些资料,后面抓到了它的实现效果

util.js(主要就是一个 ajax 的post请求,携带一个onUploadProgress属性)

import {ElMessage} from "element-plus";
import axios from "axios";

const baseUrl = "http://127.0.0.1:8080"

export function message(msg, type) {
    ElMessage({
        message: msg,
        type: type,
        center: true,
        showClose: true,
    })
}

export const postFileRequest = (url, data, onUploadProgress) => {
    return axios({
        method: 'post',
        url: baseUrl + url,
        data: data,
        onUploadProgress: onUploadProgress,
    })
}

App.vue(主要的逻辑都写在这里了,这里的异步和Promise,真的给我上了一课,我对这些概念的理解层次还差了不少)
而且我在尝试的时候,还通过提问GPT发现了:for循环中使用了await,这会导致循环在遇到第一个await时立即退出;真是还没学到家

<template>
  <link rel="stylesheet" href="/style/css/iconfont.css">

  <div class="drop-area" @drop="getDropItems" @click="showFilePicker">
    <div>
      <i class="iconfont icon-upload"/>
      <div class="tip-text">
        Drop file here or
        <em>click to uploadem>
      div>
    div>
  div>

  <div class="file-list">
    <div v-for="(item, index) in data.fileList" :key="index" class="single-file">
      <MyProgress :percentage="item.percentage" :content="item.name"/>
    div>
  div>
template>

<script>
import {onBeforeMount, reactive} from "vue";
import MyProgress from "@/MyProgress.vue";
import {message, postFileRequest} from "@/util";

export default {
  name: "App",
  components: {MyProgress},
  setup() {
    const data = reactive({
      fileList: [],
      isUploading: false,
    });
    onBeforeMount(() => {
      onload = function () {
        document.addEventListener("drop", function (e) {
          //拖离
          e.preventDefault();
        });
        document.addEventListener("dragleave", function (e) {
          //拖后放
          e.preventDefault();
        });
        document.addEventListener("dragenter", function (e) {
          //拖进
          e.preventDefault();
        });
        document.addEventListener("dragover", function (e) {
          //拖来拖去
          e.preventDefault();
        });
      };
    });


    function getFileFromEntryRecursively(entry) {
      return new Promise((resolve) => {
        if (entry.isFile) {
          entry.file((file) => {
            data.fileList.push({
              name: entry.fullPath.substring(entry.fullPath.lastIndexOf("/") + 1, entry.fullPath.length),
              percentage: 0,
              file: file
            });
            resolve();
          });
        } else {
          let reader = entry.createReader();
          reader.readEntries((entries) => {
            Promise.all(entries.map(entry => getFileFromEntryRecursively(entry))).then(() => {
              resolve();
            });
          });
        }
      });
    }

    async function getDropItems(event) {
      if (data.isUploading) {
        message("正在上传...", "info");
        return;
      }
      data.fileList = [];
      data.isUploading = true;
      const items = event.dataTransfer.items;
      const promises = [];
      for (const item of items) {
        if (item.kind === "file") {
          const entry = item.webkitGetAsEntry();
          promises.push(getFileFromEntryRecursively(entry));
        }
      }
      await Promise.all(promises);

      upload();
    }

    function upload() {
      for (let i = 0; i < data.fileList.length; i++) {
        const onUploadProgress = (progressEvent) => {
          data.fileList[i].percentage = parseInt(Number(((progressEvent.loaded / progressEvent.total) * 100)).toFixed(0));
        };

        const formData = new FormData();
        formData.append("file", data.fileList[i].file);

        postFileRequest("/fragment-info/upload", formData, onUploadProgress).then((res) => {
          if (res.data.code === "200") {
            message(res.data.msg, "success");
          } else if (res.data.code === "500") {
            message(res.data.msg, "error");
          }
        });
      }
    }

    const pickerOpts = {
      excludeAcceptAllOption: false,
      multiple: true,
    };

    async function showFilePicker() {
      if (data.isUploading) {
        message("正在上传...", "info");
        return;
      }

      let fileHandle;
      try {
        fileHandle = await window.showOpenFilePicker(pickerOpts);
        data.fileList = [];
        data.isUploading = true;
      } catch (e) {
        if (e.name === 'AbortError' && e.message === 'The user aborted a request.') {
          message("用户没有选择文件", "info");
          return;
        } else {
          throw e;
        }
      }

      for (let i = 0; i < fileHandle.length; i++) {
        const file = await fileHandle[i].getFile();
        data.fileList.push({
          name: fileHandle[i].name,
          percentage: 0,
          file: file
        });
      }

      upload();
    }

    return {
      data,
      getDropItems,
      showFilePicker,
    };
  },
};
script>

<style>
* {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}

.drop-area {
  margin: 100px auto 0;
  width: 800px;
  height: 180px;
  border: 1px dashed #dcdfe6;
  display: flex;
  align-items: center;
  justify-content: center;
}

.drop-area:hover {
  border-color: #409eff;
  cursor: pointer;
}

.icon-upload::before {
  display: flex;
  justify-content: center;
  font-size: 40px;
  margin: 10px 0;
}

.tip-text {
  color: #606266;
  font-size: 14px;
  text-align: center;
}

.tip-text em {
  color: #409eff;
  font-style: normal;
}

.file-list {
  margin: 0 auto;
  width: 800px;
}

.single-file {
  margin: 10px 0;
}
style>

进度条还是和上面的一样

效果展示

这次是自动的进度条展示,和之前模拟的差不多(为了方便演示,我在后端设置了最大上传大小,改为了100MB,后续的分片上传,我选择将每个分片设置为2MB,当然大小可以自己调整)

在application.properties里面增加一个配置(设置单个文件最大100MB,总请求最大200MB)

spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=100MB
spring.servlet.multipart.max-request-size=200MB

加上分片的效果

后端代码没有变化,主要还是前端App.vue里面的逻辑添加了一些,处理分片相关的逻辑

<template>
  <link rel="stylesheet" href="/style/css/iconfont.css">

  <div class="drop-area" @drop="getDropItems" @click="showFilePicker">
    <div>
      <i class="iconfont icon-upload"/>
      <div class="tip-text">
        Drop file here or
        <em>click to uploadem>
      div>
    div>
  div>

  <div class="file-list">
    <div v-for="(item, index) in data.fileList" :key="index" class="single-file" @click="showFragmentInfo(item)">
      <MyProgress :percentage="item.percentage" :content="item.name"/>
    div>
  div>

  <el-dialog v-model="data.fragmentDialogVisible" title="分片详情查看" width="80%">
    <div v-for="(item, index) in data.showFragmentList" :key="index" class="single-file">
      <MyProgress :percentage="item.percentage" :content="item.name"/>
    div>
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="data.fragmentDialogVisible = false">关闭el-button>
      span>
    template>
  el-dialog>
template>

<script>
import {onBeforeMount, reactive} from "vue";
import MyProgress from "@/MyProgress.vue";
import {EACH_FILE, message, postFileRequest} from "@/util";

export default {
  name: "App",
  components: {MyProgress},
  setup() {
    const data = reactive({
      fileList: [],
      isUploading: false,
      fragmentDialogVisible: false,
      showFragmentList: []
    });

    onBeforeMount(() => {
      onload = function () {
        document.addEventListener("drop", function (e) {
          //拖离
          e.preventDefault();
        });
        document.addEventListener("dragleave", function (e) {
          //拖后放
          e.preventDefault();
        });
        document.addEventListener("dragenter", function (e) {
          //拖进
          e.preventDefault();
        });
        document.addEventListener("dragover", function (e) {
          //拖来拖去
          e.preventDefault();
        });
      };
    });

    function getFileFromEntryRecursively(entry) {
      return new Promise((resolve) => {
        if (entry.isFile) {
          entry.file((file) => {
            data.fileList.push({
              name: entry.fullPath.substring(entry.fullPath.lastIndexOf("/") + 1, entry.fullPath.length),
              percentage: 0,
              file: file,
              totalSize: file.size,
              totalCompleteSize: 0
            });
            resolve();
          });
        } else {
          let reader = entry.createReader();
          reader.readEntries((entries) => {
            Promise.all(entries.map(entry => getFileFromEntryRecursively(entry))).then(() => {
              resolve();
            });
          });
        }
      });
    }

    async function getDropItems(event) {
      if (data.isUploading) {
        message("正在上传...", "info");
        return;
      }
      data.fileList = [];
      data.isUploading = true;
      const items = event.dataTransfer.items;
      const promises = [];
      for (const item of items) {
        if (item.kind === "file") {
          const entry = item.webkitGetAsEntry();
          promises.push(getFileFromEntryRecursively(entry));
        }
      }
      await Promise.all(promises);

      upload();
    }

    function upload() {
      for (let i = 0; i < data.fileList.length; i++) {
        const fragmentCount = Math.floor(data.fileList[i].file.size / EACH_FILE) + 1;
        const fragmentList = [];
        for (let j = 0; j < fragmentCount; j++) {
          fragmentList.push({
            id: j,
            fragmentFile: data.fileList[i].file.slice(j * EACH_FILE, (j + 1) * EACH_FILE),
            completeSize: 0,
            name: data.fileList[i].name + "分片" + (j + 1),
            percentage: 0,
          });
        }
        data.fileList[i].fragmentList = fragmentList;

        for (let j = 0; j < data.fileList[i].fragmentList.length; j++) {
          const onUploadProgress = (progressEvent) => {
            data.fileList[i].fragmentList[j].completeSize = progressEvent.loaded;
            data.fileList[i].fragmentList[j].percentage = parseInt(Number(((progressEvent.loaded / progressEvent.total) * 100)).toFixed(0));
            updateTotalPercentage(i);
          };

          const formData = new FormData();
          formData.append("file", data.fileList[i].fragmentList[j].fragmentFile, data.fileList[i].fragmentList[j].name);

          postFileRequest("/fragment-info/upload", formData, onUploadProgress).then((res) => {
            if (res.data.code === "200") {
              message(res.data.msg, "success");
            } else if (res.data.code === "500") {
              message(res.data.msg, "error");
            }
          });
        }
      }
    }

    function updateTotalPercentage(i) {
      let totalCompleteSize = 0;
      for (let j = 0; j < data.fileList[i].fragmentList.length; j++) {
        totalCompleteSize += data.fileList[i].fragmentList[j].completeSize;
      }
      data.fileList[i].totalCompleteSize = totalCompleteSize;
      data.fileList[i].percentage = parseInt(Number(data.fileList[i].totalCompleteSize / data.fileList[i].totalSize * 100).toFixed(0));
    }

    const pickerOpts = {
      excludeAcceptAllOption: false,
      multiple: true,
    };

    async function showFilePicker() {
      if (data.isUploading) {
        message("正在上传...", "info");
        return;
      }

      let fileHandle;
      try {
        fileHandle = await window.showOpenFilePicker(pickerOpts);
        data.fileList = [];
        data.isUploading = true;
      } catch (e) {
        if (e.name === 'AbortError' && e.message === 'The user aborted a request.') {
          message("用户没有选择文件", "info");
          return;
        } else {
          throw e;
        }
      }

      for (let i = 0; i < fileHandle.length; i++) {
        const file = await fileHandle[i].getFile();
        data.fileList.push({
          name: fileHandle[i].name,
          percentage: 0,
          file: file,
          totalSize: file.size,
          totalCompleteSize: 0
        });
      }

      upload();
    }

    function showFragmentInfo(item) {
      data.showFragmentList = item.fragmentList;
      data.fragmentDialogVisible = true;
    }

    return {
      data,
      getDropItems,
      showFilePicker,
      showFragmentInfo,
    };
  },
};
script>

<style>
* {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}

.drop-area {
  margin: 100px auto 0;
  width: 800px;
  height: 180px;
  border: 1px dashed #dcdfe6;
  display: flex;
  align-items: center;
  justify-content: center;
}

.drop-area:hover {
  border-color: #409eff;
  cursor: pointer;
}

.icon-upload::before {
  display: flex;
  justify-content: center;
  font-size: 40px;
  margin: 10px 0;
}

.tip-text {
  color: #606266;
  font-size: 14px;
  text-align: center;
}

.tip-text em {
  color: #409eff;
  font-style: normal;
}

.file-list {
  margin: 0 auto;
  width: 800px;
}

.single-file {
  margin: 10px 0;
}
style>

效果展示

分片大小目前设置为2MB

加上MD5的校验,实现秒传和分片的效果

在这部分,我是真的被JavaScript的这个Promise和async、await给整麻了;感觉自己还差的不少

在这部分就加上了数据库部分的逻辑,Dao层采用的是Mybatis-Plus,然后本来是打算采用16位的byte数组来存md5字符串转化后的结果,不过在实现的时候遇到了一点小问题,后面我会在尝试一下看看;主要是考虑到数据库索引的速度;不过如果采用char(32) 类型的话,加上索引,速度应该也还不错

数据库创建,就只简单的创建了两个表,后面会在Gitee上同步完整版本,添加上安全校验方面的一些内容

CREATE TABLE `file_info`  (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `file_name` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '文件名称',
  `MD5` char(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '文件的MD5值',
  `path` varchar(200) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '文件的路径',
  `create_time` datetime NOT NULL COMMENT '文件创建时间',
  `delete_state` bit(1) NOT NULL COMMENT '文件删除状态',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

CREATE TABLE `fragment_info`  (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `fragment_name` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '分片文件名称',
  `fragment_order` int(11) NOT NULL COMMENT '分片文件序号',
  `md5` char(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '分片文件的MD5值,采用转为16字节的数字存储',
  `path` varchar(200) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '分片文件存储路径',
  `create_time` datetime NOT NULL COMMENT '分片文件创建时间',
  `delete_state` bit(1) NOT NULL COMMENT '删除状态(0表示未删除,1表示删除)',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 58 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

在这个数据表创建部分,实际少了两个字段,分别是文件的id,以及分片文件的id及其主文件id,这样会更方便后续的功能开发;不过目前的小demo,当前的数据表是够用的

后端代码(目前主要实现上传部分的逻辑,后端文件保存到指定目录和拼接的相关部分还没有补全)

package com.boot.controller;

import cn.hutool.core.collection.ListUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.boot.entity.FileInfo;
import com.boot.entity.Result;
import com.boot.service.IFileInfoService;
import com.boot.util.FileUtil;
import com.boot.util.GetCurrentTime;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.io.File;
import java.util.List;
import java.util.Map;

/**
 * 

* 前端控制器 *

* * @author bbyh * @since 2023-12-27 */
@RestController @RequestMapping("/file-info") public class FileInfoController { @Resource private IFileInfoService fileInfoService; @PostMapping("/generateFile") public Result generateFile(@RequestBody Map<String, Object> map) { String name = (String) map.get("name"); String md5 = (String) map.get("md5"); List<String> fragmentMd5List = ListUtil.toList(map.get("fragmentMd5List").toString()); FileInfo fileInfo = new FileInfo(); fileInfo.setFileName(name); fileInfo.setMd5(md5); fileInfo.setPath(FileUtil.ROOT_PATH + md5 + File.separator + name); fileInfo.setCreateTime(GetCurrentTime.getCurrentTimeBySecond()); fileInfo.setDeleteState(false); fileInfoService.save(fileInfo); return Result.success("文件:" + name + "上传成功", null); } @GetMapping("/checkMd5") public Result checkMd5(@RequestParam String md5) { QueryWrapper<FileInfo> wrapper = new QueryWrapper<>(); wrapper.eq("md5", md5).eq("delete_state", "0"); FileInfo fileInfo = fileInfoService.getOne(wrapper); if (fileInfo != null) { return Result.success("MD5已存在", null); } else { fileInfoService.remove(wrapper); return Result.error("MD5不存在", null); } } }
package com.boot.controller;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.boot.entity.FragmentInfo;
import com.boot.entity.Result;
import com.boot.service.IFragmentInfoService;
import com.boot.util.GetCurrentTime;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.Resource;
import java.io.File;

import static com.boot.util.FileUtil.FRAGMENT_SPLIT;
import static com.boot.util.FileUtil.ROOT_PATH;

/**
 * 

* 前端控制器 *

* * @author bbyh * @since 2023-12-27 */
@Slf4j @RestController @RequestMapping("/fragment-info") public class FragmentInfoController { @Resource private IFragmentInfoService fragmentInfoService; @PostMapping("/upload") public Result upload(@RequestBody MultipartFile file, @RequestParam String md5) { String originalFilename = file.getOriginalFilename(); assert originalFilename != null; int lastIndexOf = originalFilename.lastIndexOf(FRAGMENT_SPLIT); FragmentInfo fragmentInfo = new FragmentInfo(); fragmentInfo.setFragmentName(originalFilename); fragmentInfo.setFragmentOrder(Integer.parseInt(originalFilename.substring(lastIndexOf + FRAGMENT_SPLIT.length()))); fragmentInfo.setPath(ROOT_PATH + md5 + File.separator + originalFilename); fragmentInfo.setMd5(md5); fragmentInfo.setCreateTime(GetCurrentTime.getCurrentTimeBySecond()); fragmentInfo.setDeleteState(false); fragmentInfoService.save(fragmentInfo); return Result.success("分片文件:" + originalFilename + "上传成功", null); } @GetMapping("/checkMd5") public Result checkMd5(@RequestParam String md5) { QueryWrapper<FragmentInfo> wrapper = new QueryWrapper<>(); wrapper.eq("md5", md5).eq("delete_state", "0"); FragmentInfo fragmentInfo = fragmentInfoService.getOne(wrapper); if (fragmentInfo != null) { return Result.success("MD5已存在", null); } else { fragmentInfoService.remove(wrapper); return Result.error("MD5不存在", null); } } }

App.vue,这里的异步的一些内容,我是感觉真的麻了,后面需要再调一调,我感觉里面肯定存在着bug,不过我目前还没测试出来;还遇到了progressEvent对象的loaded大小和文件原本的大小不一致的问题,难搞啊,后面我巧妙的转换了一下,解决了这个bug

<template>
  <link rel="stylesheet" href="/style/css/iconfont.css">

  <div class="drop-area" @drop="getDropItems" @click="showFilePicker">
    <div>
      <i class="iconfont icon-upload"/>
      <div class="tip-text">
        Drop file here or
        <em>click to uploadem>
      div>
    div>
  div>

  <div class="file-list">
    <div v-for="(item, index) in data.fileList" :key="index" class="single-file" @click="showFragmentInfo(item)">
      <MyProgress :percentage="item.percentage" :content="item.name" :transition="item.transition"/>
    div>
  div>

  <el-dialog v-model="data.fragmentDialogVisible" title="分片详情查看" width="80%">
    <div v-for="(item, index) in data.showFragmentList" :key="index" class="single-file">
      <MyProgress :percentage="item.percentage" :content="item.name"/>
    div>
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="data.fragmentDialogVisible = false">关闭el-button>
      span>
    template>
  el-dialog>
template>

<script>
import {onBeforeMount, reactive} from "vue";
import MyProgress from "@/MyProgress.vue";
import {calculateMD5, EACH_FILE, getRequest, message, postFileRequest, postRequest} from "@/util";

export default {
  name: "App",
  components: {MyProgress},
  setup() {
    const data = reactive({
      fileList: [],
      isUploading: false,
      fragmentDialogVisible: false,
      showFragmentList: []
    });

    onBeforeMount(() => {
      onload = function () {
        document.addEventListener("drop", function (e) {
          //拖离
          e.preventDefault();
        });
        document.addEventListener("dragleave", function (e) {
          //拖后放
          e.preventDefault();
        });
        document.addEventListener("dragenter", function (e) {
          //拖进
          e.preventDefault();
        });
        document.addEventListener("dragover", function (e) {
          //拖来拖去
          e.preventDefault();
        });
      };
    });

    function getFileFromEntryRecursively(entry) {
      return new Promise((resolve) => {
        if (entry.isFile) {
          entry.file((file) => {
            data.fileList.push({
              name: entry.fullPath.substring(entry.fullPath.lastIndexOf("/") + 1, entry.fullPath.length),
              percentage: 0,
              file: file,
              totalSize: file.size,
              totalCompleteSize: 0,
              isUpload: false
            });
            resolve();
          });
        } else {
          let reader = entry.createReader();
          reader.readEntries((entries) => {
            Promise.all(entries.map(entry => getFileFromEntryRecursively(entry))).then(() => {
              resolve();
            });
          });
        }
      });
    }

    async function getDropItems(event) {
      if (data.isUploading) {
        message("正在上传...", "info");
        return;
      }
      data.fileList = [];
      data.isUploading = true;
      const items = event.dataTransfer.items;
      const promises = [];
      for (const item of items) {
        if (item.kind === "file") {
          const entry = item.webkitGetAsEntry();
          promises.push(getFileFromEntryRecursively(entry));
        }
      }
      await Promise.all(promises);

      await upload();
    }

    async function upload() {
      const checkMd5Tip = message("正在校验文件的md5,请稍候", "info");
      await checkMd5(checkMd5Tip);

      sliceFile();

      const checkFragmentMd5Tip = message("正在校验分片文件的md5,请稍候", "info");
      await checkFragmentMd5(checkFragmentMd5Tip);

      for (let i = 0; i < data.fileList.length; i++) {
        if (data.fileList[i].isUpload) {
          continue;
        }

        for (let j = 0; j < data.fileList[i].fragmentList.length; j++) {
          if (data.fileList[i].fragmentList[j].percentage !== 100) {
            const onUploadProgress = (progressEvent) => {
              data.fileList[i].fragmentList[j].percentage = parseInt(Number(((progressEvent.loaded / progressEvent.total) * 100)).toFixed(0));
              data.fileList[i].fragmentList[j].completeSize = progressEvent.loaded / progressEvent.total * data.fileList[i].fragmentList[j].fragmentFile.size;
              updateTotalPercentage(i);
            };

            const formData = new FormData();
            formData.append("file", data.fileList[i].fragmentList[j].fragmentFile, data.fileList[i].fragmentList[j].name);

            postFileRequest("/fragment-info/upload?md5=" + data.fileList[i].fragmentList[j].md5, formData, onUploadProgress).then((res) => {
              if (res.data.code === 500) {
                message(res.data.msg, "error");
              }
            });
          }
        }
      }
    }

    async function checkMd5(checkMd5Tip) {
      const promises = [];
      const promisesCheckMd5 = [];
      for (let i = 0; i < data.fileList.length; i++) {
        promises.push(calculateMD5(data.fileList[i].file).then(md5 => {
          data.fileList[i].md5 = md5;
          promisesCheckMd5.push(getRequest("/file-info/checkMd5?md5=" + md5).then((res) => {
            if (res.data.code === 200) {
              data.fileList[i].percentage = 100;
              data.fileList[i].isUpload = true;
              data.fileList[i].transition = "none";
              data.fileList[i].totalCompleteSize = data.fileList[i].file.size;
              message(data.fileList[i].name + "文件上传完成", "success");
              checkUploadOver(i);
            }
          }));
        }));
      }
      await Promise.all(promises);
      await Promise.all(promisesCheckMd5);
      checkMd5Tip.close();
    }

    function sliceFile() {
      for (let i = 0; i < data.fileList.length; i++) {
        if (data.fileList[i].isUpload) {
          continue;
        }

        const fragmentCount = Math.floor(data.fileList[i].file.size / EACH_FILE) + 1;
        const fragmentList = [];
        for (let j = 0; j < fragmentCount; j++) {
          fragmentList.push({
            id: j,
            fragmentFile: data.fileList[i].file.slice(j * EACH_FILE, (j + 1) * EACH_FILE),
            completeSize: 0,
            name: data.fileList[i].name + "--分片" + (j + 1),
            percentage: 0,
          });
        }
        data.fileList[i].fragmentList = fragmentList;
      }
    }

    async function checkFragmentMd5(checkFragmentMd5Tip) {
      const promises = [];
      const promisesCheckMd5 = [];
      for (let i = 0; i < data.fileList.length; i++) {
        if (data.fileList[i].isUpload) {
          continue;
        }

        for (let j = 0; j < data.fileList[i].fragmentList.length; j++) {
          promises.push(calculateMD5(data.fileList[i].fragmentList[j].fragmentFile).then(md5 => {
            data.fileList[i].fragmentList[j].md5 = md5;
            promisesCheckMd5.push(getRequest("/fragment-info/checkMd5?md5=" + md5).then((res) => {
              if (res.data.code === 200) {
                data.fileList[i].fragmentList[j].percentage = 100;
                data.fileList[i].fragmentList[j].completeSize = data.fileList[i].fragmentList[j].fragmentFile.size;
              }
            }));
          }));
        }
      }
      await Promise.all(promises);
      await Promise.all(promisesCheckMd5);
      checkFragmentMd5Tip.close();
    }

    async function updateTotalPercentage(i) {
      let totalCompleteSize = 0;
      for (let j = 0; j < data.fileList[i].fragmentList.length; j++) {
        totalCompleteSize += data.fileList[i].fragmentList[j].completeSize;
      }
      data.fileList[i].totalCompleteSize = totalCompleteSize;
      data.fileList[i].percentage = parseInt(Number(data.fileList[i].totalCompleteSize / data.fileList[i].totalSize * 100).toFixed(0));
      if (data.fileList[i].percentage === 100) {
        if (!data.fileList[i].isUpload) {
          data.fileList[i].isUpload = true;
          message(data.fileList[i].name + "文件上传完成", "success");
          await generateFile(i);
          checkUploadOver(i);
        }
      }
    }

    async function generateFile(i) {
      const fragmentMd5List = [];
      for (let j = 0; j < data.fileList[i].fragmentList.length; j++) {
        fragmentMd5List.push(data.fileList[i].fragmentList[j].md5);
      }

      await postRequest("/file-info/generateFile", {
        name: data.fileList[i].name,
        md5: data.fileList[i].md5,
        fragmentMd5List: fragmentMd5List
      }).then((res) => {
        if (res.data.code === 500) {
          message(res.data.msg, "error");
        }
      });
    }

    function checkUploadOver() {
      let isOver = true;
      for (let i = 0; i < data.fileList.length; i++) {
        if (data.fileList[i].percentage !== 100) {
          isOver = false;
          break
        }
      }
      if (isOver) {
        data.isUploading = false;
      }
    }

    const pickerOpts = {
      excludeAcceptAllOption: false,
      multiple: true,
    };

    async function showFilePicker() {
      if (data.isUploading) {
        message("正在上传...", "info");
        return;
      }

      let fileHandle;
      try {
        fileHandle = await window.showOpenFilePicker(pickerOpts);
        data.fileList = [];
        data.isUploading = true;
      } catch (e) {
        if (e.name === 'AbortError' && e.message === 'The user aborted a request.') {
          message("用户没有选择文件", "info");
          return;
        } else {
          throw e;
        }
      }

      for (let i = 0; i < fileHandle.length; i++) {
        const file = await fileHandle[i].getFile();
        data.fileList.push({
          name: fileHandle[i].name,
          percentage: 0,
          file: file,
          totalSize: file.size,
          totalCompleteSize: 0,
          isUpload: false,
        });
      }

      await upload();
    }

    function showFragmentInfo(item) {
      data.showFragmentList = item.fragmentList;
      data.fragmentDialogVisible = true;
    }

    return {
      data,
      getDropItems,
      showFilePicker,
      showFragmentInfo,
    };
  },
};
script>

<style>
* {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}

.drop-area {
  margin: 100px auto 0;
  width: 800px;
  height: 180px;
  border: 1px dashed #dcdfe6;
  display: flex;
  align-items: center;
  justify-content: center;
}

.drop-area:hover {
  border-color: #409eff;
  cursor: pointer;
}

.icon-upload::before {
  display: flex;
  justify-content: center;
  font-size: 40px;
  margin: 10px 0;
}

.tip-text {
  color: #606266;
  font-size: 14px;
  text-align: center;
}

.tip-text em {
  color: #409eff;
  font-style: normal;
}

.file-list {
  margin: 0 auto;
  width: 800px;
}

.single-file {
  margin: 10px 0;
}
style>

MyProgress.vue(由于我在测试的过程中发现,那个宽度会经常被卡住,所以我设置了transition属性,在秒传的时候就直接不过渡了)

<template>
  <link rel="stylesheet" href="/style/css/iconfont.css">

  <div class="progress-container">
    <div class="bar">
      <div class="percentage" :style="{'width': props.percentage + '%', 'transition' : props.transition ? props.transition : 'width 0.2s linear'}">
        <span class="text-inside">{{ props.content + " " + props.percentage + "%" }}span>
      div>
    div>
    <div class="tip-content">
      <span v-show="props.percentage !== 100">{{ props.percentage + "%" }}span>
      <i class="iconfont icon-over" v-show="props.percentage === 100"/>
    div>
  div>
template>

<script>
export default {
  props: ["percentage", "content", "transition"],
  setup(props) {
    return {
      props
    }
  }
}
script>

<style scoped>
.progress-container {
  display: flex;
  height: 30px;
  cursor: pointer;
  border: 1px dashed #dcdfe6;
  padding: 0 10px;
}

.bar {
  color: white;
  font-weight: 500;
  line-height: 30px;
  font-size: 14px;
  flex: 1;
}

.percentage {
  border-radius: 30px;
  background-color: #67c23a;
  white-space: nowrap;
  word-break: break-all;
  overflow: hidden;
}

.text-inside {
  padding-right: 10px;
  padding-left: 15px;
  float: right;
}

.tip-content {
  padding: 0 10px;
  font-size: 16px;
  line-height: 30px;
  width: 40px;
}

.icon-over::before {
  font-size: 24px;
  color: #67c23a;
}
style>

util.js(生成md5字符串采用了 crypto-js 库,还是比较方便的)

import {ElMessage} from "element-plus";
import axios from "axios";
import {MD5} from 'crypto-js';

const baseUrl = "http://127.0.0.1:8080"

export function message(msg, type) {
    return ElMessage({
        message: msg,
        type: type,
        center: true,
        showClose: true,
    })
}

export const getRequest = (url) => {
    return axios({
        method: 'get',
        url: baseUrl + url
    })
}

export const postRequest = (url, data) => {
    return axios({
        method: 'post',
        url: baseUrl + url,
        data: data,
    })
}

export const postFileRequest = (url, data, onUploadProgress) => {
    return axios({
        method: 'post',
        url: baseUrl + url,
        data: data,
        onUploadProgress: onUploadProgress,
    })
}

export const calculateMD5 = (file) => {
    return new Promise(resolve => {
        const fileReader = new FileReader();
        fileReader.readAsBinaryString(file);
        fileReader.onloadend = event => {
            resolve(MD5(event.target.result).toString());
        }
    });
}

export const EACH_FILE = 1024 * 1024 * 2;

后续开发说明

考虑到文章的篇幅,以及代码后面会多一些,我就都放到了Gitee上了,后面设计功能包括:之前写好的分片上传和秒传,下载部分也是设计成了分片下载,最后合并,不过还没有加上断点重下的功能实现;后面可以考虑结合浏览器自带的IndexDB数据库,然后实现该效果。

后面我尝试了一下,选择16字节的数组来存储32位MD5字符串转换后的结果是没问题的;然后我加上了一个简单的管理界面,来方便查看

效果预览
文件分片上传(模拟网盘效果)_第1张图片

文件分片上传(模拟网盘效果)_第2张图片

我简单测试了一下,还存在着不少的bug,主要有一些异常情况的处理,没有很完善;然后就是上传文件的数量限制和大小限制,没有进行很详细的设置;这方面可以再后面自主添加

源码下载

参见Gitee–在线网盘系统

你可能感兴趣的:(javascript,开发语言,ecmascript)