若依前后端分离版整合文件分片上传、断点续传
一、前期准备
需要若依官网下载一份若依前后端分离版的工程项目,并且能在本地成功运行。
运行若依前后端分离版,电脑需要安装以下软件环境:
- JDK1.8.x
- MySQL8.0.x
- Redis3或者Redis5都可以
- Maven3.6.1
- Node.js v12.18.0及以上版本
如果是新手,建议软件环境的版本与我所用的保持一致。
具体如何运行若依前后端分离项目,可去网上查询相关的资料,这里就不过多的讲解如何运行若依的前后端分离项目。
二、依赖安装
找到ruoyi-ui的项目工程,在对应目录的导航栏上输入cmd后回车(或者使用Vs Code或者WebStorm打开此项目工程,然后打开Terminal),在相关的窗口输入以下安装命令即可成功安装这些依赖
npm install vue-simple-uploader
npm install spark-md5
npm install jquery
安装完成后,我们打开项目工程中的package.json文件,如果文件中出现如下图所示的依赖,表示已经安装成功。
三、相关流程
四、相关说明以及核心代码
(一)相关说明
如(三)中的流程所示,上传前先会进行MD5的校验,根据文件内容生成的MD5值查询表中是否存在此文件,如果查询出存在此文件,则告诉前端从第几片开始传,反之则会从第一片开始传。
这里,我将文件上传进行了暂停的操作,这里展示一下文件上传暂停后是以什么样的形式存储的。
上图就是文件一个一个的分片,一个分片是5M
从图中上传的请求中,可以看到一共是189个分片,当它传到143片时,我点下了暂停键。
从请求中可以看到分片的大小是5120000,文件总大小totalSize是970134691,这里用文件总大小/分片大小得到了分片总数,一共是189片(这里是四舍五入后得到的)。
当所有的分片文件上传成功后,会进行合并。
合并成功后,会返回一个成功success的标识并自动关闭文件列表弹窗,然后重新请求文件管理列表数据。
(二)核心代码
(1)设计好对应的数据库,实现文件断点续传、闪传,需要新建两个数据表,一个是文件分片记录表,一个是已上传文件记录表。
(2)新建ruoyi-fileupload模块,在pom.xml文件中将ruoyi-common引入进来
(3)相关代码
后端所用的实体类
文件分片
package com.ruoyi.fileupload.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import org.springframework.web.multipart.MultipartFile;
import java.io.Serializable;
import java.util.Date;
/**
*
* @TableName sys_chunk
*/
@TableName(value ="sys_chunk")
@Data
public class SysChunk implements Serializable {
/**
* 主键ID,唯一标识
*/
@TableId
private Long chunkId;
/**
* 文件块编号
*/
private Integer chunkNumber;
/**
* 分块大小
*/
private Long chunkSize;
/**
* 当前分块大小
*/
private Long currentChunkSize;
/**
* 文件名
*/
private String fileName;
/**
* 文件标识,MD5
*/
private String identifier;
/**
* 相对路径
*/
private String relativePath;
/**
* 总块数
*/
private Integer totalChunks;
/**
* 总大小
*/
private Long totalSize;
/**
* 创建时间
*/
private Date createTime;
/**
* 创建人
*/
private String createBy;
/**
* 更新时间
*/
private Date updateTime;
/**
* 更新人
*/
private String updateBy;
/**
* 二进制文件
*/
@TableField(exist = false)
private MultipartFile file;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
文件列表
package com.ruoyi.fileupload.domain;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
*
* @author 14820
* @TableName sys_filelist
*/
@TableName(value ="sys_filelist")
@Data
public class SysFilelist implements Serializable {
/**
* 主键ID
*/
@TableId
private Long fileId;
/**
* 文件名
*/
private String fileName;
/**
* 唯一标识,MD5
*/
private String identifier;
/**
* 链接
*/
private String url;
/**
* 本地地址
*/
private String locationAddress;
/**
* 文件总大小
*/
private Long totalSize;
/**
* 创建时间
*/
private Date createTime;
/**
* 创建人
*/
private String createBy;
/**
* 更新时间
*/
private Date updateTime;
/**
* 更新人
*/
private String updateBy;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
后端Controller控制层
@RestController
@RequestMapping("/fileUpload/api")
public class SysFileController extends BaseController {
@Autowired
private SysFileService sysFileService;
/**
* 上传文件
* @param sysChunk 文件分片实体对象
* @param response 响应
* @return 是否上传成功
*/
@PostMapping("/upload")
public AjaxResult postFileUpload(@ModelAttribute SysChunk sysChunk, HttpServletResponse response) {
boolean flag = sysFileService.postFileUpload(sysChunk, response);
AjaxResult ajaxResult = toAjax(flag);
if (ajaxResult.isSuccess()) {
ajaxResult.put("needMerge", true);
ajaxResult.put("result", true);
}
return ajaxResult;
}
/**
* 检查文件上传状态
*/
@GetMapping("/upload")
public CheckSysChunkVO getFileUpload(@ModelAttribute SysChunk sysChunk, HttpServletResponse response) {
//查询根据md5查询文件是否存在
CheckSysChunkVO fileUpload = sysFileService.getFileUpload(sysChunk, response);
return fileUpload;
}
/**
* 合并请求
* @param sysFilelist 已上传文件实体
* @return 合并是否成功
*/
@PostMapping("/merge")
public AjaxResult merge(SysFilelist sysFilelist) {
String path = sysFileService.mergeFile(sysFilelist);
return AjaxResult.success("操作成功", path);
}
}
后端Service业务实现层
@Service
@Slf4j
public class SysFileServiceImpl implements SysFileService {
@Value("${ruoyi.profile}")
private String filePath;
private final static String folderPath = "/file";
@Autowired
private SysChunkService sysChunkService;
@Autowired
private SysFilelistService sysFilelistService;
@Autowired
private SysFilelistMapper sysFilelistMapper;
/**
* 每一个上传块都会包含如下分块信息:
* chunkNumber: 当前块的次序,第一个块是 1,注意不是从 0 开始的。
* totalChunks: 文件被分成块的总数。
* chunkSize: 分块大小,根据 totalSize 和这个值你就可以计算出总共的块数。注意最后一块的大小可能会比这个要大。
* currentChunkSize: 当前块的大小,实际大小。
* totalSize: 文件总大小。
* identifier: 这个就是每个文件的唯一标示,md5码
* fileName: 文件名。
* relativePath: 文件夹上传的时候文件的相对路径属性。
* 一个分块可以被上传多次,当然这肯定不是标准行为,,这种重传也但是在实际上传过程中是可能发生这种事情的是本库的特性之一。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean postFileUpload(SysChunk sysChunk, HttpServletResponse response) {
MultipartFile file = sysChunk.getFile();
log.debug("file originName: {}, chunkNumber: {}", file.getOriginalFilename(), sysChunk.getChunkNumber());
Path path = Paths.get(generatePath(filePath + folderPath, sysChunk));
try {
Files.write(path, sysChunk.getFile().getBytes());
log.debug("文件 {} 写入成功, md5:{}", sysChunk.getFileName(), sysChunk.getIdentifier());
return sysChunkService.save(sysChunk);
//写入数据库
} catch (IOException e) {
throw new RuntimeException("上传失败" + e.getMessage());
}
}
@Override
public CheckSysChunkVO getFileUpload(SysChunk sysChunk, HttpServletResponse response) {
// 检查该文件是否存在于fileList中吗,直接返回skipUpload为true,执行闪传
CheckSysChunkVO checkSysChunkVO = new CheckSysChunkVO();
String identifier = sysChunk.getIdentifier();
// 先查询文件分片管理表和已上传文件记录表
List<SysChunk> sysChunkList = sysChunkService.list(new LambdaQueryWrapper<SysChunk>().eq(SysChunk::getIdentifier, identifier));
List<SysFilelist> sysFilelistList = sysFilelistService.list(new LambdaQueryWrapper<SysFilelist>().eq(SysFilelist::getIdentifier, identifier));
// 检查文件中是否存在于sysFilelistList中
if (sysFilelistList != null && !sysFilelistList.isEmpty()) {
checkSysChunkVO.setSkipUpload(true);
return checkSysChunkVO;
}
// 获取已存在的块的chunkNumber列表并返回给前端
if (sysChunkList != null && !sysChunkList.isEmpty()) {
List<Integer> uploadedChunks = sysChunkList.stream()
.map(SysChunk::getChunkNumber)
.collect(Collectors.toList());
checkSysChunkVO.setUploaded(uploadedChunks);
}
return checkSysChunkVO;
}
/**
* 合并请求
* @param sysFilelist 已上传文件实体
* @return 合并是否成功
*/
@Override
@Transactional(rollbackFor = Exception.class)
public String mergeFile(SysFilelist sysFilelist) {
// 获取文件的名称
String fileName = sysFilelist.getFileName();
String file = filePath + folderPath + "/" + sysFilelist.getIdentifier() + "/" + fileName;
String folder = filePath + folderPath + "/" + sysFilelist.getIdentifier();
String url = folderPath + "/" + sysFilelist.getIdentifier() + "/" + fileName;
merge(file, folder, fileName);
//当前文件已存在数据库中时,返回已存在标识
Long selectCount = sysFilelistMapper.selectCount(new LambdaQueryWrapper<SysFilelist>()
.eq(SysFilelist::getFileName, sysFilelist.getFileName())
.eq(SysFilelist::getIdentifier, sysFilelist.getIdentifier()));
if (selectCount > 0) {
return url;
}
sysFilelist.setLocationAddress(file);
sysFilelist.setUrl(url);
sysFilelist.setCreateTime(new Date());
sysFilelist.setCreateBy(SecurityUtils.getUserId().toString());
boolean flag = sysFilelistService.save(sysFilelist);
if (flag) {
// 插入文件记录成功后,删除chunk表中的对应记录,释放空间
LambdaQueryWrapper<SysChunk> chunkLambdaQueryWrapper = new LambdaQueryWrapper<SysChunk>()
.eq(SysChunk::getFileName, sysFilelist.getFileName())
.eq(SysChunk::getIdentifier, sysFilelist.getIdentifier());
sysChunkService.remove(chunkLambdaQueryWrapper);
}
return url;
}
/**
* 生成块文件所在地址
*/
private String generatePath(String uploadFolder, SysChunk sysChunk) {
StringBuilder stringBuilder = new StringBuilder();
// 文件夹地址md5
stringBuilder.append(uploadFolder).append("/").append(sysChunk.getIdentifier());
//判断uploadFolder/identifier 路径是否存在,不存在则创建
if (!Files.isWritable(Paths.get(stringBuilder.toString()))) {
log.info("path not exist,create path: {}", stringBuilder.toString());
try {
Files.createDirectories(Paths.get(stringBuilder.toString()));
} catch (IOException e) {
log.error("生成时出现问题" + e.getMessage(), e);
throw new RuntimeException("生成时出现问题" + e.getMessage());
}
}
//文件夹地址/md5/文件名-1
return stringBuilder.append("/")
.append(sysChunk.getFileName())
.append("-")
.append(sysChunk.getChunkNumber()).toString();
}
/**
* 文件合并
*
* @param targetFile 要形成的文件名
* @param folder 要形成的文件夹地址
* @param fileName 文件的名称
*/
public static void merge(String targetFile, String folder, String fileName) {
// try {
// Files.createFile(Paths.get(targetFile));
// Files.list(Paths.get(folder))
// .filter(path -> !path.getFileName().toString().equals(fileName))
// .sorted((o1, o2) -> {
// String p1 = o1.getFileName().toString();
// String p2 = o2.getFileName().toString();
// int i1 = p1.lastIndexOf("-");
// int i2 = p2.lastIndexOf("-");
// return Integer.valueOf(p2.substring(i2)).compareTo(Integer.valueOf(p1.substring(i1)));
// })
// .forEach(path -> {
// try {
// // 以追加的形式写入文件
// Files.write(Paths.get(targetFile), Files.readAllBytes(path), StandardOpenOption.APPEND);
// // 合并后删除该块
// Files.delete(path);
// } catch (IOException e) {
// log.error(e.getMessage(), e);
// }
// });
// } catch (IOException e) {
// log.error(e.getMessage(), e);
// throw new RuntimeException("合并出现错误," + e.getMessage());
// }
try {
// 创建目标文件
Files.createFile(Paths.get(targetFile));
// 收集需要合并的文件路径并排序
List<Path> filesToMerge = Files.list(Paths.get(folder))
.filter(path -> !path.getFileName().toString().equals(fileName))
.sorted((o1, o2) -> {
String p1 = o1.getFileName().toString();
String p2 = o2.getFileName().toString();
int i1 = p1.lastIndexOf("-");
int i2 = p2.lastIndexOf("-");
return Integer.valueOf(p2.substring(i2)).compareTo(Integer.valueOf(p1.substring(i1)));
})
.collect(Collectors.toList());
// 注释掉的逻辑中 是以追加的形式写入文件 这样做在传送大文件时会出现合并失败的情况
// 我的推断:可能是因为每次循环迭代中都会执行文件的读取和写入操作,这种方式对于大文件来说效率不高,并且可能会导致内存不足或者文件操作异常
// 所以这里使用缓冲流逐个合并文件, 不一次性读取整个文件内容,而是使用缓冲区逐段读取和写入,以降低内存使用量
try (OutputStream out = Files.newOutputStream(Paths.get(targetFile), StandardOpenOption.APPEND)) {
for (Path path : filesToMerge) {
try (InputStream in = Files.newInputStream(path)) {
byte[] buffer = new byte[8192]; // 8KB缓冲区
int len;
while ((len = in.read(buffer)) > 0) {
out.write(buffer, 0, len);
}
}
// 合并后删除该块
Files.delete(path);
}
}
} catch (IOException e) {
log.error("合并出现错误:" + e.getMessage(), e);
throw new RuntimeException("合并出现错误," + e.getMessage());
}
}
}
主要逻辑:当点击上传按钮上传文件时,前端它会把上传的文件根据你设定的分块的大小切分成多少块,而且会根据文件的内容计算出一个md5值,计算好之后,它会到后端先请求/fileUpload/api/upload这个接口(注意:controller控制层中这个接口有两个请求),首先它会走get请求,检查上传的文件是否已经存在,如果存在,直接返回true,证明上传过,会直接返回一个地址。检查完成后,如果它发现文件没有上传过,它会再发一个post请求。这里会根据你自定义分片的大小,把文件内容给你传过来,后端获取到后,会开始写入(因为这个项目过程中我没有配置OSS对象存储,所以我将文件是保存到了本地)。写完后,它会往sys_chunk这张表中添加一条数据,当chunkNumber 等于 分块的总数时,前端就会触发合并的请求,合并的方法中,会到你指定的位置去获取文件(这里文件夹的名称,就是你文件的md5值),获取完文件后会对文件进行排序(升序排列),排序完成后,会通过缓冲流逐个合并文件,合并完成后 ,会将其写入到你的目的地,最后再把原先的分块文件删掉。
前端相关的代码
前端上传的组件使用的是vue-simple-uploader,组件官方地址https://github.com/simple-uploader/vue-uploader,具体如何引入可以参考官方文档。
文件列表页面
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" :inline="true" label-width="68px">
<el-form-item label="文件名" prop="fileName">
<el-input
v-model="queryParams.fileName"
placeholder="请输入文件名"
clearable
size="small"
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item>
<div>
</div>
<el-button type="primary" icon="el-icon-upload" size="mini" @click="upload" v-hasPermi="['system:file:upload']">上传</el-button>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery" v-hasPermi="['system:filelist:query']">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['system:filelist:remove']"
>删除</el-button>
</el-col>
<!-- <el-col :span="1.5">
<el-button
type="warning"
icon="el-icon-download"
size="mini"
@click="handleExport"
v-hasPermi="['background:filelist:export']"
>导出</el-button>
</el-col>-->
</el-row>
<el-table v-loading="loading" :data="filelistList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" align="center" />
<el-table-column label="文件名" align="center" prop="fileName" />
<el-table-column label="本地地址" align="center" prop="locationAddress" :show-overflow-tooltip="true"/>
<el-table-column label="文件总大小" align="center" prop="totalSize" :formatter="storageFormatter"/>
<el-table-column label="创建时间" align="center" prop="createTime" :formatter="dataFormat"/>
<el-table-column label="创建人" align="center" prop="nickName" />
</el-table>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<!-- 添加或修改已上传文件列表对话框 -->
<el-dialog :title="title" :visible.sync="open" width="500px">
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-form-item label="文件名" prop="fileName">
<el-input v-model="form.fileName" placeholder="请输入文件名" />
</el-form-item>
<el-form-item label="唯一标识,MD5" prop="identifier">
<el-input v-model="form.identifier" placeholder="请输入唯一标识,MD5" />
</el-form-item>
<el-form-item label="链接" prop="url">
<el-input v-model="form.url" placeholder="请输入链接" />
</el-form-item>
<el-form-item label="本地地址" prop="location">
<el-input v-model="form.location" placeholder="请输入本地地址" />
</el-form-item>
<el-form-item label="文件总大小" prop="totalSize">
<el-input v-model="form.totalSize" placeholder="请输入文件总大小" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listFilelist, getFilelist, delFilelist, addFilelist, updateFilelist, exportFilelist } from "@/api/system/file/filelist";
import Bus from '@/assets/js/bus'
import {getToken} from '@/utils/auth'
export default {
name: "Filelist",
data() {
return {
// 遮罩层
loading: true,
// 选中数组
ids: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 总条数
total: 0,
// 已上传文件列表表格数据
filelistList: [],
// 弹出层标题
title: "",
// 是否显示弹出层
open: false,
// 查询参数
queryParams: {
pageNum: 1,
pageSize: 10,
fileName: undefined,
identifier: undefined,
url: undefined,
location: undefined,
totalSize: undefined
},
// 表单参数
form: {},
// 表单校验
rules: {
fileName: [
{ required: true, message: "文件名不能为空", trigger: "blur" }
],
identifier: [
{ required: true, message: "唯一标识,MD5不能为空", trigger: "blur" }
],
url: [
{ required: true, message: "链接不能为空", trigger: "blur" }
],
}
};
},
created() {
this.getList();
Bus.$on('refreshFileList', this.getList); // 监听刷新文件列表事件
},
beforeDestroy() {
Bus.$off('refreshFileList', this.getList); // 销毁前取消监听
},
methods: {
storageFormatter(row, column){
let totalSize= row.totalSize;
//1073741824为1G
if(totalSize>=1073741824){
return Math.round((row.totalSize/1073741824)*100)/100+"G"
}else if(totalSize>=1048576){ //1048576为1M
return Math.round((row.totalSize/1048576)*100)/100+"M"
}else{
return Math.round((row.totalSize/1024)*100)/100+"K"
}
},
// 时间格式化
dataFormat(row,column){
let t = new Date(row.createTime); //row 表示一行数据, createTime 表示要格式化的字段名称
let year=t.getFullYear(),
month=t.getMonth()+1,
day=t.getDate(),
hour=t.getHours(),
min=t.getMinutes(),
sec=t.getSeconds();
let newTime = year+'-'+
(month<10?'0'+month:month)+'-'+
(day<10?'0'+day:day)+' '+
(hour<10?'0'+hour:hour)+':'+
(min<10?'0'+min:min)+':'+
(sec<10?'0'+sec:sec);
return newTime;
},
upload() {
// 打开文件选择框
Bus.$emit('openUploader', {
token: getToken()
})
},
/** 查询已上传文件列表列表 */
getList() {
this.loading = true;
listFilelist(this.queryParams).then(response => {
this.filelistList = response.rows;
this.total = response.total;
this.loading = false;
});
},
// 取消按钮
cancel() {
this.open = false;
this.reset();
},
// 表单重置
reset() {
this.form = {
id: undefined,
fileName: undefined,
identifier: undefined,
url: undefined,
location: undefined,
totalSize: undefined
};
this.resetForm("form");
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1;
this.getList();
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm");
this.handleQuery();
},
// 多选框选中数据
// handleSelectionChange(selection) {
// this.ids = selection.map(item => item.id)
// this.single = selection.length!=1
// this.multiple = !selection.length
// },
/** 多选框选中数据 */
handleSelectionChange(selection) {
this.ids = selection.map(item => item.id)
this.multiple = !selection.length
},
/** 新增按钮操作 */
handleAdd() {
this.reset();
this.open = true;
this.title = "添加已上传文件列表";
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset();
const id = row.id || this.ids
getFilelist(id).then(response => {
this.form = response.data;
this.open = true;
this.title = "修改已上传文件列表";
});
},
/** 提交按钮 */
submitForm: function() {
this.$refs["form"].validate(valid => {
if (valid) {
if (this.form.id != undefined) {
updateFilelist(this.form).then(response => {
if (response.code === 200) {
this.msgSuccess("修改成功");
this.open = false;
this.getList();
} else {
this.msgError(response.msg);
}
});
} else {
addFilelist(this.form).then(response => {
if (response.code === 200) {
this.msgSuccess("新增成功");
this.open = false;
this.getList();
} else {
this.msgError(response.msg);
}
});
}
}
});
},
/** 删除按钮操作 */
handleDelete(row) {
const ids = row.id || this.ids;
this.$confirm('是否确认删除已上传文件列表编号为"' + ids + '"的数据项?', "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(function() {
return delFilelist(ids);
}).then(() => {
this.getList();
this.msgSuccess("删除成功");
}).catch(function() {});
},
/** 导出按钮操作 */
handleExport() {
const queryParams = this.queryParams;
this.$confirm('是否确认导出所有已上传文件列表数据项?', "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(function() {
return exportFilelist(queryParams);
}).then(response => {
this.download(response.msg);
}).catch(function() {});
}
},
mounted() {
// 文件选择后的回调
Bus.$on('fileAdded', () => {
console.log('文件已选择')
});
// 文件上传成功的回调
Bus.$on('fileSuccess', () => {
console.log('文件上传成功')
});
}
};
</script>
上传组件部分的源码
<template>
<div id="global-uploader">
<!-- 上传 -->
<uploader
ref="uploader"
:options="options"
:autoStart="false"
@file-added="onFileAdded"
@file-success="onFileSuccess"
@file-progress="onFileProgress"
@file-error="onFileError"
class="uploader-app">
<uploader-unsupport></uploader-unsupport>
<uploader-btn id="global-uploader-btn" :attrs="attrs" ref="uploadBtn">选择文件</uploader-btn>
<uploader-list v-show="panelShow">
<div class="file-panel" slot-scope="props" :class="{'collapse': collapse}">
<div class="file-title">
<h2>文件列表</h2>
<div class="operate">
<el-button @click="fileListShow" type="text" :title="collapse ? '展开':'折叠' ">
<i class="el-icon-d-caret" style="color:black;font-size: 18px"
:class="collapse ? 'inuc-fullscreen': 'inuc-minus-round'"></i>
</el-button>
<el-button @click="close" type="text" title="关闭">
<i class="el-icon-close" style="color:black;font-size: 18px"></i>
</el-button>
</div>
</div>
<ul class="file-list">
<li v-for="file in props.fileList" :key="file.id">
<uploader-file :class="'file_' + file.id" ref="files" :file="file" :list="true"></uploader-file>
</li>
<div class="no-file" v-if="!props.fileList.length"><i class="iconfont icon-empty-file"></i> 暂无待上传文件</div>
</ul>
</div>
</uploader-list>
</uploader>
</div>
</template>
<script>
/**
* 全局上传插件
* 调用方法:Bus.$emit('openUploader', {}) 打开文件选择框,参数为需要传递的额外参数
* 监听函数:Bus.$on('fileAdded', fn); 文件选择后的回调
* Bus.$on('fileSuccess', fn); 文件上传成功的回调
*/
import {ACCEPT_CONFIG} from '@/assets/js/config'
import Bus from '@/assets/js/bus'
import SparkMD5 from 'spark-md5';
import { fileMerge } from '@/api/system/file/fileuploader/fileuploader';
export default {
data() {
return {
options: {
// 目标上传 URL
target: process.env.VUE_APP_BASE_API +'/fileUpload/api/upload',
//分块大小
chunkSize: 5 * 1024 * 1000,
//上传文件时文件的参数名,默认file
fileParameterName: 'file',
//并发上传数
//simultaneousUploads: 1,
//最大自动失败重试上传次数
maxChunkRetries: 2,
//重试间隔 单位毫秒
//chunkRetryInterval: 5000,
//是否开启服务器分片校验
testChunks: true,
// 服务器分片校验函数,秒传及断点续传基础
checkChunkUploadedByResponse: function (chunk, message) {
let objMessage = JSON.parse(message);
if (objMessage.skipUpload) {
return true;
}
return (objMessage.uploaded || []).indexOf(chunk.offset + 1) >= 0
},
headers: {
Authorization: ''
},
// 额外的自定义查询参数
query() {
}
},
attrs: {
accept: ACCEPT_CONFIG.getAll()
},
panelShow: false, //选择文件后,展示上传panel
collapse: false
}
},
mounted() {
//接收子组件触发的事件
Bus.$on('openUploader', query => {
this.params = query || {};
this.options.headers.Authorization = 'Bearer ' + query.token
if (this.$refs.uploadBtn) {
$("#global-uploader-btn").click();
}
});
},
computed: {
//Uploader实例
uploader() {
return this.$refs.uploader.uploader;
}
},
methods: {
onFileAdded(file) {
this.panelShow = true;
this.computeMD5(file);
Bus.$emit('fileAdded');
},
//上传过程中,会不断触发file-progress上传进度的回调
onFileProgress(rootFile, file, chunk) {
console.log(`上传中 ${file.name},chunk:${chunk.startByte / 1024 / 1024} ~ ${chunk.endByte / 1024 / 1024}`)
},
onFileSuccess(rootFile, file, response, chunk) {
let res = JSON.parse(response);
// TODO 如有需要 解开注释 和后台协议如何处理这种情况:服务器自定义的错误(即虽返回200,但是是错误的情况),这种错误是Uploader无法拦截的
// if (!res.result) {
// this.$message({message: res.message, type: 'error'});
// // 文件状态设为“失败”
// this.statusSet(file.id, 'failed');
// return
// }
// 如果服务端返回需要合并
if (res.needMerge) {
// 文件状态设为“合并中”
this.statusSet(file.id, 'merging');
let param = {
'fileName': rootFile.name,
'identifier': rootFile.uniqueIdentifier,
'totalSize': rootFile.size
}
fileMerge(param).then(res => {
// 文件合并成功
Bus.$emit('fileSuccess');
this.statusRemove(file.id);
this.panelShow = false
Bus.$emit('refreshFileList'); // 触发刷新文件列表事件
}).catch(e => {
console.log("合并异常,重新发起请求,文件名为:", file.name)
//由于网络或服务器原因,导致合并过程中断线,此时如果不重新发起请求,就会进入失败的状态,导致该文件无法重试
file.retry();
});
// 不需要合并
} else {
Bus.$emit('fileSuccess');
console.log('上传成功');
this.panelShow = false
Bus.$emit('refreshFileList'); // 触发刷新文件列表事件
}
},
onFileError(rootFile, file, response, chunk) {
this.$message({
message: response,
type: 'error'
})
},
/**
* 计算md5,实现断点续传及秒传
* @param file
*/
computeMD5(file) {
let fileReader = new FileReader();
let time = new Date().getTime();
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
let currentChunk = 0;
const chunkSize = 10 * 1024 * 1000;
let chunks = Math.ceil(file.size / chunkSize);
let spark = new SparkMD5.ArrayBuffer();
// 文件状态设为"计算MD5"
this.statusSet(file.id, 'md5');
file.pause();
loadNext();
fileReader.onload = (e => {
spark.append(e.target.result);
if (currentChunk < chunks) {
currentChunk++;
loadNext();
// 实时展示MD5的计算进度
this.$nextTick(() => {
$(`.myStatus_${file.id}`).text('校验MD5 ' + ((currentChunk / chunks) * 100).toFixed(0) + '%')
})
} else {
let md5 = spark.end();
this.computeMD5Success(md5, file);
console.log(`MD5计算完毕:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${file.size} 用时:${new Date().getTime() - time} ms`);
}
});
fileReader.onerror = function () {
this.error(`文件${file.name}读取出错,请检查该文件`)
file.cancel();
};
function loadNext() {
let start = currentChunk * chunkSize;
let end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));
}
},
computeMD5Success(md5, file) {
// 将自定义参数直接加载uploader实例的opts上
Object.assign(this.uploader.opts, {
query: {
...this.params,
fileName: file.name
}
})
file.uniqueIdentifier = md5;
file.resume();
this.statusRemove(file.id);
},
fileListShow() {
let $list = $('#global-uploader .file-list');
if ($list.is(':visible')) {
$list.slideUp();
this.collapse = true;
} else {
$list.slideDown();
this.collapse = false;
}
},
close() {
this.uploader.cancel();
this.panelShow = false;
},
/**
* 新增的自定义的状态: 'md5'、'transcoding'、'failed'
* @param id
* @param status
*/
statusSet(id, status) {
let statusMap = {
md5: {
text: '校验MD5',
bgc: '#fff'
},
merging: {
text: '合并中',
bgc: '#e2eeff'
},
transcoding: {
text: '转码中',
bgc: '#e2eeff'
},
failed: {
text: '上传失败',
bgc: '#e2eeff'
}
}
this.$nextTick(() => {
$(`<p class="myStatus_${id}"></p>`).appendTo(`.file_${id} .uploader-file-status`).css({
'position': 'absolute',
'top': '0',
'left': '0',
'right': '0',
'bottom': '0',
'zIndex': '1',
'line-height': 'initial',
'backgroundColor': statusMap[status].bgc
}).text(statusMap[status].text);
})
},
statusRemove(id) {
this.$nextTick(() => {
$(`.myStatus_${id}`).remove();
})
},
error(msg) {
this.$notify({
title: '错误',
message: msg,
type: 'error',
duration: 2000
})
}
},
watch: {},
destroyed() {
Bus.$off('openUploader');
},
components: {}
}
</script>
<style scoped lang="scss">
#global-uploader {
position: fixed;
z-index: 20;
right: 30%;
top: 28%;
h1, h2 {
font-weight: normal;
}
ul {
list-style-type: none;
padding: 0px 4px;
}
li {
display: inline-block;
}
.uploader-app {
width: 660px;
}
.file-panel {
background-color: #fff;
border: 1px solid #e2e2e2;
border-radius: 7px 7px 0 0;
box-shadow: 0 0 10px rgba(0, 0, 0, .2);
.file-title {
display: flex;
height: 45px;
padding: 0 15px;
border-bottom: 1px solid #ddd;
font-size: 14px;
.operate {
flex: 1;
text-align: right;
}
}
.file-list {
position: relative;
height: 264px;
width: 654px;
overflow-x: hidden;
overflow-y: auto;
background-color: #fff;
> li {
background-color: #fff;
}
}
&.collapse {
.file-title {
background-color: #E7ECF2;
}
}
}
.no-file {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 16px;
}
.uploader-file {
width: 654px;
}
///deep/ .uploader-file-icon {
.uploader-file-icon {
&:before {
content: '' !important;
}
&[icon=image] {
background: url('~@/assets/images/image-icon.png');
}
&[icon=video] {
background: url('~@/assets/images/video-icon.png');
}
&[icon=document] {
background: url('~@/assets/images/text-icon.png');
}
}
///deep/ .uploader-file-actions > span {
.uploader-file-actions > span {
margin-right: 6px;
}
}
/* 隐藏上传按钮 */
#global-uploader-btn {
position: absolute;
clip: rect(0, 0, 0, 0);
}
/*.uploader-list>ul>li{*/
/* width:100%;*/
/* color:red;*/
/* margin-bottom: 0;*/
/*}*/
/*a {*/
/* color: #42b983;*/
/*}*/
</style>