本人23应届菜鸟,2月份入职一直工作到现在,工作时间也半年了,记录一下成长过程。

由于公司前端使用的是Vue3+Ts+AntdVue封装的Vben-Admin框架,只需看源码,就可分析处理整个的Form表单引用流程,封装只需根据业务需求实现就好。下面就由这两部分组成本文章。效果图:

组件整体结构

  • 一个Upload组件右边一个span标签,下面是for循环渲染出来的列表
  • 由a标签和三个按钮组成
  • a标签显示文件名支持点击预览,也可以点击按钮预览
  • 预览是打开一个新的Model(FilePreviewModal)目前仅支持图片、音视频
  • 实现思路是下载文件到浏览器创建一个Blob对象,src指向这个对象的内存地址
  • 上传和下载以及删除就是发送请求调用oss的Api

组件参数说明(对应组件属性)

  • 文件长度(fileLength):如只需要上传一个文件,通过参数设置上传文件会替换当前文件,如果是多个文件则会替换最早上传的文件
  • 隐藏关键操作按钮(showOperate):true/false
  • 提示信息(uploadPrompt):字符串
  • 文件保存格式(value):格式为:(oss存储中的名称:文件实际名称)/分割,代表多个
  • 上传前调用方法(beforeUpload):用于校验文件,上传之前调用
  • 上传下载删除接口需要自行修改,不支持传参形式,文章最后放出代码
  • 其他属性也支持Antd的Upload组件

由于本人对前端技术了解有限,目前只可以模仿前辈代码,才可以把需求实现的差不多,而且其中很多底层意义不了解,这也是我写这篇文章的原因,来加深学习。

技术细节

一、框架中的引用

在VbenAdmin中Form封装成了FormSchema[]数组形式的,代码如下

    {
      field: 'field2',
      component: 'Input',
      label: '字段2',
      colProps: { span: 8 },
    },
    {
      field: 'field3',
      component: 'Select',
      label: '字段3',
      colProps: { span: 8 },
    },

从代码结构中可以看出Input就是一个输入框,Select是一个下拉框。而且框架文档中也给出了自定义组件的方法,具体请看官方文档https://doc.vvbin.cn/components/form.html根据文档中的方法修改后即可直接使用组件名称引入

// 在 src/components/Form/src/componentMap.ts 内,添加需要的组件,并在上方 ComponentType 添加相应的类型 key
componentMap.set('componentName', 组件);

// ComponentType
export type ComponentType = xxxx | 'componentName';

这一步最简单,但是实现需要先写出来自定义组件,最终自定义组件代码如下:

 {
    field: 'filed',
    label: '附件',
    component: 'UploadFile',
    componentProps: () => {
      return {
        uploadPrompt:
          '单个文件不超过20M,文件必须为.mp3或.wav格式文件,且文件命名为:XXXXXXXX',
        fileLength: 5,
        multiple: true,
        beforeUpload: (file) => beforeUploadMedia(file),
      };
    },
  }

二、封装Upload组件

首先业务需求是支持批量上传、支持文件预览、支持文件下载、支持文件长度限制、显示提示信息,根据以上Upload组件Html代码如下:

<template>
  <Upload
    v-bind="getBindValue"
    v-if="showOperate"
    name="file"
    :showUploadList="false"
    :beforeUpload="(file, fileList) => beforeUpload(file, fileList)"
    :customRequest="(file) => handleUpload(file)"
    v-model:value="state"
    @change="handleChange"
  >
    <div>
      <a-button type="primary">
        <UploadOutlined />
        选择文件
      </a-button>
    </div>
  </Upload>
  <span class="tip" v-if="uploadPrompt">{{ uploadPrompt }}</span>
  <div v-for="(item, index) in fileList" :key="index">
    <div class="button-container">
      <div :class="{ 'disabled-div': disabled }">
        <a @click="handlePreview(item)" :class="{ 'disabled-click': disabled }">
          {{ item.name }}
        </a>
      </div>
      <a-button type="link" @click="handlePreview(item)" :disabled="disabled">预览</a-button>
      <a-button type="link" @click="downLoadFile(item)">下载</a-button>
      <a-button type="link" @click="removeFile(index)" v-if="showOperate">删除</a-button>
    </div>
  </div>
  <FilePreviewModal @register="registerFilePreviewModal" />
</template>
<style lang="less" scoped>
  .disabled-click {
    pointer-events: none;
  }

  .disabled-div {
    cursor: not-allowed;
  }

  .tip {
    display: inline;
    margin-left: 10px;
    color: #fc6c54;
  }

  .button-container {
    display: flex;
    align-items: center;
    justify-content: space-around;
    width: 100%;
    border-bottom: 1px solid #d9ebf8;

    div {
      padding: 0 10px;
      border-radius: 5px;
      color: #0464cc;
    }

    & > div:nth-child(1) {
      flex: 1;
      margin-left: 0;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
  }
</style>

Upload的Script代码如下:


<script lang="ts">
  import { Upload, message } from 'ant-design-vue';
  import { defineComponent, ref, computed, unref, watchEffect, PropType } from 'vue';
  import { UploadOutlined } from '@ant-design/icons-vue';
  import { useModal } from '/@/components/Modal';
  import FilePreviewModal from './components/FilePreviewModal.vue';
  import { uploadFile, downloadFiles, deleteFile, downloadFilesThen } from './upload.api';
  import { useAttrs } from '@vben/hooks';
  import { FileItem, getObjName, setFileType } from '@/utils/upload';
  import { useMessage } from '/@/hooks/web/useMessage';
  import { useRuleFormItem } from '/@/hooks/component/useFormItem';
  import { useI18n } from '/@/hooks/web/useI18n';

  export default defineComponent({
    name: 'UploadFile',
    components: { FilePreviewModal, UploadOutlined, Upload },
    props: {
      showOperate: { type: Boolean, default: true }, // 是否显示上传和删除按钮
      fileLength: { type: Number, default: 20 }, // 最大上传文件个数
      value: { type: String, default: '' }, // 绑定的文件字符串:以/分割
      uploadPrompt: { type: String }, // 上传提示
      beforeUpload: { type: Function as PropType<(...args) => any>, default: null }, // 上传前调用方法
    },
    emits: ['change'],
    setup(props, { emit }) {
      const attrs = useAttrs();
      const { t } = useI18n();
      const getBindValue = computed(() => ({ ...unref(attrs), ...props }));
      const [registerFilePreviewModal, { openModal: openFilePreviewModal }] = useModal();
      const { createConfirm } = useMessage();

      const fileList = ref<FileItem[]>([]);

      const disabled = ref(false);

      /**
       * 我的理解是:校验表单项以及在FormSchema中直接使用需要添加(必要)
       * 第一个参数为组件暴露的参数
       * 第二个参数为组件和Form项绑定的值
       * 第三个参数为什么时候校验
       */
      const [state] = useRuleFormItem(props, 'value', 'change');

      // 监听属性值改变,只要改变就执行以下代码
      watchEffect(() => {
        const { value } = props;
        if (value) {
          const list = value.split('/');
          fileList.value = [];
          list.map((item) => {
            if (item) {
              fileList.value.push({ name: item.split(':')[1], fileName: item });
            }
          });
        } else {
          fileList.value = [];
        }
      });

      // 更新value的值
      const updatePropByFileValue = async () => {
        let file = '';
        fileList.value.map((item) => {
          if (item.fileName != 'undefined') file += item.fileName + '/';
        });
        emit('change', file.substring(0, file.length - 1));
      };

      // 判断文件长度和属性长度
      const vaildFileListLength = async () => {
        const length = fileList.value.length;
        if (length > props.fileLength) {
          for (let i = 0; i <= length - props.fileLength; i++) {
            deleteFile(getObjName(fileList.value[0].fileName));
            fileList.value.splice(0, 1);
          }
        } else if (length === props.fileLength) {
          deleteFile(getObjName(fileList.value[0].fileName));
          fileList.value.splice(0, 1);
        }
        await updatePropByFileValue();
      };

      const handlePreview = async (fileItem) => {
        disabled.value = true;
        try {
          fileItem.type = setFileType(fileItem.name);
          if (!['audio', 'img', 'video'].includes(fileItem.type)) {
            message.info('当前文件暂不支持预览,请下载后查看');
            return;
          }
          
          /**
           * const downloadFilesThen = async (fileName: string) => {
           * const data = await defHttp.request(
           *      { url: Api.downloadFile + '/' + getObjName(fileName), method: 'GET', responseType: 'blob' },
           *      { isTransformResponse: false },
           *      ).catch((error) => {
           *        console.log(error);
           *      });
           * const blobURL = window.URL.createObjectURL(new Blob([data]));
           * return blobURL;
           * };
           */
          const res = await downloadFilesThen(fileItem.fileName);

          fileItem.url = res;
          openFilePreviewModal(true, fileItem);
        } finally {
          disabled.value = false;
        }
      };

      const removeFile = (index) => {
        createConfirm({
          title: '是否确认删除文件?',
          content: '删除后请及时保存或提交,否则文件不可查看。',
          onOk: () => {
            // 自定义删除接口 如:const deleteFile = (objName: string) => defHttp.get({ url: '/file/delete' + '/' + objName });
            deleteFile(getObjName(fileList.value[index].fileName));
            fileList.value.splice(index, 1);
            updatePropByFileValue();
            message.success('删除成功');
          },
        });
      };

      const downLoadFile = (item) => {
        /**
         * 自定义下载接口
         * const downloadFiles = (params?: Recordable) => {
         *        downloadFile({
         *          url: Api.downloadFile + '/' + getObjName(params?.fileName),
         *          fileName: getFileName(params?.name),
         *          fileSuffix: '.' + getFileSuffix(params?.fileName),
         *         });
         *        };
         */
        downloadFiles({ name: item.name, fileName: item.fileName });
      };

      const handleUpload = async (files) => {
        if (files) {
          files.filename = files.file.name;
           // 自定义上传接口 如:const uploadFile = (params: any) => defHttp.uploadFile({ url: '/file/upload' }, params);
          const res = await uploadFile(files);
          await vaildFileListLength();
          let { value } = props;
          if (value) {
            value += '/' + res;
          } else {
            value = res;
          }
          emit('change', value);
          message.success('上传成功');
        }
      };

      // 组件更改时执行
      function handleChange() {
        let { value } = props;
        emit('change', value);
      }

      return {
        t,
        attrs,
        state,
        handleChange,
        registerFilePreviewModal,
        disabled,
        handlePreview,
        fileList,
        downLoadFile,
        getBindValue,
        removeFile,
        handleUpload,
      };
    },
  });
</script>

文件预览FilePreviewModal组件的Html代码如下:

<template>
  <BasicModal
    v-bind="$attrs"
    :title="title"
    :width="1200"
    :minHeight="height"
    :maskClosable="false"
    @register="registerModal"
    :footer="null"
    :centered="true"
    :destroyOnClose="true"
  >
    <div class="file-container">
      <Image v-if="fileObj.type === 'img'" :src="fileObj.url" />
      <audio ref="audio" v-if="fileObj.type === 'audio'" controls :src="fileObj.url"></audio>
      <video ref="video" v-if="fileObj.type === 'video'" controls :src="fileObj.url"></video>
    </div>
  </BasicModal>
</template>
<style lang="less" scoped>
  .file-container {
    align-items: center;
    font-size: 0;
    line-height: 1;

    audio {
      width: 100%;
    }

    video {
      width: 100%;
    }

    img {
      width: 100%;
    }
  }
</style>

FilePreviewModal Script代码如下:


<script lang="ts" setup>
  import { ref } from 'vue';
  import { Image } from 'ant-design-vue';
  import { BasicModal, useModalInner } from '/@/components/Modal';

  const title = ref('');
  const height = ref(50);
  const fileObj = {
    type: '',
    url: '',
    fileName: '',
  };

  const [registerModal] = useModalInner(async (data) => {
    title.value = '文件预览:' + data.name;
    fileObj.type = data.type;
    fileObj.fileName = data.fileName;
    fileObj.url = data.url;
    if (fileObj.type === 'img') {
      height.value = 700;
    }
    if (fileObj.type === 'audio') {
      height.value = 20;
    }
    if (fileObj.type === 'video') {
      height.value = 20;
    }
  });
</script>

小结

第一次封装前端组件遇到很多不懂的地方,但也是提升自己的一种方法。

本片文章到此就结束啦,第一次写请多多包涵呀,不清楚的请评论或者私信哦。

下一篇准备前后端集成SocketIO,感兴趣的一键三连哦!

公众号同时发布,请搜索:PCode进阶。

GitHub 加速计划 / vu / vue
207.54 K
33.66 K
下载
vuejs/vue: 是一个用于构建用户界面的 JavaScript 框架,具有简洁的语法和丰富的组件库,可以用于开发单页面应用程序和多页面应用程序。
最近提交(Master分支:2 个月前 )
73486cb5 * chore: fix link broken Signed-off-by: snoppy <michaleli@foxmail.com> * Update packages/template-compiler/README.md [skip ci] --------- Signed-off-by: snoppy <michaleli@foxmail.com> Co-authored-by: Eduardo San Martin Morote <posva@users.noreply.github.com> 4 个月前
e428d891 Updated Browser Compatibility reference. The previous currently returns HTTP 404. 5 个月前
Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐