自定义图片上传组件,支持批量上传,实时显示进度,图片预览等

47 阅读3分钟

自定义上传组件 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) => {
    // TODO
    console.error('💥 上传失败:', error);
  },
  onTaskRemoved: (params) => {
    // TODO
    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/*', // Set to accept only image files
  directory: false, // Select directories instead of files if set true
  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(() => {
  // TODO
});

// 唤起相机或者相册
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.loading = true;
    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; // 实时进度:0~100
  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; task: UploadTask }) => void;
}

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; task: UploadTask }) => void;

  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; i < availableSlots; i++) {
      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;