refactor(upload): 重新调整文件上传组件,支持大文件分块上传
This commit is contained in:
parent
00d48b8df8
commit
2c77015862
|
@ -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 }
|
||||||
|
})
|
||||||
|
}
|
|
@ -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<FileUploadConfig> = {
|
||||||
|
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<UploadFileItem[]>([])
|
||||||
|
|
||||||
|
// 当前上传中的文件数量
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const pendingFiles = uploadQueue.value.filter(item => item.status === UploadStatus.PENDING)
|
||||||
|
|
||||||
|
if (pendingFiles.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 控制并发数量
|
||||||
|
const uploadPromises: Promise<void>[] = []
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
|
@ -116,7 +116,7 @@ function createRequest(instance: AxiosInstance) {
|
||||||
// 请求体
|
// 请求体
|
||||||
data: {},
|
data: {},
|
||||||
// 请求超时
|
// 请求超时
|
||||||
timeout: 10000,
|
timeout: 5000,
|
||||||
// 跨域请求时是否携带 Cookies
|
// 跨域请求时是否携带 Cookies
|
||||||
withCredentials: false
|
withCredentials: false
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 <T>(config: AxiosRequestConfig & {
|
||||||
|
onUploadProgress?: (progressEvent: any) => void
|
||||||
|
timeout?: number
|
||||||
|
}): Promise<T> => {
|
||||||
|
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)
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { FormInstance, UploadUserFile } from "element-plus"
|
import type { FormInstance, UploadUserFile } from "element-plus"
|
||||||
import { batchDeleteFilesApi, deleteFileApi, getFileListApi, uploadFileApi } from "@@/apis/files"
|
import { batchDeleteFilesApi, deleteFileApi, getFileListApi } from "@@/apis/files"
|
||||||
import { getTableDataApi } from "@@/apis/tables"
|
import { UploadStatus, useFileUpload } from "@@/composables/useFileUpload"
|
||||||
import { usePagination } from "@@/composables/usePagination"
|
import { usePagination } from "@@/composables/usePagination"
|
||||||
import { Delete, Download, Refresh, Search, Upload } from "@element-plus/icons-vue"
|
import { Delete, Download, Refresh, Search, Upload } from "@element-plus/icons-vue"
|
||||||
import { ElLoading, ElMessage, ElMessageBox } from "element-plus"
|
import { ElLoading, ElMessage, ElMessageBox } from "element-plus"
|
||||||
|
@ -19,7 +19,47 @@ const loading = ref<boolean>(false)
|
||||||
const { paginationData, handleCurrentChange, handleSizeChange } = usePagination()
|
const { paginationData, handleCurrentChange, handleSizeChange } = usePagination()
|
||||||
const uploadDialogVisible = ref(false)
|
const uploadDialogVisible = ref(false)
|
||||||
const uploadFileList = ref<UploadUserFile[]>([])
|
const uploadFileList = ref<UploadUserFile[]>([])
|
||||||
const uploadLoading = ref(false)
|
|
||||||
|
// 使用新的文件上传 composable
|
||||||
|
const {
|
||||||
|
uploadQueue,
|
||||||
|
isUploading,
|
||||||
|
totalProgress,
|
||||||
|
stats,
|
||||||
|
addFiles,
|
||||||
|
removeFile,
|
||||||
|
retryFile,
|
||||||
|
startUpload,
|
||||||
|
clearCompleted
|
||||||
|
} = useFileUpload({
|
||||||
|
autoUpload: false,
|
||||||
|
maxConcurrent: 2,
|
||||||
|
onSuccess: () => {
|
||||||
|
// 上传成功后刷新文件列表
|
||||||
|
getTableData()
|
||||||
|
},
|
||||||
|
onComplete: (results) => {
|
||||||
|
// 所有文件上传完成
|
||||||
|
const successCount = results.filter(item => item.status === UploadStatus.SUCCESS).length
|
||||||
|
const errorCount = results.filter(item => item.status === UploadStatus.ERROR).length
|
||||||
|
|
||||||
|
if (errorCount === 0) {
|
||||||
|
ElMessage.success(`成功上传 ${successCount} 个文件`)
|
||||||
|
} else {
|
||||||
|
ElMessage.warning(`上传完成:成功 ${successCount} 个,失败 ${errorCount} 个`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理已完成的文件
|
||||||
|
setTimeout(() => {
|
||||||
|
clearCompleted()
|
||||||
|
uploadDialogVisible.value = false
|
||||||
|
uploadFileList.value = []
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算上传按钮的加载状态
|
||||||
|
const uploadLoading = computed(() => isUploading.value)
|
||||||
|
|
||||||
// 定义文件数据类型
|
// 定义文件数据类型
|
||||||
interface FileData {
|
interface FileData {
|
||||||
|
@ -86,30 +126,22 @@ function handleUpload() {
|
||||||
uploadDialogVisible.value = true
|
uploadDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitUpload() {
|
/**
|
||||||
uploadLoading.value = true
|
* 提交上传
|
||||||
try {
|
* 使用新的文件上传系统
|
||||||
const formData = new FormData()
|
*/
|
||||||
uploadFileList.value.forEach((file) => {
|
function submitUpload() {
|
||||||
if (file.raw) {
|
if (uploadFileList.value.length === 0) {
|
||||||
formData.append("files", file.raw)
|
ElMessage.warning("请选择要上传的文件")
|
||||||
}
|
return
|
||||||
})
|
|
||||||
|
|
||||||
await uploadFileApi(formData)
|
|
||||||
ElMessage.success("文件上传成功")
|
|
||||||
getTableData()
|
|
||||||
uploadDialogVisible.value = false
|
|
||||||
uploadFileList.value = []
|
|
||||||
} catch (error: unknown) {
|
|
||||||
let errorMessage = "上传失败"
|
|
||||||
if (error instanceof Error) {
|
|
||||||
errorMessage += `: ${error.message}`
|
|
||||||
}
|
|
||||||
ElMessage.error(errorMessage)
|
|
||||||
} finally {
|
|
||||||
uploadLoading.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将文件添加到上传队列
|
||||||
|
const files = uploadFileList.value.map(file => file.raw).filter(Boolean) as File[]
|
||||||
|
addFiles(files)
|
||||||
|
|
||||||
|
// 开始上传
|
||||||
|
startUpload()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载文件
|
// 下载文件
|
||||||
|
@ -373,6 +405,59 @@ onActivated(() => {
|
||||||
拖拽文件到此处或<em>点击上传</em>
|
拖拽文件到此处或<em>点击上传</em>
|
||||||
</div>
|
</div>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
|
|
||||||
|
<!-- 上传进度显示 -->
|
||||||
|
<div v-if="uploadQueue.length > 0" class="upload-progress-section">
|
||||||
|
<div class="upload-stats">
|
||||||
|
<span>总进度: {{ totalProgress }}%</span>
|
||||||
|
<span>成功: {{ stats.success }}</span>
|
||||||
|
<span>失败: {{ stats.error }}</span>
|
||||||
|
<span>等待: {{ stats.pending }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-file-list">
|
||||||
|
<div
|
||||||
|
v-for="fileItem in uploadQueue"
|
||||||
|
:key="fileItem.id"
|
||||||
|
class="upload-file-item"
|
||||||
|
:class="`status-${fileItem.status}`"
|
||||||
|
>
|
||||||
|
<div class="file-info">
|
||||||
|
<span class="file-name">{{ fileItem.name }}</span>
|
||||||
|
<span class="file-size">({{ formatFileSize(fileItem.size) }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-progress">
|
||||||
|
<el-progress
|
||||||
|
:percentage="fileItem.progress"
|
||||||
|
:status="fileItem.status === 'success' ? 'success' : fileItem.status === 'error' ? 'exception' : undefined"
|
||||||
|
:show-text="false"
|
||||||
|
:stroke-width="4"
|
||||||
|
/>
|
||||||
|
<span class="progress-text">{{ fileItem.progress }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="fileItem.status === 'error'"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
@click="retryFile(fileItem.id)"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="fileItem.status !== 'uploading'"
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
@click="removeFile(fileItem.id)"
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="uploadDialogVisible = false">
|
<el-button @click="uploadDialogVisible = false">
|
||||||
取消
|
取消
|
||||||
|
@ -380,9 +465,10 @@ onActivated(() => {
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="uploadLoading"
|
:loading="uploadLoading"
|
||||||
|
:disabled="uploadFileList.length === 0"
|
||||||
@click="submitUpload"
|
@click="submitUpload"
|
||||||
>
|
>
|
||||||
确认上传
|
{{ uploadLoading ? '上传中...' : '确认上传' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
@ -554,4 +640,91 @@ onActivated(() => {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 上传进度相关样式 */
|
||||||
|
.upload-progress-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-item.status-uploading {
|
||||||
|
border-color: #409eff;
|
||||||
|
background-color: #ecf5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-item.status-success {
|
||||||
|
border-color: #67c23a;
|
||||||
|
background-color: #f0f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-item.status-error {
|
||||||
|
border-color: #f56c6c;
|
||||||
|
background-color: #fef0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-progress {
|
||||||
|
flex: 0 0 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #606266;
|
||||||
|
min-width: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
Loading…
Reference in New Issue