自定义上传组件 HXUploader.vue
<template>
<div class="image-container">
<ul class="grid-container">
<li v-for="(item, index) in valueFormat" :key="item.taskId" class="image-box" @click="handlePreview(index)">
<el-progress
v-show="item.loading"
type="circle"
:percentage="item.progress"
/>
<el-image v-show="!item.loading" :src="item.fileUrl" fit="cover" lazy class="w-full h-full">
<template #placeholder>
<div class="w-full h-full flex-x-center flex-y-center">
<div class="image-loading" />
</div>
</template>
<template #error>
<div class="w-full h-full flex-x-center flex-y-center">
<el-icon :size="$getSize(24)">
<Picture />
</el-icon>
</div>
</template>
</el-image>
<el-icon v-if="showRemove" class="clearIcon" @click.stop="handleRemoveConfirm(index)">
<CircleClose />
</el-icon>
</li>
<li v-if="isShowUpload" class="image-box" @click="handleShow">
<el-icon :size="$getSize(24)"><Plus /></el-icon>
<span v-show="uploadTextStr" class="mt6 font12">{{ uploadTextStr }}</span>
</li>
</ul>
<HXImageViewer
v-model="state.ruleForm.showImageViewer"
:imageIndex="state.ruleForm.imageIndex"
:previewSrcList="valueFormat"
/>
</div>
</template>
<script setup lang="ts" name="HXUploader">
import { useFileDialog } from '@vueuse/core';
import { useConfirm } from "/@/hooks";
import { Plus, CircleClose, Picture } from '@element-plus/icons-vue';
import { uploadFileApi, getFileUrlApi } from "/@/api/common";
import $getSize from '/@/utils/px2vw';
import commonFunction from "/@/utils/commonFunction";
import UploadQueue from '/@/utils/upload';
const { generateUniqueID } = commonFunction();
const uploadQueue = new UploadQueue({
maxConcurrent: 3,
maxRetries: 0,
retryDelay: 1000,
onProgress: (data) => {
onUploadProgress(data.taskId, data.progress);
},
onComplete: (result) => {
onUploadComplete(result);
},
onError: (error) => {
console.error('💥 上传失败:', error);
},
onTaskRemoved: (params) => {
console.log('🗑️ 任务已移除:', params);
},
});
const props = defineProps({
modelValue: { type: Array, default: () => [] },
uploadText: { type: String, default: "" },
showUpload: { type: Boolean, default: true },
showRemove: { type: Boolean, default: true },
showRemoveConfirm: { type: Boolean, default: false },
showRemoveConfirmText: { type: String, default: '请确认是否删除该图片?' },
uploadLimit: { type: Number, default: Infinity },
});
const emit = defineEmits<{
(e: "update:modelValue", value: any): void;
(e: "remove", value: any): void;
(e: "preview", value: any): void;
(e: "change", value: any): void;
}>();
const formData = () => ({
loading: false,
showImageViewer: false,
imageIndex: 0,
percentage: 0,
});
const state = reactive({
ruleForm: formData()
});
const valueFormat = computed({
get: () => {
const { modelValue } = props;
return modelValue ?? [];
},
set: (value) => {
emit("update:modelValue", value);
},
});
const uploadTextStr = computed(() => {
return state.ruleForm.loading ? '' : props.uploadText;
});
const isShowUpload = computed(() => {
return props.showUpload && valueFormat.value.length < props.uploadLimit;
});
const previewSrcList = computed(() => {
return valueFormat.value.map(item => item.fileUrl);
});
const { files, open, reset, onCancel, onChange } = useFileDialog({
accept: 'image/*',
directory: false,
multiple: true
})
onChange(async (files) => {
if (!files) return;
const limit = props.uploadLimit - valueFormat.value.length;
if (files.length > limit) {
ElMessage({ type: "error", message: `上传文件数量超过最大限制数量:${props.uploadLimit}` });
return;
}
const uploadFiles = Array.from(files);
uploadFiles.forEach(file => {
const taskId = generateUniqueID();
uploadQueue.addFile(file, taskId);
valueFormat.value.push({ taskId, loading: true, progress: 0 });
});
uploadQueue.start();
})
onCancel(() => {
});
const handleShow = () => {
if (state.ruleForm.loading) return;
reset();
open();
};
const handleRemoveConfirm = (index) => {
const { showRemoveConfirm, showRemoveConfirmText } = props;
showRemoveConfirm
? useConfirm(handleRemove, { index }, showRemoveConfirmText)
: handleRemove({ index });
};
const handleRemove = (params) => {
const { index } = params ?? {};
valueFormat.value?.splice(index, 1);
emit('change', valueFormat.value);
};
const handlePreview = (index) => {
state.ruleForm.imageIndex = index;
state.ruleForm.showImageViewer = true;
};
const onUploadProgress = (taskId, progress) => {
const target = valueFormat.value.find(item => item.taskId === taskId);
if (target) {
target.progress = progress;
}
};
const onUploadComplete = (result) => {
const { taskId, fileKey, fileName, fileUrl } = result;
const target = valueFormat.value.find(item => item.taskId === taskId);
if (target) {
target.fileKey = fileKey;
target.fileName = fileName;
target.fileUrl = fileUrl;
target.loading = false;
target.progress = 100;
}
emit('change', valueFormat.value);
};
</script>
<style lang="scss" scoped>
:deep(.el-image) {
border-radius: 6px;
}
:deep(.el-progress-circle) {
width: 60px
上传队列 uploadQueue.ts
import { uploadFileApi, getFileUrlApi } from "/@/api/common"
import commonFunction from "/@/utils/commonFunction"
// 公共函数
const { generateUniqueID } = commonFunction()
interface UploadTask {
file: File
progress: number
status: 'pending' | 'uploading' | 'success' | 'failed' | 'cancelled'
retryCount: number
taskId: string
abortController: AbortController | null
uploadRequest?: Promise<void>
}
interface UploadOptions {
maxConcurrent?: number
maxRetries?: number
retryDelay?: number
onProgress?: (data: {
type: 'queueUpdate' | 'progress' | 'uploadProgress'
queue?: UploadTask[]
taskId?: string
progress?: number
status?: string
fileUrl?: string
error?: Error
file?: File
}) => void
onComplete?: (result: {
taskId: string
fileKey: string
fileName: string
fileUrl: string
file: File
}) => void
onError?: (error: {
taskId: string
error: Error
file: File
}) => void
onTaskRemoved?: (params: { index: number
}
class UploadQueue {
private maxConcurrent: number
private maxRetries: number
private retryDelay: number
private queue: UploadTask[] = []
private activeTasks = 0
private isPaused = false
private onProgress: (data: {
type: 'queueUpdate' | 'progress' | 'uploadProgress'
queue?: UploadTask[]
taskId?: string
progress?: number
status?: string
fileUrl?: string
error?: Error
file?: File
}) => void
private onComplete: (result: {
taskId: string
fileKey: string
fileName: string
fileUrl: string
file: File
}) => void
private onError: (error: {
taskId: string
error: Error
file: File
}) => void
private onTaskRemoved: (params: { index: number
constructor(options: UploadOptions = {}) {
this.maxConcurrent = options.maxConcurrent ?? 3
this.maxRetries = options.maxRetries ?? 2
this.retryDelay = options.retryDelay ?? 1000
this.onProgress = options.onProgress ?? (() => {})
this.onComplete = options.onComplete ?? (() => {})
this.onError = options.onError ?? (() => {})
this.onTaskRemoved = options.onTaskRemoved ?? (() => {})
}
/**
* 添加文件到队列(支持外部传入 taskId)
* @param file 文件对象
* @param taskId 外部传入的唯一 ID(如 file.id)
* @returns 任务对象
*/
addFile(file: File, taskId?: string): UploadTask {
const id = taskId || generateUniqueID()
const task: UploadTask = {
file,
progress: 0,
status: 'pending',
retryCount: 0,
taskId: id,
abortController: null,
}
this.queue.push(task)
this.onProgress({ type: 'queueUpdate', queue: [...this.queue] })
return task
}
/**
* 开始上传
*/
start(): void {
if (this.isPaused || this.queue.length === 0) return
this.isPaused = false
this._run()
}
/**
* 暂停上传
*/
pause(): void {
this.isPaused = true
this.queue.forEach(task => {
if (task.status === 'uploading' && task.abortController) {
task.abortController.abort()
}
})
}
/**
* 恢复上传
*/
resume(): void {
if (!this.isPaused) return
this.isPaused = false
this._run()
}
/**
* 移除指定索引的任务
* @param index 队列索引
*/
remove(index: number): void {
const task = this.queue.splice(index, 1)[0]
if (task && task.abortController) {
task.abortController.abort()
}
this.onTaskRemoved({ index, task })
this.onProgress({ type: 'queueUpdate', queue: [...this.queue] })
}
/**
* 重试指定任务(根据 taskId)
* @param taskId 文件 ID
*/
retry(taskId: string): void {
const task = this.queue.find(t => t.taskId === taskId)
if (!task) return
if (task.status !== 'failed' && task.status !== 'cancelled') return
task.status = 'pending'
task.retryCount = 0
task.progress = 0
this._run()
}
/**
* 根据 taskId 获取任务
* @param taskId 任务 ID
* @returns 任务对象或 undefined
*/
getTaskById(taskId: string): UploadTask | undefined {
return this.queue.find(t => t.taskId === taskId)
}
/**
* 根据 taskId 取消上传
* @param taskId 任务 ID
*/
cancel(taskId: string): void {
const task = this.getTaskById(taskId)
if (!task) return
if (task.abortController) {
task.abortController.abort()
}
task.status = 'cancelled'
this.onProgress({ type: 'progress', taskId, progress: task.progress, status: 'cancelled' })
}
/**
* 核心运行逻辑:并发控制 + 补位
*/
private _run(): void {
if (this.isPaused || this.queue.length === 0) return
const availableSlots = this.maxConcurrent - this.activeTasks
for (let i = 0
const task = this.queue.find(t => t.status === 'pending')
if (!task) break
task.status = 'uploading'
this.activeTasks++
this._uploadTask(task)
}
}
/**
* 上传单个任务(支持实时上传进度)
* @param task 上传任务
*/
private async _uploadTask(task: UploadTask): Promise<void> {
const { file, taskId } = task
try {
const abortController = new AbortController()
task.abortController = abortController
// ✅ 1. 上传文件(带上传进度)
const uploadRes = await uploadFileApi(
{ file },
(progressEvent: ProgressEvent) => {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
task.progress = percentCompleted
// ✅ 实时返回上传进度(使用外部传入的 taskId)
this.onProgress({
type: 'uploadProgress',
taskId,
progress: percentCompleted,
file,
})
}
)
const resourceId = uploadRes?.data