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