Quasar企业级文件批量上传功能增强
# 企业级文件批量上传功能增强
我将为您的UniversalFileUploader组件添加企业级文件批量上传功能,包括并发控制、重试机制、上传队列管理和更完善的错误处理。
## 修改后的代码
```vue
<!-- UniversalFileUploader.vue -->
<template>
<div class="universal-file-uploader">
<!-- 文件选择区域 -->
<q-card class="upload-card q-mb-md">
<q-card-section>
<div class="text-h6">企业级文件上传</div>
<!-- 分类选择 -->
<q-select
v-model="currentCategory"
:options="categories"
label="选择分类"
class="q-mb-md"
emit-value
map-options
/>
<!-- 上传配置 -->
<div class="upload-config q-mb-md">
<div class="row items-center q-mb-sm">
<div class="col-6">
<div class="text-caption">并发上传数: {{ concurrentUploads }}</div>
<q-slider
v-model="concurrentUploads"
:min="1"
:max="10"
:step="1"
label
label-always
class="q-mt-sm"
/>
</div>
<div class="col-6 q-pl-md">
<div class="text-caption">自动重试次数: {{ maxRetries }}</div>
<q-slider
v-model="maxRetries"
:min="0"
:max="5"
:step="1"
label
label-always
class="q-mt-sm"
/>
</div>
</div>
<q-checkbox v-model="autoStartUploads" label="选择文件后自动开始上传" class="q-mr-md">
<q-tooltip :delay="500" anchor="top middle" self="bottom middle">
启用后选择文件将自动开始上传,无需手动点击上传按钮
</q-tooltip>
</q-checkbox>
<q-checkbox v-model="stopOnFailure" label="遇到错误时停止所有上传">
<q-tooltip :delay="500" anchor="top middle" self="bottom middle">
启用后当任何文件上传失败时将停止所有后续上传
</q-tooltip>
</q-checkbox>
</div>
<!-- 文件类型筛选 -->
<div class="file-type-filter q-mb-md">
<q-btn-toggle
v-model="currentFileType"
spread
no-caps
:options="fileTypeOptions"
class="q-mb-sm"
>
<!-- 为每个按钮自定义内容,添加工具提示 -->
<template v-slot:all>
<div class="btn-with-tooltip">
<q-icon name="folder" class="q-mr-xs" />
<span>所有文件</span>
<q-tooltip :delay="500" anchor="top middle" self="bottom middle">
支持所有类型的文件:图片、PDF、Word、Excel、PowerPoint等,最大20MB
</q-tooltip>
</div>
</template>
<template v-slot:images>
<div class="btn-with-tooltip">
<q-icon name="image" class="q-mr-xs" />
<span>图片</span>
<q-tooltip :delay="500" anchor="top middle" self="bottom middle">
支持JPG、PNG、GIF、WEBP、BMP等图片格式,最大20MB
</q-tooltip>
</div>
</template>
<template v-slot:pdf>
<div class="btn-with-tooltip">
<q-icon name="picture_as_pdf" class="q-mr-xs" />
<span>PDF</span>
<q-tooltip :delay="500" anchor="top middle" self="bottom middle">
支持PDF文档格式,最大20MB
</q-tooltip>
</div>
</template>
<template v-slot:office>
<div class="btn-with-tooltip">
<q-icon name="description" class="q-mr-xs" />
<span>Office</span>
<q-tooltip :delay="500" anchor="top middle" self="bottom middle">
支持Microsoft Office文档:Word(.docx)、PowerPoint(.pptx)、Excel(.xlsx),最大20MB
</q-tooltip>
</div>
</template>
</q-btn-toggle>
<div class="row items-center">
<q-checkbox v-model="allowMultiple" label="允许多选" class="col-4">
<q-tooltip :delay="500" anchor="top middle" self="bottom middle">
启用后可一次选择多个文件进行上传
</q-tooltip>
</q-checkbox>
<q-checkbox v-model="enableChunkedUpload" label="启用分片上传" class="col-4">
<q-tooltip :delay="500" anchor="top middle" self="bottom middle">
对大文件启用分片上传以提高上传稳定性
</q-tooltip>
</q-checkbox>
<q-checkbox v-model="preserveQueue" label="保留上传队列" class="col-4">
<q-tooltip :delay="500" anchor="top middle" self="bottom middle">
上传完成后保留文件队列,便于继续添加文件
</q-tooltip>
</q-checkbox>
</div>
</div>
<!-- 文件选择 -->
<q-file
v-model="localFiles"
:multiple="allowMultiple"
:accept="currentAcceptTypes"
:label="fileInputLabel"
counter
@update:model-value="handleFilesSelected"
>
<template v-slot:prepend>
<q-icon name="attach_file" />
</template>
</q-file>
<!-- 上传按钮组 -->
<div class="upload-actions row q-mt-md">
<q-btn
color="primary"
icon="cloud_upload"
:label="uploadButtonLabel"
class="col-6"
:loading="uploading"
:disable="!canUpload"
@click="startUpload"
/>
<q-btn
v-if="isPaused"
color="orange"
icon="play_arrow"
label="继续上传"
class="col-3 q-ml-sm"
:disable="!hasPendingFiles"
@click="resumeUpload"
/>
<q-btn
v-else
color="orange"
icon="pause"
label="暂停上传"
class="col-3 q-ml-sm"
:disable="!uploading"
@click="pauseUpload"
/>
<q-btn
color="negative"
icon="stop"
label="停止上传"
class="col-3 q-ml-sm"
:disable="!uploading"
@click="stopUpload"
/>
</div>
</q-card-section>
</q-card>
<!-- 文件列表 -->
<q-card v-if="fileList.length > 0" class="files-card">
<q-card-section>
<div class="text-h6">上传队列 ({{ fileList.length }})</div>
<div class="row items-center q-mt-sm">
<q-btn
flat
dense
icon="delete_sweep"
label="清空列表"
class="q-mr-md"
@click="clearAllFiles"
/>
<q-btn
flat
dense
icon="refresh"
label="重新上传失败项"
:disable="failedFiles.length === 0"
@click="retryFailed"
/>
<q-space />
<div class="text-caption">
已选择: {{ totalSelectedFiles }} 个文件, 总大小: {{ formatFileSize(totalSelectedSize) }}
</div>
</div>
</q-card-section>
<q-separator />
<q-list separator>
<q-item
v-for="(fileItem, index) in fileList"
:key="fileItem.id"
class="file-item"
:class="{
'uploading-item': uploadProgress[fileItem.id] && !uploadProgress[fileItem.id].completed,
'success-item': uploadProgress[fileItem.id] && uploadProgress[fileItem.id].completed && !uploadProgress[fileItem.id].error,
'error-item': uploadProgress[fileItem.id] && uploadProgress[fileItem.id].error,
'pending-item': !uploadProgress[fileItem.id] || (!uploadProgress[fileItem.id].completed && !uploadProgress[fileItem.id].error)
}"
>
<q-item-section avatar>
<q-avatar rounded :color="getFileIconColor(fileItem.type)" text-color="white">
<q-icon :name="getFileIcon(fileItem.type)" />
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{ fileItem.name }}</q-item-label>
<q-item-label caption>
{{ formatFileSize(fileItem.size) }} · {{ getFileTypeName(fileItem.type) }}
</q-item-label>
<!-- 上传状态信息 -->
<div v-if="uploadProgress[fileItem.id]" class="upload-status">
<q-linear-progress
v-if="!uploadProgress[fileItem.id].completed && !uploadProgress[fileItem.id].error"
:value="uploadProgress[fileItem.id].progress / 100"
:color="getProgressColor(uploadProgress[fileItem.id].progress)"
class="q-mt-xs"
size="10px"
/>
<div class="status-text text-caption">
<span v-if="uploadProgress[fileItem.id].completed && !uploadProgress[fileItem.id].error">
<q-icon name="check_circle" color="positive" size="16px" />
上传成功
</span>
<span v-else-if="uploadProgress[fileItem.id].error" class="text-negative">
<q-icon name="error" color="negative" size="16px" />
上传失败: {{ uploadProgress[fileItem.id].error }}
<span v-if="uploadProgress[fileItem.id].retryCount > 0">
(重试 {{ uploadProgress[fileItem.id].retryCount }}/{{ maxRetries }})
</span>
</span>
<span v-else>
上传中: {{ uploadProgress[fileItem.id].progress }}%
<span v-if="uploadProgress[fileItem.id].retryCount > 0">
(重试 {{ uploadProgress[fileItem.id].retryCount }}/{{ maxRetries }})
</span>
</span>
</div>
</div>
</q-item-section>
<q-item-section side top>
<div class="file-actions">
<q-btn
v-if="fileItem.previewUrl"
flat
round
color="info"
icon="visibility"
@click="showPreview(fileItem)"
class="q-mr-xs"
/>
<q-btn
v-if="uploadProgress[fileItem.id] && uploadProgress[fileItem.id].error"
flat
round
color="primary"
icon="refresh"
@click="retrySingleFile(fileItem.id)"
class="q-mr-xs"
>
<q-tooltip>重试此文件</q-tooltip>
</q-btn>
<q-btn
flat
round
color="negative"
icon="delete"
@click="removeFile(index)"
>
<q-tooltip>从队列中移除</q-tooltip>
</q-btn>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card>
<!-- 上传统计 -->
<q-card v-if="fileList.length > 0" class="stats-card q-mt-md">
<q-card-section class="q-py-sm">
<div class="row items-center">
<div class="col-4 text-center">
<div class="text-h6">{{ completedFiles }}</div>
<div class="text-caption">已完成</div>
</div>
<div class="col-4 text-center">
<div class="text-h6">{{ uploadingFiles }}</div>
<div class="text-caption">上传中</div>
</div>
<div class="col-4 text-center">
<div class="text-h6">{{ pendingFiles }}</div>
<div class="text-caption">等待中</div>
</div>
</div>
</q-card-section>
<q-linear-progress
:value="overallProgress / 100"
color="primary"
size="10px"
/>
<q-card-section class="q-py-sm">
<div class="row items-center">
<div class="col-6">
总进度: {{ overallProgress }}%
</div>
<div class="col-6 text-right">
速度: {{ formatSpeed(currentSpeed) }}
</div>
</div>
</q-card-section>
</q-card>
<!-- 上传结果通知 -->
<q-dialog v-model="showUploadResult" persistent>
<q-card style="min-width: 400px">
<q-card-section>
<div class="text-h6">上传结果汇总</div>
</q-card-section>
<q-card-section class="q-pt-none">
<div class="row">
<div class="col-4 text-center">
<div class="text-h4 text-positive">{{ uploadResult.success }}</div>
<div class="text-caption">成功</div>
</div>
<div class="col-4 text-center">
<div class="text-h4 text-warning">{{ uploadResult.skipped }}</div>
<div class="text-caption">跳过</div>
</div>
<div class="col-4 text-center">
<div class="text-h4 text-negative">{{ uploadResult.failed }}</div>
<div class="text-caption">失败</div>
</div>
</div>
<q-separator class="q-my-md" />
<div v-if="failedFiles.length > 0">
<p class="text-weight-bold">失败文件 ({{ failedFiles.length }}):</p>
<q-scroll-area style="height: 150px" class="q-mt-sm">
<div v-for="file in failedFiles" :key="file.id" class="failed-file-item">
<q-icon name="error" color="negative" size="16px" class="q-mr-sm" />
<span>{{ file.name }}</span>
<span class="text-caption text-grey q-ml-sm">
({{ uploadProgress[file.id]?.error }})
</span>
</div>
</q-scroll-area>
</div>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="关闭" v-close-popup />
<q-btn
v-if="failedFiles.length > 0"
color="primary"
label="重试失败文件"
@click="retryFailed"
/>
<q-btn
v-if="!preserveQueue"
color="positive"
label="清空队列并关闭"
@click="clearAndClose"
/>
</q-card-actions>
</q-card>
</q-dialog>
<!-- 图片预览对话框 -->
<q-dialog v-model="showImagePreview" maximized>
<q-card>
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">{{ previewImage.name }}</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section class="flex flex-center">
<q-img :src="previewImage.url" style="max-height: 80vh; max-width: 80vw" contain />
</q-card-section>
</q-card>
</q-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { useQuasar } from 'quasar'
import type { AxiosProgressEvent, CancelTokenSource } from 'axios'
import axios from 'axios'
import { apiClient } from 'src/services/axios'
// 类型定义
interface FileUploadItem {
id: string
nativeFile: File
name: string
size: number
type: string
previewUrl?: string
category: string
status: 'pending' | 'uploading' | 'completed' | 'error' | 'paused'
}
interface UploadProgress {
id: string
name: string
progress: number
completed: boolean
error?: string
retryCount: number
uploadedSize: number
totalSize: number
speed: number
startTime?: number
}
interface UploadResult {
success: number
failed: number
skipped: number
}
interface FileTypeOption {
label: string
value: string
icon: string
accept: string
slot: string
}
interface UploadStats {
totalFiles: number
completed: number
uploading: number
pending: number
failed: number
totalSize: number
uploadedSize: number
overallProgress: number
speed: number
}
// Quasar插件
const $q = useQuasar()
// 响应式状态
const localFiles = ref<File[] | null>(null)
const fileList = ref<FileUploadItem[]>([])
const currentCategory = ref<string>('general')
const currentFileType = ref<string>('all')
const allowMultiple = ref<boolean>(true)
const uploading = ref<boolean>(false)
const uploadProgress = ref<Record<string, UploadProgress>>({})
const showUploadResult = ref<boolean>(false)
const uploadResult = ref<UploadResult>({ success: 0, failed: 0, skipped: 0 })
const cancelTokens = ref<Record<string, CancelTokenSource>>({})
const showImagePreview = ref<boolean>(false)
const previewImage = ref<{ name: string; url: string }>({ name: '', url: '' })
// 企业级上传功能状态
const concurrentUploads = ref<number>(3)
const maxRetries = ref<number>(3)
const autoStartUploads = ref<boolean>(false)
const stopOnFailure = ref<boolean>(false)
const enableChunkedUpload = ref<boolean>(false)
const preserveQueue = ref<boolean>(false)
const isPaused = ref<boolean>(false)
const uploadQueue = ref<string[]>([]) // 存储待上传文件的ID
const activeUploads = ref<Set<string>>(new Set()) // 存储当前正在上传的文件ID
const currentSpeed = ref<number>(0) // 当前上传速度 (bytes/sec)
const speedUpdateInterval = ref<number | null>(null)
// 文件类型选项
const fileTypeOptions = ref<FileTypeOption[]>([
{
label: '', // 留空,因为我们使用插槽自定义内容
value: 'all',
icon: '', // 留空,因为我们使用插槽自定义图标
accept: '',
slot: 'all', // 指定使用的插槽名称
},
{
label: '',
value: 'images',
icon: '',
accept: 'image/*',
slot: 'images',
},
{
label: '',
value: 'pdf',
icon: '',
accept: '.pdf',
slot: 'pdf',
},
{
label: '',
value: 'office',
icon: '',
accept: '.docx, .pptx, .xlsx',
slot: 'office',
},
])
// 分类选项
const categories = ref<Array<{ label: string; value: string }>>([
{ label: '通用文件', value: 'general' },
{ label: '文档资料', value: 'documents' },
{ label: '演示文稿', value: 'presentations' },
{ label: '数据表格', value: 'spreadsheets' },
{ label: '报告材料', value: 'reports' },
{ label: '合同协议', value: 'contracts' },
])
// 计算属性
const currentAcceptTypes = computed<string>(() => {
if (currentFileType.value === 'all') {
return 'image/*, .pdf, .docx, .pptx, .xlsx'
}
const option = fileTypeOptions.value.find((opt) => opt.value === currentFileType.value)
return option ? option.accept : ''
})
const fileInputLabel = computed<string>(() => {
const option = fileTypeOptions.value.find((opt) => opt.value === currentFileType.value)
const typeLabel = option ? option.label : '文件'
return `选择${typeLabel}${allowMultiple.value ? '(可多选)' : ''}`
})
const uploadButtonLabel = computed<string>(() => {
if (isPaused.value) return '已暂停'
return uploading.value ? `上传中 (${activeUploads.value.size}/${concurrentUploads.value})` : `开始上传 (${fileList.value.length})`
})
const canUpload = computed<boolean>(() => {
return fileList.value.length > 0 && !uploading.value && !isPaused.value
})
const hasPendingFiles = computed<boolean>(() => {
return fileList.value.some(file =>
!uploadProgress.value[file.id]?.completed &&
!uploadProgress.value[file.id]?.error
)
})
const failedFiles = computed<FileUploadItem[]>(() => {
return fileList.value.filter(
(fileItem) => uploadProgress.value[fileItem.id]?.error
)
})
const totalSelectedFiles = computed<number>(() => {
return fileList.value.length
})
const totalSelectedSize = computed<number>(() => {
return fileList.value.reduce((total, file) => total + file.size, 0)
})
const completedFiles = computed<number>(() => {
return Object.values(uploadProgress.value).filter(
progress => progress.completed && !progress.error
).length
})
const uploadingFiles = computed<number>(() => {
return activeUploads.value.size
})
const pendingFiles = computed<number>(() => {
return fileList.value.length - completedFiles.value - failedFiles.value.length - uploadingFiles.value
})
const overallProgress = computed<number>(() => {
if (fileList.value.length === 0) return 0
const totalSize = fileList.value.reduce((sum, file) => sum + file.size, 0)
const uploadedSize = Object.values(uploadProgress.value).reduce(
(sum, progress) => sum + progress.uploadedSize, 0
)
return totalSize > 0 ? Math.round((uploadedSize / totalSize) * 100) : 0
})
// 监听文件选择自动上传
watch(() => fileList.value.length, (newLength, oldLength) => {
if (autoStartUploads.value && newLength > oldLength && !uploading.value) {
startUpload()
}
})
// 初始化速度计算间隔
const initSpeedCalculator = () => {
if (speedUpdateInterval.value) {
clearInterval(speedUpdateInterval.value)
}
let lastUploadedSize = 0
speedUpdateInterval.value = window.setInterval(() => {
const currentUploadedSize = Object.values(uploadProgress.value).reduce(
(sum, progress) => sum + progress.uploadedSize, 0
)
currentSpeed.value = currentUploadedSize - lastUploadedSize
lastUploadedSize = currentUploadedSize
}, 1000)
}
// 处理文件选择
const handleFilesSelected = (newFiles: File[] | null): void => {
if (!newFiles) return
const addedFiles: FileUploadItem[] = []
Array.from(newFiles).forEach((file: File) => {
// 检查文件类型
if (!isValidFileType(file)) {
$q.notify({
type: 'negative',
message: `不支持的文件类型: ${file.name}。请选择图片、PDF或Office文档。`,
})
return
}
// 检查文件大小 (限制为20MB)
if (file.size > 20 * 1024 * 1024) {
$q.notify({
type: 'negative',
message: `文件过大: ${file.name} (最大20MB)`,
})
return
}
// 检查是否已存在同名文件
if (fileList.value.some((item) => item.name === file.name)) {
$q.notify({
type: 'warning',
message: `文件已存在: ${file.name}`,
})
return
}
// 生成唯一ID
const id = crypto.randomUUID()
// 创建预览URL (如果是图片)
let previewUrl: string | undefined
if (isImage(file)) {
previewUrl = URL.createObjectURL(file)
}
// 创建文件项
const fileItem: FileUploadItem = {
id,
nativeFile: file,
name: file.name,
size: file.size,
type: file.type || getFileTypeFromName(file.name),
previewUrl,
category: currentCategory.value,
status: 'pending'
}
addedFiles.push(fileItem)
})
// 添加到文件列表
fileList.value.push(...addedFiles)
// 添加到上传队列
if (uploading.value && !isPaused.value) {
addedFiles.forEach(file => uploadQueue.value.push(file.id))
processUploadQueue()
}
// 重置文件输入
localFiles.value = null
// 显示添加成功通知
if (addedFiles.length > 0) {
$q.notify({
type: 'positive',
message: `已添加 ${addedFiles.length} 个文件到上传队列`,
timeout: 2000
})
}
}
// 移除文件
const removeFile = (index: number): void => {
const fileItem = fileList.value[index]
// 释放预览URL
if (fileItem?.previewUrl) {
URL.revokeObjectURL(fileItem.previewUrl)
}
// 取消上传 (如果正在进行)
if (uploading.value && cancelTokens.value[fileItem.id]) {
cancelTokens.value[fileItem.id]!.cancel('用户取消上传')
delete cancelTokens.value[fileItem.id]
// 从活动上传中移除
activeUploads.value.delete(fileItem.id)
}
// 从上传队列中移除
const queueIndex = uploadQueue.value.indexOf(fileItem.id)
if (queueIndex !== -1) {
uploadQueue.value.splice(queueIndex, 1)
}
// 从列表中移除
fileList.value.splice(index, 1)
// 如果没有文件了,停止上传
if (fileList.value.length === 0) {
stopUpload()
}
}
// 清空所有文件
const clearAllFiles = (): void => {
// 释放所有预览URL
fileList.value.forEach((fileItem) => {
if (fileItem.previewUrl) {
URL.revokeObjectURL(fileItem.previewUrl)
}
})
// 取消所有进行中的上传
Object.values(cancelTokens.value).forEach((source) => {
source.cancel('用户清空文件列表')
})
cancelTokens.value = {}
activeUploads.value.clear()
// 清空文件列表和队列
fileList.value = []
uploadQueue.value = []
uploading.value = false
isPaused.value = false
}
// 开始上传
const startUpload = async (): Promise<void> => {
if (fileList.value.length === 0) return
uploading.value = true
isPaused.value = false
uploadResult.value = { success: 0, failed: 0, skipped: 0 }
// 初始化上传队列
uploadQueue.value = fileList.value
.filter(file =>
!uploadProgress.value[file.id] ||
!uploadProgress.value[file.id].completed ||
uploadProgress.value[file.id].error
)
.map(file => file.id)
// 初始化上传进度
fileList.value.forEach((fileItem) => {
if (!uploadProgress.value[fileItem.id]) {
uploadProgress.value[fileItem.id] = {
id: fileItem.id,
name: fileItem.name,
progress: 0,
completed: false,
retryCount: 0,
uploadedSize: 0,
totalSize: fileItem.size,
speed: 0
}
} else if (uploadProgress.value[fileItem.id].error) {
// 重置错误状态但保留重试计数
uploadProgress.value[fileItem.id].error = undefined
uploadProgress.value[fileItem.id].completed = false
}
})
// 初始化速度计算
initSpeedCalculator()
// 开始处理上传队列
processUploadQueue()
}
// 暂停上传
const pauseUpload = (): void => {
isPaused.value = true
// 取消所有当前上传
Object.values(cancelTokens.value).forEach((source) => {
source.cancel('用户暂停上传')
})
cancelTokens.value = {}
activeUploads.value.clear()
$q.notify({
type: 'info',
message: '上传已暂停',
timeout: 2000
})
}
// 继续上传
const resumeUpload = (): void => {
if (!isPaused.value) return
isPaused.value = false
uploading.value = true
// 重新初始化速度计算
initSpeedCalculator()
// 继续处理上传队列
processUploadQueue()
$q.notify({
type: 'positive',
message: '上传已继续',
timeout: 2000
})
}
// 停止上传
const stopUpload = (): void => {
// 取消所有进行中的上传
Object.values(cancelTokens.value).forEach((source) => {
source.cancel('用户停止上传')
})
// 重置状态
cancelTokens.value = {}
activeUploads.value.clear()
uploading.value = false
isPaused.value = false
// 清空速度计算间隔
if (speedUpdateInterval.value) {
clearInterval(speedUpdateInterval.value)
speedUpdateInterval.value = null
}
$q.notify({
type: 'info',
message: '上传已停止',
timeout: 2000
})
}
// 处理上传队列
const processUploadQueue = async (): Promise<void> => {
// 检查是否应该继续处理队列
if (isPaused.value || !uploading.value || uploadQueue.value.length === 0) {
// 如果队列已空且没有活动上传,完成上传
if (uploadQueue.value.length === 0 && activeUploads.value.size === 0) {
finishUpload()
}
return
}
// 计算可用的上传槽位
const availableSlots = concurrentUploads.value - activeUploads.value.size
// 启动新的上传任务
for (let i = 0; i < availableSlots && uploadQueue.value.length > 0; i++) {
const fileId = uploadQueue.value.shift()!
// 检查文件是否已经完成上传
if (uploadProgress.value[fileId]?.completed && !uploadProgress.value[fileId]?.error) {
continue
}
// 添加到活动上传
activeUploads.value.add(fileId)
// 查找文件项
const fileItem = fileList.value.find(item => item.id === fileId)
if (!fileItem) {
activeUploads.value.delete(fileId)
continue
}
// 开始上传
uploadSingleFile(fileItem).then(() => {
// 上传完成后从活动上传中移除
activeUploads.value.delete(fileId)
// 继续处理队列
processUploadQueue()
}).catch(() => {
// 错误处理已在uploadSingleFile中完成
activeUploads.value.delete(fileId)
processUploadQueue()
})
}
}
// 完成上传
const finishUpload = (): void => {
uploading.value = false
isPaused.value = false
// 清空速度计算间隔
if (speedUpdateInterval.value) {
clearInterval(speedUpdateInterval.value)
speedUpdateInterval.value = null
}
// 计算上传结果
const completed = Object.values(uploadProgress.value).filter(
progress => progress.completed && !progress.error
).length
const failed = Object.values(uploadProgress.value).filter(
progress => progress.error
).length
const skipped = fileList.value.length - completed - failed
uploadResult.value = {
success: completed,
failed: failed,
skipped: skipped
}
// 显示结果对话框
showUploadResult.value = true
// 发送通知
if (completed > 0) {
$q.notify({
type: 'positive',
message: `上传完成: ${completed} 个成功, ${failed} 个失败`,
timeout: 5000,
actions: [
{
label: '查看详情',
color: 'white',
handler: () => {
showUploadResult.value = true
}
}
]
})
}
// 如果不保留队列,清空已成功的文件
if (!preserveQueue.value) {
fileList.value = fileList.value.filter(
fileItem => !uploadProgress.value[fileItem.id]?.completed ||
uploadProgress.value[fileItem.id]?.error
)
}
}
// 上传单个文件
const uploadSingleFile = async (fileItem: FileUploadItem): Promise<void> => {
const formData = new FormData()
formData.append('file', fileItem.nativeFile)
formData.append('category', fileItem.category)
formData.append('original_name', fileItem.name)
// 创建取消令牌
const source = axios.CancelToken.source()
cancelTokens.value[fileItem.id] = source
// 更新进度,设置开始时间
if (!uploadProgress.value[fileItem.id].startTime) {
uploadProgress.value[fileItem.id].startTime = Date.now()
}
try {
await apiClient.post('/knowledge/files/', formData, {
cancelToken: source.token,
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
if (progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
updateProgress(
fileItem.id,
progress,
progressEvent.loaded,
progressEvent.total
)
}
},
timeout: 300000, // 5分钟超时
})
// 标记为完成
updateProgress(fileItem.id, 100, fileItem.size, fileItem.size, true)
} catch (error: unknown) {
if (axios.isCancel(error)) {
// 上传被取消,不视为错误
updateProgress(fileItem.id, 0, 0, fileItem.size, false, '已取消')
} else {
// 处理其他错误
const message = axios.isAxiosError(error)
? error.response?.data?.message || '上传失败'
: '上传失败'
// 增加重试计数
const retryCount = uploadProgress.value[fileItem.id].retryCount + 1
if (retryCount <= maxRetries.value) {
// 还可以重试,将文件重新加入队列
updateProgress(
fileItem.id,
0,
0,
fileItem.size,
false,
`上传失败,准备重试 (${retryCount}/${maxRetries.value})`,
retryCount
)
// 将文件重新加入队列
uploadQueue.value.unshift(fileItem.id)
} else {
// 重试次数用尽,标记为失败
updateProgress(
fileItem.id,
0,
0,
fileItem.size,
false,
message,
retryCount
)
// 如果设置了错误时停止,暂停上传
if (stopOnFailure.value) {
pauseUpload()
}
}
throw error
}
} finally {
// 清理取消令牌
delete cancelTokens.value[fileItem.id]
}
}
// 取消所有上传
const cancelAllUploads = (): void => {
Object.values(cancelTokens.value).forEach((source) => {
source.cancel('用户取消所有上传')
})
cancelTokens.value = {}
activeUploads.value.clear()
uploading.value = false
$q.notify({
type: 'info',
message: '已取消所有上传任务',
})
}
// 重试失败的上传
const retryFailed = async (): Promise<void> => {
showUploadResult.value = false
// 将失败的文件添加到队列
failedFiles.value.forEach(file => {
// 重置进度
if (uploadProgress.value[file.id]) {
uploadProgress.value[file.id].progress = 0
uploadProgress.value[file.id].completed = false
uploadProgress.value[file.id].error = undefined
uploadProgress.value[file.id].uploadedSize = 0
uploadProgress.value[file.id].startTime = undefined
// 注意:保留重试计数,以便继续累计
}
// 添加到队列
if (!uploadQueue.value.includes(file.id)) {
uploadQueue.value.push(file.id)
}
})
// 开始上传
startUpload()
}
// 重试单个文件
const retrySingleFile = (fileId: string): void => {
const fileItem = fileList.value.find(item => item.id === fileId)
if (!fileItem) return
// 重置进度
if (uploadProgress.value[fileId]) {
uploadProgress.value[fileId].progress = 0
uploadProgress.value[fileId].completed = false
uploadProgress.value[fileId].error = undefined
uploadProgress.value[fileId].uploadedSize = 0
uploadProgress.value[fileId].startTime = undefined
// 保留重试计数
}
// 添加到队列开头
if (!uploadQueue.value.includes(fileId)) {
uploadQueue.value.unshift(fileId)
}
// 如果当前没有上传,开始上传
if (!uploading.value) {
startUpload()
} else {
// 如果已有上传在处理,触发队列处理
processUploadQueue()
}
}
// 显示图片预览
const showPreview = (fileItem: FileUploadItem): void => {
if (fileItem.previewUrl) {
previewImage.value = {
name: fileItem.name,
url: fileItem.previewUrl,
}
showImagePreview.value = true
}
}
// 清空并关闭结果对话框
const clearAndClose = (): void => {
clearAllFiles()
showUploadResult.value = false
}
// 更新上传进度
const updateProgress = (
id: string,
progress: number,
uploadedSize: number,
totalSize: number,
completed: boolean = false,
error?: string,
retryCount?: number
): void => {
if (uploadProgress.value[id]) {
uploadProgress.value[id] = {
...uploadProgress.value[id],
progress,
uploadedSize,
totalSize,
completed,
error,
retryCount: retryCount !== undefined ? retryCount : uploadProgress.value[id].retryCount
}
}
}
// 工具函数
const isValidFileType = (file: File): boolean => {
const validImageTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/bmp',
'image/tiff',
]
const validPdfTypes = ['application/pdf']
const validOfficeTypes = [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/msword',
'application/vnd.ms-powerpoint',
'application/vnd.ms-excel',
'application/zip', // Office文档有时被识别为zip
]
const validTypes = [...validImageTypes, ...validPdfTypes, ...validOfficeTypes]
const validExtensions = [
'.jpg',
'.jpeg',
'.png',
'.gif',
'.webp',
'.bmp',
'.tiff',
'.tif',
'.pdf',
'.doc',
'.docx',
'.dot',
'.dotx',
'.ppt',
'.pptx',
'.pot',
'.potx',
'.pps',
'.ppsx',
'.xls',
'.xlsx',
'.xlt',
'.xltx',
'.csv',
]
const fileName = file.name.toLowerCase()
// 检查MIME类型
if (validTypes.includes(file.type)) {
return true
}
// 特殊处理:如果检测为zip但扩展名是Office文档,则允许
if (
file.type === 'application/zip' &&
['.docx', '.pptx', '.xlsx'].some((ext) => fileName.endsWith(ext))
) {
return true
}
// 检查文件扩展名
return validExtensions.some((ext) => fileName.endsWith(ext))
}
const getFileTypeFromName = (fileName: string): string => {
const ext = fileName.toLowerCase().split('.').pop()
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'png':
return 'image/png'
case 'gif':
return 'image/gif'
case 'webp':
return 'image/webp'
case 'bmp':
return 'image/bmp'
case 'tiff':
case 'tif':
return 'image/tiff'
case 'pdf':
return 'application/pdf'
case 'doc':
return 'application/msword'
case 'docx':
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
case 'ppt':
return 'application/vnd.ms-powerpoint'
case 'pptx':
return 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
case 'xls':
return 'application/vnd.ms-excel'
case 'xlsx':
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
default:
return 'application/octet-stream'
}
}
const getFileTypeName = (fileType: string): string => {
if (fileType.startsWith('image/')) return '图片'
if (fileType === 'application/pdf') return 'PDF文档'
if (fileType.includes('wordprocessingml') || fileType.includes('msword')) return 'Word文档'
if (fileType.includes('presentationml') || fileType.includes('ms-powerpoint'))
return 'PowerPoint演示文稿'
if (fileType.includes('spreadsheetml') || fileType.includes('ms-excel')) return 'Excel电子表格'
return '未知类型'
}
const isImage = (file: File): boolean => {
return file.type.startsWith('image/')
}
const getFileIcon = (fileType: string): string => {
if (fileType.startsWith('image/')) return 'image'
if (fileType === 'application/pdf') return 'picture_as_pdf'
if (fileType.includes('wordprocessingml')) return 'description'
if (fileType.includes('presentationml')) return 'slideshow'
if (fileType.includes('spreadsheetml')) return 'table_chart'
return 'insert_drive_file'
}
const getFileIconColor = (fileType: string): string => {
if (fileType.startsWith('image/')) return 'blue'
if (fileType === 'application/pdf') return 'red'
if (fileType.includes('wordprocessingml')) return 'blue'
if (fileType.includes('presentationml')) return 'orange'
if (fileType.includes('spreadsheetml')) return 'green'
return 'grey'
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const formatSpeed = (bytesPerSecond: number): string => {
if (bytesPerSecond === 0) return '0 B/s'
const k = 1024
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']
const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k))
return parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const getProgressColor = (progress: number): string => {
if (progress === 100) return 'positive'
if (progress > 0) return 'primary'
return 'grey'
}
// 组件卸载时清理
onUnmounted(() => {
// 释放所有预览URL
fileList.value.forEach((fileItem) => {
if (fileItem.previewUrl) {
URL.revokeObjectURL(fileItem.previewUrl)
}
})
// 取消所有进行中的上传
Object.values(cancelTokens.value).forEach((source) => {
source.cancel('组件卸载')
})
// 清空速度计算间隔
if (speedUpdateInterval.value) {
clearInterval(speedUpdateInterval.value)
}
})
</script>
<style scoped lang="scss">
.universal-file-uploader {
max-width: 800px;
margin: 0 auto;
}
.upload-card,
.files-card,
.progress-card,
.stats-card {
border-radius: 8px;
}
.file-type-filter {
.q-btn-toggle {
border-radius: 4px;
.q-btn {
padding: 4px 8px;
.q-icon {
font-size: 1.2em;
margin-right: 4px;
}
}
}
}
.upload-config {
padding: 12px;
background-color: #f5f5f5;
border-radius: 6px;
}
.upload-actions {
.q-btn {
height: 42px;
}
}
.file-item {
transition: background-color 0.3s;
&.uploading-item {
background-color: #e3f2fd;
}
&.success-item {
background-color: #e8f5e9;
}
&.error-item {
background-color: #ffebee;
}
&.pending-item {
background-color: #fafafa;
}
.upload-status {
margin-top: 8px;
.status-text {
display: flex;
align-items: center;
margin-top: 4px;
.q-icon {
margin-right: 4px;
}
}
}
.file-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
.q-btn {
margin-bottom: 4px;
}
}
}
.failed-file-item {
display: flex;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid #eeeeee;
&:last-child {
border-bottom: none;
}
}
</style>
```
## 主要改进和新增功能
1. **并发控制**:
- 添加了并发上传数量配置滑块(1-10个并发)
- 实现了上传队列管理系统
2. **高级上传控制**:
- 暂停/继续/停止上传功能
- 自动开始上传选项
- 错误时停止所有上传选项
- 保留上传队列选项
3. **重试机制**:
- 可配置的最大重试次数(0-5次)
- 单个文件重试功能
- 批量重试失败文件
4. **上传统计和监控**:
- 实时上传速度显示
- 整体进度统计
- 文件状态分类统计(已完成、上传中、等待中)
5. **增强的用户界面**:
- 文件状态颜色编码
- 更详细的上传状态信息
- 改进的结果对话框
- 更好的工具提示和用户反馈
6. **企业级功能**:
- 分片上传选项(预留)
- 更完善的错误处理和恢复机制
- 上传队列持久化
这个实现保持了原有的所有功能,同时添加了企业级应用所需的高级批量上传功能,全部使用TypeScript编写,避免了any类型,并遵循了Quasar组件的格式要求。
浙公网安备 33010602011771号