Vue Elementui+SpringBoot做大文件切片上传

简介: Vue Elementui+SpringBoot做大文件切片上传

主要思想是前端做大文件的MD5计算和大文件的切分,后台做小文件的合并和MD5的校验,直接上代码。

前端VUE:

<template><el-uploadref="upload"class="upload-demo"action="#":http-request="handleUpload":on-change="handleFileChange":before-upload="handleBeforeUpload":auto-upload="true":multiple="false":file-list="fileList"><el-buttonslot="trigger"size="small"type="primary">选取文件</el-button><!-- <el-button style="margin-left: 10px;" size="small" type="success" @click="handleUpload">上传文件</el-button> --></el-upload></template><script>importSparkMD5from'spark-md5'importaxiosfrom"axios";
import {
getKey,completeUpload} from"@/api/fileupload";
exportdefault {
data() {
return {
percent: 0,
fileList: [],
chunkSize: 6*1024*1024, // 分片大小为2MB    }
  },
methods: {
handleFileChange(file) {
this.fileList= [file]
    },
handleBeforeUpload(file) {
// 根据文件大小判断是否需要分片if (file.size>this.chunkSize) {
file.chunked=truefile.percent=0      }
returntrue    },
handleUpload(params) {
constfile=params.fileletstartTime=newDate().getTime()
if (file.chunked) {
constloading=this.$loading({
lock: true,
text: '大文件正在计算M5,请稍后',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)'        });
constreaderTotal=newFileReader()
readerTotal.readAsArrayBuffer(file)
readerTotal.onload= () => {
constsparkTotal=newSparkMD5.ArrayBuffer()
sparkTotal.append(readerTotal.result)
constmd5=sparkTotal.end()
letquery= {name:md5}
getKey(query).then(resKey=>{
consttotalChunks=Math.ceil(file.size/this.chunkSize)
letcurrentChunk=0constconfig= {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: (progressEvent) => {
this.percent=Math.round((currentChunk+1) /totalChunks*100)
loading.setText('大文件正在分片上传,请稍后'+this.percent+'%')
              },
            };
constreader=newFileReader()
constspark=newSparkMD5.ArrayBuffer()
constuploadChunk= () => {
conststart=currentChunk*this.chunkSizeconstend=Math.min(file.size, start+this.chunkSize)
reader.readAsArrayBuffer(file.slice(start, end))
reader.onload= () => {
spark.append(reader.result)
constchunk=newBlob([reader.result])
chunk.file=filechunk.currentChunk=currentChunkchunk.totalChunks=totalChunkschunk.sparkMD5=spark.end()
constformData=newFormData()
formData.append('chunk', chunk)
formData.append('filename', file.name)
formData.append('totalChunks', totalChunks)
formData.append('key', resKey.data)
formData.append('currentChunk', currentChunk)
axios.post(process.env.VUE_APP_BASE_API+"app/fileupload/file/upload/chunk", formData, config)
                  .then(response=> {
if (currentChunk<totalChunks-1) {
currentChunk++uploadChunk()
                    } else {
letdata= {key:resKey.data,fileName:file.name}
completeUpload(data).then(res=>{
this.fileList= []
loading.close();
this.percent=0this.$message.success('上传成功')
letendTime=newDate().getTime()
console.log('Chunk Cost:'+endTime-startTime)
                      })
                    }
                  })
                  .catch(error=> {
this.$message.error(error.message)
                  })
              }
            }
uploadChunk()
          });
        }
      } else {
constloading=this.$loading({
lock: true,
text: '正在上传,请稍后',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)'        });
constself=this;
constfile=params.fileconstformData=newFormData();
formData.append(self.upload_name, file);
constconfig= {
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress: (progressEvent) => {
this.percent=              ((progressEvent.loaded/progressEvent.total) *100) |0;
loading.setText('正在上传,请稍后'+this.percent+'%')
          },
        };
axios        .post(
process.env.VUE_APP_BASE_API+"app/fileupload/file/upload",
formData,
config        )
        .then((res) => {
loading.close();
this.$message.success('上传成功')
this.percent=0letendTime=newDate().getTime()
console.log('Common Cost:'+endTime-startTime)
        })
        .catch((err) => {
console.log(err);
        });
      }
    }
  }
}
</script>

api/fileupload.js

importrequestfrom'@/utils/request'exportfunctiongetKey(query) {
returnrequest({
url: '/app/fileupload/getKey',
method: 'get',
params: query  })
}
exportfunctioncompleteUpload(data) {
returnrequest({
url: '/app/fileupload/completeUpload',
method: 'post',
data  })
}

utils/request.js

importaxiosfrom'axios'import { MessageBox, Message } from'element-ui'importstorefrom'@/store'import { getToken } from'@/utils/auth'importrouterfrom'@/router'axios.defaults.headers.common['Content-Type'] ='application/json;charset=UTF-8'axios.defaults.headers.common['X-Requested-With'] ='XMLHttpRequest'axios.defaults.withCredentials=true// create an axios instanceconstservice=axios.create({
baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url// withCredentials: true, // send cookies when cross-domain requeststimeout: 50000// request timeout})
// request interceptorservice.interceptors.request.use(
config=> {
// do something before request is sentif (store.getters.token) {
// let each request carry token// ['X-Token'] is a custom headers key// please modify it according to the actual situationconfig.headers['X-Token'] =getToken()
    }
returnconfig  },
error=> {
// do something with request errorconsole.log(error) // for debugreturnPromise.reject(error)
  }
)
// response interceptorservice.interceptors.response.use(
/**   * If you want to get http information such as headers or status   * Please return  response => response  *//**   * Determine the request status by custom code   * Here is just an example   * You can also judge the status by HTTP Status Code   */response=> {
constres=response.dataif(res.ret===200||res.ret===404){
returnres;
    }
// if the custom code is not 20000, it is judged as an error.if (res.code!==20000) {
// 50008: Illegal token; 50012: Other clients logged in; 50014: Token expired;// if (res.code === 50008 || res.code === 50012 || res.code === 50014) {if (res.code===50014) {
// to re-loginMessageBox.confirm('您已经登出了,您可以取消继续待在此页面或者重新登录', '登出确认', {
confirmButtonText: '重新登录',
cancelButtonText: '取消',
type: 'warning'        }).then(() => {
store.dispatch('user/resetToken').then(() => {
if (res.data===2) {
router.push(`/login`)
            } else {
router.push(`/member/login`)
            }
// location.reload()// const type = this.$store.getters.type// if (type === 1) {//   this.$router.push(`/member/login?redirect=${this.$route.fullPath}`)// } else {//   this.$router.push(`/login?redirect=${this.$route.fullPath}`)// }          })
        })
      } else {
Message({
message: res.message||'Error',
type: 'error',
duration: 5*1000        })
      }
returnPromise.reject(newError(res.message||'Error'))
    } else {
returnres    }
  },
error=> {
Message({
message: '网络异常',
type: 'error',
duration: 5*1000    })
returnPromise.reject(error)
  }
)
exportdefaultservice

自行安装axios,spark-md5库,下面就是后端代码

importjakarta.servlet.http.HttpServletRequest;
importorg.springframework.beans.factory.annotation.Autowired;
importorg.springframework.beans.factory.annotation.Value;
importorg.springframework.web.bind.annotation.*;
importorg.springframework.web.multipart.MultipartFile;
importorg.springframework.web.multipart.MultipartHttpServletRequest;
importjava.io.*;
importjava.nio.channels.FileChannel;
importjava.nio.file.Files;
importjava.nio.file.Paths;
importjava.nio.file.Path;
importjava.text.SimpleDateFormat;
importjava.util.*;
importjava.util.stream.Collectors;
@RestController@RequestMapping("/fileupload")
publicclassFileUploadController {
@Value("${file.dir}")
privateStringfiledir;
@AutowiredprivateMessageUtilsmessageUtils;
@RequestMapping(value="/getKey")
public@ResponseBodyResponseDatagetKey(@RequestParamStringname) throwsException{
ResponseDatarest=newResponseData();
rest.setRet(20000);
rest.setMsg("success");
Stringkey=UUID.randomUUID().toString();
rest.setData(key);
AppConstants.fileKeyMd5Map.put(key,name);
AppConstants.fileKeyChunkMap.put(key,newArrayList<>());
returnrest;
    }
@RequestMapping(value="/completeUpload")
public@ResponseBodyResponseDatacompleteUpload(@RequestBodyFileCompleteUploadDtofileCompleteUploadDto,HttpServletRequestrequest) throwsException{
ResponseDatarest=newResponseData();
rest.setRet(20000);
rest.setMsg("success");
Stringkey=fileCompleteUploadDto.getKey();
Stringmd5=AppConstants.fileKeyMd5Map.get(key);
//按照顺序合并文件List<FileChunkObjDto>fileChunkObjDtoList=AppConstants.fileKeyChunkMap.get(key);
List<FileChunkObjDto>newfileChunkObjDtoList=fileChunkObjDtoList.stream().sorted(Comparator.comparing(FileChunkObjDto::getIndex)).collect(Collectors.toList());
StringfileExt=fileCompleteUploadDto.getFileName().substring(fileCompleteUploadDto.getFileName().lastIndexOf(".") +1).toLowerCase();
StringlocalHost=UrlUtils.getLocalRealIp();
Stringport=String.valueOf(request.getLocalPort());
SimpleDateFormatdf=newSimpleDateFormat("yyyyMMddHHmmss");
StringnewFileName=df.format(newDate()) +"_"+localHost+"_"+port+"_"+newRandom().nextInt(1000) +"."+fileExt;
FilenewFile=newFile(tifiledir+newFileName);
FileChannelresultfileChannel=newFileOutputStream(newFile).getChannel();
for(FileChunkObjDtofileChunkObjDto:newfileChunkObjDtoList){
FileChannelfileChannel=newFileInputStream(tifiledir+fileChunkObjDto.getFileName()).getChannel();
resultfileChannel.transferFrom(fileChannel,resultfileChannel.size(),fileChannel.size());
fileChannel.close();
        }
resultfileChannel.close();
//校验md5//      System.out.println("a md5:"+md5);//      System.out.println("b md5:"+ FileUtils.getMD5(tifiledir+newFileName));for(FileChunkObjDtofileChunkObjDto:newfileChunkObjDtoList){
try {
PathsourcePath=Paths.get(tifiledir+fileChunkObjDto.getFileName());
Files.delete(sourcePath);
            }catch (Exceptione){
            }
        }
//删除keyAppConstants.fileKeyMd5Map.remove(key);
AppConstants.fileKeyChunkMap.remove(key);
returnrest;
    }
@RequestMapping(value="/file/upload/chunk")
public@ResponseBodyResponseDatauploadChunkFile(@RequestParamMap<String,String>map, HttpServletRequestrequest) throwsException{
ResponseDatarest=newResponseData();
rest.setRet(20000);
rest.setMsg("success");
MultipartHttpServletRequestmultipartRequest= (MultipartHttpServletRequest) request;
Map<String, MultipartFile>fileMap=multipartRequest.getFileMap();
Stringkey=map.get("key");
StringcurrentChunk=map.get("currentChunk");
List<FileChunkObjDto>fileChunkObjDtoList=AppConstants.fileKeyChunkMap.get(key);
FileChunkObjDtofileChunkObjDto=newFileChunkObjDto();
fileChunkObjDto.setIndex(Integer.parseInt(currentChunk));
StringctxPath=filedir;
//创建文件夹Filefile=newFile(ctxPath);
if (!file.exists()) {
file.mkdirs();
        }
StringlocalHost=UrlUtils.getLocalRealIp();
Stringport=String.valueOf(request.getLocalPort());
SimpleDateFormatdf=newSimpleDateFormat("yyyyMMddHHmmss");
for (Map.Entry<String, MultipartFile>entity : fileMap.entrySet()) {
// 上传文件名MultipartFilemf=entity.getValue();
StringnewFileName=df.format(newDate()) +"_"+localHost+"_"+port+"_"+newRandom().nextInt(1000);
fileChunkObjDto.setFileName(newFileName);
fileChunkObjDtoList.add(fileChunkObjDto);
StringfilePath=ctxPath+newFileName;
FileuploadFile=newFile(filePath);
InputStreamin=null;
FileOutputStreamout=null;
try {
byteb[] =newbyte[1024*1024];
inti=0;
in=mf.getInputStream();
out=newFileOutputStream(uploadFile);
while ((i=in.read(b)) !=-1) {
//写出读入的字节数组,每次从0到所读入的位置,不会多写out.write(b, 0, i);
                }
            } catch (IOExceptione) {
e.printStackTrace();
            } finally {
try {
//关闭流if (in!=null) {
in.close();
                    }
if (out!=null) {
out.close();
                    }
                } catch (IOExceptione) {
thrownewRuntimeException(e);
                }
            }
        }
returnrest;
    }
@RequestMapping(value="/file/upload")
public@ResponseBodyResponseDatauploadFile(@RequestParamMap<String,String>map, HttpServletRequestrequest) throwsException{
ResponseDatarest=newResponseData();
rest.setRet(20000);
rest.setMsg("success");
MultipartHttpServletRequestmultipartRequest= (MultipartHttpServletRequest) request;
Map<String, MultipartFile>fileMap=multipartRequest.getFileMap();
StringctxPath=filedir;
//创建文件夹Filefile=newFile(ctxPath);
if (!file.exists()) {
file.mkdirs();
        }
StringfileName=null;
StringlocalHost=UrlUtils.getLocalRealIp();
Stringport=String.valueOf(request.getLocalPort());
SimpleDateFormatdf=newSimpleDateFormat("yyyyMMddHHmmss");
for (Map.Entry<String, MultipartFile>entity : fileMap.entrySet()) {
// 上传文件名MultipartFilemf=entity.getValue();
fileName=mf.getOriginalFilename();
StringfileExt=fileName.substring(fileName.lastIndexOf(".") +1).toLowerCase();
StringnewFileName=df.format(newDate()) +"_"+localHost+"_"+port+"_"+newRandom().nextInt(1000) +"."+fileExt;
StringfilePath=ctxPath+newFileName;
FileuploadFile=newFile(filePath);
InputStreamin=null;
FileOutputStreamout=null;
try {
byteb[] =newbyte[1024*1024];
inti=0;
in=mf.getInputStream();
out=newFileOutputStream(uploadFile);
while ((i=in.read(b)) !=-1) {
//写出读入的字节数组,每次从0到所读入的位置,不会多写out.write(b, 0, i);
                }
            } catch (IOExceptione) {
e.printStackTrace();
            } finally {
try {
//关闭流if (in!=null) {
in.close();
                    }
if (out!=null) {
out.close();
                    }
                } catch (IOExceptione) {
thrownewRuntimeException(e);
                }
            }
        }
returnrest;
    }
}

AppConstants.java

publicstaticfinalMap<String,String>fileKeyMd5Map=newHashMap<>(){};
publicstaticfinalMap<String, List<FileChunkObjDto>>fileKeyChunkMap=newHashMap<>(){};

UrlUtils.java

publicclassUrlUtils {
publicstaticintvalidateUrl(Stringurl){
URLu=null;
HttpURLConnectionurlconn=null;
intstate=0;
do{
try {
u=newURL(url);
urlconn= (HttpURLConnection) u.openConnection();
//System.out.println(urlconn.);urlconn.setConnectTimeout(2000);
state=urlconn.getResponseCode();
            }catch(SocketTimeoutExceptione){
state=2;
e.printStackTrace();
            }catch (Exceptione) {
e.printStackTrace();
break;
            }
        }while(false);
returnstate;
    }
publicstaticStringgetLocalRealIp(){
InetAddressaddr=null;
try {
addr=InetAddress.getLocalHost();
            } catch (UnknownHostExceptione) {
e.printStackTrace();
            }
byte[] ipAddr=addr.getAddress();
//String ipAddrStr = "";StringBuilderipAddrStr=newStringBuilder();
for (inti=0; i<ipAddr.length; i++) {
if (i>0) {
ipAddrStr.append(".");
                }
ipAddrStr.append(ipAddr[i] &0xFF);
            }
//System.out.println(ipAddrStr);returnipAddrStr.toString();
    }
/** * 获取当前网络ip * @param request * @return */publicstaticStringgetIpAddr(HttpServletRequestrequest){  
StringipAddress=request.getHeader("x-forwarded-for");  
if(ipAddress==null||ipAddress.length() ==0||"unknown".equalsIgnoreCase(ipAddress)) {  
ipAddress=request.getHeader("Proxy-Client-IP");  
            }  
if(ipAddress==null||ipAddress.length() ==0||"unknown".equalsIgnoreCase(ipAddress)) {  
ipAddress=request.getHeader("WL-Proxy-Client-IP");  
            }  
if(ipAddress==null||ipAddress.length() ==0||"unknown".equalsIgnoreCase(ipAddress)) {  
ipAddress=request.getRemoteAddr();  
if(ipAddress.equals("127.0.0.1") ||ipAddress.equals("0:0:0:0:0:0:0:1")){  
//根据网卡取本机配置的IP  InetAddressinet=null;  
try {  
inet=InetAddress.getLocalHost();  
                    } catch (UnknownHostExceptione) {  
e.printStackTrace();  
                    }  
ipAddress=inet.getHostAddress();  
                }  
            }  
//对于通过多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割  if(ipAddress!=null&&ipAddress.length()>15){ //"***.***.***.***".length() = 15  if(ipAddress.indexOf(",")>0){  
ipAddress=ipAddress.substring(0,ipAddress.indexOf(","));  
                }  
            }  
returnipAddress;   
    }
publicstaticvoidmain(String []args){
System.out.println(getLocalRealIp());
    }
}

ResponseData.java

importcom.alibaba.fastjson.JSON;
importcom.github.pagehelper.PageInfo;
importorg.springframework.util.Assert;
importjava.util.Iterator;
importjava.util.LinkedHashMap;
importjava.util.Map;
/*** 目前vue前台解析返回格式是这样的*/publicclassResponseDataextendsLinkedHashMap<String, Object> {
//设置返回标准publicstaticfinalStringRET="code";//返回代码publicstaticfinalStringMSG="message";//返回信息publicstaticfinalStringDATA="data";//其他内容publicstaticfinalStringTIMESTAMP="timestamp";
publicResponseData() {
this.setRet(200).setMsg("请求成功").setTimestamp(System.currentTimeMillis());
    }
publicResponseData(intret, Stringmsg) {
this.setRet(ret).setMsg(msg).setTimestamp(System.currentTimeMillis());
    }
publicResponseData(intret, Stringmsg, ObjectattributeValue) {
this.setRet(ret).setMsg(msg).setTimestamp(System.currentTimeMillis()).setData(attributeValue);
    }
publicResponseData(intret, Stringmsg, PageInfopageData) {
this.setRet(ret).setMsg(msg).setTimestamp(System.currentTimeMillis()).setPageData(pageData);
    }
publicResponseData(intret, Stringmsg, StringattributeName, ObjectattributeValue) {
this.setRet(ret).setMsg(msg).setTimestamp(System.currentTimeMillis()).addAttribute(attributeName, attributeValue);
    }
publicintgetRet() {
return (Integer)super.get(RET);
    }
publicResponseDatasetRet(intret) {
this.put(RET, ret);
returnthis;
    }
publiclonggetTimestamp() {
return (Long)super.get(TIMESTAMP);
    }
publicResponseDatasetTimestamp(longtimestamp) {
this.put(TIMESTAMP, timestamp);
returnthis;
    }
publicStringgetMsg() {
return (String)super.get(MSG);
    }
publicResponseDatasetMsg(Stringmsg) {
this.put(MSG, msg);
returnthis;
    }
publicResponseDatasetData(ObjectattributeValue) {
this.put(DATA, attributeValue);
returnthis;
    }
publicResponseDatasetPageData(PageInfopageinfo){
PageDatapageData=newPageData(pageinfo);
this.put(DATA,pageData);
returnthis;
    }
publicResponseDataaddAttribute(StringattributeName, ObjectattributeValue) {
Assert.notNull(attributeName, "Model attribute name must not be null");
this.put(attributeName, attributeValue);
returnthis;
    }
publicResponseDataaddAllAttributes(Map<String, ?>attributes) {
if (attributes!=null) {
this.putAll(attributes);
        }
returnthis;
    }
publicResponseDatamergeAttributes(Map<String, ?>attributes) {
if (attributes!=null) {
Iteratorvar2=attributes.entrySet().iterator();
while(var2.hasNext()) {
Map.Entry<String,?>entry= (Map.Entry<String,?>)var2.next();
if (!this.containsKey(entry.getKey())) {
this.put(entry.getKey(), entry.getValue());
                }
            }
        }
returnthis;
    }
publicbooleancontainsAttribute(StringattributeName) {
returnthis.containsKey(attributeName);
    }
publicStringtoJsonString() {
returnJSON.toJSONString(this);
    }
}

FileChunkObjDto.java和FileCompleteUploadDto.java

@DatapublicclassFileChunkObjDto {
privateIntegerindex;
privateStringfileName;
}
@DatapublicclassFileCompleteUploadDto {
privateStringkey;
privateStringfileName;
}

md5的比较校验可以自行做业务判断,缓存的处理可以使用redis做终端断点续传的长期存储改造。

目录
相关文章
|
1月前
|
JavaScript 安全 Java
如何使用 Spring Boot 和 Ant Design Pro Vue 构建一个具有动态路由和菜单功能的前后端分离应用。
本文介绍了如何使用 Spring Boot 和 Ant Design Pro Vue 构建一个具有动态路由和菜单功能的前后端分离应用。首先,创建并配置 Spring Boot 项目,实现后端 API;然后,使用 Ant Design Pro Vue 创建前端项目,配置动态路由和菜单。通过具体案例,展示了如何快速搭建高效、易维护的项目框架。
114 62
|
6天前
|
存储 JavaScript 前端开发
基于 SpringBoot 和 Vue 开发校园点餐订餐外卖跑腿Java源码
一个非常实用的校园外卖系统,基于 SpringBoot 和 Vue 的开发。这一系统源于黑马的外卖案例项目 经过站长的进一步改进和优化,提供了更丰富的功能和更高的可用性。 这个项目的架构设计非常有趣。虽然它采用了SpringBoot和Vue的组合,但并不是一个完全分离的项目。 前端视图通过JS的方式引入了Vue和Element UI,既能利用Vue的快速开发优势,
52 13
|
14天前
|
JavaScript 安全 Java
java版药品不良反应智能监测系统源码,采用SpringBoot、Vue、MySQL技术开发
基于B/S架构,采用Java、SpringBoot、Vue、MySQL等技术自主研发的ADR智能监测系统,适用于三甲医院,支持二次开发。该系统能自动监测全院患者药物不良反应,通过移动端和PC端实时反馈,提升用药安全。系统涵盖规则管理、监测报告、系统管理三大模块,确保精准、高效地处理ADR事件。
|
1月前
|
Java 应用服务中间件
SpringBoot获取项目文件的绝对路径和相对路径
SpringBoot获取项目文件的绝对路径和相对路径
91 1
SpringBoot获取项目文件的绝对路径和相对路径
|
1月前
|
网络协议 Java
springboot配置hosts文件
springboot配置hosts文件
46 11
|
1月前
|
存储 前端开发 JavaScript
|
1月前
|
存储 Java API
|
1月前
|
JavaScript 安全 Java
如何使用 Spring Boot 和 Ant Design Pro Vue 构建一个前后端分离的应用框架,实现动态路由和菜单功能
本文介绍了如何使用 Spring Boot 和 Ant Design Pro Vue 构建一个前后端分离的应用框架,实现动态路由和菜单功能。首先,确保开发环境已安装必要的工具,然后创建并配置 Spring Boot 项目,包括添加依赖和配置 Spring Security。接着,创建后端 API 和前端项目,配置动态路由和菜单。最后,运行项目并分享实践心得,帮助开发者提高开发效率和应用的可维护性。
80 2
|
1月前
|
JavaScript Java 项目管理
Java毕设学习 基于SpringBoot + Vue 的医院管理系统 持续给大家寻找Java毕设学习项目(附源码)
基于SpringBoot + Vue的医院管理系统,涵盖医院、患者、挂号、药物、检查、病床、排班管理和数据分析等功能。开发工具为IDEA和HBuilder X,环境需配置jdk8、Node.js14、MySQL8。文末提供源码下载链接。
|
1月前
|
JavaScript NoSQL Java
CC-ADMIN后台简介一个基于 Spring Boot 2.1.3 、SpringBootMybatis plus、JWT、Shiro、Redis、Vue quasar 的前后端分离的后台管理系统
CC-ADMIN后台简介一个基于 Spring Boot 2.1.3 、SpringBootMybatis plus、JWT、Shiro、Redis、Vue quasar 的前后端分离的后台管理系统
45 0

热门文章

最新文章