From a3ce4b508a0d146d62163185a4d71a9c1425eaf7 Mon Sep 17 00:00:00 2001 From: KAI <1373639299@qq.com> Date: Tue, 12 Aug 2025 13:11:59 +0000 Subject: [PATCH] =?UTF-8?q?feat(system/file):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=88=86=E7=89=87=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 +- pnpm-lock.yaml | 27 +- src/apis/system/multipart-upload.ts | 32 + src/apis/system/type.ts | 53 ++ src/components/MultipartUpload/index.vue | 432 ++++++++++ src/hooks/index.ts | 1 + src/hooks/modules/useMultipartUploader.ts | 791 ++++++++++++++++++ src/types/components.d.ts | 1 + src/utils/drag-drop-file-util.ts | 49 ++ src/utils/md5-worker.ts | 155 ++++ src/views/system/file/main/FileMain/index.vue | 39 +- 11 files changed, 1565 insertions(+), 19 deletions(-) create mode 100644 src/apis/system/multipart-upload.ts create mode 100644 src/components/MultipartUpload/index.vue create mode 100644 src/hooks/modules/useMultipartUploader.ts create mode 100644 src/utils/drag-drop-file-util.ts create mode 100644 src/utils/md5-worker.ts diff --git a/package.json b/package.json index 663b100..dc583bf 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "vue-router": "^4.3.3", "vue3-tree-org": "^4.2.2", "xe-utils": "^3.5.7", - "xgplayer": "^2.31.6" + "xgplayer": "^2.31.6", + "spark-md5": "^3.0.2" }, "devDependencies": { "@antfu/eslint-config": "^2.16.3", @@ -67,6 +68,7 @@ "@types/lodash-es": "^4.17.12", "@types/node": "^20.2.5", "@types/query-string": "^6.3.0", + "@types/spark-md5": "^3.0.5", "@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue-jsx": "^3.1.0", "@vue/tsconfig": "^0.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8107ec2..2f7bf5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: query-string: specifier: ^9.0.0 version: 9.0.0 + spark-md5: + specifier: ^3.0.2 + version: 3.0.2 v-viewer: specifier: ^3.0.10 version: 3.0.13(viewerjs@1.11.6)(vue@3.5.12(typescript@5.0.4)) @@ -162,6 +165,9 @@ importers: '@types/query-string': specifier: ^6.3.0 version: 6.3.0 + '@types/spark-md5': + specifier: ^3.0.5 + version: 3.0.5 '@vitejs/plugin-vue': specifier: ^5.2.1 version: 5.2.1(vite@5.2.11(@types/node@20.12.12)(less@4.2.0)(sass@1.77.2)(terser@5.31.0))(vue@3.5.12(typescript@5.0.4)) @@ -697,6 +703,7 @@ packages: '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} @@ -704,6 +711,7 @@ packages: '@humanwhocodes/object-schema@2.0.3': resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead '@humanwhocodes/retry@0.3.0': resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} @@ -813,55 +821,46 @@ packages: resolution: {integrity: sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.17.2': resolution: {integrity: sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.17.2': resolution: {integrity: sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.17.2': resolution: {integrity: sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-powerpc64le-gnu@4.17.2': resolution: {integrity: sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.17.2': resolution: {integrity: sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.17.2': resolution: {integrity: sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.17.2': resolution: {integrity: sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.17.2': resolution: {integrity: sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.17.2': resolution: {integrity: sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA==} @@ -1192,6 +1191,9 @@ packages: '@types/sortablejs@1.15.8': resolution: {integrity: sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==} + '@types/spark-md5@3.0.5': + resolution: {integrity: sha512-lWf05dnD42DLVKQJZrDHtWFidcLrHuip01CtnC2/S6AMhX4t9ZlEUj4iuRlAnts0PQk7KESOqKxeGE/b6sIPGg==} + '@types/svgo@2.6.4': resolution: {integrity: sha512-l4cmyPEckf8moNYHdJ+4wkHvFxjyW6ulm9l4YGaOxeyBWPhBOT0gvni1InpFPdzx1dKf/2s62qGITwxNWnPQng==} @@ -4091,6 +4093,9 @@ packages: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} deprecated: Please use @jridgewell/sourcemap-codec instead + spark-md5@3.0.2: + resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==} + spdx-correct@3.2.0: resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} @@ -5772,6 +5777,8 @@ snapshots: '@types/sortablejs@1.15.8': {} + '@types/spark-md5@3.0.5': {} + '@types/svgo@2.6.4': dependencies: '@types/node': 20.12.12 @@ -9050,6 +9057,8 @@ snapshots: sourcemap-codec@1.4.8: {} + spark-md5@3.0.2: {} + spdx-correct@3.2.0: dependencies: spdx-expression-parse: 3.0.1 diff --git a/src/apis/system/multipart-upload.ts b/src/apis/system/multipart-upload.ts new file mode 100644 index 0000000..5480dc9 --- /dev/null +++ b/src/apis/system/multipart-upload.ts @@ -0,0 +1,32 @@ +// 分片上传 API 封装 +import type * as T from './type' +import http from '@/utils/http' + +export type * from './type' + +const BASE_URL = '/system/multipart-upload' + +/** @desc 初始化分片上传 */ +export function initMultipartUpload(data: T.MultiPartUploadInitReq) { + return http.post(`${BASE_URL}/init`, data) +} + +/** @desc 上传分片 */ +export function uploadPart(data: T.UploadPartReq, signal?: AbortSignal) { + const formData = new FormData() + formData.append('file', data.file) + formData.append('uploadId', data.uploadId) + formData.append('partNumber', String(data.partNumber)) + formData.append('path', data.path) + return http.post(`${BASE_URL}/part`, formData, { signal }) +} + +/** @desc 完成上传 */ +export function completeMultipartUpload(params: T.CompleteMultipartUploadReq) { + return http.get(`${BASE_URL}/complete/${params.uploadId}`) +} + +/** @desc 取消上传 */ +export function cancelUpload(params: T.CancelUploadParams) { + return http.get(`${BASE_URL}/cancel/${params.uploadId}`) +} diff --git a/src/apis/system/type.ts b/src/apis/system/type.ts index 8250ba7..507ab08 100644 --- a/src/apis/system/type.ts +++ b/src/apis/system/type.ts @@ -467,3 +467,56 @@ export interface MessageQuery { export interface MessagePageQuery extends MessageQuery, PageQuery { } + +/** 分片上传 - 初始化参数 */ +export interface MultiPartUploadInitReq { + fileName: string + fileSize: number + fileMd5: string + parentPath: string + metaData: Record +} + +/** 分片上传 - 初始化响应 */ +export interface MultiPartUploadInitResp { + uploadId: string + partSize: number + path: string + uploadedPartNumbers: number[] +} + +/** 分片上传 - 上传分片参数 */ +export interface UploadPartReq { + uploadId: string + partNumber: number + file: Blob + path: string +} + +/** 分片上传 - 上传分片响应 */ +export interface UploadPartResp { + /** 分片编号 */ + partNumber: number + /** 分片ETag */ + partETag: string + /** 分片大小 */ + partSize: number + /** 是否成功 */ + success: boolean + /** 错误信息 */ + errorMessage?: string +} + +/** 分片上传 - 完成上传参数 */ +export interface CompleteMultipartUploadReq { + uploadId: string + partETags: Array<{ + partNumber: number + eTag: string + }> +} + +/** 分片上传 - 取消上传参数 */ +export interface CancelUploadParams { + uploadId: string +} diff --git a/src/components/MultipartUpload/index.vue b/src/components/MultipartUpload/index.vue new file mode 100644 index 0000000..55d6a84 --- /dev/null +++ b/src/components/MultipartUpload/index.vue @@ -0,0 +1,432 @@ + + + + + diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 34b9233..c6b9267 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -7,3 +7,4 @@ export * from './modules/useDevice' export * from './modules/useBreakpoint' export * from './modules/useDownload' export * from './modules/useResetReactive' +export * from './modules/useMultipartUploader' diff --git a/src/hooks/modules/useMultipartUploader.ts b/src/hooks/modules/useMultipartUploader.ts new file mode 100644 index 0000000..8763ca8 --- /dev/null +++ b/src/hooks/modules/useMultipartUploader.ts @@ -0,0 +1,791 @@ +// 分片上传通用 hooks,支持多文件/多分片并发、暂停、恢复、取消、重试等 +import { computed, onUnmounted, ref } from 'vue' +import { throttle } from 'lodash-es' +import { + cancelUpload, + completeMultipartUpload, + initMultipartUpload, + uploadPart, +} from '@/apis/system/multipart-upload' + +// 文件上传任务对象类型 +export interface FileTask { + uid: string // 唯一标识 + file: File // 文件对象 + relativePath: string // 相对路径(支持文件夹结构) + parentPath: string // 文件夹根路径 + status: 'waiting' | 'uploading' | 'paused' | 'completed' | 'failed' | 'cancelled' // 状态 + progress: number // 上传进度(0-1) + uploadedChunks: number[] // 已上传分片编号 + totalChunks: number // 总分片数 + chunkSize: number // 分片大小(由后端返回) + fileName: string // 文件名 + fileType: string // 文件类型 + fileSize: number // 文件大小 + fileMd5?: string // 文件MD5 + uploadId?: string // 分片上传ID + path?: string // 文件路径(由后端返回) + partETags: Array<{ partNumber: number, eTag: string }> // 分片ETag列表 + errorMessage?: string // 错误信息 + abortController?: AbortController // 请求中断控制器 + _uploading?: boolean // 标记是否正在上传(内部控制) + _pause?: () => void // 暂停方法 + _resume?: () => void // 继续方法 + _cancel?: () => void // 取消方法 + _retryCount?: Map // 分片重试次数记录 +} + +/** + * useMultipartUploader - 通用分片上传 hooks + * @param props.maxConcurrentFiles 最大同时上传文件数(全局并发) + * @param props.maxConcurrentChunks 每个文件分片上传最大并发数 + * @param props.maxUploadWorkers 最大上传Worker数量(基于CPU核心数) + * @returns 上传相关响应式状态与操作方法 + */ +export function useMultipartUploader(props: { + maxConcurrentFiles?: number + maxConcurrentChunks?: number + maxUploadWorkers?: number + rootPath?: string +}) { + // 获取CPU核心数,用于控制Worker数量 + const getCpuCores = () => { + if (typeof navigator !== 'undefined' && navigator.hardwareConcurrency) { + return navigator.hardwareConcurrency + } + return 2 // 默认2个核心 + } + + // 所有上传任务列表 + const fileTasks = ref([]) + // 当前正在上传的文件数量 + const uploadingCount = computed(() => fileTasks.value.filter((t) => t.status === 'uploading').length) + // 最大并发上传文件数 + const maxConcurrent = computed(() => props.maxConcurrentFiles ?? getCpuCores()) + // 每个文件分片上传最大并发数 + const maxChunkConcurrent = computed(() => props.maxConcurrentChunks ?? getCpuCores()) + // 最大上传Worker数量 + const maxUploadWorkers = computed(() => props.maxUploadWorkers ?? getCpuCores() / 2) + + // 本地队列管理 + const uploadQueue = ref>([]) + const activeUploads = ref>(new Set()) // 正在上传的分片ID集合 + + const md5CalculatingTaskUid = ref(null) // MD5计算中的任务ID + const performanceStats = ref<{ + md5StartTime: number + md5EndTime: number + uploadStartTime: number + uploadEndTime: number + totalTime: number + } | null>(null) + + // MD5 Worker实例 + let md5Worker: Worker | null = null + + /** 节流的进度更新函数 */ + const updateTaskProgress = throttle((task: FileTask, totalChunks: number) => { + const currentFinishedChunks = task.uploadedChunks.length + if (totalChunks > 0) { + task.progress = Number(Math.min(currentFinishedChunks / totalChunks, 1).toFixed(2)) + } else { + task.progress = 0 + } + }, 150) + + /** + * 初始化MD5 Worker + */ + function initMd5Worker() { + if (typeof Worker !== 'undefined' && !md5Worker) { + // eslint-disable-next-line no-console + console.log('[Hooks] 初始化MD5 Worker...') + md5Worker = new Worker('/src/utils/md5-worker.ts', { type: 'module' }) + md5Worker.onmessage = function (e) { + const { type, taskId, md5, error } = e.data + + if (type === 'complete' && md5) { + const task = fileTasks.value.find((t) => t.uid === taskId) + if (task) { + task.fileMd5 = md5 + md5CalculatingTaskUid.value = null + // eslint-disable-next-line no-console + console.log(`[Hooks] MD5计算完成: ${task.fileName}, MD5: ${md5}`) + } + } else if (type === 'error') { + console.error('MD5计算失败:', error) + md5CalculatingTaskUid.value = null + } + } + } + } + + /** + * 计算文件MD5(使用Web Worker - 优化版本) + */ + function calcFileMd5(file: File, taskUid: string): Promise { + return new Promise((resolve, reject) => { + if (!md5Worker) { + initMd5Worker() + } + + if (md5Worker) { + md5CalculatingTaskUid.value = taskUid + + // 记录MD5计算开始时间 + performanceStats.value = { + md5StartTime: Date.now(), + md5EndTime: 0, + uploadStartTime: 0, + uploadEndTime: 0, + totalTime: 0, + } + + // 根据文件大小动态调整分块和分片大小 + const blockSize = file.size > 200 * 1024 * 1024 ? 50 * 1024 * 1024 : 25 * 1024 * 1024 // 50MB或25MB块 + const chunkSize = file.size > 100 * 1024 * 1024 ? 10 * 1024 * 1024 : 2 * 1024 * 1024 // 10MB或2MB分片 + + // eslint-disable-next-line no-console + console.log(`[Hooks] 发送文件到Worker: ${file.name}, 大小: ${(file.size / 1024 / 1024).toFixed(2)}MB, 块大小: ${(blockSize / 1024 / 1024).toFixed(2)}MB, 分片大小: ${(chunkSize / 1024 / 1024).toFixed(2)}MB`) + + md5Worker.postMessage({ + file, + taskId: taskUid, + blockSize, + chunkSize, + }) + + // 监听完成事件 + const handleComplete = (e: MessageEvent) => { + const { type, taskId, md5 } = e.data + if (type === 'complete' && taskId === taskUid) { + md5Worker?.removeEventListener('message', handleComplete) + + // 记录MD5计算结束时间 + if (performanceStats.value) { + performanceStats.value.md5EndTime = Date.now() + const md5Time = performanceStats.value.md5EndTime - performanceStats.value.md5StartTime + // eslint-disable-next-line no-console + console.log(`MD5计算完成,耗时: ${md5Time}ms,文件大小: ${formatFileSize(file.size)}`) + } + + resolve(md5) + } + } + + md5Worker.addEventListener('message', handleComplete) + } else { + reject(new Error('Web Worker not supported')) + } + }) + } + + /** + * 添加分片到上传队列 + */ + function addChunkToQueue(task: FileTask, chunkNumber: number) { + const chunkId = `${task.uid}-${chunkNumber}` + if (!activeUploads.value.has(chunkId)) { + uploadQueue.value.push({ task, chunkNumber }) + // eslint-disable-next-line no-console + console.log(`添加分片到队列: ${task.fileName} - 分片${chunkNumber}`) + processUploadQueue() + } + } + + /** + * 处理上传队列 - 优化版本 + */ + function processUploadQueue() { + // eslint-disable-next-line no-console + console.log(`[Hooks] 处理上传队列,队列长度: ${uploadQueue.value.length}, 活跃上传数: ${activeUploads.value.size}`) + // 智能队列处理:优先处理小文件的分片,避免大文件阻塞 + const sortedQueue = [...uploadQueue.value].sort((a, b) => { + // 优先处理已完成更多分片的文件 + const aProgress = a.task.uploadedChunks.length / a.task.totalChunks + const bProgress = b.task.uploadedChunks.length / b.task.totalChunks + return bProgress - aProgress + }) + + while (sortedQueue.length > 0 && activeUploads.value.size < maxUploadWorkers.value) { + const { task, chunkNumber } = sortedQueue.shift()! + const chunkId = `${task.uid}-${chunkNumber}` + + // eslint-disable-next-line no-console + console.log(`[Hooks] 检查分片: ${task.fileName} - 分片${chunkNumber}, 任务状态: ${task.status}`) + if (task.status === 'uploading' && !activeUploads.value.has(chunkId)) { + // eslint-disable-next-line no-console + console.log(`[Hooks] 开始上传分片: ${task.fileName} - 分片${chunkNumber}`) + activeUploads.value.add(chunkId) + uploadChunk(task, chunkNumber) + + // 从原始队列中移除已处理的项目 + const index = uploadQueue.value.findIndex((item) => + item.task.uid === task.uid && item.chunkNumber === chunkNumber, + ) + if (index > -1) { + uploadQueue.value.splice(index, 1) + } + } else { + // eslint-disable-next-line no-console + console.log(`[Hooks] 跳过分片: ${task.fileName} - 分片${chunkNumber}, 原因: 状态不是uploading或已在活跃上传中`) + } + } + } + + /** + * 上传单个分片 + */ + async function uploadChunk(task: FileTask, chunkNumber: number) { + const chunkId = `${task.uid}-${chunkNumber}` + + try { + const start = (chunkNumber - 1) * task.chunkSize + const end = Math.min(start + task.chunkSize, task.fileSize) + const chunkBlob = task.file.slice(start, end) + + // 创建 AbortController 用于中断请求 + if (!task.abortController) { + task.abortController = new AbortController() + } + + const res = await uploadPart({ + uploadId: task.uploadId!, + partNumber: chunkNumber, + file: chunkBlob, + path: task.path!, + }, task.abortController.signal) + + // 检查上传是否成功 + if (res.data && res.data.success) { + // 保存ETag + task.partETags.push({ + partNumber: chunkNumber, + eTag: res.data.partETag, + }) + + // 更新已上传分片列表 + if (!task.uploadedChunks.includes(chunkNumber)) { + task.uploadedChunks.push(chunkNumber) + } + + updateTaskProgress(task, task.totalChunks) + + // 检查是否所有分片都上传完成 + if (task.uploadedChunks.length >= task.totalChunks) { + await completeMultipartUpload({ + uploadId: task.uploadId!, + partETags: task.partETags, + }) + task.status = 'completed' + task.progress = 1 + startNextTasks() + } + } else { + // 上传失败,抛出错误 + const errorMessage = res.data?.errorMessage || '分片上传失败' + throw new Error(`分片${chunkNumber}上传失败: ${errorMessage}`) + } + } catch (error) { + // 检查是否是取消请求导致的错误 + if (error instanceof Error && error.name === 'AbortError') { + // eslint-disable-next-line no-console + console.log(`分片上传被取消: ${task.fileName} - 分片${chunkNumber}`) + return + } + + console.error(`分片上传失败: ${task.fileName} - 分片${chunkNumber}`, error) + + // 检查任务是否已经被取消或暂停 + if (task.status === 'cancelled' || task.status === 'paused') { + // eslint-disable-next-line no-console + console.log(`任务 ${task.fileName} 已被取消或暂停,跳过错误处理`) + return + } + + // 检查是否是网络错误或服务器错误 + const isNetworkError = error instanceof TypeError + || (error as any)?.message?.includes('Network') + || (error as any)?.message?.includes('fetch') + + const isServerError = (error as any)?.response?.status >= 500 + || (error as any)?.response?.status === 429 + + if (isNetworkError || isServerError) { + // 初始化重试计数器 + if (!task._retryCount) { + task._retryCount = new Map() + } + + const currentRetryCount = task._retryCount.get(chunkNumber) || 0 + const maxRetries = 3 // 最大重试3次 + + if (currentRetryCount < maxRetries) { + // 网络错误或服务器错误,将分片重新加入队列进行重试 + // eslint-disable-next-line no-console + console.log(`分片 ${chunkNumber} 上传失败,第${currentRetryCount + 1}次重试: ${task.fileName}`) + + // 更新重试次数 + task._retryCount.set(chunkNumber, currentRetryCount + 1) + + // 延迟重试,避免立即重试 + setTimeout(() => { + if (task.status === 'uploading' && !activeUploads.value.has(chunkId)) { + addChunkToQueue(task, chunkNumber) + } + }, 2000 * (currentRetryCount + 1)) // 递增延迟:2秒、4秒、6秒 + } else { + // 超过最大重试次数,标记任务失败 + // eslint-disable-next-line no-console + console.log(`分片 ${chunkNumber} 重试次数超过限制,标记任务失败: ${task.fileName}`) + task.status = 'failed' + task.errorMessage = `分片 ${chunkNumber} 重试次数超过限制` + + // 清理队列中该任务的所有分片 + uploadQueue.value = uploadQueue.value.filter((item) => item.task.uid !== task.uid) + + // 清理正在上传的分片 + const activeChunkIds = Array.from(activeUploads.value).filter((id) => id.startsWith(task.uid)) + activeChunkIds.forEach((id) => activeUploads.value.delete(id)) + + // 启动下一个任务 + startNextTasks() + } + } else { + // 其他错误(如认证错误、参数错误等),标记任务失败 + // eslint-disable-next-line no-console + console.log(`任务 ${task.fileName} 遇到不可恢复的错误,标记为失败`) + task.status = 'failed' + task.errorMessage = (error as Error)?.message || '上传失败' + + // 清理队列中该任务的所有分片 + uploadQueue.value = uploadQueue.value.filter((item) => item.task.uid !== task.uid) + + // 清理正在上传的分片 + const activeChunkIds = Array.from(activeUploads.value).filter((id) => id.startsWith(task.uid)) + activeChunkIds.forEach((id) => activeUploads.value.delete(id)) + + // 启动下一个任务 + startNextTasks() + } + } finally { + activeUploads.value.delete(chunkId) + processUploadQueue() // 继续处理队列 + } + } + + /** + * 分片上传核心逻辑,处理单个文件的分片上传、并发、暂停、恢复、取消等 + * @param task FileTask + */ + async function uploadFileTask(task: FileTask) { + try { + // eslint-disable-next-line no-console + console.log(`[Hooks] 开始上传任务: ${task.fileName}, 当前状态: ${task.status}`) + // 1. 初始化分片上传,获取 uploadId + if (!task.uploadId) { + // eslint-disable-next-line no-console + console.log(`[Hooks] 任务 ${task.fileName} 没有 uploadId,准备调用 initMultipartUpload`) + // 若没有MD5,先计算 + if (!task.fileMd5) { + // eslint-disable-next-line no-console + console.log(`[Hooks] 任务 ${task.fileName} 没有 MD5,开始计算...`) + task.fileMd5 = await calcFileMd5(task.file, task.uid) + } + + // eslint-disable-next-line no-console + console.log(`[Hooks] 调用 initMultipartUpload: ${task.fileName}, MD5: ${task.fileMd5}, 路径: ${task.parentPath}`) + + // 确保parentPath不是空字符串,如果是则使用"/" + const parentPath = task.parentPath && task.parentPath !== '' ? task.parentPath : '/' + + const res = await initMultipartUpload({ + fileName: task.fileName, + fileSize: task.fileSize, + fileMd5: task.fileMd5, + parentPath, + metaData: { + contentType: task.fileType, + originalName: task.fileName, + }, + }) + + if (res && res.data) { + // eslint-disable-next-line no-console + console.log(`[Hooks] initMultipartUpload 成功: ${task.fileName}, uploadId: ${res.data.uploadId}`) + task.uploadId = res.data.uploadId + task.chunkSize = res.data.partSize + task.path = res.data.path + + // 处理断点续传:如果后端返回了已上传的分片编号 + if (res.data.uploadedPartNumbers && res.data.uploadedPartNumbers.length > 0) { + // eslint-disable-next-line no-console + console.log(`[Hooks] 发现已上传分片: ${task.fileName}, 已上传分片: ${res.data.uploadedPartNumbers.join(',')}`) + // 将已上传的分片编号添加到任务中 + task.uploadedChunks = [...res.data.uploadedPartNumbers] + + // 计算当前进度 + const totalChunks = Math.ceil(task.fileSize / task.chunkSize) + updateTaskProgress(task, totalChunks) + + // eslint-disable-next-line no-console + console.log(`[Hooks] 断点续传进度: ${task.fileName}, 进度: ${(task.progress * 100).toFixed(1)}%`) + } + } else { + // eslint-disable-next-line no-console + console.log(`[Hooks] initMultipartUpload 失败: ${task.fileName}`) + task.status = 'failed' + return + } + } + + // 2. 计算总分片数 + const totalChunks = Math.ceil(task.fileSize / task.chunkSize) + task.totalChunks = totalChunks + + // eslint-disable-next-line no-console + console.log(`[Hooks] 计算总分片数: ${task.fileName}, 总分片数: ${totalChunks}, 分片大小: ${task.chunkSize}`) + + // 检查是否有断点续传的分片 + const hasResumeData = task.uploadedChunks.length > 0 + + if (!hasResumeData) { + // 如果没有断点续传数据,重新初始化 + task.uploadedChunks = [] + task.partETags = [] + task.progress = 0 + } else { + // 有断点续传数据,计算当前进度 + // eslint-disable-next-line no-console + console.log(`[Hooks] 发现断点续传数据: ${task.fileName}, 已上传分片: ${task.uploadedChunks.join(',')}`) + updateTaskProgress(task, task.totalChunks) + } + + // 将所有未完成的分片添加到队列 + // eslint-disable-next-line no-console + console.log(`[Hooks] 开始添加分片到队列: ${task.fileName}`) + for (let i = 1; i <= totalChunks; i++) { + // 只添加未上传的分片 + if (!task.uploadedChunks.includes(i)) { + addChunkToQueue(task, i) + } + } + // eslint-disable-next-line no-console + console.log(`[Hooks] 分片添加完成: ${task.fileName}, 队列长度: ${uploadQueue.value.length}`) + + // 挂载暂停/取消控制方法到 task + task._pause = () => { + task.status = 'paused' + // 暂停时清空队列中该任务的分片 + uploadQueue.value = uploadQueue.value.filter((item) => item.task.uid !== task.uid) + // 清理正在上传的分片 + const activeChunkIds = Array.from(activeUploads.value).filter((id) => id.startsWith(task.uid)) + activeChunkIds.forEach((id) => activeUploads.value.delete(id)) + } + + task._resume = () => { + if (task.status === 'paused') { + task.status = 'uploading' + // 重新添加未完成的分片到队列 + for (let i = 1; i <= task.totalChunks; i++) { + if (!task.uploadedChunks.includes(i)) { + addChunkToQueue(task, i) + } + } + } + } + + task._cancel = () => { + task.status = 'cancelled' + // 中断所有正在进行的请求 + if (task.abortController) { + task.abortController.abort() + } + // 清空队列中该任务的分片 + uploadQueue.value = uploadQueue.value.filter((item) => item.task.uid !== task.uid) + // 清理正在上传的分片 + const activeChunkIds = Array.from(activeUploads.value).filter((id) => id.startsWith(task.uid)) + activeChunkIds.forEach((id) => activeUploads.value.delete(id)) + if (task.uploadId) { + cancelUpload({ uploadId: task.uploadId }) + } + } + } catch (e) { + task.status = 'failed' + startNextTasks() + } + } + + /** + * 启动下一个可用的上传任务(受最大并发数限制) + */ + function startNextTasks() { + let available = maxConcurrent.value - uploadingCount.value + for (const task of fileTasks.value) { + if (available <= 0) break + if ((task.status === 'waiting' || task.status === 'uploading') && !task._uploading) { + task.status = 'uploading' + task._uploading = true + available-- + uploadFileTask(task) + } + } + } + + /** + * 全部开始上传(将所有 waiting 状态任务置为 uploading 并启动并发上传) + */ + function startAllUpload() { + // eslint-disable-next-line no-console + console.log('[Hooks] 开始上传按钮被点击,准备启动所有等待中的任务') + // eslint-disable-next-line no-console + console.log('[Hooks] 当前任务列表:', fileTasks.value.map((t) => ({ name: t.fileName, status: t.status }))) + for (const task of fileTasks.value) { + if (task.status === 'waiting' || task.status === 'paused') { + task._uploading = false // 标记尚未调度 + // 如果是暂停状态,需要重新激活 + if (task.status === 'paused') { + task.status = 'uploading' + } + } + } + startNextTasks() + } + + /** + * 添加文件/文件夹到上传队列 + * @param files File[] + * @param parentPath 父目录 + * @param isFolder 是否为文件夹 + */ + function addFiles(files: File[], parentPath: string, isFolder = false) { + // 验证文件的有效性 + const validFiles = files.filter((file) => { + if (!file) { + return false + } + if (file.size === 0) { + return false + } + if (!file.name || file.name.trim() === '') { + return false + } + return true + }) + + if (validFiles.length === 0) { + return + } + + for (const file of validFiles) { + const relativePath = (file as any).webkitRelativePath || '/' + let parent = '' + + // 调试:查看 webkitRelativePath 的实际内容 + // eslint-disable-next-line no-console + console.log('文件路径调试:', { + fileName: file.name, + webkitRelativePath: relativePath, + isFolder, + }) + + if (isFolder) { + // 文件夹上传:如果有webkitRelativePath,则路径为 rootPath + webkitRelativePath + // 如果没有webkitRelativePath,则路径为 rootPath + if (relativePath && relativePath !== '/') { + // 有webkitRelativePath的情况,例如:folder/file.txt + parent = props.rootPath || parentPath || '/' + // 确保路径格式正确,去除结尾的斜杠 + if (parent.length > 1 && parent.endsWith('/')) { + parent = parent.slice(0, -1) + } + + // 从 webkitRelativePath 中提取文件夹路径(去掉文件名) + const pathParts = relativePath.split('/') + // 去掉最后一个部分(文件名),只保留文件夹路径 + pathParts.pop() + const folderPath = pathParts.join('/') // 重新组合文件夹路径 + + // 组合路径:rootPath + 文件夹路径 + if (folderPath) { + parent = `${parent}/${folderPath}` + } + } else { + // 没有webkitRelativePath的情况,直接使用rootPath + parent = props.rootPath || parentPath || '/' + } + } else { + // 普通文件上传:直接使用rootPath + parent = props.rootPath || parentPath || '/' + } + + // 去除 parentPath 结尾的 / + if (parent.length > 1 && parent.endsWith('/')) { + parent = parent.slice(0, -1) + } + + // 确保路径不以双斜杠开头 + if (parent.startsWith('//')) { + parent = parent.substring(1) + } + + // eslint-disable-next-line no-console + console.log('最终路径:', { + fileName: file.name, + parentPath: parent, + relativePath, + }) + + const task: FileTask = { + uid: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`, + file, + fileName: file.name, + fileType: file.type, + fileSize: file.size, + relativePath, + parentPath: parent, + status: 'waiting', + progress: 0, + uploadedChunks: [], + totalChunks: 0, + chunkSize: 0, // 初始化时设为0,后续由后端返回 + fileMd5: '', + path: '', // 初始化时设为空,后续由后端返回 + partETags: [], + errorMessage: '', // 初始化错误信息 + abortController: new AbortController(), // 初始化请求中断控制器 + _retryCount: new Map(), // 初始化重试计数器 + } + + // 立即开始计算MD5,但不自动开始上传 + calcFileMd5(file, task.uid).then((md5) => { + task.fileMd5 = md5 + }).catch((_error) => { + task.status = 'failed' + }) + + fileTasks.value.push(task) + } + } + + // 暂停单个任务 + function pauseTask(task: FileTask) { + // eslint-disable-next-line no-console + console.log(`暂停任务: ${task.fileName}`) + task._pause?.() + } + + // 恢复单个任务 + function resumeTask(task: FileTask) { + // eslint-disable-next-line no-console + console.log(`[Hooks] 继续任务: ${task.fileName}, 当前状态: ${task.status}`) + task._resume?.() + if (task.status === 'paused') { + task._uploading = false + startNextTasks() + } + } + + // 取消单个任务 + function cancelTask(task: FileTask) { + // eslint-disable-next-line no-console + console.log(`取消任务: ${task.fileName}`) + + // 中断所有正在进行的请求 + if (task.abortController) { + task.abortController.abort() + // eslint-disable-next-line no-console + console.log(`已中断任务 ${task.fileName} 的所有请求`) + } + + task._cancel?.() + } + + // 启动单个任务 + function startTask(task: FileTask) { + if (task.status === 'waiting') { + task.status = 'uploading' + task._uploading = false + startNextTasks() + } + } + + // 失败重试单个任务 + function retryTask(task: FileTask) { + if (task.status === 'failed') { + task.status = 'uploading' + task.progress = 0 + // 重试时保留已上传的分片信息,支持断点续传 + // task.uploadedChunks = [] // 注释掉,保留断点续传数据 + // task.partETags = [] // 注释掉,保留断点续传数据 + task._uploading = false + task._retryCount = new Map() // 重置重试计数器 + task.errorMessage = '' // 清除错误信息 + task.abortController = new AbortController() // 重新创建请求中断控制器 + startNextTasks() + } + } + + // 清空所有上传任务 + function clearAllTasks() { + fileTasks.value = [] + } + + // 删除单个任务 + function removeTask(task: FileTask) { + // 先取消任务 + if (task.status === 'uploading' || task.status === 'waiting' || task.status === 'paused') { + task._cancel?.() + } + // 清理队列中的分片 + uploadQueue.value = uploadQueue.value.filter((item) => item.task.uid !== task.uid) + // 清理正在上传的分片 + const activeChunkIds = Array.from(activeUploads.value).filter((id) => id.startsWith(task.uid)) + activeChunkIds.forEach((id) => activeUploads.value.delete(id)) + // 从任务列表中移除 + fileTasks.value = fileTasks.value.filter((t) => t.uid !== task.uid) + } + + // 文件大小格式化工具 + function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}` + } + + // 组件销毁时,终止所有上传任务和Worker + onUnmounted(() => { + fileTasks.value.forEach((task) => { + if (task.status === 'uploading' || task.status === 'waiting' || task.status === 'paused') { + pauseTask(task) + } + }) + + if (md5Worker) { + md5Worker.terminate() + md5Worker = null + } + }) + + return { + fileTasks, + uploadingCount, + maxConcurrent, + maxChunkConcurrent, + uploadFileTask, + startNextTasks, + startAllUpload, + addFiles, + pauseTask, + resumeTask, + cancelTask, + startTask, + retryTask, + clearAllTasks, + removeTask, + formatFileSize, + md5CalculatingTaskUid, + } +} diff --git a/src/types/components.d.ts b/src/types/components.d.ts index 7ea3f11..5b9bb06 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -52,6 +52,7 @@ declare module 'vue' { JsonPretty: typeof import('./../components/JsonPretty/index.vue')['default'] MinuteForm: typeof import('./../components/GenCron/CronForm/component/minute-form.vue')['default'] MonthForm: typeof import('./../components/GenCron/CronForm/component/month-form.vue')['default'] + MultipartUpload: typeof import('./../components/MultipartUpload/index.vue')['default'] ParentView: typeof import('./../components/ParentView/index.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] diff --git a/src/utils/drag-drop-file-util.ts b/src/utils/drag-drop-file-util.ts new file mode 100644 index 0000000..4227576 --- /dev/null +++ b/src/utils/drag-drop-file-util.ts @@ -0,0 +1,49 @@ +/** + * 递归读取 DataTransferItemList 中的所有文件(支持文件夹结构) + * 自动为每个 File 对象添加 webkitRelativePath 属性 + * 仅在支持 File System Access API 的浏览器下有效 + */ +export async function getFilesFromDataTransferItems(items: DataTransferItemList): Promise { + const files: File[] = [] + + async function traverse(handle: FileSystemHandle, path = ''): Promise { + if (handle.kind === 'file') { + const file = await (handle as FileSystemFileHandle).getFile() + // 创建新的 File 对象,包含相对路径信息 + const fileWithPath = new File([file], file.name, { + type: file.type, + lastModified: file.lastModified, + }) + // 使用 Object.defineProperty 添加 webkitRelativePath + Object.defineProperty(fileWithPath, 'webkitRelativePath', { + value: path + file.name, + writable: false, + enumerable: true, + configurable: true, + }) + files.push(fileWithPath) + } else if (handle.kind === 'directory') { + for await (const [name, childHandle] of (handle as any).entries()) { + await traverse(childHandle, `${path}${name}/`) + } + } + } + + for (const item of Array.from(items)) { + if (item.kind === 'file' && 'getAsFileSystemHandle' in item) { + const handle = await (item as any).getAsFileSystemHandle() + if (handle) { + await traverse(handle, '') + } + } + } + + return files +} + +/** + * 检查当前浏览器是否支持 File System Access API 拖拽文件夹 + */ +export function isFileSystemAccessAPISupported(): boolean { + return typeof window !== 'undefined' && 'getAsFileSystemHandle' in DataTransferItem.prototype +} diff --git a/src/utils/md5-worker.ts b/src/utils/md5-worker.ts new file mode 100644 index 0000000..6699e96 --- /dev/null +++ b/src/utils/md5-worker.ts @@ -0,0 +1,155 @@ +import SparkMD5 from 'spark-md5' + +// 确保在 Web Worker 环境中运行 +if (typeof globalThis !== 'undefined') { + // 监听来自主线程的消息 + globalThis.addEventListener('message', (event) => { + const { file, taskId, blockSize, chunkSize } = event.data + + if (file && taskId && blockSize && chunkSize) { + calculateFileMd5Optimized(file, taskId, blockSize, chunkSize) + } else { + globalThis.postMessage({ + type: 'error', + taskId: taskId || 'unknown', + error: 'Missing required parameters: file, taskId, blockSize, chunkSize', + }) + } + }) +} + +function calculateFileMd5Optimized(file: File, taskId: string, blockSize: number, chunkSize: number) { + const totalSize = file.size + const blocks = Math.ceil(totalSize / blockSize) + const blockHashes: string[] = Array.from({ length: blocks }) + let processedBytes = 0 + let processedBlocks = 0 + + const maxConcurrency = Math.max(2, navigator.hardwareConcurrency || 2) + let activeWorkers = 0 + let nextBlockIndex = 0 + + console.log(`[Worker] 使用并发块处理: 最大并发 ${maxConcurrency},总块数: ${blocks}`) + + function processBlock(blockIndex: number): Promise { + return new Promise((resolve, reject) => { + try { + const start = blockIndex * blockSize + const end = Math.min(start + blockSize, totalSize) + const block = file.slice(start, end) + + const spark = new SparkMD5.ArrayBuffer() + const chunks = Math.ceil(block.size / chunkSize) + let currentChunk = 0 + const reader = new FileReader() + + reader.onload = function (e: ProgressEvent) { + try { + if (e.target?.result) { + spark.append(e.target.result as ArrayBuffer) + processedBytes += (e.target.result as ArrayBuffer).byteLength + + globalThis.postMessage({ + type: 'progress', + taskId, + progress: processedBytes / totalSize, + processedBytes, + totalSize, + }) + + currentChunk++ + if (currentChunk < chunks) { + loadNextChunk() + } else { + blockHashes[blockIndex] = spark.end() + processedBlocks++ + + console.log(`[Worker] 块 ${blockIndex + 1}/${blocks} 处理完成`) + + resolve() + } + } else { + reject(new Error('FileReader result is null')) + } + } catch (error) { + reject(error) + } + } + + reader.onerror = function (e: ProgressEvent) { + console.error(`[Worker] 文件读取错误:`, e) + globalThis.postMessage({ + type: 'error', + taskId, + error: e, + }) + reject(new Error('FileReader error')) + } + + function loadNextChunk() { + try { + const chunkStart = currentChunk * chunkSize + const chunkEnd = Math.min(chunkStart + chunkSize, block.size) + const blob = block.slice(chunkStart, chunkEnd) + reader.readAsArrayBuffer(blob) + } catch (error) { + reject(error) + } + } + + loadNextChunk() + } catch (error) { + reject(error) + } + }) + } + + function scheduleBlocks() { + while (activeWorkers < maxConcurrency && nextBlockIndex < blocks) { + const currentIndex = nextBlockIndex++ + activeWorkers++ + processBlock(currentIndex).then(() => { + activeWorkers-- + if (processedBlocks >= blocks) { + // 所有块完成,计算最终 MD5 + try { + const finalSpark = new SparkMD5.ArrayBuffer() + blockHashes.forEach((hash) => { + const hashBuffer = new TextEncoder().encode(hash) + finalSpark.append(hashBuffer) + }) + const finalMd5 = finalSpark.end() + + console.info(`[Worker] 所有块完成,最终 MD5: ${finalMd5}`) + globalThis.postMessage({ + type: 'complete', + taskId, + md5: finalMd5, + }) + } catch (error) { + console.error(`[Worker] 计算最终 MD5 时出错:`, error) + globalThis.postMessage({ + type: 'error', + taskId, + error, + }) + } + } else { + // 继续调度 + scheduleBlocks() + } + }).catch((error) => { + activeWorkers-- + console.error(`[Worker] 处理块时出错:`, error) + globalThis.postMessage({ + type: 'error', + taskId, + error, + }) + }) + } + } + + // 启动调度器 + scheduleBlocks() +} diff --git a/src/views/system/file/main/FileMain/index.vue b/src/views/system/file/main/FileMain/index.vue index 98d4553..52249cd 100644 --- a/src/views/system/file/main/FileMain/index.vue +++ b/src/views/system/file/main/FileMain/index.vue @@ -12,16 +12,35 @@ - -