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

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

    • 文章说明
    • 简单模拟拖拽文件夹和选择文件的进度条效果
    • 效果展示
    • 结合后端实现文件上传
    • 效果展示
    • 加上分片的效果
    • 效果展示
    • 加上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 upload</em>
      </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;

/**
 * <p>
 * 前端控制器
 * </p>
 *
 * @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 upload</em>
      </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 upload</em>
      </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;

/**
 * <p>
 * 前端控制器
 * </p>
 *
 * @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;

/**
 * <p>
 * 前端控制器
 * </p>
 *
 * @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 upload</em>
      </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字符串转换后的结果是没问题的;然后我加上了一个简单的管理界面,来方便查看

效果预览
在这里插入图片描述

在这里插入图片描述

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

源码下载

参见Gitee–在线网盘系统

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/281801.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

单片机原理及应用:开关控制LED多种点亮模式

从这篇文章开始&#xff0c;我们不再只研究单一的外设工作&#xff0c;而是将LED、数码管、开关、按键搭配在一起研究&#xff0c;这篇文章主要介绍LED和开关能擦出怎样的火花&#xff0c;同时也介绍一些函数封装的知识。 由于开关有闭合与打开两种状态&#xff0c;LED有左移流…

el-table表格自动循环向上滚动鼠标放上去停止,移开恢复

排序的图标是两个图片&#xff0c;点击向后端发请求带不同的参数 <template><div style"height: 100%" class"table-content"><div :style"{ position: absolute, z-index: 9999, right: 3%, top: 0 }":class"tagBtn bg${c…

MySQL例行检查

MySQL例行检查 1.实例例行检查1.1线程1.2索引1.3临时表1.4连接数1.5BINLOG1.6锁1.7WAIT事件1.8MySQL状态 2.事务与锁例行检查2.1查看索引的cardinality2.2查看是否存在事务阻塞现象2.3查看事务执行时长以及执行的所有SQL2.4事务与锁 3.库表例行检查3.1查看缺失主键的表3.2冗余索…

【网络安全 | Misc】miss_01 太湖杯

解压时提示输入密码&#xff1a; 如果 frFlags 或 deFlags 不为0会导致zip的伪加密 将deFlags的值修改为0 将9改为0&#xff0c;另存为123.zip&#xff1a; 即可绕过加密&#xff1a; 得到一个zip一个docx&#xff0c;但zip需要密码&#xff1a; 因此看docx有无敏感信息&#x…

文心一言 VS 讯飞星火 VS chatgpt (168)-- 算法导论13.2 1题

一、用go语言&#xff0c;写出 RIGHT-ROTATE 的伪代码。 文心一言&#xff1a; 在计算机科学中&#xff0c;右旋转&#xff08;RIGHT-ROTATE&#xff09;是一种常见的操作&#xff0c;通常用于数组或链表。以下是一个使用Go语言实现右旋转的伪代码&#xff1a; func rightRo…

java maven项目添加oracle jdbc的依赖

一般添加依赖是直接在pom.xml中添加配置即可&#xff0c;Maven会自动获取对应的jar包&#xff0c;但是oracle驱动依赖添加后会显示红色&#xff0c;代表找不到依赖项&#xff0c;是因为Oracle授权问题&#xff0c;Maven3不提供Oracle JDBC driver&#xff0c;为了在Maven项目中…

基于JAVA的农家乐订餐系统 开源项目

目录 一、摘要1.1 项目介绍1.2 项目录屏 二、功能模块2.1 用户2.2 管理员 三、系统展示四、核心代码4.1 查询菜品类型4.2 查询菜品4.3 加购菜品4.4 新增菜品收藏4.5 新增菜品留言 五、免责说明 一、摘要 1.1 项目介绍 基于JAVAVueSpringBootMySQL的农家乐订餐系统&#xff0c…

springmvc中controller路由出现404

[java]springmvc中controller路由出现404 problem [java]springmvc中controller路由出现404 reason 可能原因有很多 idea配置不对编译配置不对xml配置jsp位置 solution 核对idea配置 mac: idea->File -> Project Structure 最重要的是 Artifacts&#xff0c;默认配…

金三银四-JAVA核心知识高频面试题

又要快到一年一度的金三银四&#xff0c;开始复习啦&#xff5e;&#xff01; 每天一点点。。 目录 一、内存模型设计 二、synchronized和ReentrantLock的区别 三、垃圾回收机制 四、优化垃圾回收机制 4.1 了解应用需求 4.2. 调整堆大小 4.3. 减少对象分配 4.4. 使用合…

进阶学习——Linux系统服务器硬件认识与RAID磁盘

目录 一、服务器知识补充 1.硬件 2.服务器常见故障 二、认识RAID 1.什么是RAID 2.RAID的优点 3.RAID的实现方式 三、RAID磁盘陈列 1.RAID 0 磁盘陈列介绍——RAID 0 2.RAID 1 磁盘陈列介绍——RAID 1 3.RAID 5 磁盘陈列介绍——RAID 5 4.RAID 6 磁盘陈列介绍——RA…

基于SpringBoot实现的前后端分离电影评分项目,功能:注册登录、浏览影片、热门影片、搜索、评分、片单、聊天、动态

一、项目介绍 本项目主要基于SpringBoot、Mybatis-plus、MySQL、Redis实现的影片评分项目。 本系统是前后端分离的&#xff0c;分别由三个子项目构成&#xff1a;java服务端、用户前端、管理员管理前端 关键词&#xff1a;springboot java vue mysql reids websocket 毕业设计…

Windows 10启用Hyper-V

Windows 10启用Hyper-V 官网教程PowerShell 启用 Hyper-V启用 Hyper-V 角色 我们知道VMware是创建虚拟机的好工具&#xff0c;那Windows平台上有没有虚拟工具呢&#xff1f; 今天我们要讲解的就是Windows才入局的虚拟工具&#xff1a;Hyper-V 官网教程 https://learn.microsof…

【中南林业科技大学】计算机组成原理复习包括题目讲解(超详细)

来都来了点个赞收藏关注一下再走呗&#x1f339;&#x1f339;&#x1f339;&#x1f339; 第1章&#xff1a;绪论 1.冯诺依曼机特点&#xff0c;与现代计算机的区别 冯诺依曼计算机的基本思想是&#xff1a;程序和数据以二进制形式表示&#xff0c;存储程序控制。在计算机中&…

结构体:搜索链表

#include<iostream> #include<iomanip> using namespace std; struct Student //创建结构体Student {int number; //学号char name[20]; //姓名float Chinese, Math, English; //成绩语数英Student* next; //下一个节点 }; Student* CreateList() //创建链表 {Stud…

【CSS】基础知识梳理和总结

1. 前言 CSS&#xff08;Cascading Style Sheets&#xff0c;层叠样式表&#xff09;&#xff0c;用来为HTML文档添加样式的计算机语言。HTML中加载样式的方法有三种&#xff1a; 通过<link>标签加载外部样式表&#xff08;External Style Sheet&#xff09;&#xff0c…

test mock-03-wiremock 模拟 HTTP 服务的开源工具 flexible and open source API mocking

拓展阅读 test 之 jmockit-01-overview jmockit-01-test 之 jmockit 入门使用案例 mockito-01-overview mockito 简介及入门使用 PowerMock Mock Server ChaosBlade-01-测试混沌工程平台整体介绍 jvm-sandbox 入门简介 wiremock WireMock是一个流行的开源工具&#xf…

副业类小报童热门专栏TOP15

今天介绍15个副业小报童&#xff0c;可以说是当前小报童平台&#xff0c;副业类专栏的天花板内容了 这些专栏&#xff0c;都有免费内容可以查看&#xff0c;而且还是3天无理由退款的&#xff0c;完全可以尝试着订阅一波 关键单价都非常亲民&#xff0c;怎么都不亏&#xff01…

C++/CLI——1简介

C/CLI——1简介 如果你是.net程序员&#xff0c;不免会用到C/C写的库。对于简单的调用&#xff0c;可以直接使用DllImport来完成就可以&#xff0c;详情可参考C#调用C/C从零深入讲解。但是对于复杂的C类和对象&#xff0c;尤其是类似于OCC的大型C项目&#xff0c;DllImport可能…

简单几步制作翻页电子画册

翻页电子画册是一种非常流行的电子书形式&#xff0c;它能够以生动、美观、有趣的方式展示您的内容。如果您想要制作自己的翻页电子画册&#xff0c;以下是一些简单的步骤&#xff0c;可以帮助您轻松上手。 首先&#xff0c;你需要一款在线制作电子杂志平台。比如FLBOOK&#x…

再薅!Pika全球开放使用;字节版GPTs免费不限量;大模型应用知识地图;MoE深度好文;2024年AIGC发展轨迹;李飞飞最新自传 | ShowMeAI日报

&#x1f440;日报&周刊合集 | &#x1f3a1;生产力工具与行业应用大全 | &#x1f9e1; 点赞关注评论拜托啦&#xff01; &#x1f440; 终于&#xff01;AI视频生成平台 Pika 面向所有用户开放网页端 https://twitter.com/pika_labs Pika 营销很猛&#xff0c;讲述的「使…