From 45bccc6ba013570e0a2e9eec0506e9e849ebbc63 Mon Sep 17 00:00:00 2001 From: callmeyan Date: Tue, 10 May 2022 08:33:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E6=AD=A5=E5=AE=9E=E7=8E=B0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- driver/pom.xml | 6 + .../driver/controller/UploadController.java | 80 + .../xyz/longicorn/driver/pojo/FileInfo.java | 2 +- .../longicorn/driver/service/FileService.java | 16 +- .../driver/service/FolderService.java | 3 +- .../xyz/longicorn/driver/util/ImageUtils.java | 25 + driver/src/main/resources/application.yml | 6 + web/package-lock.json | 51 +- web/package.json | 5 +- web/src/Main.vue | 133 +- web/src/assets/main.less | 30 + web/src/components/FileIcon.vue | 9 +- .../components/file-uploader/FileUpload.vue | 1352 +++++++++++++++++ web/src/components/file-uploader/Index.vue | 250 +++ .../components/file-uploader/InputFile.vue | 38 + .../file-uploader/chunk/ChunkUploadHandler.js | 395 +++++ .../file-uploader/utils/chunkUpload.js | 59 + .../components/file-uploader/utils/request.js | 87 ++ web/src/service/api.js | 48 +- web/src/service/type.d.ts | 4 + 20 files changed, 2516 insertions(+), 83 deletions(-) create mode 100644 driver/src/main/java/xyz/longicorn/driver/controller/UploadController.java create mode 100644 driver/src/main/java/xyz/longicorn/driver/util/ImageUtils.java create mode 100644 web/src/components/file-uploader/FileUpload.vue create mode 100644 web/src/components/file-uploader/Index.vue create mode 100644 web/src/components/file-uploader/InputFile.vue create mode 100644 web/src/components/file-uploader/chunk/ChunkUploadHandler.js create mode 100644 web/src/components/file-uploader/utils/chunkUpload.js create mode 100644 web/src/components/file-uploader/utils/request.js diff --git a/driver/pom.xml b/driver/pom.xml index e0091c0..01571a7 100644 --- a/driver/pom.xml +++ b/driver/pom.xml @@ -25,6 +25,12 @@ + + net.coobird + thumbnailator + 0.4.17 + + org.springframework.boot spring-boot-starter-data-redis diff --git a/driver/src/main/java/xyz/longicorn/driver/controller/UploadController.java b/driver/src/main/java/xyz/longicorn/driver/controller/UploadController.java new file mode 100644 index 0000000..f5b085e --- /dev/null +++ b/driver/src/main/java/xyz/longicorn/driver/controller/UploadController.java @@ -0,0 +1,80 @@ +package xyz.longicorn.driver.controller; + +import lombok.SneakyThrows; +import org.springframework.util.DigestUtils; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import xyz.longicorn.driver.config.BizException; +import xyz.longicorn.driver.dto.ApiResult; +import xyz.longicorn.driver.pojo.FileInfo; +import xyz.longicorn.driver.pojo.FolderInfo; +import xyz.longicorn.driver.service.FileService; +import xyz.longicorn.driver.service.FolderService; + +import javax.annotation.Resource; +import java.io.File; +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api") +public class UploadController { + private final static String SAVE_PATH = "D:\\tmp\\upload"; + private final static String ACCESS_PATH = "/upload"; + @Resource + private FileService fileService; + @Resource + private FolderService folderService; + private final static Map FILE_MIME_TYPE = new HashMap<>(); + + public UploadController() { + FILE_MIME_TYPE.put("image/png", "png"); + FILE_MIME_TYPE.put("image/jpeg", "jpg"); + FILE_MIME_TYPE.put("image/jpg", "jpg"); + FILE_MIME_TYPE.put("image/bmp", "bmp"); + FILE_MIME_TYPE.put("image/webp", "webp"); + FILE_MIME_TYPE.put("image/gif", "gif"); + } + + @SneakyThrows + @PostMapping("/upload") + public ApiResult upload(@RequestParam("parent") String parent, @RequestPart("file") MultipartFile file) { + // + // 1.计算md5 + String md5 = DigestUtils.md5DigestAsHex(file.getInputStream()); + FileInfo f = fileService.getByMd5(md5); // + // + FileInfo fileInfo = new FileInfo(); + if (parent.equals("/")) { + fileInfo.setFolderId(0l); + } else { + FolderInfo folder = folderService.getByPath(1, parent); + if (folder == null) { + throw BizException.create("保存目录不存在"); + } + fileInfo.setFolderId(folder.getId()); + } + fileInfo.setUid(1l); + fileInfo.setHash(md5);// + fileInfo.setName(file.getOriginalFilename()); + fileInfo.setSize(file.getSize()); + String type = file.getContentType().toLowerCase(); + if (FILE_MIME_TYPE.containsKey(type)) { + type = FILE_MIME_TYPE.get(type); + } else { + type = "file"; + } + fileInfo.setType(type); + if (f != null) { // 系统已经存在了该文件 + // 不保存上传文件 直接copy数据 + fileInfo.setPath(f.getPath()); + } else { + String path = SAVE_PATH + "\\" + md5;//文件的保存路径 + fileInfo.setPath(path); + // 爆保存上传文件 + file.transferTo(new File(path)); + } + fileService.save(fileInfo);//保存文件信息 + return ApiResult.success(null); + } +} diff --git a/driver/src/main/java/xyz/longicorn/driver/pojo/FileInfo.java b/driver/src/main/java/xyz/longicorn/driver/pojo/FileInfo.java index d17faa3..42524c8 100644 --- a/driver/src/main/java/xyz/longicorn/driver/pojo/FileInfo.java +++ b/driver/src/main/java/xyz/longicorn/driver/pojo/FileInfo.java @@ -24,7 +24,7 @@ public class FileInfo implements Serializable { private String hash; private Long folderId; private String type; - private Integer size; + private Long size; private String path; private Date createTime; private Date updateTime; diff --git a/driver/src/main/java/xyz/longicorn/driver/service/FileService.java b/driver/src/main/java/xyz/longicorn/driver/service/FileService.java index 00c5812..66d57b6 100644 --- a/driver/src/main/java/xyz/longicorn/driver/service/FileService.java +++ b/driver/src/main/java/xyz/longicorn/driver/service/FileService.java @@ -12,15 +12,23 @@ import java.util.List; public class FileService extends ServiceImpl { /** * 查询了目录下的文件列表集合 + * * @param uid * @param folderId * @return */ - public List listByFolderId(int uid,long folderId){ + public List listByFolderId(int uid, long folderId) { QueryWrapper q = new QueryWrapper(); - q.eq("uid",uid); - q.eq("folder_id",folderId); - q.eq("status",1); // TODO 枚举 + q.eq("uid", uid); + q.eq("folder_id", folderId); + q.eq("status", 1); // TODO 枚举 return this.list(q); } + + public FileInfo getByMd5(String md5) { + QueryWrapper q = new QueryWrapper(); + q.eq("hash", md5);//根据MD5查询是否已经存在改文件 + q.last("limit 1"); + return this.getOne(q); + } } diff --git a/driver/src/main/java/xyz/longicorn/driver/service/FolderService.java b/driver/src/main/java/xyz/longicorn/driver/service/FolderService.java index 9ae8035..b6fb734 100644 --- a/driver/src/main/java/xyz/longicorn/driver/service/FolderService.java +++ b/driver/src/main/java/xyz/longicorn/driver/service/FolderService.java @@ -85,8 +85,7 @@ public class FolderService extends ServiceImpl { fNew.setUid(uid); fNew.setName(name); fNew.setParentId(parentId); - fNew.setPath(parent + "/" + name); - ; + fNew.setPath((parent.equals("/")?"":parent) + "/" + name); return this.save(fNew) ? fNew : null; } } diff --git a/driver/src/main/java/xyz/longicorn/driver/util/ImageUtils.java b/driver/src/main/java/xyz/longicorn/driver/util/ImageUtils.java new file mode 100644 index 0000000..51859e6 --- /dev/null +++ b/driver/src/main/java/xyz/longicorn/driver/util/ImageUtils.java @@ -0,0 +1,25 @@ +package xyz.longicorn.driver.util; + +import lombok.SneakyThrows; +import net.coobird.thumbnailator.Thumbnails; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.List; + +public class ImageUtils { + private static final List imageTypes = Arrays.asList("png", "jpg", "jpeg", "gif", "bmp", "webp"); + + public static boolean isImage(String type) { + return imageTypes.contains(type.toLowerCase()); + } + + @SneakyThrows + public static void getPreview(String imagePath, OutputStream os) { + Thumbnails.of(new File(imagePath)) + .size(256, 256) + .outputFormat("jpg").toOutputStream(os); + } +} diff --git a/driver/src/main/resources/application.yml b/driver/src/main/resources/application.yml index 30468f0..5ed3c33 100644 --- a/driver/src/main/resources/application.yml +++ b/driver/src/main/resources/application.yml @@ -45,6 +45,12 @@ spring: pathmatch: # 因为Springfox使用的路径匹配是基于AntPathMatcher的,而Spring Boot 2.6.X使用的是PathPatternMatcher。 matching-strategy: ant_path_matcher + + # 上传 + servlet: + multipart: + max-file-size: 100MB + max-request-size: 100MB springfox: documentation: enabled: true diff --git a/web/package-lock.json b/web/package-lock.json index aaad2da..b6ba5a5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,13 +9,16 @@ "version": "0.0.0", "dependencies": { "@element-plus/icons-vue": "^1.1.4", + "browser-md5-file": "^1.1.1", "element-plus": "^2.1.11", + "fetch-progress": "^1.3.0", "less": "^4.1.2", "less-loader": "^10.2.0", "qs": "^6.10.3", "vue": "^3.2.25", "vue-router": "^4.0.15", - "vue-simple-context-menu": "^4.0.2" + "vue-simple-context-menu": "^4.0.2", + "vue-upload-component": "^2.8.22" }, "devDependencies": { "@vitejs/plugin-vue": "^2.3.1", @@ -491,6 +494,14 @@ "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.1.1.tgz", "integrity": "sha512-p4DO/JXwjs8klJyJL8Q2oM4ks5fUTze/h5k10oPPKMiLe1fj3G1QMzPHNmN1Py4ycOk7WlO2DcGXv1qiESJCZA==" }, + "node_modules/browser-md5-file": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/browser-md5-file/-/browser-md5-file-1.1.1.tgz", + "integrity": "sha512-9h2UViTtZPhBa7oHvp5mb7MvJaX5OKEPUsplDwJ800OIV+In7BOR3RXOMB78obn2iQVIiS3WkVLhG7Zu1EMwbw==", + "dependencies": { + "spark-md5": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.20.3", "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.20.3.tgz", @@ -1081,6 +1092,11 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "peer": true }, + "node_modules/fetch-progress": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/fetch-progress/-/fetch-progress-1.3.0.tgz", + "integrity": "sha512-BCeKkVRx0x4mk/ykGGJ9FA2oJgrSp/lQgMiy2Ub+S2SMipt+po2uULUBM3OMOM/5XiwPpM4QyYmbYv/e98NWng==" + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", @@ -1613,6 +1629,11 @@ "resolved": "https://registry.npmmirror.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==" }, + "node_modules/spark-md5": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/spark-md5/-/spark-md5-2.0.2.tgz", + "integrity": "sha512-9WfT+FYBEvlrOOBEs484/zmbtSX4BlGjzXih1qIEWA1yhHbcqgcMHkiwXoWk2Sq1aJjLpcs6ZKV7JxrDNjIlNg==" + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz", @@ -1819,6 +1840,11 @@ "vue": "^3.2.31" } }, + "node_modules/vue-upload-component": { + "version": "2.8.22", + "resolved": "https://registry.npmmirror.com/vue-upload-component/-/vue-upload-component-2.8.22.tgz", + "integrity": "sha512-AJpETqiZrgqs8bwJQpWTFrRg3i6s7cUodRRZVnb1f94Jvpd0YYfzGY4zluBqPmssNSkUaYu7EteXaK8aW17Osw==" + }, "node_modules/watchpack": { "version": "2.3.1", "resolved": "https://registry.npmmirror.com/watchpack/-/watchpack-2.3.1.tgz", @@ -2317,6 +2343,14 @@ "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.1.1.tgz", "integrity": "sha512-p4DO/JXwjs8klJyJL8Q2oM4ks5fUTze/h5k10oPPKMiLe1fj3G1QMzPHNmN1Py4ycOk7WlO2DcGXv1qiESJCZA==" }, + "browser-md5-file": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/browser-md5-file/-/browser-md5-file-1.1.1.tgz", + "integrity": "sha512-9h2UViTtZPhBa7oHvp5mb7MvJaX5OKEPUsplDwJ800OIV+In7BOR3RXOMB78obn2iQVIiS3WkVLhG7Zu1EMwbw==", + "requires": { + "spark-md5": "^2.0.2" + } + }, "browserslist": { "version": "4.20.3", "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.20.3.tgz", @@ -2683,6 +2717,11 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "peer": true }, + "fetch-progress": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/fetch-progress/-/fetch-progress-1.3.0.tgz", + "integrity": "sha512-BCeKkVRx0x4mk/ykGGJ9FA2oJgrSp/lQgMiy2Ub+S2SMipt+po2uULUBM3OMOM/5XiwPpM4QyYmbYv/e98NWng==" + }, "fsevents": { "version": "2.3.2", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", @@ -3099,6 +3138,11 @@ "resolved": "https://registry.npmmirror.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==" }, + "spark-md5": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/spark-md5/-/spark-md5-2.0.2.tgz", + "integrity": "sha512-9WfT+FYBEvlrOOBEs484/zmbtSX4BlGjzXih1qIEWA1yhHbcqgcMHkiwXoWk2Sq1aJjLpcs6ZKV7JxrDNjIlNg==" + }, "supports-color": { "version": "8.1.1", "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz", @@ -3226,6 +3270,11 @@ "click-outside-vue3": "^4.0.1" } }, + "vue-upload-component": { + "version": "2.8.22", + "resolved": "https://registry.npmmirror.com/vue-upload-component/-/vue-upload-component-2.8.22.tgz", + "integrity": "sha512-AJpETqiZrgqs8bwJQpWTFrRg3i6s7cUodRRZVnb1f94Jvpd0YYfzGY4zluBqPmssNSkUaYu7EteXaK8aW17Osw==" + }, "watchpack": { "version": "2.3.1", "resolved": "https://registry.npmmirror.com/watchpack/-/watchpack-2.3.1.tgz", diff --git a/web/package.json b/web/package.json index d1023f6..4dec2e8 100644 --- a/web/package.json +++ b/web/package.json @@ -9,13 +9,16 @@ }, "dependencies": { "@element-plus/icons-vue": "^1.1.4", + "browser-md5-file": "^1.1.1", "element-plus": "^2.1.11", + "fetch-progress": "^1.3.0", "less": "^4.1.2", "less-loader": "^10.2.0", "qs": "^6.10.3", "vue": "^3.2.25", "vue-router": "^4.0.15", - "vue-simple-context-menu": "^4.0.2" + "vue-simple-context-menu": "^4.0.2", + "vue-upload-component": "^2.8.22" }, "devDependencies": { "@vitejs/plugin-vue": "^2.3.1", diff --git a/web/src/Main.vue b/web/src/Main.vue index d3cc5a8..704a04b 100644 --- a/web/src/Main.vue +++ b/web/src/Main.vue @@ -1,12 +1,15 @@ diff --git a/web/src/components/file-uploader/Index.vue b/web/src/components/file-uploader/Index.vue new file mode 100644 index 0000000..7420f95 --- /dev/null +++ b/web/src/components/file-uploader/Index.vue @@ -0,0 +1,250 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/file-uploader/InputFile.vue b/web/src/components/file-uploader/InputFile.vue new file mode 100644 index 0000000..29a548a --- /dev/null +++ b/web/src/components/file-uploader/InputFile.vue @@ -0,0 +1,38 @@ + + diff --git a/web/src/components/file-uploader/chunk/ChunkUploadHandler.js b/web/src/components/file-uploader/chunk/ChunkUploadHandler.js new file mode 100644 index 0000000..3013d14 --- /dev/null +++ b/web/src/components/file-uploader/chunk/ChunkUploadHandler.js @@ -0,0 +1,395 @@ +import { + default as request, + createRequest, + sendFormRequest +} from '../utils/request' + +export default class ChunkUploadHandler { + /** + * Constructor + * + * @param {File} file + * @param {Object} options + */ + constructor(file, options) { + this.file = file + this.options = options + this.chunks = [] + this.sessionId = null + this.chunkSize = null + this.speedInterval = null + } + + /** + * Gets the max retries from options + */ + get maxRetries() { + return parseInt(this.options.maxRetries, 10) + } + + /** + * Gets the max number of active chunks being uploaded at once from options + */ + get maxActiveChunks() { + return parseInt(this.options.maxActive, 10) + } + + /** + * Gets the file type + */ + get fileType() { + return this.file.type + } + + /** + * Gets the file size + */ + get fileSize() { + return this.file.size + } + + /** + * Gets the file name + */ + get fileName() { + return this.file.name + } + + /** + * Gets action (url) to upload the file + */ + get action() { + return this.options.action || null + } + + /** + * Gets the body to be merged when sending the request in start phase + */ + get startBody() { + return this.options.startBody || {} + } + + /** + * Gets the body to be merged when sending the request in upload phase + */ + get uploadBody() { + return this.options.uploadBody || {} + } + + /** + * Gets the body to be merged when sending the request in finish phase + */ + get finishBody() { + return this.options.finishBody || {} + } + + /** + * Gets the headers of the requests from options + */ + get headers() { + return this.options.headers || {} + } + + /** + * Whether it's ready to upload files or not + */ + get readyToUpload() { + return !!this.chunks + } + + /** + * Gets the progress of the chunk upload + * - Gets all the completed chunks + * - Gets the progress of all the chunks that are being uploaded + */ + get progress() { + const completedProgress = (this.chunksUploaded.length / this.chunks.length) * 100 + const uploadingProgress = this.chunksUploading.reduce((progress, chunk) => { + return progress + ((chunk.progress | 0) / this.chunks.length) + }, 0) + + return Math.min(completedProgress + uploadingProgress, 100) + } + + /** + * Gets all the chunks that are pending to be uploaded + */ + get chunksToUpload() { + return this.chunks.filter(chunk => { + return !chunk.active && !chunk.uploaded + }) + } + + /** + * Whether there are chunks to upload or not + */ + get hasChunksToUpload() { + return this.chunksToUpload.length > 0 + } + + /** + * Gets all the chunks that are uploading + */ + get chunksUploading() { + return this.chunks.filter(chunk => { + return !!chunk.xhr && !!chunk.active + }) + } + + /** + * Gets all the chunks that have finished uploading + */ + get chunksUploaded() { + return this.chunks.filter(chunk => { + return !!chunk.uploaded + }) + } + + /** + * Creates all the chunks in the initial state + */ + createChunks() { + this.chunks = [] + + let start = 0 + let end = this.chunkSize + while (start < this.fileSize) { + this.chunks.push({ + blob: this.file.file.slice(start, end), + startOffset: start, + active: false, + retries: this.maxRetries + }) + start = end + end = start + this.chunkSize + } + } + + /** + * Updates the progress of the file with the handler's progress + */ + updateFileProgress() { + this.file.progress = this.progress + } + + /** + * Paues the upload process + * - Stops all active requests + * - Sets the file not active + */ + pause() { + this.file.active = false + this.stopChunks() + } + + /** + * Stops all the current chunks + */ + stopChunks() { + this.chunksUploading.forEach(chunk => { + chunk.xhr.abort() + chunk.active = false + }) + + this.stopSpeedCalc() + } + + /** + * Resumes the file upload + * - Sets the file active + * - Starts the following chunks + */ + resume() { + this.file.active = true + this.startChunking() + } + + /** + * Starts the file upload + * + * @returns Promise + * - resolve The file was uploaded + * - reject The file upload failed + */ + upload() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve + this.reject = reject + }) + this.start() + + return this.promise + } + + /** + * Start phase + * Sends a request to the backend to initialise the chunks + */ + start() { + request({ + method: 'POST', + headers: Object.assign({}, this.headers, { + 'Content-Type': 'application/json' + }), + url: this.action, + body: Object.assign(this.startBody, { + phase: 'start', + mime_type: this.fileType, + size: this.fileSize, + name: this.fileName + }) + }).then(res => { + if (res.status !== 'success') { + this.file.response = res + return this.reject('server') + } + + this.sessionId = res.data.session_id + this.chunkSize = res.data.end_offset + + this.createChunks() + this.startChunking() + }).catch(res => { + this.file.response = res + this.reject('server') + }) + } + + /** + * Starts to upload chunks + */ + startChunking() { + for (let i = 0; i < this.maxActiveChunks; i++) { + this.uploadNextChunk() + } + + this.startSpeedCalc() + } + + /** + * Uploads the next chunk + * - Won't do anything if the process is paused + * - Will start finish phase if there are no more chunks to upload + */ + uploadNextChunk() { + if (this.file.active) { + if (this.hasChunksToUpload) { + return this.uploadChunk(this.chunksToUpload[0]) + } + + if (this.chunksUploading.length === 0) { + return this.finish() + } + } + } + + /** + * Uploads a chunk + * - Sends the chunk to the backend + * - Sets the chunk as uploaded if everything went well + * - Decreases the number of retries if anything went wrong + * - Fails if there are no more retries + * + * @param {Object} chunk + */ + uploadChunk(chunk) { + chunk.progress = 0 + chunk.active = true + this.updateFileProgress() + chunk.xhr = createRequest({ + method: 'POST', + headers: this.headers, + url: this.action + }) + + chunk.xhr.upload.addEventListener('progress', function (evt) { + if (evt.lengthComputable) { + chunk.progress = Math.round(evt.loaded / evt.total * 100) + } + }, false) + + sendFormRequest(chunk.xhr, Object.assign(this.uploadBody, { + phase: 'upload', + session_id: this.sessionId, + start_offset: chunk.startOffset, + chunk: chunk.blob + })).then(res => { + chunk.active = false + if (res.status === 'success') { + chunk.uploaded = true + } else { + if (chunk.retries-- <= 0) { + this.stopChunks() + return this.reject('upload') + } + } + + this.uploadNextChunk() + }).catch(() => { + chunk.active = false + if (chunk.retries-- <= 0) { + this.stopChunks() + return this.reject('upload') + } + + this.uploadNextChunk() + }) + } + + /** + * Finish phase + * Sends a request to the backend to finish the process + */ + finish() { + this.updateFileProgress() + this.stopSpeedCalc() + + request({ + method: 'POST', + headers: Object.assign({}, this.headers, { + 'Content-Type': 'application/json' + }), + url: this.action, + body: Object.assign(this.finishBody, { + phase: 'finish', + session_id: this.sessionId + }) + }).then(res => { + this.file.response = res + if (res.status !== 'success') { + return this.reject('server') + } + + this.resolve(res) + }).catch(res => { + this.file.response = res + this.reject('server') + }) + } + + + /** + * Sets an interval to calculate and + * set upload speed every 3 seconds + */ + startSpeedCalc() { + this.file.speed = 0 + let lastUploadedBytes = 0 + if (!this.speedInterval) { + this.speedInterval = window.setInterval(() => { + let uploadedBytes = (this.progress / 100) * this.fileSize + this.file.speed = (uploadedBytes - lastUploadedBytes) + lastUploadedBytes = uploadedBytes + }, 1000) + } + } + + /** + * Removes the upload speed interval + */ + stopSpeedCalc() { + this.speedInterval && window.clearInterval(this.speedInterval) + this.speedInterval = null + this.file.speed = 0 + } +} diff --git a/web/src/components/file-uploader/utils/chunkUpload.js b/web/src/components/file-uploader/utils/chunkUpload.js new file mode 100644 index 0000000..0fd46cd --- /dev/null +++ b/web/src/components/file-uploader/utils/chunkUpload.js @@ -0,0 +1,59 @@ +const CHUNK_SIZE = 1048576 +const ChunkActiveUploads = {} + +const chunkUploadStart = (req, res) => { + const uuid = Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1) + ChunkActiveUploads[uuid] = {} + + return res.json({ + status: 'success', + data: { + session_id: uuid, + start_offset: 0, + end_offset: CHUNK_SIZE + } + }) +} + +const chunkUploadPart = (req, res) => { + setTimeout(() => { + const rand = Math.random() + if (rand <= 0.25) { + res.status(500) + res.json({ status: 'error', error: 'server' }) + } else { + res.send({ status: 'success' }) + } + }, 100 + parseInt(Math.random() * 2000, 10)) +} + +const chunkUploadFinish = (req, res) => { + setTimeout(() => { + const rand = Math.random() + if (rand <= 0.25) { + res.status(500) + res.json({ status: 'error', error: 'server' }) + } else { + res.send({ status: 'success' }) + } + }, 100 + parseInt(Math.random() * 2000, 10)) +} + +module.exports = (req, res) => { + if (!req.body.phase) { + return chunkUploadPart(req, res) + } + + switch (req.body.phase) { + case 'start': + return chunkUploadStart(req, res) + + case 'upload': + return chunkUploadPart(req, res) + + case 'finish': + return chunkUploadFinish(req, res) + } +} diff --git a/web/src/components/file-uploader/utils/request.js b/web/src/components/file-uploader/utils/request.js new file mode 100644 index 0000000..f63b44e --- /dev/null +++ b/web/src/components/file-uploader/utils/request.js @@ -0,0 +1,87 @@ +/** + * Creates a XHR request + * + * @param {Object} options + */ +export const createRequest = (options) => { + const xhr = new XMLHttpRequest() + xhr.open(options.method || 'GET', options.url) + xhr.responseType = 'json' + if (options.headers) { + Object.keys(options.headers).forEach(key => { + xhr.setRequestHeader(key, options.headers[key]) + }) + } + + return xhr +} + +/** + * Sends a XHR request with certain body + * + * @param {XMLHttpRequest} xhr + * @param {Object} body + */ +export const sendRequest = (xhr, body) => { + return new Promise((resolve, reject) => { + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + var response + try { + response = JSON.parse(xhr.response) + } catch (err) { + response = xhr.response + } + resolve(response) + } else { + reject(xhr.response) + } + } + xhr.onerror = () => reject(xhr.response) + xhr.send(JSON.stringify(body)) + }) +} + +/** + * Sends a XHR request with certain form data + * + * @param {XMLHttpRequest} xhr + * @param {Object} data + */ +export const sendFormRequest = (xhr, data) => { + const body = new FormData() + for (var name in data) { + body.append(name, data[name]) + } + + return new Promise((resolve, reject) => { + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + var response + try { + response = JSON.parse(xhr.response) + } catch (err) { + response = xhr.response + } + resolve(response) + } else { + reject(xhr.response) + } + } + xhr.onerror = () => reject(xhr.response) + xhr.send(body) + }) +} + +/** + * Creates and sends XHR request + * + * @param {Object} options + * + * @returns Promise + */ +export default function (options) { + const xhr = createRequest(options) + + return sendRequest(xhr, options.body) +} diff --git a/web/src/service/api.js b/web/src/service/api.js index d29c550..de4a6ba 100644 --- a/web/src/service/api.js +++ b/web/src/service/api.js @@ -3,10 +3,10 @@ const API_PATH = "http://localhost:8080" /** * * @param api - * @param {'GET'|'POST'|string} method + * @param {'GET'|'POST'|'FILE'|string} method * @param postData */ -function request(api, method = 'GET', postData = {}) { +function request(api, method = 'GET', postData = {}, progressChange = null) { //return fetch(API_PATH + api).then(res => res.json()); let options = { method @@ -18,6 +18,11 @@ function request(api, method = 'GET', postData = {}) { params.push(`${key}=${postData[key]}`); } api += (api.includes('?') ? '&' : '?') + params.join('&'); + } else if (method.toUpperCase() == 'FILE') { + options = { + method: 'POST', + body: postData // 参数 + } } else { options = { method, @@ -40,7 +45,8 @@ function request(api, method = 'GET', postData = {}) { reject(new Error(result.message)) } } - fetch(API_PATH + api, options).then(res => res.json()) + fetch(API_PATH + api, options) + .then(res => res.json()) .then(processResult) .catch(e => { reject(e); @@ -67,5 +73,41 @@ export default { create(parent, name) { return request(`/api/folder/create`, 'POST', {parent, name}); } + }, + upload(parent, file, onProcess = null) { + // 上传文件到某个目录 + const postData = new FormData(); // 将数据封装成form表单 + postData.append("parent", parent); // 父目录 + postData.append("file", file);// 文件 + return new Promise((resolve, reject) => { + let request = new XMLHttpRequest(); + request.open('POST', API_PATH + '/api/upload'); + request.upload.addEventListener('progress', function (e) { + // upload progress as percentage + let progress = (e.loaded / e.total) * 100; + onProcess({ + uploaded: e.loaded, + total: e.total, + progress + }) + }); + request.addEventListener('load', function (e) { + // HTTP status message (200, 404 etc) + let ret = {message: null} + try { + ret = JSON.parse(request.response) + if (request.status == 200) { + resolve(JSON.parse(request.response)); + } else { + reject(Error(ret.message || '上传文件出错!')); + } + } catch (e) { + console.log(e) + reject(Error('上传文件异常')) + } + }); + request.send(postData); + }) + // return request('/api/upload', 'FILE', postData, onProcess) } } \ No newline at end of file diff --git a/web/src/service/type.d.ts b/web/src/service/type.d.ts index dcf406d..3742e4b 100644 --- a/web/src/service/type.d.ts +++ b/web/src/service/type.d.ts @@ -4,6 +4,10 @@ declare type FileItem = { id: number, name: string, path: string, + /** + * 缩略图 + */ + thumb: string, size: number, type: 'folder' | string, updateTime: string