import React, { useImperativeHandle, useRef, useState } from 'react'; import classNames from 'classnames'; import uploadFile from './upload/request'; import attrAccept from './upload/attr-accept'; import traverseFileTree from './upload/traverse-file-tree'; import { useUploadState } from './context'; import type { BeforeUploadFileType, UploadRequestOption, } from './types/request'; import type { CnOSSUploadRef } from './types/oss-upload-ref'; import type { ICnUploadFile } from './types'; import { getFilePercentOffset } from './upload/get-file-percent-offset'; import { convertFileToObject } from './upload/convert-file-to-object'; interface Props { /** * 是否支持拖拽上传 */ dragger?: boolean; className?: string; setIsDragEnter?: React.Dispatch>; } export const CnUploader = React.forwardRef< CnOSSUploadRef, React.PropsWithChildren >((props, ref) => { const { children, dragger, setIsDragEnter, className } = props; const { onUploadInit, onUploadProcess, onUploadSuccess, onError, fileValidator, getValue, limited, props: rootProps, setUploadReq, onRemove, } = useUploadState(); const [fileInputRandomKey, setFileInputRandomKey] = useState(); const { beforeUpload, disabled, limit, multiple, accept, capture, directory, openFileDialogOnClick, headers, ossUploadConfig, fetchOSSToken, } = rootProps; // 剩余上传数量 const remainUploadNum = limit - (getValue().length || 0); const fileInputRef = useRef(null); const onClick = () => { if (!openFileDialogOnClick || limited || disabled) return; fileInputRef.current?.click(); }; /** * 在将文件处理完成后,执行文件上传逻辑 */ async function processFile( file: File, fileList: File[], ): Promise { let transformedFile: BeforeUploadFileType | undefined = file; try { fileValidator(file); if (beforeUpload) { transformedFile = await beforeUpload(file, fileList); } } catch (error) { onError(convertFileToObject(file, { status: 'error', error })); // 当文件校验失败,不处理文件,并忽略上传 transformedFile = false; } if (transformedFile === false) return null; const parsedData = (typeof transformedFile === 'object' || typeof transformedFile === 'string') && transformedFile ? transformedFile : file; let parsedFile: File; if (parsedData instanceof File) { parsedFile = parsedData; } else { parsedFile = new File([parsedData], file.name, { type: file.type }); } return parsedFile; } async function post({ percent, ..._file }: ICnUploadFile) { return new Promise((resolve, reject) => { const { host, downloadUrl, url, key, ...data } = _file.response; const file = { ..._file, url, downloadUrl, key }; const requestOption: UploadRequestOption = { action: host, data: { ...data, key, success_action_status: 200 }, file: _file.originFile, headers, withCredentials: ossUploadConfig?.withCredentials || false, onProgress: (e) => { let filePercent = 0; if (e.total && e.loaded && e.total > 0) { filePercent = (e.loaded / e.total) * 100; } if (filePercent < getFilePercentOffset(file.originFile.size)) return; onUploadProcess({ ...file, status: 'uploading', percent: filePercent, }); }, onSuccess: () => { onUploadSuccess({ ...file, status: 'done' }); resolve(undefined); }, onError: reject, }; setUploadReq(key, uploadFile(requestOption)); }); } /** * 根据组件配置可接受的文件类型过滤 */ function filterFiles( fileList: FileList, isAccepted: (file: File) => boolean = (file) => attrAccept(file, accept), ) { const files: File[] = []; for (let i = 0; i < fileList.length; i++) { if (i >= remainUploadNum) return files; const file = fileList[i]; if (isAccepted(file)) files.push(file); } return files; } async function uploadFiles(files: FileList | File[]) { setFileInputRandomKey(Date.now()); // 将 FileList 转成可被迭代的文件数组(File[]),避免外部接受到的类型产生变化 const _fileList: File[] = []; const len = files.length; for (let i = 0; i < len; i++) { _fileList.push(files[i]); } const fileList: ICnUploadFile[] = []; try { for (let i = 0; i < len; i++) { const file = await processFile(_fileList[i], _fileList); if (file) { fileList.push( convertFileToObject(file, { status: 'idle', percent: 0 }), ); } } onUploadInit(fileList); // @ts-ignore: Reason for ignoring: Serial upload is a specific requirement if (rootProps.serialUpload) { for (const file of fileList) { const ossData = await fetchOSSToken({ fileName: file.name }); if (!ossData) return; const _file = { ...file, key: ossData.key, percent: getFilePercentOffset(file.size), response: ossData, }; onUploadProcess(_file); await post(_file); } } else { await Promise.all( fileList.map(async (file) => { try { const ossData = await fetchOSSToken({ fileName: file.name }); if (!ossData) { onError({ ...file, status: 'error', error: new Error('Failed to fetch OSS token'), }); return; } if (typeof ossData !== 'object') { throw new Error('Data out of specification'); } const _file = { ...file, key: ossData.key, percent: getFilePercentOffset(file.size), response: ossData, }; onUploadProcess(_file); await post(_file); } catch (error) { // 单个文件上传失败,从当前文件列表中查找已有 key 的文件 const currentFileList = getValue(); const fileWithKey = currentFileList.find( (item) => item.originFile === file.originFile, ); onError({ ...(fileWithKey || file), status: 'error', error: error as Error, }); } }), ); } } catch (error) { // 其他未预期的错误(如 processFile 阶段) console.error('Unexpected error in uploadFiles:', error); } } useImperativeHandle(ref, () => ({ uploader: { uploadFiles, onClick, removeFile: onRemove, getFileList: getValue, }, })); const onFileDrop = (e: React.DragEvent) => { if (disabled || limited || !dragger) return; e.preventDefault(); if (directory) { traverseFileTree( Array.prototype.slice.call(e.dataTransfer.items), uploadFiles, (_file: File) => attrAccept(_file, accept), ); return; } if (multiple === false) { uploadFiles([e.dataTransfer.files[0]]); return; } uploadFiles(filterFiles(e.dataTransfer.files)); setIsDragEnter?.(false); }; // because input don't have directory/webkitdirectory type declaration const dirProps = directory ? { directory: 'directory', webkitdirectory: 'webkitdirectory' } : {}; return ( { if (limited || disabled) return; setIsDragEnter?.(true); }} onDragLeave={() => setIsDragEnter?.(false)} onDragEnd={() => setIsDragEnter?.(false)} onDragOver={(e) => { if (disabled || limited || !dragger) return; e.preventDefault(); }} onDrop={onFileDrop} onClick={onClick} onKeyDown={(e) => e.key === 'Enter' && onClick()} > e.stopPropagation()} // 避免触发2次选文件 onChange={(e) => { e.stopPropagation(); const { target: { files }, } = e; if (!files) return; uploadFiles( filterFiles( files, (file) => !directory || attrAccept(file, accept), ), ); }} /> {children} ); }); CnUploader.displayName = 'CnUploader';