如何封装一个实用的上传组件

1,106 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天,点击查看活动详情

前言

马上放假了,时间上相对宽裕,对最近做的东西进行一些总结。
今天我们来看一个非常实用的组件,上传组件

我们先从组件的定位、组件的应用场景、组件的特性几个方面进行归纳

定位:

对于上传组件,基础的组件功能属于基础组件范畴,满足上传的基本功能。
业务平台的多样化,促使我们需要在基础组件之上,进行多样化扩展,满足业务功能的需求,这个多样化扩展隶属于业务组件层。但是需要注意几个点,进行组件约定,防止腐烂。

  • 业务公用性
  • 保持功能独立性,尽量解耦
  • 无业务需求,不开发

image.png

image.png

应用场景:

  • 数据导入,以excel为载体,一般都是成员列表、成绩导入等业务数据,方便用户进行批量操作
  • 文件管理,需要兼容格式较多,PNG/JPG/GIF/PDF/DOC/XLS/txt/md/html/mp4/avi等常用格式,多应用于平台的资源管理类功能
  • 独立业务功能,图片、头像临时上传需要

以上3个应用场景,一般的平台都有应用,但是不同场景对上传组件的功能要求可能不同。

这就需要考虑到业务特性以及产品规划能力。

可根据实际情况,从定位角度对不同场景的进行应用。

属性特征

1、单文件上传、多文件上传
2、文件格式校验、文件大小校验
3、上传文件维护(显示、移除)

组件实现

目录

image.png

对上传组件划分了3层,分别是 Base、Simple、Dialog

Base属于基础组件,提供简单的文件选择,和样式展示

Simple 复合组件-提供系统级别的扩展功能

1、上传提示语
2、文件格式、文件大小、模板下载
3、已上传列表管理-移除

Dialog 业务型组件

1、展示方式-弹窗
2、文件上传功能
3、业务参数控制、组件功能控制

image.png

入口文件

/*
 * @Description: 
 * @Author: daerduo
 */

import Upload from './Base';
import Simple from './Simple';
import Dialog from './Dialog';

// 复合组件-上传组件
Upload.Simple = Simple
// 业务组件
Upload.Dialog = Dialog

export default Upload;

使用方式

<Upload.Dialog
  ref="importRefs"
  :options="UploadConfig"
  :request-params="UploadParams"
  @refresh="refresh"
/>

/**
 * 导入配置
 */
const UploadConfig = {
  title: '导入成员',
  download: {
    type: 'AccountVo',
    tempName: '导入成员模板.xlsx'
  }
}
<Upload.Simple ref="SimpleUploadRefs" :multiple="multiple" :file-rule="FILE_TYPE" :options="SimpleUploadConfig">
  <template #notes="scope">
    <span>只允许导入</span>
    <span class="primary">XLS/XLSX</span>
    <span>格式,且大小不超过10M</span>
    <span v-if="getDownloadConfig()">,如果需要也可以<a class="download primary" title="点击下载" @click="downloadTempHandler">下载模板</a></span>
  </template>
</Upload.Simple>

/**
 * 上传文件大小限制
 */
const SimpleUploadConfig = {
  fileSize: 10
}

默认是Base,之间使用标签即可

<Upload @fileChange="fileChange" :multiple="multiple"></Upload>

组件之间的交互

image.png

基础上传组件 Base

基础的组件是基于现有的第三方组件库(element)el-upload基础上,做界面扩展和功能增强使用

  • 界面扩展部分

主要是外框内部文字、图标的调整以,在el-upload的默认插槽slot

image.png

  • 行为部分

multiple : props属性控制,受外部调用控制,默认是false,单选模式
on-change : 添加文件、上传成功和上传失败时都会被调用
on-remove:文件列表移除文件时触发

<template>
  <div>
    <el-upload drag :multiple="multiple" :http-request="(val) => { }" :on-change="fileChange" :on-remove="fileRemove">
      <div class="el-upload__text">
        <div class="upload-icon primary">
          <svg-icon icon-class="icon-shangchuan"></svg-icon>
        </div>
        将文件拖到此处或 <em>点击上传</em>
      </div>
    </el-upload>
  </div>
</template>
<script setup>
const props = defineProps({
  multiple: {
    type: Boolean,
    default: false
  },
})
const emit = defineEmits(["fileChange", "remove"]);

/**
 * 选中文件后回调
 */
const fileChange = (file, fileList) => {
  emit('fileChange',file,fileList)
}

/**
 * 文件列表移除文件时触发
 */
const fileRemove = (file) => {
  emit('remove',file)
}

</script>
<style lang="scss" scoped>
@import "../style/base.scss"
</style>

展示型组件 Simple

对于基础组件,只能满足组件的选择,实际业务应用,通用性不强。展示型组件对基础组件的进一步封装,主要目的是满足业务功能需求,这一点很重要。

因要考虑页面交互方式,交互方式根据业务场景可划分为

1、在组件内嵌套,如表单页面,做为表单项存在
2、独立个体,在弹窗内内使用,如数据导入等

需要满足以上2种方式。

<template>
  <div>
    <div class="import-tips">
      <svg-icon icon-class="icon-mianxing-jieshishuoming"></svg-icon>
      填写模板时请仔细阅读文件中的说明文字,并严格按照其中所属规则填写,否则可能会导入失败
    </div>
    <!-- 基础上传组件 -->
    <BaseUpload @fileChange="fileChange" :multiple="multiple"></BaseUpload>
    <!-- 文件格式限制、tips -->
    <div class="notes">
      <slot name="notes"></slot>
    </div>
    <!-- 选择后-文件列表 -->
    <div class="after" v-for="(item, index) in uploadForm.files" :key="index">
      <div>
        <span style="color: #283241; margin-left: 10px">{{ item.name }}</span>
      </div>
      <div @click="removeFile(item)" class="remove-file" title="移除文件">
        <svg-icon icon-class="icon-shanchu"></svg-icon>
      </div>
    </div>
  </div>
</template>
<script setup>
import { ElMessage } from "element-plus";
import BaseUpload from "../Base";
import useSimple from "./Simple";
const { uploadForm, clearForm, removeFile } = useSimple();

const emit = defineEmits(["fileChange", "remove"]);

const props = defineProps({
  multiple: {
    type: Boolean,
    default: false,
  },
  limit: {
    type: Number,
    default: 1,
  },
  options: {
    type: Object,
    default: {},
  },
  fileRule: {
    type: Array,
    default: () => {},
  },
});

// 获取父组件传递值-配置
const getOpions = () => props.options;

/**
 * 下载模板
 * excel
 */
const downloadTemplateHandler = (params) => {
  const data = {};
  downloadHandler(data);
};

/**
 * 获取文件校验格式
 */
const getFileRule = () => {
  return props.fileRule || [];
};

const fileSizeRule = (file, fileSize) => {
  if (!fileSize) return true
  
  const isSize = file.size / 1024 / 1024 < fileSize

  if (!isSize) {
    ElMessage({
      type: 'warning',
      message: `上传文件大小不能超过${fileSize}M!`
    })
    return false
  }

  return true
}

/**
 * 选中文件后回调
 */
const fileChange = (file, fileList) => {
  const uploadFile = uploadForm.files;
  const fileType = file.raw.type;

  const limit = props.limit;
  if (uploadFile && uploadFile.length == limit) {
    ElMessage({
      type: "warning",
      message: `最多上传${limit}个文件!`,
    });
    return false;
  }

  const fileRule = getFileRule();

  if (fileRule.length) {
    const isType = fileRule.includes(fileType);
    if (!isType) {
      ElMessage({
        type: "warning",
        message: "文件格式错误!",
      });
      return false;
    }
  }

  const fileSize = getOpions().fileSize
  if(!fileSizeRule(file, fileSize)) return
  

  let flag = false;
  uploadForm.files.forEach((item) => {
    if (file.name == item.name) {
      flag = true;
    }
  });
  if (!flag) {
    uploadForm.files.push(file.raw);
    emit("fileChange", file, fileList);
  }
};

/**
 * 获取文件列表
 */
const getFiles = () => {
  return uploadForm.files;
};

/**
 * 清空上传文件
 */
const clearFiles = () => {
  clearForm();
};

defineExpose({
  getFiles,
  clearFiles,
});
</script>
<style lang="scss" scoped>
@import "../style/base.scss";
.notes {
  margin: 10px 0;
}
</style>

hooks部分

import { reactive, ref } from "vue";

export default function useSimple() {

  const uploadForm = reactive({
    files: [],
  });

  const clearForm = (state) => {
    uploadForm.files = [];
  }

 /**
  * 移除文件
  */
  const removeFile = (file) => {
    let index = uploadForm.files.findIndex((item) => item.name == file.name);
    uploadForm.files.splice(index, 1);
  }
  
  return {
    uploadForm,
    clearForm,
    removeFile,
  };
}

移除已选择的文件列表,和单个文件的移除,以及向外暴露方法

从上面代码可以看出来,提供了样式多样化部分:

  • 提示说明
  • 文件选择后的列表,移除文件
  • 文件模式(单文件、多文件选择)、上传个数限制、文件格式、文件大小校验

向外暴露方法
1、getFiles 获取已上传文件列表
2、clearFiles 清楚已选择的文件列表
3、事件:fileChange,文件选择后回调、remove移除文件后回调

image.png

业务组件 Dialog

这部分是基于展示型组件之上,

如果使用场景在表单内,可直接将展示型组件做为业务容器类组件直接使用。

如果需要弹窗使用,可以在业务功能内做为业务组件进行扩展。

因当前业务组件具备一定通用性,固做为通用性业务组件使用。

当前业务组件,是以导入功能为引导,导入之前,需要先进去导入模板下载,对导入的文件需要进行以下判断

  • 上传文件模式,单文件选择
  • 文件格式限制,只能上传excel文件
  • 文件上传需要按照一定的模板,弹窗内需要提供模板下载
  • 文件上传请求

这是业务功能的要求。业务组件内,页面交互涉及到:

  • 文件上传前的校验控制
  • 上传时的进度或者Loading控制
  • 上传状态后,页面控制等,如关闭窗体、回调触发刷新页面等
<template>
  <div>
    <Dialog.Next :title="getOpions().title || defaultValue.title" ref="uploadDGRefs" width="580px"
      :show-confirm-btn="true" @close="close" @confirm="saveHandler" :limit="limit">
      <div v-loading="isLoading" :element-loading-text="getOpions().loadingText || defaultValue.loadingText"
        style="height:440px">
        <SimpleUpload ref="SimpleUploadRefs" :multiple="multiple" :file-rule="FILE_TYPE" :options="SimpleUploadConfig">
          <template #notes="scope">
            <span>只允许导入</span>
            <span class="primary">XLS/XLSX</span>
            <span>格式,且大小不超过10M</span>
            <span v-if="getDownloadConfig()">,如果需要也可以<a class="download primary" title="点击下载" @click="downloadTempHandler">下载模板</a></span>
          </template>
        </SimpleUpload>
      </div>
    </Dialog.Next>
  </div>
</template>
<script setup>
import { ElMessage } from "element-plus";
import { ref } from "vue";
import { Dialog } from "@businessComponents";
import SimpleUpload from "../Simple";
import useDialog from "./dialog";
import { Controller } from "./models";
const { uploadForm, isLoading, setLoading, downloadHandler, FILE_TYPE } = useDialog()

const props = defineProps({
  multiple: {
    type: Boolean,
    default: false
  },
  limit: {
    type: Number,
    default: 1
  },
  requestParams: {
    type: Object,
    default: {}
  },
  options: {
    type: Object,
    default: {}
  },
})

/**
 * 默认值
 */
const defaultValue = {
  title: '导入',
  loadingText: '正在导入请耐心等待...'
}

/**
 * 上传文件大小限制
 */
const SimpleUploadConfig = {
  fileSize: 10
}

// 获取父组件传递值-配置
const getOpions = () => props.options;

const uploadDGRefs = ref(null);
const SimpleUploadRefs = ref(null);
const emit = defineEmits(["refresh"]);

const getDownloadConfig = (params) => {
  return getOpions().download
}

const downloadTempHandler = (params) => {
  const config = getDownloadConfig()
  downloadHandler(config)
}
/**
 * 数据保存
 */
const saveAction = async (formData) => {
  Controller.uploadFile(formData).then((res) => {
    if (res) {
      ElMessage({
        type: "success",
        message: "添加成功!",
      });
      emit("refresh");
      close();
    }
    setLoading(false)
  }).catch(() => setLoading(false));
}

/**
 * 获取内部上传组件的文件列表
 */
const getFiles = () => {
  return SimpleUploadRefs.value.getFiles()
}

/**
 * 获取外部传入的上传参数
 */
const getRequestParams = () => {
  return props.requestParams || {}
}

// 保存
const saveHandler = () => {
  const files = getFiles()

  if (!files || !files.length) {
    return ElMessage({
      type: "warning",
      message: "请上传项目文件!",
    });
  }

  if (isLoading.value) {
    return ElMessage({
      type: "warning",
      message: "文件上传中,请稍等....",
    });
    return
  }

  setLoading(true)

  // 拼装参数-文件流 FormData
  let formData = new FormData();
  files.forEach((item) => {
    formData.append("file", item);
  });
  uploadForm.jsonData = getRequestParams()
  formData.append("jsonData", JSON.stringify(uploadForm.jsonData));

  // 上传-数据保存
  saveAction(formData)
}
// 打开
const load = () => {
  uploadDGRefs.value.open();
};

/**
 * 关闭
 */
const close = () => {
  uploadForm.jsonData = {};
  setLoading(false)
  SimpleUploadRefs.value.clearFiles()
  uploadDGRefs.value.close();
};

defineExpose({
  load,
})
</script>

<style lang="scss" scoped>
.primary {
  color: #00afa5;
}

.download {
  cursor: pointer;
  padding-left: 10px;
}
</style>

hooks

import { reactive, ref } from "vue";
import { useExport } from "@hooks";


/**
 * 上传文件格式控制
 * FILE_TYPE = [
    "application/pdf",
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    "application/msword",
    "application/vnd.ms-excel",
    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    "text/plain",
    "application/x-zip-compressed",
    "application/vnd.ms-powerpoint",
    "application/vnd.openxmlformats-officedocument.presentationml.presentation",
    "image/png",
    "image/jpeg"
  ]
 */

export default function useDialog() {

  const isLoading = ref(false);

  const setLoading = (state) => {
    isLoading.value = state
  }

  const uploadForm = reactive({
    jsonData: {
      name: "",
    },
  })  
  
  /**
   * 文件格式控制
   */
  const FILE_TYPE = [
    "application/vnd.ms-excel",
    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    "application/vnd.ms-powerpoint",
  ]
  /**
   * 文件下载映射
   */
  const DOWNLOAD_TYPE = {
    "AccountVo":"com.iss.edu.service.teachcoursemanage.model.vo.AccountVo"
  }
  /**
 * 下载模板
 * @param {*} params 
 */
  const downloadHandler = (config) => {
    const name = DOWNLOAD_TYPE[config?.type || 'AccountVo']
    useExport({
      name: 'downloadExcelTemplate',
      fileName: config?.tempName || "导入模板.xlsx",
      fileType: "application/vnd.ms-excel;charset=utf-8"
    }, { name })
  }

  return {
    isLoading,
    FILE_TYPE,
    setLoading,
    uploadForm,
    downloadHandler
  };
}

CRUD接口请求--models

import { Service,Request } from '@basic-library';
/**
 * @description: 服务器请求控制器
 * @param NO
 * @return {*}
 */
 class MService {
  // 文件导入
  async uploadFile(data) {
    const { success } = await Service.useMultiPart('importMember', data)
    return success
  }
}
const Controller = new MService()

export {
  Controller
}

代码说明

loading控制部分,使用了vue的指令 v-loding 和 element-loading-text 进行上传中,和文字控制

/**
 * 默认值
 */
const defaultValue = {
  title: '导入',
  loadingText: '正在导入请耐心等待...'
}

文件大小控制

/**
 * 上传文件大小限制
 */
const SimpleUploadConfig = {
  fileSize: 10
}

如果属性不存在,则不进行文件大小限制

下载模板控制,是根据外部传入配置控制,使用download对象,如果存在配置则进行功能开启

下载模板的功能,因比较复杂,系统层进行了hooks封装--useExport,可以翻阅之前的文章查看具体实现。 文件流转换为blob,然后使用a连接进行下载

下载的接口,与服务端进行讨论,不同模板的下载类型,是根据不同参数控制(后端微服务映射,比较麻烦)

文件上传,采用了需要拼装文件流-FormData,请求方式支持 Content-Type': 'multipart/form-data'

完结~~~