refactor(upload): 重新调整文件上传组件,支持大文件分块上传

This commit is contained in:
zstar 2025-05-27 21:43:36 +08:00
parent 00d48b8df8
commit 2c77015862
5 changed files with 716 additions and 28 deletions

View File

@ -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 }
})
}

View File

@ -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
}
}

View File

@ -116,7 +116,7 @@ function createRequest(instance: AxiosInstance) {
// 请求体 // 请求体
data: {}, data: {},
// 请求超时 // 请求超时
timeout: 10000, timeout: 5000,
// 跨域请求时是否携带 Cookies // 跨域请求时是否携带 Cookies
withCredentials: false withCredentials: false
} }

View File

@ -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)

View File

@ -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>