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

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

    • 文章说明
    • 简单模拟拖拽文件夹和选择文件的进度条效果
    • 效果展示
    • 结合后端实现文件上传
    • 效果展示
    • 加上分片的效果
    • 效果展示
    • 加上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 {@Resourceprivate 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 {@Resourceprivate 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–在线网盘系统

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

2024年【黑龙江省安全员C证】考试及黑龙江省安全员C证找解析

题库来源&#xff1a;安全生产模拟考试一点通公众号小程序 2024年黑龙江省安全员C证考试为正在备考黑龙江省安全员C证操作证的学员准备的理论考试专题&#xff0c;每个月更新的黑龙江省安全员C证找解析祝您顺利通过黑龙江省安全员C证考试。 1、【多选题】下列属于编制安全检查…

浅聊配置化-要不要实现动态表单

1、配置化的原则 配置化是一种抽象&#xff0c;把事物分成2类&#xff1a;不变的&#xff0c;可变的。 如果事物都是可变的&#xff0c;是无法实现配置化的。 配置化的根本在于找到不变的事物&#xff0c;基于不变的事物进行可变事物的配置。 所以&#xff0c;认为一切皆可…

[LLM]大模型训练(二)--DeepSpeed使用

安装DeepSpeed与集成 DeepSpeed可以通过pip安装&#xff0c;无需指定PyTorch和CUDA的版本。DeepSpeed内包含需要自定义的CUDA算子&#xff0c;将通过即时编译的方式在运行时构建。 pip install deepspeed DeepSpeed与HuggingFace Transformers直接集成。使用者可以通过在模型…

从入门到精通,30天带你学会C++【第十一天:二分查找】

目录 Everyday English 前言 二分查找 例题 50分做法 分析利弊 示例代码 示例截图 100分做法 二分查找是什么&#xff1f; 这题该怎么用二分查找&#xff1f; 示例代码 示例截图 结尾 Everyday English Look before you leap. 三思而后行 前言 今天是2024年的…

高创驱动器设置备忘

1&#xff1a;高创驱动器连接软件SVRstudio 有一代和二代&#xff0c;这里为了简述方便称SV1,SV2版本&#xff0c;它们都可以连接驱动器&#xff0c;只是使用界面上有些差异而已&#xff0c;为了设置方便的需要&#xff0c;建议同时安装两个版本&#xff1b; 2&#xff1a;通常…

爬取糖豆视频

爬虫案例积累&#xff0c;以爬取糖豆视频为例&#xff1a; 爬取视频类型的数据一般步骤&#xff1a; 1.点击media,刷新&#xff0c;播放一个视频&#xff0c;会刷新一个包&#xff0c;点击发现是播放视频的包&#xff0c; 2.复制这个包url中的关键字&#xff0c;在搜索框中进…

在宝塔Linux中安装Docker

前言 帮助使用宝塔的用户快速上手docke的安装 &#x1f4da;&#x1f4da; &#x1f3c5;我是默&#xff0c;一个在CSDN分享笔记的博主。&#x1f4da;&#x1f4da; ​​​​ &#x1f31f;在这里&#xff0c;我要推荐给大家我的专栏《Docker》。&#x1f3af;&#x1f3af…

[新版Hi3531DV200 性能强悍]

新版Hi3531DV200 性能强悍 Hi3531DV200是针对多路高清/超高清&#xff08;1080p/4M/5M/4K&#xff09;DVR产品应用开发的新一代专业SoC芯片。Hi3531DV200集成了ARM A53四核处理器和性能强大的神经网络推理引擎&#xff0c;支持多种智能算法应用。同时&#xff0c;Hi3531DV200还…

Spring Boot Admin健康检查引起的Spring Boot服务假死

问题现象 最近在spring boot项目中引入了 spring-boot-starter-actuator 后&#xff0c;测试环境开始出现服务假死的现象&#xff0c; 且这个问题十分怪异&#xff0c;只在多个微服务中的简称A的这个服务中出现&#xff0c;其他服务都没有出现这个问题&#xff0c; 之所以说…

C#学习笔记 - C#基础知识 - C#从入门到放弃 - C# Windows窗体技术及基础控件(一)

C# 入门基础知识 - C# Windows窗体技术及基础控件 第12节 Windows窗体技术及基础控件12.1 创建Winform 程序12.2 窗体的添加与删除12.3 窗体的常用属性12.4 窗体的常用事件12.5 窗体中添加控件与窗体显示和隐藏12.6 MDI 窗体12.7 窗体中控件的基本操作 更多C#基础知识点可查看&…

按照故障码类型分类的API接口

随着汽车的普及&#xff0c;车辆故障也成为了一个不可忽视的问题。对于车主来说&#xff0c;及时了解故障码的含义以及解决方案十分重要。挖数据平台为解决这一问题&#xff0c;提供了一套按照故障码类型分类的API接口&#xff0c;用于查询车辆故障、故障码适用品牌以及提供相应…

STL——queue容器

1.queue基本概念 概念&#xff1a;queue是一种先进先出&#xff08;First In First Out,FIFO&#xff09;的数据结构&#xff0c;它有两个出口。 队列容器允许从一端新增元素&#xff0c;从另一端移除元素。 队列中只有队头和队尾才可以被外界使用&#xff0c;因此队列不允许…

PHP调用系统命令/其他应用程序 并获取应用返回值的方法

PHP应用可以非常简单的调用系统中的任意应用程序并获取其返回值, 即与其他应用程序通信和整合. 使用PHP内置函数 popen, proc_open 函数即可轻松实现, 以下为示例代码: <?php error_reporting(E_ALL);/* 加入重定向以得到标准错误输出 stderr。 */ $cmd "/path/to/…

Java API 操作Docker浅谈

背景&#xff1a; 使用com.github.docker-java库可以很方便地在Java中操作Docker。下面是一个详细的教程&#xff0c;包括创建镜像、创建容器、启动容器、停止容器和删除容器的步骤以及每一步的说明。 前提&#xff1a; 首先&#xff0c;在你的Java项目中添加com.github.doc…

P8598 [蓝桥杯 2013 省 AB] 错误票据

题目背景 某涉密单位下发了某种票据&#xff0c;并要在年终全部收回。 题目描述 每张票据有唯一的 ID 号&#xff0c;全年所有票据的 ID 号是连续的&#xff0c;但 ID 的开始数码是随机选定的。因为工作人员疏忽&#xff0c;在录入 ID 号的时候发生了一处错误&#xff0c;造…

LLM、AGI、多模态AI 篇二:Prompt编写技巧

文章目录 系列生成图片(Stable Diffusion)生成文章(ChatGPT)代码生成(GitHub Copilot)Prompt编写模式Prompt模板Prompt 即代码系列 LLM、AGI、多模态AI 篇一:开源大语言模型简记 LLM、AGI、多模态AI 篇二:Prompt编写技巧 生成图片(Stable Diffusion) 视角和构图。 …

Linux之组管理和权限管理

组的概念 如图所示&#xff1a;test.txt是由tom创建的&#xff0c;所以tom是文件的所有者&#xff0c;tom归属于组A&#xff0c;组A就是文件的所在组&#xff1b;组B就是文件的其他组。 所有者 谁创建了文件&#xff0c;谁就是文件的所有者。 查看文件的所有者 指令&…

2024 计划

学习 brpc源码 netty源码 ace源码 《Javascript高级程序设计》 《《Full-stack web development with Vue.js and Node build scalable and powerful web apps with modern web stack, MongoDB, Vue, Node.js, and Express》&#xff08;之前没有看完&#xff09; 生活 每周…

Django 学习教程- Hello world入门案例

系列 Django 学习教程-介绍与安装-CSDN博客 欢迎来到第Djagno学习教程第二章Hello World 入门案例。 在本教程中&#xff0c;我将引导您完成django的Hello World入门案例。 让我们开始吧&#xff01; 版本 Django 5.0Python 3.10 创建项目 安装 Django 之后&#xff0…

信创之国产浪潮电脑+统信UOS Linux操作系统体验10:visual studio code中调试C++程序

☞ ░ 前往老猿Python博客 ░ https://blog.csdn.net/LaoYuanPython 一、引言 老猿在CSDN的《信创之国产浪潮电脑统信UOS操作系统体验2&#xff1a;安装visual studio code和cmake搭建C开发环镜》介绍了在国产浪潮电脑统信UOS操作系统中安装visual studio code和cmake搭建C开…