ProUploadFile 文件上传组件

实现效果

组件介绍

ProUploadFile 是一个基于 Element Plus 的 Upload 组件封装的文件上传组件。该组件支持拖拽上传、文件类型限制、大小限制等功能,并提供了美观的文件列表展示界面。

功能特点
  • 支持拖拽上传和点击上传
  • 支持文件类型限制
  • 支持文件大小限制
  • 支持多文件上传
  • 支持文件数量限制
  • 支持自定义文件列表展示
  • 支持文件上传进度显示
  • 支持文件预览和删除
  • 支持自定义提示信息
代码实现
/**
 * 文件上传组件
 * 基于 Element Plus 的 Upload 组件封装
 * 支持拖拽上传、文件类型限制、大小限制等功能
 * @component ProUploadFile
 */
<template>
  <div class="pro-upload-file-box">
    <el-upload
      :class="['pro-upload-file', { 'is-disabled': disabled }]"
      ref="proUploadFileRef"
      v-bind="uploadProps"
      :on-success="handleSuccess"
      :before-upload="handleBeforeUpload"
      :on-exceed="handleExceed"
      :on-error="handleError"
    >
      <slot>
        <el-button v-if="!drag" type="primary" :disabled="disabled">
          <el-icon class="upload-icon">
            <Plus />
          </el-icon>文件上传
        </el-button>
        <div v-else>
          <el-image
            class="img-upload"
            src="https://static.wxb.com.cn/frontEnd/images/ideacome-vue3-component/upload.png"
          />
          <div class="upload-guide">
            {{ dragTitle }}
          </div>
          <div v-if="accept" class="upload-support">
            支持扩展名:{{ accept.split(",").join(" ") }}
          </div>
        </div>
      </slot>
      <!-- file插槽 -->
      <template #file>
        <slot name="file" />
      </template>
      <template #tip>
        <div v-if="tip" class="upload-tip">
          {{ tip }}
        </div>
      </template>
    </el-upload>

    <div v-if="!showFileList" class="pro-upload-file-content">
      <div
        v-for="(file, index) in fileList"
        :key="file.uid"
        class="file-list-item"
        @mouseenter="hoverItem = file.uid"
        @mouseleave="hoverItem = ''"
        @click="fileClick(file)"
      >
        <div class="content">
          <div class="file-list-item-left">
            <el-image
              v-if="fileTypeObj(file.name).showFileIcon"
              class="file-icon"
              :src="fileTypeObj(file.name).fileIconUrl"
            />
            <div>
              <div class="file-list-item-name">
                {{ file.name }}
              </div>
              <div class="file-list-item-state">
                {{ file.loadingState ? "上传中" : "上传完成" }}
              </div>
            </div>
          </div>
          <template v-if="!file.loadingState">
            <el-icon
              v-if="hoverItem === file.uid && !disabled"
              class="file-list-item-icon-del"
              @click.stop="handleRemove(file, index)"
            >
              <Close />
            </el-icon>
            <el-icon v-else class="file-list-item-icon">
              <Check />
            </el-icon>
          </template>
        </div>

        <el-progress
          v-if="file.percentage < 100"
          color="rgba(11, 211, 211, 1)"
          :stroke-width="2"
          :show-text="false"
          :percentage="file.percentage"
        />
      </div>
    </div>
  </div>
</template>

<script setup name="ProUploadFile">
  import { ref, computed } from 'vue';
  import { Close, Check, Plus } from '@element-plus/icons-vue';
  import { ElMessage } from 'element-plus';

  const proUploadFileRef = ref(null);
  const hoverItem = ref('');

  // 计算属性
  const fileTypeList = computed(() => {
    if (!props.accept) return [];
    return props.accept.split(',').map(item => item.replace('.', ''));
  });

  const maxSizeInKB = computed(() => {
    if (!props.maxSize) return 0;
    return props.sizeUnit === 'MB' ? props.maxSize * 1024 : props.maxSize;
  });

  // 组件属性定义
  const props = defineProps({
    /** 上传地址 */
    action: {
      type: String,
      required: true,
      default: '',
    },
    /** 接受的文件类型 */
    accept: {
      type: String,
      default: '.xls,.xlsx',
    },
    /** 文件大小限制 */
    maxSize: {
      type: Number,
      default: 0,
    },
    /** 大小单位(KB/MB) */
    sizeUnit: {
      type: String,
      default: 'MB',
      validator: (value) => ['KB', 'MB'].includes(value),
    },
    /** 是否支持拖拽 */
    drag: {
      type: Boolean,
      default: true,
    },
    /** 文件数量限制 */
    limit: {
      type: Number,
      default: 0,
    },
    /** 是否支持多选 */
    multiple: {
      type: Boolean,
      default: false,
    },
    /** 是否显示文件列表 */
    showFileList: {
      type: Boolean,
      default: false,
    },
    /** 请求头 */
    headers: {
      type: Object,
      default: () => ({}),
    },
    /** 是否禁用 */
    disabled: {
      type: Boolean,
      default: false,
    },
    /** 提示信息 */
    tip: {
      type: String,
      default: '',
    },
    /** 拖拽提示文字 */
    dragTitle: {
      type: String,
      default: '点击或将文件拖拽到这里上传',
    },
  });

  // 组件事件定义
  const emit = defineEmits(['success', 'fileClick', 'remove', 'error', 'exceed']);

  // 文件列表双向绑定
  const fileList = defineModel('fileList', {
    type: Array,
    default: () => [],
  });

  const uploadProps = computed(() => ({
    action: props.action,
    accept: props.accept,
    limit: props.limit,
    drag: props.drag,
    multiple: props.multiple,
    autoUpload: true,
    showFileList: props.showFileList,
    headers: props.headers,
    fileList: fileList.value,
    disabled: props.disabled,
  }));

  // 文件类型映射
  const FILE_TYPE_MAP = {
    pdf: ['pdf'],
    excel: ['xls', 'xlsx', 'et'],
    csv: ['csv'],
    image: ['png', 'jpg', 'jpeg', 'gif', 'bmp'],
    doc: ['doc', 'docx'],
    zip: ['zip'],
  };

  /**
   * 通用文件类型判断方法
   * @param {string} fileName - 文件名
   * @param {string} type - 文件类型
   * @returns {boolean} 是否为指定类型
   */
  const isFileType = (fileName, type) => {
    const extension = fileName.split('.').pop().toLowerCase();
    return FILE_TYPE_MAP[type]?.includes(extension) || false;
  };

  const fileTypeObj = (fileName) => {
    const iconMap = {
      pdf: 'https://static.wxb.com.cn/frontEnd/images/ideacome-vue3-component/pdf.png',
      excel: 'https://static.wxb.com.cn/frontEnd/images/ideacome-vue3-component/exc.png',
      csv: 'https://static.wxb.com.cn/frontEnd/images/ideacome-vue3-component/csv.png',
      image: 'https://static.wxb.com.cn/frontEnd/images/ideacome-vue3-component/image.png',
      doc: 'https://static.wxb.com.cn/frontEnd/images/ideacome-vue3-component/doc.png',
      zip: 'https://static.wxb.com.cn/frontEnd/images/ideacome-vue3-component/zip.png',
    };
    for (const [type, url] of Object.entries(iconMap)) {
      if (isFileType(fileName, type)) {
        return { showFileIcon: true, fileIconUrl: url };
      }
    }
    return { showFileIcon: false, fileIconUrl: '' };
  };

  /**
   * 移除上传文件
   * @param {Object} file - 文件对象
   * @param {number} index - 文件索引
   */
  const handleRemove = (file, index) => {
    fileList.value.splice(index, 1);
    proUploadFileRef.value.handleRemove(file);
    emit('remove', file);
  };

  /**
   * 上传前校验
   * @param {File} file - 文件对象
   * @returns {boolean} 是否允许上传
   */
  const handleBeforeUpload = (file) => {
    if (!validateFileType(file)) return false;
    if (!validateFileSize(file)) return false;
    addFileToList(file);
    return true;
  };

  /**
   * 验证文件类型
   * @param {File} file - 文件对象
   * @returns {boolean} 是否符合类型要求
   */
  const validateFileType = (file) => {
    if (!props.accept) return true;

    const fileType = file.name.split('.').pop();
    if (!fileTypeList.value.includes(fileType)) {
      ElMessage({
        message: `仅支持 ${fileTypeList.value.join('、')} 格式`,
        type: 'warning',
      });
      return false;
    }
    return true;
  };

  /**
   * 验证文件大小
   * @param {File} file - 文件对象
   * @returns {boolean} 是否符合大小要求
   */
  const validateFileSize = (file) => {
    if (!props.maxSize) return true;
    const fileSize = file.size / 1024;
    if (fileSize > maxSizeInKB.value) {
      ElMessage({
        message: `大小不能超过 ${props.maxSize}${props.sizeUnit}!`,
        type: 'warning',
      });
      return false;
    }
    return true;
  };

  /**
   * 创建文件对象
   * @param {File} file - 原始文件对象
   * @returns {Object} 处理后的文件对象
   */
  const createFileObject = (file) => ({
    name: file.name,
    url: '',
    percentage: 0,
    loadingState: 1,
    type: file.type,
    uid: file.uid,
  });

  /**
   * 添加文件到列表
   * @param {File} file - 文件对象
   */
  const addFileToList = (file) => {
    const obj = createFileObject(file);

    // 模拟上传进度
    obj.interval = setInterval(() => {
      const index = fileList.value.findIndex((i) => i.uid === obj.uid);
      const targetFile = fileList.value[index];
      if (targetFile?.percentage < 90) {
        targetFile.percentage += 10;
      }
    }, 300);

    fileList.value.push(obj);
  };

  /**
   * 处理超出限制
   * @param {File[]} files - 文件列表
   * @param {File[]} uploadFiles - 已上传文件列表
   */
  const handleExceed = (files, uploadFiles) => {
    ElMessage({
      message: `最多只能上传 ${props.limit} 个文件`,
      type: 'warning',
    });
    emit('exceed', files, uploadFiles);
  };

  /**
   * 处理上传成功
   * @param {Object} response - 响应数据
   * @param {Object} uploadFile - 上传文件对象
   * @param {Object[]} uploadFiles - 上传文件列表
   */
  const handleSuccess = (response, uploadFile, uploadFiles) => {
    const index = fileList.value.findIndex((item) => item.uid === uploadFile.uid);
    const targetFile = fileList.value[index];

    if (response.code === 10000) {
      const { data } = response;
      if (data.fileUrl) targetFile.url = data.fileUrl;
      if (data.fileName) targetFile.name = data.fileName;
      targetFile.percentage = 100;
      targetFile.loadingState = 0;
    } else {
      fileList.value.splice(index, 1);
      proUploadFileRef.value.handleRemove(uploadFile);
      ElMessage({
        message: response?.msg || response?.message || '上传失败',
        type: 'error',
      });
    }

    // 清理定时器
    targetFile.interval && clearInterval(targetFile.interval);

    emit('success', response, uploadFile, uploadFiles);
  };

  /**
   * 处理上传错误
   * @param {Error} error - 错误对象
   * @param {Object} uploadFile - 上传文件对象
   * @param {Object[]} uploadFiles - 上传文件列表
   */
  const handleError = (error, uploadFile, uploadFiles) => {
    const index = fileList.value.findIndex((item) => item.uid === uploadFile.uid);
    proUploadFileRef.value.handleRemove(uploadFile);
    fileList.value[index].interval && clearInterval(fileList.value[index].interval);
    fileList.value.splice(index, 1);
    ElMessage({
      message: '上传失败',
      type: 'error',
    });
    emit('error', error, uploadFile, uploadFiles);
  };

  /**
   * 处理文件点击
   * @param {Object} file - 文件对象
   */
  const fileClick = (file) => {
    if (file.loadingState === 1) {
      ElMessage({
        message: '文件上传中!',
        type: 'warning',
      });
      return;
    }
    emit('fileClick', file);
  };
</script>

<style lang="less" scoped>
.pro-upload-file-box {
  text-align: left;
  
  .is-disabled {
    :deep(.el-upload-dragger) {
      cursor: not-allowed;
    }
  }

  .pro-upload-file {
    position: relative;
    
    .upload-icon {
      font-size: 16px;
      margin-right: 4px;
    }
    
    .img-upload {
      width: 48px;
      height: 48px;
    }
    
    .upload-guide {
      color: rgba(0, 5, 27, 0.85);
      font-size: 14px;
    }
    
    .upload-support {
      color: rgba(0, 5, 27, 0.45);
      font-size: 12px;
      margin-top: 4px;
    }
  }

  .upload-tip {
    margin-top: 8px;
    line-height: 20px;
    text-align: left;
    font-size: 12px;
    color: #909399;
  }

  .pro-upload-file-content {
    width: 100%;
    margin-top: 16px;
    
    .file-list-item {
      width: 100%;
      cursor: pointer;
      margin-bottom: 8px;
      background: #f6f7fb;
      border-radius: 4px;
      
      .content {
        display: flex;
        justify-content: space-between;
        align-items: center;
        width: 100%;
        background: #f6f7fb;
        padding: 12px 20px;
        box-sizing: border-box;
        border-radius: 4px;
      }
    }

    .file-list-item-left {
      width: calc(100% - 32px);
      display: flex;
      align-items: center;
      
      .file-icon {
        width: 40px;
        height: 40px;
        margin-right: 12px;
        flex-shrink: 0;
      }
    }

    .file-list-item-name {
      width: 100%;
      overflow: hidden;
      white-space: nowrap;
      text-overflow: ellipsis;
      color: rgba(0, 5, 27, 0.85);
      font-size: 14px;
    }

    .file-list-item-state {
      font-size: 12px;
      color: rgba(0, 5, 27, 0.45);
    }

    .file-list-item-icon {
      font-size: 16px;
      color: #0bd3d3;
    }

    .file-list-item-icon-del {
      color: rgba(0, 5, 27, 0.65);
      font-size: 16px;
    }
  }

  :deep(.el-upload-dragger) {
    border: 1px dashed rgba(11, 211, 211, 1);
    background: rgba(11, 211, 211, 0.02);
    
    &.is-dragover,
    &:hover {
      background: rgba(11, 211, 211, 0.1) !important;
      border: 1px dashed rgba(11, 211, 211, 1) !important;
    }
  }

  :deep(.el-progress-bar__outer) {
    border-radius: 4px;
  }
}
</style>
属性说明
属性名 说明 类型 默认值 必填
action 上传地址 String -
accept 接受的文件类型,如:'.xls,.xlsx' String '.xls,.xlsx'
maxSize 文件大小限制 Number 0
sizeUnit 大小单位,可选值:'KB'、'MB' String 'MB'
drag 是否支持拖拽上传 Boolean true
limit 文件数量限制 Number 0
multiple 是否支持多选 Boolean false
showFileList 是否显示文件列表 Boolean false
headers 请求头信息 Object {}
disabled 是否禁用 Boolean false
tip 提示信息 String -
dragTitle 拖拽提示文字 String '点击或将文件拖拽到这里上传'

事件 
事件名 说明 回调参数
success 文件上传成功时触发 (response, uploadFile, uploadFiles)
fileClick 点击文件时触发 (file)
remove 移除文件时触发 (file)
error 上传失败时触发 (error, uploadFile, uploadFiles)
exceed 超出文件数量限制时触发 (files, uploadFiles)
插槽
插槽名 说明
default 自定义上传按钮内容
file 自定义文件列表项内容
tip 自定义提示信息内容
使用示例
<template>
  <pro-upload-file
    action="/api/upload"
    accept=".xls,.xlsx"
    :max-size="10"
    :limit="5"
    :multiple="true"
    tip="文件大小不超过10M"
    @success="handleSuccess"
    @error="handleError"
    @remove="handleRemove"
  />
</template>

<script setup>
const handleSuccess = (response, uploadFile, uploadFiles) => {
  // 在此处写你需要的个性化步骤
  console.log('上传成功:', response);
};

const handleError = (error, uploadFile, uploadFiles) => {
  // 在此处写你需要的个性化步骤
  console.log('上传失败:', error);
};
const handleRemove = (file) => {
  // 在此处写你需要的个性化步骤
  console.log('移除文件:', file);
};
</script>
文件类型支持

组件内置支持以下文件类型的图标显示:

  • PDF文件 (.pdf)
  • Excel文件 (.xls, .xlsx, .et)
  • CSV文件 (.csv)
  • 图片文件 (.png, .jpg, .jpeg, .gif, .bmp)
  • Word文档 (.doc, .docx)
  • 压缩文件 (.zip)
注意事项

1、上传接口返回数据格式需要符合以下规范(如果不符合规范请调整组件封装的代码):

{
  code: 10000,  // 成功状态码
  data: {
    fileUrl: '文件地址',
    fileName: '文件名'
  },
  msg: '提示信息'
}

2、文件大小限制的单位默认为 MB,可通过 sizeUnit 属性修改为 KB。

3、当 showFileList 为 false 时,组件会使用自定义的文件列表展示样式。

4、上传中的文件会显示进度条,进度条颜色为 rgba(11, 211, 211, 1)。

5、组件中使用了defineModel(),defineModel()仅在vue 3.4+ 版本中可用,如果版本较低可以升版本或者用update:fileList进行替代

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐