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: {},
|
||||
// 请求超时
|
||||
timeout: 10000,
|
||||
timeout: 5000,
|
||||
// 跨域请求时是否携带 Cookies
|
||||
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>
|
||||
import type { FormInstance, UploadUserFile } from "element-plus"
|
||||
import { batchDeleteFilesApi, deleteFileApi, getFileListApi, uploadFileApi } from "@@/apis/files"
|
||||
import { getTableDataApi } from "@@/apis/tables"
|
||||
import { batchDeleteFilesApi, deleteFileApi, getFileListApi } from "@@/apis/files"
|
||||
import { UploadStatus, useFileUpload } from "@@/composables/useFileUpload"
|
||||
import { usePagination } from "@@/composables/usePagination"
|
||||
import { Delete, Download, Refresh, Search, Upload } from "@element-plus/icons-vue"
|
||||
import { ElLoading, ElMessage, ElMessageBox } from "element-plus"
|
||||
|
@ -19,7 +19,47 @@ const loading = ref<boolean>(false)
|
|||
const { paginationData, handleCurrentChange, handleSizeChange } = usePagination()
|
||||
const uploadDialogVisible = ref(false)
|
||||
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 {
|
||||
|
@ -86,30 +126,22 @@ function handleUpload() {
|
|||
uploadDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitUpload() {
|
||||
uploadLoading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
uploadFileList.value.forEach((file) => {
|
||||
if (file.raw) {
|
||||
formData.append("files", file.raw)
|
||||
/**
|
||||
* 提交上传
|
||||
* 使用新的文件上传系统
|
||||
*/
|
||||
function submitUpload() {
|
||||
if (uploadFileList.value.length === 0) {
|
||||
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>
|
||||
</div>
|
||||
</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>
|
||||
<el-button @click="uploadDialogVisible = false">
|
||||
取消
|
||||
|
@ -380,9 +465,10 @@ onActivated(() => {
|
|||
<el-button
|
||||
type="primary"
|
||||
:loading="uploadLoading"
|
||||
:disabled="uploadFileList.length === 0"
|
||||
@click="submitUpload"
|
||||
>
|
||||
确认上传
|
||||
{{ uploadLoading ? '上传中...' : '确认上传' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
@ -554,4 +640,91 @@ onActivated(() => {
|
|||
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>
|
||||
|
|
Loading…
Reference in New Issue