mirror of
				https://github.com/continew-org/continew-admin-ui.git
				synced 2025-10-31 22:57:15 +08:00 
			
		
		
		
	feat(system/file): 新增分片文件上传
This commit is contained in:
		| @@ -58,7 +58,8 @@ | |||||||
|     "vue-router": "^4.3.3", |     "vue-router": "^4.3.3", | ||||||
|     "vue3-tree-org": "^4.2.2", |     "vue3-tree-org": "^4.2.2", | ||||||
|     "xe-utils": "^3.5.7", |     "xe-utils": "^3.5.7", | ||||||
|     "xgplayer": "^2.31.6" |     "xgplayer": "^2.31.6", | ||||||
|  |     "spark-md5": "^3.0.2" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@antfu/eslint-config": "^2.16.3", |     "@antfu/eslint-config": "^2.16.3", | ||||||
| @@ -67,6 +68,7 @@ | |||||||
|     "@types/lodash-es": "^4.17.12", |     "@types/lodash-es": "^4.17.12", | ||||||
|     "@types/node": "^20.2.5", |     "@types/node": "^20.2.5", | ||||||
|     "@types/query-string": "^6.3.0", |     "@types/query-string": "^6.3.0", | ||||||
|  |     "@types/spark-md5": "^3.0.5", | ||||||
|     "@vitejs/plugin-vue": "^5.2.1", |     "@vitejs/plugin-vue": "^5.2.1", | ||||||
|     "@vitejs/plugin-vue-jsx": "^3.1.0", |     "@vitejs/plugin-vue-jsx": "^3.1.0", | ||||||
|     "@vue/tsconfig": "^0.1.3", |     "@vue/tsconfig": "^0.1.3", | ||||||
|   | |||||||
							
								
								
									
										27
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										27
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							| @@ -98,6 +98,9 @@ importers: | |||||||
|       query-string: |       query-string: | ||||||
|         specifier: ^9.0.0 |         specifier: ^9.0.0 | ||||||
|         version: 9.0.0 |         version: 9.0.0 | ||||||
|  |       spark-md5: | ||||||
|  |         specifier: ^3.0.2 | ||||||
|  |         version: 3.0.2 | ||||||
|       v-viewer: |       v-viewer: | ||||||
|         specifier: ^3.0.10 |         specifier: ^3.0.10 | ||||||
|         version: 3.0.13(viewerjs@1.11.6)(vue@3.5.12(typescript@5.0.4)) |         version: 3.0.13(viewerjs@1.11.6)(vue@3.5.12(typescript@5.0.4)) | ||||||
| @@ -162,6 +165,9 @@ importers: | |||||||
|       '@types/query-string': |       '@types/query-string': | ||||||
|         specifier: ^6.3.0 |         specifier: ^6.3.0 | ||||||
|         version: 6.3.0 |         version: 6.3.0 | ||||||
|  |       '@types/spark-md5': | ||||||
|  |         specifier: ^3.0.5 | ||||||
|  |         version: 3.0.5 | ||||||
|       '@vitejs/plugin-vue': |       '@vitejs/plugin-vue': | ||||||
|         specifier: ^5.2.1 |         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)) |         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': |   '@humanwhocodes/config-array@0.13.0': | ||||||
|     resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} |     resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} | ||||||
|     engines: {node: '>=10.10.0'} |     engines: {node: '>=10.10.0'} | ||||||
|  |     deprecated: Use @eslint/config-array instead | ||||||
|  |  | ||||||
|   '@humanwhocodes/module-importer@1.0.1': |   '@humanwhocodes/module-importer@1.0.1': | ||||||
|     resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} |     resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} | ||||||
| @@ -704,6 +711,7 @@ packages: | |||||||
|  |  | ||||||
|   '@humanwhocodes/object-schema@2.0.3': |   '@humanwhocodes/object-schema@2.0.3': | ||||||
|     resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} |     resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} | ||||||
|  |     deprecated: Use @eslint/object-schema instead | ||||||
|  |  | ||||||
|   '@humanwhocodes/retry@0.3.0': |   '@humanwhocodes/retry@0.3.0': | ||||||
|     resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} |     resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} | ||||||
| @@ -813,55 +821,46 @@ packages: | |||||||
|     resolution: {integrity: sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A==} |     resolution: {integrity: sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A==} | ||||||
|     cpu: [arm] |     cpu: [arm] | ||||||
|     os: [linux] |     os: [linux] | ||||||
|     libc: [glibc] |  | ||||||
|  |  | ||||||
|   '@rollup/rollup-linux-arm-musleabihf@4.17.2': |   '@rollup/rollup-linux-arm-musleabihf@4.17.2': | ||||||
|     resolution: {integrity: sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg==} |     resolution: {integrity: sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg==} | ||||||
|     cpu: [arm] |     cpu: [arm] | ||||||
|     os: [linux] |     os: [linux] | ||||||
|     libc: [musl] |  | ||||||
|  |  | ||||||
|   '@rollup/rollup-linux-arm64-gnu@4.17.2': |   '@rollup/rollup-linux-arm64-gnu@4.17.2': | ||||||
|     resolution: {integrity: sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A==} |     resolution: {integrity: sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A==} | ||||||
|     cpu: [arm64] |     cpu: [arm64] | ||||||
|     os: [linux] |     os: [linux] | ||||||
|     libc: [glibc] |  | ||||||
|  |  | ||||||
|   '@rollup/rollup-linux-arm64-musl@4.17.2': |   '@rollup/rollup-linux-arm64-musl@4.17.2': | ||||||
|     resolution: {integrity: sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA==} |     resolution: {integrity: sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA==} | ||||||
|     cpu: [arm64] |     cpu: [arm64] | ||||||
|     os: [linux] |     os: [linux] | ||||||
|     libc: [musl] |  | ||||||
|  |  | ||||||
|   '@rollup/rollup-linux-powerpc64le-gnu@4.17.2': |   '@rollup/rollup-linux-powerpc64le-gnu@4.17.2': | ||||||
|     resolution: {integrity: sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ==} |     resolution: {integrity: sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ==} | ||||||
|     cpu: [ppc64] |     cpu: [ppc64] | ||||||
|     os: [linux] |     os: [linux] | ||||||
|     libc: [glibc] |  | ||||||
|  |  | ||||||
|   '@rollup/rollup-linux-riscv64-gnu@4.17.2': |   '@rollup/rollup-linux-riscv64-gnu@4.17.2': | ||||||
|     resolution: {integrity: sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg==} |     resolution: {integrity: sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg==} | ||||||
|     cpu: [riscv64] |     cpu: [riscv64] | ||||||
|     os: [linux] |     os: [linux] | ||||||
|     libc: [glibc] |  | ||||||
|  |  | ||||||
|   '@rollup/rollup-linux-s390x-gnu@4.17.2': |   '@rollup/rollup-linux-s390x-gnu@4.17.2': | ||||||
|     resolution: {integrity: sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g==} |     resolution: {integrity: sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g==} | ||||||
|     cpu: [s390x] |     cpu: [s390x] | ||||||
|     os: [linux] |     os: [linux] | ||||||
|     libc: [glibc] |  | ||||||
|  |  | ||||||
|   '@rollup/rollup-linux-x64-gnu@4.17.2': |   '@rollup/rollup-linux-x64-gnu@4.17.2': | ||||||
|     resolution: {integrity: sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ==} |     resolution: {integrity: sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ==} | ||||||
|     cpu: [x64] |     cpu: [x64] | ||||||
|     os: [linux] |     os: [linux] | ||||||
|     libc: [glibc] |  | ||||||
|  |  | ||||||
|   '@rollup/rollup-linux-x64-musl@4.17.2': |   '@rollup/rollup-linux-x64-musl@4.17.2': | ||||||
|     resolution: {integrity: sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q==} |     resolution: {integrity: sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q==} | ||||||
|     cpu: [x64] |     cpu: [x64] | ||||||
|     os: [linux] |     os: [linux] | ||||||
|     libc: [musl] |  | ||||||
|  |  | ||||||
|   '@rollup/rollup-win32-arm64-msvc@4.17.2': |   '@rollup/rollup-win32-arm64-msvc@4.17.2': | ||||||
|     resolution: {integrity: sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA==} |     resolution: {integrity: sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA==} | ||||||
| @@ -1192,6 +1191,9 @@ packages: | |||||||
|   '@types/sortablejs@1.15.8': |   '@types/sortablejs@1.15.8': | ||||||
|     resolution: {integrity: sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==} |     resolution: {integrity: sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==} | ||||||
|  |  | ||||||
|  |   '@types/spark-md5@3.0.5': | ||||||
|  |     resolution: {integrity: sha512-lWf05dnD42DLVKQJZrDHtWFidcLrHuip01CtnC2/S6AMhX4t9ZlEUj4iuRlAnts0PQk7KESOqKxeGE/b6sIPGg==} | ||||||
|  |  | ||||||
|   '@types/svgo@2.6.4': |   '@types/svgo@2.6.4': | ||||||
|     resolution: {integrity: sha512-l4cmyPEckf8moNYHdJ+4wkHvFxjyW6ulm9l4YGaOxeyBWPhBOT0gvni1InpFPdzx1dKf/2s62qGITwxNWnPQng==} |     resolution: {integrity: sha512-l4cmyPEckf8moNYHdJ+4wkHvFxjyW6ulm9l4YGaOxeyBWPhBOT0gvni1InpFPdzx1dKf/2s62qGITwxNWnPQng==} | ||||||
|  |  | ||||||
| @@ -4091,6 +4093,9 @@ packages: | |||||||
|     resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} |     resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} | ||||||
|     deprecated: Please use @jridgewell/sourcemap-codec instead |     deprecated: Please use @jridgewell/sourcemap-codec instead | ||||||
|  |  | ||||||
|  |   spark-md5@3.0.2: | ||||||
|  |     resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==} | ||||||
|  |  | ||||||
|   spdx-correct@3.2.0: |   spdx-correct@3.2.0: | ||||||
|     resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} |     resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} | ||||||
|  |  | ||||||
| @@ -5772,6 +5777,8 @@ snapshots: | |||||||
|  |  | ||||||
|   '@types/sortablejs@1.15.8': {} |   '@types/sortablejs@1.15.8': {} | ||||||
|  |  | ||||||
|  |   '@types/spark-md5@3.0.5': {} | ||||||
|  |  | ||||||
|   '@types/svgo@2.6.4': |   '@types/svgo@2.6.4': | ||||||
|     dependencies: |     dependencies: | ||||||
|       '@types/node': 20.12.12 |       '@types/node': 20.12.12 | ||||||
| @@ -9050,6 +9057,8 @@ snapshots: | |||||||
|  |  | ||||||
|   sourcemap-codec@1.4.8: {} |   sourcemap-codec@1.4.8: {} | ||||||
|  |  | ||||||
|  |   spark-md5@3.0.2: {} | ||||||
|  |  | ||||||
|   spdx-correct@3.2.0: |   spdx-correct@3.2.0: | ||||||
|     dependencies: |     dependencies: | ||||||
|       spdx-expression-parse: 3.0.1 |       spdx-expression-parse: 3.0.1 | ||||||
|   | |||||||
							
								
								
									
										32
									
								
								src/apis/system/multipart-upload.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/apis/system/multipart-upload.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<T.MultiPartUploadInitResp>(`${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<T.UploadPartResp>(`${BASE_URL}/part`, formData, { signal }) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** @desc 完成上传 */ | ||||||
|  | export function completeMultipartUpload(params: T.CompleteMultipartUploadReq) { | ||||||
|  |   return http.get<string>(`${BASE_URL}/complete/${params.uploadId}`) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** @desc 取消上传 */ | ||||||
|  | export function cancelUpload(params: T.CancelUploadParams) { | ||||||
|  |   return http.get<void>(`${BASE_URL}/cancel/${params.uploadId}`) | ||||||
|  | } | ||||||
| @@ -467,3 +467,56 @@ export interface MessageQuery { | |||||||
|  |  | ||||||
| export interface MessagePageQuery extends MessageQuery, PageQuery { | export interface MessagePageQuery extends MessageQuery, PageQuery { | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /** 分片上传 - 初始化参数 */ | ||||||
|  | export interface MultiPartUploadInitReq { | ||||||
|  |   fileName: string | ||||||
|  |   fileSize: number | ||||||
|  |   fileMd5: string | ||||||
|  |   parentPath: string | ||||||
|  |   metaData: Record<string, string> | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** 分片上传 - 初始化响应 */ | ||||||
|  | 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 | ||||||
|  | } | ||||||
|   | |||||||
							
								
								
									
										432
									
								
								src/components/MultipartUpload/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										432
									
								
								src/components/MultipartUpload/index.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,432 @@ | |||||||
|  | <template> | ||||||
|  |   <a-row :gutter="16" class="multipart-uploader-responsive-row"> | ||||||
|  |     <a-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24"> | ||||||
|  |       <div | ||||||
|  |         class="multipart-uploader-table-flex" | ||||||
|  |         :class="{ dragover: isDragOver }" | ||||||
|  |         @dragover.prevent="onDragOver" | ||||||
|  |         @dragleave.prevent="onDragLeave" | ||||||
|  |         @drop.prevent="onDrop" | ||||||
|  |       > | ||||||
|  |         <!-- 文件/文件夹选择和全局操作按钮 --> | ||||||
|  |         <div class="upload-select-area-flex"> | ||||||
|  |           <div class="upload-btns-left"> | ||||||
|  |             <a-button @click="triggerFileInput">选择文件</a-button> | ||||||
|  |             <a-button style="margin-left: 8px;" @click="triggerFolderInput">选择文件夹</a-button> | ||||||
|  |             <input ref="fileInput" type="file" multiple style="display: none" @change="onFileChange" /> | ||||||
|  |             <input ref="folderInput" type="file" webkitdirectory directory style="display: none" @change="onFolderChange" /> | ||||||
|  |           </div> | ||||||
|  |           <div class="upload-btns-right"> | ||||||
|  |             <a-button type="primary" @click="startAllUpload">开始上传</a-button> | ||||||
|  |             <a-button style="margin-left: 8px;" status="danger" @click="clearAllTasks">清空</a-button> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |         <div style="margin-bottom: 8px; color: #888; font-size: 13px;"> | ||||||
|  |           支持拖拽文件到此区域上传(文件夹请使用"选择文件夹"按钮) | ||||||
|  |           <br /> | ||||||
|  |           <small style="color: #999;">提示:拖拽上传时,所有文件将上传到根目录</small> | ||||||
|  |         </div> | ||||||
|  |         <!-- 表格区域 --> | ||||||
|  |         <div class="gi-table-flex-body"> | ||||||
|  |           <div class="gi-table-flex-container"> | ||||||
|  |             <a-table | ||||||
|  |               :data="fileTasks" | ||||||
|  |               :columns="columns" | ||||||
|  |               row-key="uid" | ||||||
|  |               :pagination="pagination" | ||||||
|  |               style="height: 100%; background: transparent;" | ||||||
|  |             > | ||||||
|  |               <template #progress="{ record }"> | ||||||
|  |                 <template v-if="md5CalculatingTaskUid === record.uid"> | ||||||
|  |                   <span style="color: #888;">正在计算MD5...</span> | ||||||
|  |                 </template> | ||||||
|  |                 <template v-else> | ||||||
|  |                   <a-progress :percent="record.progress" :animation="true" size="large" /> | ||||||
|  |                 </template> | ||||||
|  |               </template> | ||||||
|  |               <template #status="{ record }"> | ||||||
|  |                 <div> | ||||||
|  |                   <a-tag :color="statusColor(record.status)" size="small">{{ statusText(record.status) }}</a-tag> | ||||||
|  |                   <div v-if="record.status === 'failed' && record.errorMessage" style="margin-top: 4px; font-size: 12px; color: #f56c6c;"> | ||||||
|  |                     {{ record.errorMessage }} | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |               </template> | ||||||
|  |               <template #actions="{ record }"> | ||||||
|  |                 <a-space> | ||||||
|  |                   <a-tooltip v-if="record.status === 'waiting'" content="开始"> | ||||||
|  |                     <a-button size="mini" type="text" @click="startTask(record)"><IconPlayArrow /></a-button> | ||||||
|  |                   </a-tooltip> | ||||||
|  |                   <a-tooltip v-if="record.status === 'uploading'" content="暂停"> | ||||||
|  |                     <a-button size="mini" type="text" @click="pauseTask(record)"><IconPause /></a-button> | ||||||
|  |                   </a-tooltip> | ||||||
|  |                   <a-tooltip v-if="record.status === 'paused'" content="继续"> | ||||||
|  |                     <a-button size="mini" type="text" @click="resumeTask(record)"><IconPlayArrow /></a-button> | ||||||
|  |                   </a-tooltip> | ||||||
|  |                   <a-tooltip v-if="record.status === 'failed'" content="重试"> | ||||||
|  |                     <a-button size="mini" type="text" @click="retryTask(record)"><IconRefresh /></a-button> | ||||||
|  |                   </a-tooltip> | ||||||
|  |                   <a-tooltip content="取消"> | ||||||
|  |                     <a-button v-if="record.status !== 'completed' && record.status !== 'cancelled'" size="mini" type="text" @click="cancelTask(record)"><IconClose /></a-button> | ||||||
|  |                   </a-tooltip> | ||||||
|  |                   <a-tooltip content="删除"> | ||||||
|  |                     <a-button size="mini" type="text" status="danger" @click="removeTask(record)"><IconDelete /></a-button> | ||||||
|  |                   </a-tooltip> | ||||||
|  |                 </a-space> | ||||||
|  |               </template> | ||||||
|  |             </a-table> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </a-col> | ||||||
|  |   </a-row> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script lang="ts" setup> | ||||||
|  | import { h, ref, resolveComponent } from 'vue' | ||||||
|  | import { IconClose, IconDelete, IconPause, IconPlayArrow, IconRefresh } from '@arco-design/web-vue/es/icon' | ||||||
|  | import { useMultipartUploader } from '@/hooks/modules/useMultipartUploader' | ||||||
|  | import { getFilesFromDataTransferItems, isFileSystemAccessAPISupported } from '@/utils/drag-drop-file-util' | ||||||
|  |  | ||||||
|  | // 组件props定义 | ||||||
|  | const props = defineProps<{ | ||||||
|  |   extraParams?: Record<string, any> | ||||||
|  |   maxConcurrentFiles?: number | ||||||
|  |   maxConcurrentChunks?: number | ||||||
|  |   maxUploadWorkers?: number | ||||||
|  |   rootPath?: string | ||||||
|  | }>() | ||||||
|  | // 文件/文件夹选择input引用 | ||||||
|  | const fileInput = ref<HTMLInputElement | null>(null) | ||||||
|  | const folderInput = ref<HTMLInputElement | null>(null) | ||||||
|  | // 拖拽高亮状态 | ||||||
|  | const isDragOver = ref(false) | ||||||
|  | const pagination = { | ||||||
|  |   pageSize: 10, | ||||||
|  |   showTotal: true, | ||||||
|  |   showJumper: true, | ||||||
|  |   position: ['bottomCenter'], | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 文件大小格式化工具 | ||||||
|  | function formatFileSize(bytes: number) { | ||||||
|  |   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]}` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 表格列定义 | ||||||
|  | const columns = [ | ||||||
|  |   { | ||||||
|  |     title: '名称', | ||||||
|  |     dataIndex: 'fileName', | ||||||
|  |     ellipsis: true, | ||||||
|  |     render: ({ record }) => h( | ||||||
|  |       resolveComponent('a-tooltip'), | ||||||
|  |       { content: record.fileName, placement: 'top' }, | ||||||
|  |       () => h('span', record.fileName), | ||||||
|  |     ), | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     title: '文件目录', | ||||||
|  |     dataIndex: 'relativePath', | ||||||
|  |     ellipsis: true, | ||||||
|  |     render: ({ record }) => { | ||||||
|  |       // 显示完整路径 | ||||||
|  |       const displayPath = record.parentPath | ||||||
|  |  | ||||||
|  |       // 确保路径格式正确 | ||||||
|  |       if (record.relativePath && record.relativePath !== '/') { | ||||||
|  |         // 对于文件夹上传,relativePath格式为:folderName/file.txt | ||||||
|  |         // 我们只需要显示parentPath,因为它已经包含了正确的路径 | ||||||
|  |         const pathParts = record.relativePath.split('/') | ||||||
|  |         if (pathParts.length > 1) { | ||||||
|  |           // 如果是文件夹内的文件,只显示parentPath | ||||||
|  |           // parentPath已经是/test/upload这样的格式 | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       return h( | ||||||
|  |         resolveComponent('a-tooltip'), | ||||||
|  |         { content: displayPath, placement: 'top' }, | ||||||
|  |         () => h('span', displayPath), | ||||||
|  |       ) | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     title: '文件类型', | ||||||
|  |     dataIndex: 'fileType', | ||||||
|  |     ellipsis: true, | ||||||
|  |     render: ({ record }) => h( | ||||||
|  |       resolveComponent('a-tooltip'), | ||||||
|  |       { content: record.fileType, placement: 'top' }, | ||||||
|  |       () => h('span', record.fileType), | ||||||
|  |     ), | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     title: '文件大小', | ||||||
|  |     dataIndex: 'fileSize', | ||||||
|  |     ellipsis: true, | ||||||
|  |     render: ({ record }) => formatFileSize(record.fileSize), | ||||||
|  |     width: 120, | ||||||
|  |   }, | ||||||
|  |   { title: '进度', slotName: 'progress', width: 140 }, | ||||||
|  |   { title: '状态', slotName: 'status', width: 80 }, | ||||||
|  |   { title: '操作', slotName: 'actions', width: 150 }, | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | // 使用 useMultipartUploader composable | ||||||
|  | const { | ||||||
|  |   fileTasks, | ||||||
|  |   uploadingCount: _uploadingCount, | ||||||
|  |   maxConcurrent: _maxConcurrent, | ||||||
|  |   maxChunkConcurrent: _maxChunkConcurrent, | ||||||
|  |   startAllUpload, | ||||||
|  |   addFiles, | ||||||
|  |   pauseTask, | ||||||
|  |   resumeTask, | ||||||
|  |   cancelTask, | ||||||
|  |   startTask, | ||||||
|  |   retryTask, | ||||||
|  |   clearAllTasks, | ||||||
|  |   removeTask, | ||||||
|  |   formatFileSize: _formatFileSize, | ||||||
|  |   md5CalculatingTaskUid, | ||||||
|  | } = useMultipartUploader({ | ||||||
|  |   maxConcurrentFiles: props.maxConcurrentFiles, | ||||||
|  |   maxConcurrentChunks: props.maxConcurrentChunks, | ||||||
|  |   maxUploadWorkers: props.maxUploadWorkers, | ||||||
|  |   rootPath: props.rootPath, | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | // 触发文件选择 | ||||||
|  | function triggerFileInput() { | ||||||
|  |   fileInput.value?.click() | ||||||
|  | } | ||||||
|  | // 触发文件夹选择 | ||||||
|  | function triggerFolderInput() { | ||||||
|  |   folderInput.value?.click() | ||||||
|  | } | ||||||
|  | // 文件选择事件处理 | ||||||
|  | function onFileChange(e: Event) { | ||||||
|  |   const files = (e.target as HTMLInputElement).files | ||||||
|  |   if (!files) return | ||||||
|  |   // 移除 clearAllTasks(),改为追加模式 | ||||||
|  |   // 普通文件上传路径 = rootPath | ||||||
|  |   addFiles(Array.from(files), props.rootPath || '', false) | ||||||
|  |   // 不要自动 startAllUpload() | ||||||
|  |   ;(e.target as HTMLInputElement).value = '' | ||||||
|  | } | ||||||
|  | // 文件夹选择事件处理 | ||||||
|  | function onFolderChange(e: Event) { | ||||||
|  |   const files = (e.target as HTMLInputElement).files | ||||||
|  |   if (!files) return | ||||||
|  |   // 移除 clearAllTasks(),改为追加模式 | ||||||
|  |   // 带目录文件上传路径 = rootPath | ||||||
|  |   // 文件夹上传时,webkitRelativePath会自动包含文件夹路径 | ||||||
|  |   addFiles(Array.from(files), props.rootPath || '', true) | ||||||
|  |   // 不要自动 startAllUpload() | ||||||
|  |   ;(e.target as HTMLInputElement).value = '' | ||||||
|  | } | ||||||
|  | // 拖拽进入区域 | ||||||
|  | function onDragOver(_e: DragEvent) { | ||||||
|  |   isDragOver.value = true | ||||||
|  | } | ||||||
|  | // 拖拽离开区域 | ||||||
|  | function onDragLeave(_e: DragEvent) { | ||||||
|  |   isDragOver.value = false | ||||||
|  | } | ||||||
|  | // 拖拽释放文件/文件夹 | ||||||
|  | async function onDrop(e: DragEvent) { | ||||||
|  |   isDragOver.value = false | ||||||
|  |   e.preventDefault() | ||||||
|  |  | ||||||
|  |   let files: File[] | ||||||
|  |   if (isFileSystemAccessAPISupported()) { | ||||||
|  |     files = await getFilesFromDataTransferItems(e.dataTransfer!.items) | ||||||
|  |     addFiles(files, props.rootPath || '', true) | ||||||
|  |   } else { | ||||||
|  |     files = Array.from(e.dataTransfer?.files || []) | ||||||
|  |     // 验证文件的有效性 | ||||||
|  |     const validFiles = files.filter((file) => { | ||||||
|  |       return !(!file || file.size === 0) | ||||||
|  |     }) | ||||||
|  |     if (validFiles.length === 0) { | ||||||
|  |       return | ||||||
|  |     } | ||||||
|  |     // 检查是否有文件夹结构 | ||||||
|  |     const hasFolder = validFiles.some((f) => { | ||||||
|  |       if ((f as any).webkitRelativePath) { | ||||||
|  |         return true | ||||||
|  |       } | ||||||
|  |       return f.name.includes('/') || f.name.includes('\\') | ||||||
|  |     }) | ||||||
|  |     addFiles(validFiles, props.rootPath || '', hasFolder) | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | // 状态文本映射 | ||||||
|  | function statusText(status: string) { | ||||||
|  |   switch (status) { | ||||||
|  |     case 'waiting': return '等待中' | ||||||
|  |     case 'uploading': return '上传中' | ||||||
|  |     case 'paused': return '已暂停' | ||||||
|  |     case 'completed': return '已完成' | ||||||
|  |     case 'failed': return '失败' | ||||||
|  |     case 'cancelled': return '已取消' | ||||||
|  |     default: return status | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | // 状态颜色映射 | ||||||
|  | function statusColor(status: string) { | ||||||
|  |   switch (status) { | ||||||
|  |     case 'waiting': return '#909399' | ||||||
|  |     case 'uploading': return '#409EFF' | ||||||
|  |     case 'paused': return '#E6A23C' | ||||||
|  |     case 'completed': return '#67C23A' | ||||||
|  |     case 'failed': return '#F56C6C' | ||||||
|  |     case 'cancelled': return '#C0C4CC' | ||||||
|  |     default: return '#909399' | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style lang="scss" scoped> | ||||||
|  | .multipart-uploader-table-flex { | ||||||
|  |   padding: 24px; | ||||||
|  |   border-radius: 8px; | ||||||
|  |   box-shadow: 0 2px 8px #0000000d; | ||||||
|  |   border: 2px dashed #e5e6eb; | ||||||
|  |   transition: border-color 0.2s, background 0.2s; | ||||||
|  |   min-width: 1000px; | ||||||
|  |   max-width: 1200px; | ||||||
|  |   margin: 0 auto; | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   height: 700px; | ||||||
|  | } | ||||||
|  | .multipart-uploader-table-flex.dragover { | ||||||
|  |   border: 2px dashed #409eff; | ||||||
|  | } | ||||||
|  | .upload-select-area-flex { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: space-between; | ||||||
|  |   margin-bottom: 16px; | ||||||
|  | } | ||||||
|  | .upload-btns-left { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  | } | ||||||
|  | .upload-btns-right { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   margin-left: auto; | ||||||
|  | } | ||||||
|  | .upload-select-area { | ||||||
|  |   margin-bottom: 16px; | ||||||
|  | } | ||||||
|  | .gi-table-flex-body { | ||||||
|  |   flex: 1; | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   min-height: 400px; | ||||||
|  |   border-radius: 8px; | ||||||
|  |   box-shadow: 0 1px 4px #0001; | ||||||
|  |   padding: 8px 0 0 0; | ||||||
|  | } | ||||||
|  | .gi-table-flex-container { | ||||||
|  |   flex: 1; | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   min-height: 400px; | ||||||
|  |   height: 100%; | ||||||
|  |   background: transparent; | ||||||
|  | } | ||||||
|  | :deep(.arco-table) { | ||||||
|  |   flex: 1; | ||||||
|  |   min-height: 400px; | ||||||
|  |   height: 100%; | ||||||
|  |   background: transparent; | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  | } | ||||||
|  | :deep(.arco-table-th) { | ||||||
|  |   min-width: 120px; | ||||||
|  |   font-weight: 500; | ||||||
|  | } | ||||||
|  | :deep(.arco-table-td) { | ||||||
|  |   max-width: 400px; | ||||||
|  |   min-width: 120px; | ||||||
|  |   white-space: nowrap; | ||||||
|  |   text-overflow: ellipsis; | ||||||
|  |   overflow: hidden; | ||||||
|  | } | ||||||
|  | :deep(.arco-table-td:nth-child(1)), | ||||||
|  | :deep(.arco-table-th:nth-child(1)) { | ||||||
|  |   min-width: 200px; | ||||||
|  |   max-width: 350px; | ||||||
|  | } | ||||||
|  | :deep(.arco-table-td:nth-child(2)), | ||||||
|  | :deep(.arco-table-th:nth-child(2)) { | ||||||
|  |   min-width: 180px; | ||||||
|  |   max-width: 300px; | ||||||
|  | } | ||||||
|  | :deep(.arco-table-td:last-child), | ||||||
|  | :deep(.arco-table-th:last-child) { | ||||||
|  |   min-width: 160px; | ||||||
|  |   max-width: 200px; | ||||||
|  | } | ||||||
|  | :deep(.arco-table-element):has(tbody .arco-table-tr-empty) { | ||||||
|  |   height: 100%; | ||||||
|  | } | ||||||
|  | :deep(.arco-table-pagination) { | ||||||
|  |   margin-top: auto !important; | ||||||
|  |   padding-bottom: 8px; | ||||||
|  | } | ||||||
|  | .multipart-uploader-responsive-row { | ||||||
|  |   width: 100%; | ||||||
|  |   margin: 0; | ||||||
|  | } | ||||||
|  | @media (max-width: 1200px) { | ||||||
|  |   .multipart-uploader-table-flex { | ||||||
|  |     min-width: 100%; | ||||||
|  |     max-width: 100%; | ||||||
|  |     padding: 12px; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @media (max-width: 900px) { | ||||||
|  |   .multipart-uploader-table-flex { | ||||||
|  |     min-width: 100%; | ||||||
|  |     max-width: 100%; | ||||||
|  |     padding: 6px; | ||||||
|  |   } | ||||||
|  |   .gi-table-flex-body { | ||||||
|  |     min-height: 200px; | ||||||
|  |     height: 300px; | ||||||
|  |     padding: 0; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @media (max-width: 600px) { | ||||||
|  |   .multipart-uploader-table-flex { | ||||||
|  |     min-width: 100vw; | ||||||
|  |     max-width: 100vw; | ||||||
|  |     border-radius: 0; | ||||||
|  |     padding: 2px; | ||||||
|  |   } | ||||||
|  |   .gi-table-flex-body { | ||||||
|  |     min-height: 120px; | ||||||
|  |     height: 180px; | ||||||
|  |     padding: 0; | ||||||
|  |   } | ||||||
|  |   .upload-select-area-flex { | ||||||
|  |     flex-direction: column; | ||||||
|  |     align-items: flex-start; | ||||||
|  |     gap: 8px; | ||||||
|  |   } | ||||||
|  |   .upload-btns-right { | ||||||
|  |     margin-left: 0; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -7,3 +7,4 @@ export * from './modules/useDevice' | |||||||
| export * from './modules/useBreakpoint' | export * from './modules/useBreakpoint' | ||||||
| export * from './modules/useDownload' | export * from './modules/useDownload' | ||||||
| export * from './modules/useResetReactive' | export * from './modules/useResetReactive' | ||||||
|  | export * from './modules/useMultipartUploader' | ||||||
|   | |||||||
							
								
								
									
										791
									
								
								src/hooks/modules/useMultipartUploader.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										791
									
								
								src/hooks/modules/useMultipartUploader.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<number, number> // 分片重试次数记录 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 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<FileTask[]>([]) | ||||||
|  |   // 当前正在上传的文件数量 | ||||||
|  |   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<Array<{ task: FileTask, chunkNumber: number }>>([]) | ||||||
|  |   const activeUploads = ref<Set<string>>(new Set()) // 正在上传的分片ID集合 | ||||||
|  |  | ||||||
|  |   const md5CalculatingTaskUid = ref<string | null>(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<string> { | ||||||
|  |     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, | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										1
									
								
								src/types/components.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								src/types/components.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -52,6 +52,7 @@ declare module 'vue' { | |||||||
|     JsonPretty: typeof import('./../components/JsonPretty/index.vue')['default'] |     JsonPretty: typeof import('./../components/JsonPretty/index.vue')['default'] | ||||||
|     MinuteForm: typeof import('./../components/GenCron/CronForm/component/minute-form.vue')['default'] |     MinuteForm: typeof import('./../components/GenCron/CronForm/component/minute-form.vue')['default'] | ||||||
|     MonthForm: typeof import('./../components/GenCron/CronForm/component/month-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'] |     ParentView: typeof import('./../components/ParentView/index.vue')['default'] | ||||||
|     RouterLink: typeof import('vue-router')['RouterLink'] |     RouterLink: typeof import('vue-router')['RouterLink'] | ||||||
|     RouterView: typeof import('vue-router')['RouterView'] |     RouterView: typeof import('vue-router')['RouterView'] | ||||||
|   | |||||||
							
								
								
									
										49
									
								
								src/utils/drag-drop-file-util.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/utils/drag-drop-file-util.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | |||||||
|  | /** | ||||||
|  |  * 递归读取 DataTransferItemList 中的所有文件(支持文件夹结构) | ||||||
|  |  * 自动为每个 File 对象添加 webkitRelativePath 属性 | ||||||
|  |  * 仅在支持 File System Access API 的浏览器下有效 | ||||||
|  |  */ | ||||||
|  | export async function getFilesFromDataTransferItems(items: DataTransferItemList): Promise<File[]> { | ||||||
|  |   const files: File[] = [] | ||||||
|  |  | ||||||
|  |   async function traverse(handle: FileSystemHandle, path = ''): Promise<void> { | ||||||
|  |     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 | ||||||
|  | } | ||||||
							
								
								
									
										155
									
								
								src/utils/md5-worker.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										155
									
								
								src/utils/md5-worker.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<void> { | ||||||
|  |     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<FileReader>) { | ||||||
|  |           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<FileReader>) { | ||||||
|  |           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() | ||||||
|  | } | ||||||
| @@ -12,16 +12,35 @@ | |||||||
|     <a-row justify="space-between" class="file-main__search"> |     <a-row justify="space-between" class="file-main__search"> | ||||||
|       <!-- 左侧区域 --> |       <!-- 左侧区域 --> | ||||||
|       <a-space wrap> |       <a-space wrap> | ||||||
|         <a-upload v-permission="['system:file:upload']" :show-file-list="false" :custom-request="handleUpload"> |         <!-- 上传文件按钮改为下拉菜单,包含普通上传和分片上传 --> | ||||||
|           <template #upload-button> |         <a-dropdown trigger="click"> | ||||||
|           <a-button type="primary" shape="round"> |           <a-button type="primary" shape="round"> | ||||||
|               <template #icon> |  | ||||||
|             <icon-upload /> |             <icon-upload /> | ||||||
|               </template> |             上传文件 | ||||||
|               <template #default>上传</template> |           </a-button> | ||||||
|  |           <template #content> | ||||||
|  |             <!-- 普通上传 --> | ||||||
|  |             <a-upload v-permission="['system:file:upload']" :show-file-list="false" :custom-request="handleUpload" style="display: block;"> | ||||||
|  |               <template #upload-button> | ||||||
|  |                 <a-button type="text" style="width: 100%; text-align: left;"> | ||||||
|  |                   普通上传 | ||||||
|                 </a-button> |                 </a-button> | ||||||
|               </template> |               </template> | ||||||
|             </a-upload> |             </a-upload> | ||||||
|  |             <!-- 分片上传 --> | ||||||
|  |             <a-button type="text" style="width: 100%; text-align: left;" @click="visible = true"> | ||||||
|  |               分片上传 | ||||||
|  |             </a-button> | ||||||
|  |           </template> | ||||||
|  |         </a-dropdown> | ||||||
|  |         <a-modal v-model:visible="visible" title="分片上传" :width="width > 1350 ? 1350 : '100%'" :footer="false" @close="search"> | ||||||
|  |           <MultipartUpload | ||||||
|  |             v-if="visible" | ||||||
|  |             :root-path="queryForm.parentPath" | ||||||
|  |             :chunk-size="5 * 1024 * 1024" | ||||||
|  |             :max-concurrent-files="3" | ||||||
|  |           /> | ||||||
|  |         </a-modal> | ||||||
|  |  | ||||||
|         <a-input-group> |         <a-input-group> | ||||||
|           <a-input v-model="queryForm.originalName" :placeholder="queryForm.type && queryForm.type !== '0' ? '请输入名称' : '在当前目录下搜索名称'" allow-clear style="width: 200px" /> |           <a-input v-model="queryForm.originalName" :placeholder="queryForm.type && queryForm.type !== '0' ? '请输入名称' : '在当前目录下搜索名称'" allow-clear style="width: 200px" /> | ||||||
| @@ -101,6 +120,7 @@ | |||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { Message, Modal, type RequestOption } from '@arco-design/web-vue' | import { Message, Modal, type RequestOption } from '@arco-design/web-vue' | ||||||
| import { api as viewerApi } from 'v-viewer' | import { api as viewerApi } from 'v-viewer' | ||||||
|  | import { useWindowSize } from '@vueuse/core' | ||||||
| import { | import { | ||||||
|   openFileDetailModal, |   openFileDetailModal, | ||||||
|   openFileRenameModal, |   openFileRenameModal, | ||||||
| @@ -122,7 +142,7 @@ const FilePreview = defineAsyncComponent(() => import('@/components/FilePreview/ | |||||||
| const FileList = defineAsyncComponent(() => import('./FileList.vue')) | const FileList = defineAsyncComponent(() => import('./FileList.vue')) | ||||||
| const route = useRoute() | const route = useRoute() | ||||||
| const { mode, selectedFileIds, toggleMode, addSelectedFileItem } = useFileManage() | const { mode, selectedFileIds, toggleMode, addSelectedFileItem } = useFileManage() | ||||||
|  | const { width } = useWindowSize() | ||||||
| const queryForm = reactive<FileQuery>({ | const queryForm = reactive<FileQuery>({ | ||||||
|   originalName: undefined, |   originalName: undefined, | ||||||
|   parentPath: (!route.query.type || route.query.type?.toString() === '0') ? '/' : undefined, |   parentPath: (!route.query.type || route.query.type?.toString() === '0') ? '/' : undefined, | ||||||
| @@ -248,7 +268,6 @@ const handleMulDelete = () => { | |||||||
|     }, |     }, | ||||||
|   }) |   }) | ||||||
| } | } | ||||||
|  |  | ||||||
| // 上传 | // 上传 | ||||||
| const handleUpload = (options: RequestOption) => { | const handleUpload = (options: RequestOption) => { | ||||||
|   const controller = new AbortController() |   const controller = new AbortController() | ||||||
| @@ -276,6 +295,8 @@ const handleUpload = (options: RequestOption) => { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | const visible = ref(false) | ||||||
|  |  | ||||||
| onBeforeRouteUpdate((to) => { | onBeforeRouteUpdate((to) => { | ||||||
|   if (!to.query.type) return |   if (!to.query.type) return | ||||||
|   if (to.query.type === '0' || !to.query.type) { |   if (to.query.type === '0' || !to.query.type) { | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 KAI
					KAI