mirror of
https://github.com/continew-org/continew-admin-ui.git
synced 2025-09-08 06:59:20 +08:00
feat(system/file): 新增分片文件上传
This commit is contained in:
@@ -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",
|
||||
|
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
@@ -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
|
||||
|
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 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/useDownload'
|
||||
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']
|
||||
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']
|
||||
|
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-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">
|
||||
<template #icon>
|
||||
<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>
|
||||
</template>
|
||||
</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 v-model="queryForm.originalName" :placeholder="queryForm.type && queryForm.type !== '0' ? '请输入名称' : '在当前目录下搜索名称'" allow-clear style="width: 200px" />
|
||||
@@ -101,6 +120,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Message, Modal, type RequestOption } from '@arco-design/web-vue'
|
||||
import { api as viewerApi } from 'v-viewer'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import {
|
||||
openFileDetailModal,
|
||||
openFileRenameModal,
|
||||
@@ -122,7 +142,7 @@ const FilePreview = defineAsyncComponent(() => import('@/components/FilePreview/
|
||||
const FileList = defineAsyncComponent(() => import('./FileList.vue'))
|
||||
const route = useRoute()
|
||||
const { mode, selectedFileIds, toggleMode, addSelectedFileItem } = useFileManage()
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const queryForm = reactive<FileQuery>({
|
||||
originalName: undefined,
|
||||
parentPath: (!route.query.type || route.query.type?.toString() === '0') ? '/' : undefined,
|
||||
@@ -248,7 +268,6 @@ const handleMulDelete = () => {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 上传
|
||||
const handleUpload = (options: RequestOption) => {
|
||||
const controller = new AbortController()
|
||||
@@ -276,6 +295,8 @@ const handleUpload = (options: RequestOption) => {
|
||||
}
|
||||
}
|
||||
|
||||
const visible = ref(false)
|
||||
|
||||
onBeforeRouteUpdate((to) => {
|
||||
if (!to.query.type) return
|
||||
if (to.query.type === '0' || !to.query.type) {
|
||||
|
Reference in New Issue
Block a user