antDesignPro项目:自定义上传组件(分片传、取消上传等)

第一步:安装库(取消请求用)

npm install --save yet-another-abortcontroller-polyfill

第二步:写上传组件 & 使用
上传组件:组件中分片传,仅最简单实现(分片传),秒传&断续上传暂未实现。
/*
 * @Description: 上传&查看文件组件(手动上传,大文件分片上传,取消上传, 点击文件名可在线预览)
*/

import { ProFormUploadButton } from '@ant-design/pro-components';
import { App } from 'antd';
import type { UploadFile } from 'antd/es/upload/interface';
import type { UploadChangeParam, UploadProps } from 'antd/es/upload';
import { getToken } from '@/utils/getToken';
import PreviewImg from '@/components/PreviewImg';
import { useState, useRef, forwardRef, useImperativeHandle } from 'react';
import { ALLOWED_FILE_TYPE } from '@/utils/const';
import { isPicture } from '@/utils/index';
import {
  createMultipartUploadUsingPOST,
  completeMultipartUploadUsingPOST,
} from '@/services/xx/FileInfoController';
import {
  partFileUploadUsingPOST,
  uploadFileUsingPOST,
} from '@/services/file-related/FileInfoController';
import 'yet-another-abortcontroller-polyfill';
// const ACTION_URL = process.env.UPLOAD_FILE_ACTION_URL; // 文件上传的域名
const UPLOAD_FILE_API_PARAM_NAME = 'files'; // 上传文件接口:固定的传参
const SINGLE_CHUNK_SIZE = 5 * 1024 * 1024; // 单个分片尺寸,5兆

interface CustomUploadFileProps {
  label: string; // 表单项:左侧标题
  max?: number; // 最大上传个数
  required?: boolean; // 是否必填
  fieldName?: string; // 该表单项:绑定的字段名
  extra?: string; // 控件下方的灰色提示文本
  accept?: string; // 可上传的文件类型
  multiple?: boolean; // 是否可以多选
  isView?: boolean; // 是否是查看
}

const CustomUploadFile = forwardRef<any, CustomUploadFileProps>((props, ref) => {
  const {
    label,
    max,
    required = true,
    fieldName = 'files',
    extra = '支持.png、.pdf、.jpeg、.jpg、.psd等文件格式; 上传后,可点击中间区域(文件名),预览文件。',
    accept = ALLOWED_FILE_TYPE,
    multiple = true,
    isView = false,
  } = props;
  const { message } = App.useApp();
  const [showPreviewImg, setShowPreviewImg] = useState(false);
  const [previewImgSrc, setPreviewImgSrc] = useState('');
  const reqsMap = useRef<Record<string, AbortController[]>>({});
  const directOpenFileTypesList = ['png', 'jpg', 'jpeg', 'pdf']; // * txt文件直接window.open会出现:中文乱码的问题。
  const officeFileTypesList = ['doc', 'docx', 'xlsx', 'xls', 'txt'];
  // const unablePreviewTypesList = ['psd']; // 不支持预览的文件

/**
  * @params fileUid { String } 文件的uid值 (文件名可能重名,但uid值唯一)
*/
const cancelUpload = (fileUid?: string) => {
    if (fileUid) {
      if (Array.isArray(reqsMap.current[fileUid])) {
        reqsMap.current[fileUid].forEach((controller: AbortController) => {
          controller.abort();
        });
      }
      delete reqsMap.current[fileUid];
    } else {
      //  Object.keys: 只处理对象自身的属性,过滤掉对象原型链上的属性
      Object.keys(reqsMap.current).forEach((uid) => {
        const valueList = reqsMap.current[uid];
        if (Array.isArray(valueList)) {
          valueList.forEach((controller: AbortController) => {
            controller.abort();
          });
        }
        delete reqsMap.current[uid];
      });
    }
  };

  // 将子组件的方法 暴露给父组件
  useImperativeHandle(ref, () => ({
    cancelUpload,
  }));

  /**
   * @method 文件变化触发(上传、删除文件等,上传一个文件过程中,会触发多次该函数)
   * @note 该钩子函数会多次触发(status状态从uploading => done, 类似上传进度函数,会多次触发,直到上传完毕)
   */
  const onFileChange = (info: UploadChangeParam<UploadFile>) => {
    const { file, fileList }: { file: UploadFile; fileList: UploadFile[] } = info;
    switch (file?.status) {
      case 'error': {
        // file.error是onError传递过来的第一个参数的信息
        if (file.error !== 'canceled') {
          const errMsg = file?.response || file?.error || '上传失败了~';
          message.warning(`${file?.name}文件上传失败:${errMsg}`);
        }
        fileList.forEach((item: UploadFile, index: number) => {
          if (item?.uid === file?.uid) {
            fileList.splice(index, 1); // 上传失败的文件,需要自动删除,不显示在页面上。
          }
        });
        break;
      }
      case 'done':
        delete reqsMap.current[file?.uid];
        message.success(`${file?.name}文件已上传`);
        break;
      case 'removed':
        cancelUpload(file?.uid);
        message.success(`${file?.name}文件已删除`);
        break;
    }
  };

  const handlePreview = (file: UploadFile) => {
    const url = file?.url || file?.response?.urlPath; // 之前文件(编辑页):file?.url回显,新增的文件上传:file.response.urlPath
    if (isPicture(file?.name)) {
      setPreviewImgSrc(url);
      setShowPreviewImg(true);
    } else {
      const fileType = file?.name?.split('.')?.pop() || '';
      if (directOpenFileTypesList.includes(fileType)) {
        window.open(url);
      } else if (officeFileTypesList.includes(fileType)) {
        window.open(`https://wps-view.zhihuipk.com/?src=${url}`);
      } else {
        message.warning('当前文件类型格式,暂不支持预览~');
      }
    }
  };

  const customUpload = async (config: any) => {
    let succChunkNum = 0;
    const { file, onSuccess, onError, onProgress } = config;
    const { uid, name, size } = config.file;
    reqsMap.current[uid] = [];
    try {
      if (size <= SINGLE_CHUNK_SIZE) {
        const formData = new FormData();
        formData.append('files', file);
        const res = await uploadFileUsingPOST(formData);
        if (res.success) {
          onSuccess(res?.data?.[0], file);
        }
        return;
      }
      const totalChunkCount = Math.ceil(size / SINGLE_CHUNK_SIZE);
      // @ts-ignore
      const res = await createMultipartUploadUsingPOST({
        fileName: name,
        chunkSize: totalChunkCount,
      });
      const requestList = res.data.chunks;
      for (const item of requestList) {
        const controller = new AbortController(); // create a controller
        reqsMap.current[uid].push(controller);
        // 分片开始位置
        const start = (item.partNumber - 1) * SINGLE_CHUNK_SIZE;
        // 分片结束位置
        const end = Math.min(size, start + SINGLE_CHUNK_SIZE);
        // 取文件指定范围内的byte,从而得到分片数据
        const _chunkFile = config.file.slice(start, end);
        const formData = new FormData();
        formData.append('partFile', _chunkFile);
        formData.append('partUrl', item.uploadUrl);
        // console.log('开始上传第' + item.partNumber + '个分片');
        partFileUploadUsingPOST(formData, { signal: controller.signal, skipErrorHandler: true })
          .then(
            // eslint-disable-next-line @typescript-eslint/no-loop-func
            async (uploadRes) => {
              if (uploadRes.success) {
                succChunkNum++;
                const percent = Math.round(((SINGLE_CHUNK_SIZE * succChunkNum) / size) * 100);
                onProgress({ percent });
                if (succChunkNum === totalChunkCount) {
                  const controller = new AbortController();
                  reqsMap.current[uid].push(controller);
                  const mergeRes = await completeMultipartUploadUsingPOST(
                    {
                      fileName: res?.data?.newFileName,
                      chunkSize: totalChunkCount,
                      uploadId: res?.data?.uploadId,
                    },
                    { signal: controller.signal, skipErrorHandler: true },
                  );
                  if (mergeRes.success) {
                    onSuccess(mergeRes?.data, file);
                  }
                }
              }
            },
          )
          .catch((e: any) => {
            if (e?.message !== 'canceled') {
              message.error(e?.message || '分片上传失败');
              onError(e?.message);
            }
            // onError(e?.message); // 会自动调用onFileChange函数,取消3次请求,即调用3次onFileChange,暂时不用前端提示
          });
      }
    } catch (e: any) {
      onError(e?.message);
    }
  };

  const uploadProps: UploadProps = {
    name: UPLOAD_FILE_API_PARAM_NAME,
    listType: 'picture',
    onChange: onFileChange,
    onPreview: handlePreview,
    showUploadList: { showPreviewIcon: false, showRemoveIcon: !isView },
    multiple,
    accept,
    headers: {
      Authorization: getToken() || '',
    },
    customRequest: customUpload, // 手动上传
  };

  return (
    <>
      <ProFormUploadButton
        name={fieldName} // 该组件会自动将name对应字段的值,赋初始值。
        label={isView ? '' : label}
        max={isView ? 0 : max}
        disabled={isView}
        rules={[
          {
            required,
            message: '请上传文件',
          },
        ]}
        fieldProps={uploadProps}
        // action={ACTION_URL} // 自动上传
        extra={isView ? '可点击中间区域(文件名),预览文件' : extra}
      />
      {showPreviewImg && (
        <PreviewImg visible={showPreviewImg} setVisible={setShowPreviewImg} src={previewImgSrc} />
      )}
    </>
  );
});
export default CustomUploadFile;
分片上传接口:
import { request } from 'umi';

/** 分片上传文件 POST /tms/file/shards/partFileUpload */
export async function partFileUploadUsingPOST(
  // 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
  body: {},
  options?: { [key: string]: any },
) {
  return request<API.Result>(`${process.env.API_HOST_WAYBILL}/xx/file/shards/partFileUpload`, {
    method: 'POST',
    data: body,
    requestType: 'form', // 默认是json类型,需修改为form
    ...(options || {}), // options中可配置signal、skipErrorHandler字段,signal: 用于取消请求,skipErrorHandler: umi内置,设置为true,将跳过默认的umi全局报错处理逻辑,用于自定义出错逻辑处理
  });
}

父组件中使用:
import { useRef } from 'react';
const uploadRef = useRef<any>(null);
const handleCancel = () => {
  uploadRef.current.cancelUpload(); // 取消请求
};
<CustomUploadFile ref={uploadRef} label="文件" required={isRequired} />
预览图片组件:
/*
 * @Description: 预览图片弹框
*/

import { Image } from 'antd';
import type { Dispatch, SetStateAction } from 'react';
import type { ImgItemType } from '@/types/common';
import { useState } from 'react';

type PreviewImgProps = {
  visible: boolean;
  setVisible: Dispatch<SetStateAction<boolean>>;
  src?: string; // 单个图片预览
  imgUrlList?: { url: string; name: string }[]; // 多个图片预览
  current?: number; // 从0开始依次递增,第1张图片索引为0
};

const PreviewImg = (props: PreviewImgProps) => {
  const { visible, setVisible, src, imgUrlList = null, current } = props;
  const [scaleStep] = useState(0.5);

  const ImgCom = () => {
    if (imgUrlList) {
      return (
        <Image.PreviewGroup
          preview={{
            visible,
            onVisibleChange: (vis) => setVisible(vis),
            scaleStep,
            current,
          }}
        >
          {imgUrlList.map((item: ImgItemType) => {
            // 注意:Image组件:务必要添加样式display:none,否则图片会在页面上最下方也展示。
            return <Image style={{ display: 'none' }} width={0} key={item.url} src={item.url} />;
          })}
        </Image.PreviewGroup>
      );
    } else {
      return (
        <Image
          width={200}
          style={{ display: 'none' }}
          src={src}
          preview={{
            visible,
            scaleStep,
            src,
            onVisibleChange: (value) => {
              setVisible(value);
            },
          }}
        />
      );
    }
  };
  return ImgCom();
};
export default PreviewImg;
分片上传完整逻辑:

涉及后台3个接口:查询文件、上传(小文件、分片文件)、合并分片。

  1. 获取上传到的file对象,通过spark-md5编码第三方库,计算出该文件的唯一md5编码值。
  2. 请求后台接口(查询功能),通过md5值查询该文件是否之前已经上传过 -》(1)之前已上传且上传完,后台直接返回文件的URL。-- 秒传(2)之前已上传但只上传部分片,后台返回还未上传片的序号集合,前端遍历集合去上传指定片。-- 断点续传。(3)从未上传过:若文件size小于单个分片文件大小,直接走上传接口,不用分片,否则:前端上传片-》片都上传完后,再调用后台的合并片的接口,后台返回文件URL。

 参考网址:

  • 5
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值