From f4cd2ad0be9da4d074cfcb0a7bcc5c7cd4c97be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8E=AB=E6=84=81?= Date: Thu, 24 Jul 2025 18:12:55 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(system):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=88=86=E7=89=87=E4=B8=8A=E4=BC=A0=E6=8E=A5=E5=8F=A3=E5=92=8C?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/system/chunk-upload.ts | 38 ++ src/apis/system/type.ts | 46 ++ src/components/ChunkUploader/index.vue | 547 ++++++++++++++++++ src/types/components.d.ts | 1 + src/views/system/file/main/FileMain/index.vue | 25 +- 5 files changed, 654 insertions(+), 3 deletions(-) create mode 100644 src/apis/system/chunk-upload.ts create mode 100644 src/components/ChunkUploader/index.vue diff --git a/src/apis/system/chunk-upload.ts b/src/apis/system/chunk-upload.ts new file mode 100644 index 0000000..ec4cd36 --- /dev/null +++ b/src/apis/system/chunk-upload.ts @@ -0,0 +1,38 @@ +// 分片上传 API 封装 +import type * as T from './type' +import http from '@/utils/http' + +export type * from './type' + +const BASE_URL = '/system/chunk-upload' + +/** @desc 初始化分片上传 */ +export function initChunkUpload(data: T.InitChunkUploadParams) { + return http.post>(`${BASE_URL}/init`, data) +} + +/** @desc 上传分片 */ +export function uploadChunk(data: T.UploadChunkParams) { + const formData = new FormData() + formData.append('file', data.chunk) + formData.append('uploadId', data.uploadId) + formData.append('chunkNumber', String(data.chunkNumber)) + formData.append('storageCode', data.storageCode) + formData.append('chunkMd5', data.chunkMd5) + return http.post>(`${BASE_URL}/chunk`, formData) +} + +/** @desc 完成上传 */ +export function completeUpload(params: T.CompleteUploadParams) { + return http.post>(`${BASE_URL}/complete/${params.uploadId}?storageCode=${params.storageCode}`) +} + +/** @desc 取消上传 */ +export function cancelUpload(params: T.CancelUploadParams) { + return http.del>(`${BASE_URL}/cancel/${params.uploadId}?storageCode=${params.storageCode}`) +} + +/** @desc 获取上传状态 */ +export function getUploadStatus(params: T.GetUploadStatusParams) { + return http.get>(`${BASE_URL}/status/${params.uploadId}?storageCode=${params.storageCode}`) +} diff --git a/src/apis/system/type.ts b/src/apis/system/type.ts index 3528a2b..69d2c05 100644 --- a/src/apis/system/type.ts +++ b/src/apis/system/type.ts @@ -458,3 +458,49 @@ export interface MessageQuery { export interface MessagePageQuery extends MessageQuery, PageQuery { } + +/** 分片上传 - 初始化参数 */ +export interface InitChunkUploadParams { + filename: string + fileSize: number + fileMd5: string + chunkSize: number + parentPath: string + storageCode: string + extension: string + totalChunks: number +} + +/** 分片上传 - 上传分片参数 */ +export interface UploadChunkParams { + uploadId: string + chunkNumber: number + chunk: Blob + storageCode: string + chunkMd5: string +} + +/** 分片上传 - 完成上传参数 */ +export interface CompleteUploadParams { + uploadId: string + storageCode: string +} + +/** 分片上传 - 取消上传参数 */ +export interface CancelUploadParams { + uploadId: string + storageCode: string +} + +/** 分片上传 - 获取上传状态参数 */ +export interface GetUploadStatusParams { + uploadId: string + storageCode: string +} + +/** 分片上传 - 获取上传状态响应 */ +export interface ChunkUploadStatusResp { + uploadedChunks: number + uploadedChunksList: number[] + totalChunks: number +} diff --git a/src/components/ChunkUploader/index.vue b/src/components/ChunkUploader/index.vue new file mode 100644 index 0000000..953397c --- /dev/null +++ b/src/components/ChunkUploader/index.vue @@ -0,0 +1,547 @@ + + + + + diff --git a/src/types/components.d.ts b/src/types/components.d.ts index 7ea3f11..16bc8e9 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -11,6 +11,7 @@ declare module 'vue' { Breadcrumb: typeof import('./../components/Breadcrumb/index.vue')['default'] CellCopy: typeof import('./../components/CellCopy/index.vue')['default'] Chart: typeof import('./../components/Chart/index.vue')['default'] + ChunkUploader: typeof import('./../components/ChunkUploader/index.vue')['default'] ColumnSetting: typeof import('./../components/GiTable/src/components/ColumnSetting.vue')['default'] CronForm: typeof import('./../components/GenCron/CronForm/index.vue')['default'] CronModal: typeof import('./../components/GenCron/CronModal/index.vue')['default'] diff --git a/src/views/system/file/main/FileMain/index.vue b/src/views/system/file/main/FileMain/index.vue index 98d4553..8e1efd2 100644 --- a/src/views/system/file/main/FileMain/index.vue +++ b/src/views/system/file/main/FileMain/index.vue @@ -12,7 +12,7 @@ - + + 上传文件 + + + @@ -101,6 +112,7 @@ diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 34b9233..853b421 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -7,3 +7,4 @@ export * from './modules/useDevice' export * from './modules/useBreakpoint' export * from './modules/useDownload' export * from './modules/useResetReactive' +export * from './modules/useChunkUploader' diff --git a/src/hooks/modules/useChunkUploader.ts b/src/hooks/modules/useChunkUploader.ts new file mode 100644 index 0000000..dc10941 --- /dev/null +++ b/src/hooks/modules/useChunkUploader.ts @@ -0,0 +1,402 @@ +// 分片上传通用 hooks,支持多文件/多分片并发、暂停、恢复、取消、重试等 +import { computed, onUnmounted, ref } from 'vue' +import { throttle } from 'lodash-es' +import SparkMD5 from 'spark-md5' +import { + cancelUpload, + completeUpload, + getUploadStatus, + initChunkUpload, + uploadChunk, +} from '@/apis/system/chunk-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 // 总分片数 + fileName: string // 文件名 + fileType: string // 文件类型 + fileSize: number // 文件大小 + fileMd5?: string // 新增:文件MD5 + uploadId?: string // 分片上传ID + _uploading?: boolean // 标记是否正在上传(内部控制) + _pause?: () => void // 暂停方法 + _resume?: () => void // 继续方法 + _cancel?: () => void // 取消方法 +} + +/** + * useChunkUploader - 通用分片上传 hooks + * @param props.chunkSize 分片大小(单位:字节) + * @param props.maxConcurrentFiles 最大同时上传文件数(全局并发) + * @param props.maxConcurrentChunks 每个文件分片上传最大并发数 + * @param props.defaultStorage 默认存储类型 + * @returns 上传相关响应式状态与操作方法 + */ +export function useChunkUploader(props: { + chunkSize: number + maxConcurrentFiles?: number + maxConcurrentChunks?: number + // defaultStorage?: string +}) { + // 当前选中的存储类型 + const currentStorage = ref('') + // 所有上传任务列表 + const fileTasks = ref([]) + // 当前正在上传的文件数量 + const uploadingCount = computed(() => fileTasks.value.filter((t) => t.status === 'uploading').length) + // 最大并发上传文件数 + const maxConcurrent = computed(() => props.maxConcurrentFiles ?? 3) + // 每个文件分片上传最大并发数 + const maxChunkConcurrent = computed(() => props.maxConcurrentChunks ?? 3) + const md5CalculatingTaskUid = ref(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(异步) + */ + function calcFileMd5(file: File, taskUid?: string): Promise { + return new Promise((resolve, reject) => { + if (taskUid) md5CalculatingTaskUid.value = taskUid // 新增 + const chunkSize = 2 * 1024 * 1024 // 2MB + const chunks = Math.ceil(file.size / chunkSize) + let currentChunk = 0 + const spark = new SparkMD5.ArrayBuffer() + const fileReader = new FileReader() + function loadNext() { + const start = currentChunk * chunkSize + const end = Math.min(start + chunkSize, file.size) + fileReader.readAsArrayBuffer(file.slice(start, end)) + } + fileReader.onload = (e) => { + spark.append(e.target!.result as ArrayBuffer) + currentChunk++ + if (currentChunk < chunks) { + loadNext() + } else { + if (taskUid) md5CalculatingTaskUid.value = null // 新增 + resolve(spark.end()) + } + } + fileReader.onerror = (e) => { + if (taskUid) md5CalculatingTaskUid.value = null // 新增 + reject(e) + } + loadNext() + }) + } + + /** + * 分片上传核心逻辑,处理单个文件的分片上传、并发、暂停、恢复、取消等 + * @param task FileTask + */ + async function uploadFileTask(task: FileTask) { + try { + // 1. 初始化分片上传,获取 uploadId + if (!task.uploadId) { + // 若没有MD5,先计算 + if (!task.fileMd5) { + task.fileMd5 = await calcFileMd5(task.file, task.uid) + } + const res = await initChunkUpload({ + filename: task.fileName, + fileSize: task.fileSize, + fileMd5: task.fileMd5 || '', + chunkSize: props.chunkSize, + parentPath: task.parentPath, + storageCode: currentStorage.value, + extension: task.fileName.split('.').pop() || '', + totalChunks: Math.ceil(task.fileSize / props.chunkSize), + }) + if (res) { + task.uploadId = res.data + } else { + task.status = 'failed' + return + } + } + // 2. 查询已上传分片,支持断点续传 + const statusRes = await getUploadStatus({ + uploadId: task.uploadId!, + storageCode: currentStorage.value, + }) + const uploadedChunksList = statusRes.data?.uploadedChunksList || [] + const totalChunks = Math.max(1, statusRes.data?.totalChunks || Math.ceil(task.fileSize / props.chunkSize)) + task.totalChunks = totalChunks + // 并发上传控制变量 + let currentChunk = 1 + let activeChunks = 0 + let isPaused = false + let isCancelled = false + let isFailed = false // 新增:任务失败标志 + const chunkStatus: Record = {} + // 清空已上传分片列表,重新初始化 + task.uploadedChunks = [] + // 记录已上传的分片 + uploadedChunksList.forEach((i) => { + chunkStatus[i] = true + task.uploadedChunks.push(i) + }) + // 计算已完成的分片数量,初始化进度 + const finishedChunks = task.uploadedChunks.length + if (totalChunks > 0) { + task.progress = Number(Math.min(finishedChunks / totalChunks, 1).toFixed(2)) + } else { + task.progress = 0 + } + /** + * 控制分片并发上传,递归调度 + */ + async function uploadNextChunk() { + while (activeChunks < maxChunkConcurrent.value && currentChunk <= totalChunks) { + if (chunkStatus[currentChunk]) { + currentChunk++ + continue + } + if (isPaused || isCancelled || isFailed) return // 新增 isFailed + const chunkNumber = currentChunk + activeChunks++ + currentChunk++ + const start = (chunkNumber - 1) * props.chunkSize + const end = Math.min(start + props.chunkSize, task.fileSize) + const chunkBlob = task.file.slice(start, end) + // 新增:计算分片MD5 + const chunkMd5 = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = (e) => { + const md5 = SparkMD5.ArrayBuffer.hash(e.target!.result as ArrayBuffer) + resolve(md5) + } + reader.onerror = reject + reader.readAsArrayBuffer(chunkBlob) + }) + uploadChunk({ + uploadId: task.uploadId!, + chunkNumber, + chunk: chunkBlob, + storageCode: currentStorage.value, + chunkMd5, + }) + .then(() => { + chunkStatus[chunkNumber] = true + if (!task.uploadedChunks.includes(chunkNumber)) { + task.uploadedChunks.push(chunkNumber) + } + updateTaskProgress(task, totalChunks) + // 优化:暂停/取消/失败时不再触发 completeUpload + if (isPaused || isCancelled || isFailed) return + if (task.uploadedChunks.length >= totalChunks) { + completeUpload({ uploadId: task.uploadId!, storageCode: currentStorage.value }) + .then(() => { + task.status = 'completed' + task.progress = 1 + startNextTasks() // 自动补位 + }) + .catch(() => { + task.status = 'failed' + isFailed = true // 新增 + startNextTasks() // 新增 + }) + } else { + uploadNextChunk() + } + }) + .catch(() => { + task.status = 'failed' + isFailed = true // 新增 + startNextTasks() // 新增,自动切换下一个文件 + }) + .finally(() => { + activeChunks-- + if (!isPaused && !isCancelled && !isFailed && task.uploadedChunks.length < totalChunks) { + uploadNextChunk() + } + }) + } + } + // 启动并发上传 + uploadNextChunk() + // 挂载暂停/取消控制方法到 task + task._pause = () => { + isPaused = true + task.status = 'paused' + // 暂停仅前端控制,不调后端接口 + } + task._resume = () => { + if (task.status === 'paused') { + isPaused = false + task.status = 'uploading' + uploadNextChunk() + } + } + task._cancel = () => { + isCancelled = true + task.status = 'cancelled' + cancelUpload({ uploadId: task.uploadId!, storageCode: currentStorage.value }) + } + } 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() { + for (const task of fileTasks.value) { + if (task.status === 'waiting') { + task._uploading = false // 标记尚未调度 + } + } + startNextTasks() + } + + /** + * 添加文件/文件夹到上传队列 + * @param files File[] + * @param parentPath 父目录 + * @param isFolder 是否为文件夹 + */ + function addFiles(files: File[], parentPath: string, isFolder = false) { + for (const file of files) { + const relativePath = (file as any).webkitRelativePath || '/' + let parent = isFolder ? `/${relativePath.split('/')[0] || ''}` : parentPath + if (!parent) parent = '/' + // 去除 parentPath 结尾的 / + if (parent.length > 1 && parent.endsWith('/')) { + parent = parent.slice(0, -1) + } + 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, + fileMd5: '', + } + // 计算MD5,异步赋值 + calcFileMd5(file, task.uid).then((md5) => { task.fileMd5 = md5 }) + fileTasks.value.push(task) + } + } + + // 暂停单个任务 + function pauseTask(task: FileTask) { + task._pause?.() + } + // 恢复单个任务 + function resumeTask(task: FileTask) { + task._resume?.() + if (task.status === 'paused') { + task._uploading = false + startNextTasks() + } + } + // 取消单个任务 + function cancelTask(task: FileTask) { + 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._uploading = false + startNextTasks() + } + } + // 清空所有上传任务 + function clearAllTasks() { + fileTasks.value = [] + } + // 删除单个任务 + function removeTask(task: FileTask) { + 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]}` + } + + // 组件销毁时,终止所有上传任务 + onUnmounted(() => { + fileTasks.value.forEach((task) => { + if (task.status === 'uploading' || task.status === 'waiting' || task.status === 'paused') { + pauseTask(task) + } + }) + }) + + return { + currentStorage, + fileTasks, + uploadingCount, + maxConcurrent, + maxChunkConcurrent, + uploadFileTask, + startNextTasks, + startAllUpload, + addFiles, + pauseTask, + resumeTask, + cancelTask, + startTask, + retryTask, + clearAllTasks, + removeTask, + formatFileSize, + md5CalculatingTaskUid, // 新增 + } +} diff --git a/src/types/components.d.ts b/src/types/components.d.ts index 16bc8e9..524cd7d 100644 --- a/src/types/components.d.ts +++ b/src/types/components.d.ts @@ -54,6 +54,7 @@ declare module 'vue' { MinuteForm: typeof import('./../components/GenCron/CronForm/component/minute-form.vue')['default'] MonthForm: typeof import('./../components/GenCron/CronForm/component/month-form.vue')['default'] ParentView: typeof import('./../components/ParentView/index.vue')['default'] + Progress: typeof import('./../components/ChunkUploader/components/progress.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] SecondForm: typeof import('./../components/GenCron/CronForm/component/second-form.vue')['default'] diff --git a/src/views/system/file/main/FileMain/index.vue b/src/views/system/file/main/FileMain/index.vue index 8e1efd2..1ee3b68 100644 --- a/src/views/system/file/main/FileMain/index.vue +++ b/src/views/system/file/main/FileMain/index.vue @@ -23,11 +23,9 @@ --> 上传文件 - +