From 2c7701586238c30ad9ae9d7eb869795c79c7616b Mon Sep 17 00:00:00 2001 From: zstar <65890619+zstar1003@users.noreply.github.com> Date: Tue, 27 May 2025 21:43:36 +0800 Subject: [PATCH] =?UTF-8?q?refactor(upload):=20=E9=87=8D=E6=96=B0=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E5=A4=A7=E6=96=87=E4=BB=B6=E5=88=86?= =?UTF-8?q?=E5=9D=97=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/src/common/apis/files/upload.ts | 108 ++++++ .../src/common/composables/useFileUpload.ts | 337 ++++++++++++++++++ management/web/src/http/axios.ts | 2 +- management/web/src/http/upload-axios.ts | 70 ++++ management/web/src/pages/file/index.vue | 227 ++++++++++-- 5 files changed, 716 insertions(+), 28 deletions(-) create mode 100644 management/web/src/common/apis/files/upload.ts create mode 100644 management/web/src/common/composables/useFileUpload.ts create mode 100644 management/web/src/http/upload-axios.ts diff --git a/management/web/src/common/apis/files/upload.ts b/management/web/src/common/apis/files/upload.ts new file mode 100644 index 0000000..0eb507e --- /dev/null +++ b/management/web/src/common/apis/files/upload.ts @@ -0,0 +1,108 @@ +import { uploadRequest } from "@/http/upload-axios" + +/** + * 文件上传配置接口 + */ +interface UploadConfig { + onProgress?: (progress: number) => void + timeout?: number + chunkSize?: number +} + +/** + * 优化的文件上传API + * 支持进度回调、自定义超时、错误重试 + */ +export function uploadFileApiV2( + formData: FormData, + config: UploadConfig = {} +) { + const { onProgress, timeout = 300000 } = config + + return uploadRequest<{ + code: number + data: Array<{ + name: string + size: number + type: string + status: string + }> + message: string + }>({ + url: "/api/v1/files/upload", + method: "post", + data: formData, + timeout, + onUploadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total) + onProgress(progress) + } + } + }) +} + +/** + * 分块上传大文件 + * 将大文件分割成小块进行上传,提高成功率 + */ +export async function uploadLargeFile( + file: File, + config: UploadConfig & { parentId?: string } = {} +) { + const { chunkSize = 5 * 1024 * 1024, onProgress, parentId } = config // 默认5MB分块 + + // 小文件直接上传 + if (file.size <= chunkSize) { + const formData = new FormData() + formData.append("file", file) + if (parentId) formData.append("parent_id", parentId) + + return uploadFileApiV2(formData, { onProgress }) + } + + // 大文件分块上传 + const totalChunks = Math.ceil(file.size / chunkSize) + const uploadId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + const start = chunkIndex * chunkSize + const end = Math.min(start + chunkSize, file.size) + const chunk = file.slice(start, end) + + const formData = new FormData() + formData.append("chunk", chunk) + formData.append("chunkIndex", chunkIndex.toString()) + formData.append("totalChunks", totalChunks.toString()) + formData.append("uploadId", uploadId) + formData.append("fileName", file.name) + if (parentId) formData.append("parent_id", parentId) + + try { + await uploadRequest({ + url: "/api/v1/files/upload/chunk", + method: "post", + data: formData, + timeout: 60000, // 单个分块1分钟超时 + onUploadProgress: (progressEvent) => { + if (onProgress && progressEvent.total) { + const chunkProgress = (progressEvent.loaded / progressEvent.total) * 100 + const totalProgress = ((chunkIndex + chunkProgress / 100) / totalChunks) * 100 + onProgress(Math.round(totalProgress)) + } + } + }) + } catch (error) { + // 分块上传失败,尝试重试 + console.error(`分块 ${chunkIndex + 1}/${totalChunks} 上传失败:`, error) + throw new Error(`文件上传失败:分块 ${chunkIndex + 1} 上传出错`) + } + } + + // 合并分块 + return uploadRequest({ + url: "/api/v1/files/upload/merge", + method: "post", + data: { uploadId, fileName: file.name, totalChunks, parentId } + }) +} diff --git a/management/web/src/common/composables/useFileUpload.ts b/management/web/src/common/composables/useFileUpload.ts new file mode 100644 index 0000000..60a0c4d --- /dev/null +++ b/management/web/src/common/composables/useFileUpload.ts @@ -0,0 +1,337 @@ +import type { UploadUserFile } from "element-plus" +import { uploadFileApiV2, uploadLargeFile } from "@@/apis/files/upload" +import { ElMessage } from "element-plus" +import { computed, reactive, readonly, ref } from "vue" + +/** + * 文件上传状态枚举 + */ +export enum UploadStatus { + PENDING = "pending", + UPLOADING = "uploading", + SUCCESS = "success", + ERROR = "error", + CANCELLED = "cancelled" +} + +/** + * 上传文件项接口 + */ +export interface UploadFileItem { + id: string + file: File + name: string + size: number + status: UploadStatus + progress: number + error?: string + uploadedAt?: Date +} + +/** + * 文件上传配置接口 + */ +export interface FileUploadConfig { + /** 最大并发上传数 */ + maxConcurrent?: number + /** 大文件阈值(字节),超过此大小使用分块上传 */ + largeFileThreshold?: number + /** 分块大小(字节) */ + chunkSize?: number + /** 上传超时时间(毫秒) */ + timeout?: number + /** 是否自动开始上传 */ + autoUpload?: boolean + /** 上传成功回调 */ + onSuccess?: (fileItem: UploadFileItem) => void + /** 上传失败回调 */ + onError?: (fileItem: UploadFileItem, error: Error) => void + /** 所有文件上传完成回调 */ + onComplete?: (results: UploadFileItem[]) => void +} + +/** + * 默认配置 + */ +const DEFAULT_CONFIG: Required = { + maxConcurrent: 3, + largeFileThreshold: 10 * 1024 * 1024, // 10MB + chunkSize: 5 * 1024 * 1024, // 5MB + timeout: 300000, // 5分钟 + autoUpload: false, + onSuccess: () => {}, + onError: () => {}, + onComplete: () => {} +} + +/** + * 文件上传 Composable + * 提供完整的文件上传功能,包括队列管理、进度跟踪、错误处理等 + */ +export function useFileUpload(config: FileUploadConfig = {}) { + // 合并配置 + const finalConfig = reactive({ ...DEFAULT_CONFIG, ...config }) + + // 上传队列 + const uploadQueue = ref([]) + + // 当前上传中的文件数量 + const uploadingCount = ref(0) + + // 上传状态 + const isUploading = computed(() => uploadingCount.value > 0) + + // 总体进度 + const totalProgress = computed(() => { + if (uploadQueue.value.length === 0) return 0 + const totalProgress = uploadQueue.value.reduce((sum, item) => sum + item.progress, 0) + return Math.round(totalProgress / uploadQueue.value.length) + }) + + // 统计信息 + const stats = computed(() => { + const total = uploadQueue.value.length + const pending = uploadQueue.value.filter(item => item.status === UploadStatus.PENDING).length + const uploading = uploadQueue.value.filter(item => item.status === UploadStatus.UPLOADING).length + const success = uploadQueue.value.filter(item => item.status === UploadStatus.SUCCESS).length + const error = uploadQueue.value.filter(item => item.status === UploadStatus.ERROR).length + const cancelled = uploadQueue.value.filter(item => item.status === UploadStatus.CANCELLED).length + + return { total, pending, uploading, success, error, cancelled } + }) + + /** + * 生成唯一ID + */ + function generateId(): string { + return `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + } + + /** + * 添加文件到上传队列 + */ + function addFiles(files: File[] | UploadUserFile[]): UploadFileItem[] { + const fileItems: UploadFileItem[] = [] + + files.forEach((file) => { + const rawFile = "raw" in file ? file.raw : file as File + if (!rawFile) return + + const fileItem: UploadFileItem = { + id: generateId(), + file: rawFile, + name: rawFile.name, + size: rawFile.size, + status: UploadStatus.PENDING, + progress: 0 + } + + fileItems.push(fileItem) + uploadQueue.value.push(fileItem) + }) + + // 如果启用自动上传,立即开始上传 + if (finalConfig.autoUpload) { + startUpload() + } + + return fileItems + } + + /** + * 移除文件 + */ + function removeFile(id: string): boolean { + const index = uploadQueue.value.findIndex(item => item.id === id) + if (index === -1) return false + + const fileItem = uploadQueue.value[index] + + // 如果正在上传,先取消 + if (fileItem.status === UploadStatus.UPLOADING) { + cancelFile(id) + } + + uploadQueue.value.splice(index, 1) + return true + } + + /** + * 取消文件上传 + */ + function cancelFile(id: string): boolean { + const fileItem = uploadQueue.value.find(item => item.id === id) + if (!fileItem) return false + + if (fileItem.status === UploadStatus.UPLOADING) { + fileItem.status = UploadStatus.CANCELLED + uploadingCount.value-- + } + + return true + } + + /** + * 重试上传 + */ + function retryFile(id: string): void { + const fileItem = uploadQueue.value.find(item => item.id === id) + if (!fileItem) return + + if (fileItem.status === UploadStatus.ERROR || fileItem.status === UploadStatus.CANCELLED) { + fileItem.status = UploadStatus.PENDING + fileItem.progress = 0 + fileItem.error = undefined + + if (finalConfig.autoUpload) { + startUpload() + } + } + } + + /** + * 上传单个文件 + */ + /** + * 上传单个文件 + */ + async function uploadSingleFile(fileItem: UploadFileItem): Promise { + if (fileItem.status !== UploadStatus.PENDING) return + + fileItem.status = UploadStatus.UPLOADING + fileItem.progress = 0 + uploadingCount.value++ + + // 添加取消标志 + let isCancelled = false + const checkCancellation = () => { + isCancelled = fileItem.status === UploadStatus.CANCELLED + return isCancelled + } + + try { + const formData = new FormData() + formData.append("files", fileItem.file) + + // 根据文件大小选择上传方式 + if (fileItem.size > finalConfig.largeFileThreshold) { + // 大文件分块上传 + await uploadLargeFile(fileItem.file, { + chunkSize: finalConfig.chunkSize, + timeout: finalConfig.timeout, + onProgress: (progress) => { + if (!checkCancellation()) { + fileItem.progress = progress + } + } + }) + } else { + // 普通文件上传 + await uploadFileApiV2(formData, { + timeout: finalConfig.timeout, + onProgress: (progress) => { + if (!checkCancellation()) { + fileItem.progress = progress + } + } + }) + } + + // 检查是否被取消 + if (checkCancellation()) { + return + } + + fileItem.status = UploadStatus.SUCCESS + fileItem.progress = 100 + fileItem.uploadedAt = new Date() + + finalConfig.onSuccess(fileItem) + ElMessage.success(`文件 "${fileItem.name}" 上传成功`) + } catch (error) { + // 检查是否被取消 + if (checkCancellation()) { + return + } + + fileItem.status = UploadStatus.ERROR + fileItem.error = error instanceof Error ? error.message : "上传失败" + + finalConfig.onError(fileItem, error instanceof Error ? error : new Error("上传失败")) + ElMessage.error(`文件 "${fileItem.name}" 上传失败: ${fileItem.error}`) + } finally { + uploadingCount.value-- + } + } + + /** + * 开始上传 + */ + async function startUpload(): Promise { + const pendingFiles = uploadQueue.value.filter(item => item.status === UploadStatus.PENDING) + + if (pendingFiles.length === 0) { + return + } + + // 控制并发数量 + const uploadPromises: Promise[] = [] + + for (const fileItem of pendingFiles) { + // 等待当前上传数量小于最大并发数 + while (uploadingCount.value >= finalConfig.maxConcurrent) { + await new Promise(resolve => setTimeout(resolve, 100)) + } + + const uploadPromise = uploadSingleFile(fileItem) + uploadPromises.push(uploadPromise) + } + + // 等待所有上传完成 + await Promise.allSettled(uploadPromises) + + // 触发完成回调 + finalConfig.onComplete(uploadQueue.value) + } + + /** + * 清空队列 + */ + function clearQueue(): void { + // 取消所有正在上传的文件 + uploadQueue.value + .filter(item => item.status === UploadStatus.UPLOADING) + .forEach(item => cancelFile(item.id)) + + uploadQueue.value = [] + } + + /** + * 清空已完成的文件 + */ + function clearCompleted(): void { + uploadQueue.value = uploadQueue.value.filter( + item => item.status !== UploadStatus.SUCCESS && item.status !== UploadStatus.ERROR + ) + } + + return { + // 状态 + uploadQueue: readonly(uploadQueue), + isUploading: readonly(isUploading), + totalProgress: readonly(totalProgress), + stats: readonly(stats), + + // 配置 + config: finalConfig, + + // 方法 + addFiles, + removeFile, + cancelFile, + retryFile, + startUpload, + clearQueue, + clearCompleted + } +} diff --git a/management/web/src/http/axios.ts b/management/web/src/http/axios.ts index 6ed2c2e..b5137c8 100644 --- a/management/web/src/http/axios.ts +++ b/management/web/src/http/axios.ts @@ -116,7 +116,7 @@ function createRequest(instance: AxiosInstance) { // 请求体 data: {}, // 请求超时 - timeout: 10000, + timeout: 5000, // 跨域请求时是否携带 Cookies withCredentials: false } diff --git a/management/web/src/http/upload-axios.ts b/management/web/src/http/upload-axios.ts new file mode 100644 index 0000000..ed601c6 --- /dev/null +++ b/management/web/src/http/upload-axios.ts @@ -0,0 +1,70 @@ +import type { AxiosInstance, AxiosRequestConfig } from "axios" +import { getToken } from "@@/utils/cache/cookies" +import axios from "axios" +import { merge } from "lodash-es" + +/** + * 创建专用于文件上传的axios实例 + * 具有更长的超时时间和上传进度支持 + */ +function createUploadInstance() { + const instance = axios.create() + + // 请求拦截器 + instance.interceptors.request.use( + (config) => { + // 为大文件上传设置更长的超时时间 + if (!config.timeout) { + config.timeout = 300000 // 5分钟 + } + return config + }, + error => Promise.reject(error) + ) + + // 响应拦截器 + instance.interceptors.response.use( + response => response.data, + (error) => { + if (error.code === "ECONNABORTED" && error.message.includes("timeout")) { + ElMessage.error("文件上传超时,请检查网络连接或尝试上传较小的文件") + } + return Promise.reject(error) + } + ) + + return instance +} + +/** + * 创建文件上传专用请求方法 + * @param instance axios实例 + */ +function createUploadRequest(instance: AxiosInstance) { + return (config: AxiosRequestConfig & { + onUploadProgress?: (progressEvent: any) => void + timeout?: number + }): Promise => { + const token = getToken() + + const defaultConfig: AxiosRequestConfig = { + baseURL: import.meta.env.VITE_BASE_URL, + headers: { + Authorization: token ? `Bearer ${token}` : undefined + }, + timeout: config.timeout || 300000, // 默认5分钟 + withCredentials: false, + // 上传进度回调 + onUploadProgress: config.onUploadProgress + } + + const mergeConfig = merge(defaultConfig, config) + return instance(mergeConfig) + } +} + +/** 文件上传专用实例 */ +const uploadInstance = createUploadInstance() + +/** 文件上传专用请求方法 */ +export const uploadRequest = createUploadRequest(uploadInstance) diff --git a/management/web/src/pages/file/index.vue b/management/web/src/pages/file/index.vue index 88dd450..015f045 100644 --- a/management/web/src/pages/file/index.vue +++ b/management/web/src/pages/file/index.vue @@ -1,7 +1,7 @@