input + React自定义上传组件【可自定义拓展】

input + React自定义上传组件【可自定义拓展】

  • 组件代码
import React, { useState, useRef, useCallback, forwardRef, useImperativeHandle, useEffect } from 'react';
import { UploadOutlined, FileOutlined, DeleteOutlined, LoadingOutlined, EyeOutlined, DownloadOutlined } from '@ant-design/icons';
import { Button, message, Spin } from 'antd';
import './index.css';
import { env, i18nValue } from '@/utils';
import { request } from 'ice';
import { CustomUploadRef, CustomUploadProps, CustomUploadFile } from './types/index';
import { RcFile } from 'antd/es/upload';
import { showErrorNotification } from '@/utils/util';


const EmptyObject = {};
const EmptyArray = [];
const DEFAULT_FILE_URL = '/file/fileupload/upload';

/**
 * 自定义拖拽上传组件
 * 支持拖拽上传和点击上传,保持与 Ant Design Upload 一致的 API
 * 包括操作:
 * fileList 文件列表
 * multiple 是否一次性上传多个
 * accept 支持的文件类型
 * disable 是否禁用上传
 * maxCount 允许用户上传的最大数量
 * isDragger 是否可拖拽上传
 * showUploadList 展示文件列表
 * fileListRender 自定义渲染文件列表
 * beforeUpload 上传前的校验方法
 * customRequest 自定义请求
 * onChange 文件变化回调事件
 * onRemove 删除事件
 * dragContent 拖拽区域文字
 */
const CustomizeUpload = forwardRef<CustomUploadRef, CustomUploadProps>((props, ref) => {
  const {
    multiple = false,
    defaultFileList = EmptyArray,
    accept,
    disabled = false,
    maxCount,
    maxSize = 100,
    isDragger = false,
    showUploadList = true,
    fileListRender,
    beforeUpload,
    customRequest,
    uploadBtnProps,
    onChange,
    onRemove,
    dragContent,
  } = props;
  /** 文件列表 */
  const [fileList, setFileList] = useState<CustomUploadFile[]>([]);
  const [isDragOver, setIsDragOver] = useState(false);
  const fileInputRef = useRef<HTMLInputElement>(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setFileList(defaultFileList)
  }, [defaultFileList]);

  // 验证文件大小
  const validateFileSize = useCallback((file: File): boolean => {
    const fileSizeMB = file.size / 1024 / 1024;
    if (fileSizeMB > maxSize) {
      message.error(`文件大小不能超过 ${maxSize}MB`);
      return false;
    }
    return true;
  }, [maxSize]);

  // 验证文件数量
  const validateFileCount = useCallback((newFiles: File[]): boolean => {
    if (!maxCount) return true;
    const currentCount = fileList.length;
    const newCount = newFiles.length;
    if (currentCount + newCount > maxCount) {
      message.error(`最多只能上传 ${maxCount} 个文件`);
      return false;
    }
    return true;
  }, [fileList.length, maxCount]);

  const handleUploadProgress = useCallback((fileItem: CustomUploadFile, percent: number) => {
    setFileList(prev => prev.map(item =>
    (item.fileId === fileItem.fileId
      ? { ...item, percent, status: 'uploading' }
      : item),
    ));
  }, []);

  // 处理上传成功
  const handleUploadSuccess = useCallback((fileItem: CustomUploadFile, response: any) => {
    const updatedFile: CustomUploadFile = {
      ...fileItem,
      status: 'done',
      percent: 100,
      ...response,
    };
    console.log(fileList, '====================');

    // setFileList(prev => prev.map(item =>
    //   (item.fileId === fileItem.fileId ? updatedFile : item),
    // ));

    onChange?.({
      type:'success',
      file: updatedFile,
      fileList: fileList.map(item =>
        (item.fileId === fileItem.fileId ? updatedFile : item),
      ),
    });
  }, [fileList, onChange]);

  // 处理上传错误
  const handleUploadError = useCallback((fileItem: CustomUploadFile, error: Error, updatedFileList: CustomUploadFile[]) => {
    const updatedFile: CustomUploadFile = {
      ...fileItem,
      status: 'error',
      error,
    };
    showErrorNotification(error ?? '请求失败请联系管理员', '上传失败')

    onChange?.({
      type:'error',
      file: updatedFile,
      fileList: updatedFileList.filter(item => item?.fileId !== fileItem.fileId),
    });
  }, [fileList, onChange]);

  // 移除文件
  const handleRemoveFile = useCallback(async (fileItem: CustomUploadFile) => {
    // 执行 onRemove 钩子
    if (onRemove) {
      const result = await onRemove(fileItem);
      if (result === false) return; // 阻止移除
    }

    const updatedFileList = fileList.filter(item => item.fileId !== fileItem.fileId);
    setFileList(updatedFileList);

    onChange?.({
      type:'remove',
      file: { ...fileItem, status: 'removed' },
      fileList: updatedFileList,
    });
  }, [fileList, onRemove, onChange]);


  // 模拟上传过程
  const simulateUpload = useCallback(async (fileItem: CustomUploadFile, updatedFileList: CustomUploadFile[]) => {
    let progress = 0;
    const formData = new FormData();
    formData.append('file', fileItem.originFileObj as Blob);

    setLoading(true);
    try {
      const res = await request({
        url: DEFAULT_FILE_URL,
        method: 'post',
        data: formData,
        baseURL: env.apiFile,
        onUploadProgress: (progressEvent) => {
          if (progressEvent.total) {
            progress = Math.round((progressEvent.loaded / progressEvent.total) * 100);
            handleUploadProgress(fileItem, progress);
          }
        },
        headers: { menuCode: 'user', userOperation: 'UPLOAD_FILE', oprDocNumber: '888' },
      });

      if (res?.code === 'R000') {
        const { fileName, fileId, url } = res.data;
        /** 需要进行转换则增加转换逻辑 */
        // let fileConfig = { };
        handleUploadSuccess && handleUploadSuccess(res.data, {});
        message.success(i18nValue('上传成功'));
      } else if (res?.code) {
        handleUploadError(fileItem, res?.message, updatedFileList);
        // showErrorNotification(i18nValue(res?.message) || i18nValue('上传失败'), i18nValue('提示'));
      }
    } catch (error) {
      setLoading(false);
    } finally {
      setLoading(false);
    }
  }, [handleUploadError, handleUploadProgress, handleUploadSuccess]);

  /** 触发文件选择 */
  const triggerFileSelect = useCallback(() => {
    if (disabled || !fileInputRef.current) return;
    fileInputRef.current.click();
  }, [disabled]);

  // 上传单个文件
  const uploadFile = useCallback((fileItem: CustomUploadFile, updatedFileList: CustomUploadFile[]) => {
    const file = fileItem.originFileObj;
    if (!file) return;

    // 如果提供了自定义上传请求
    if (customRequest) {
      customRequest({
        file,
        onSuccess: (response, uploadedFile) => {
          handleUploadSuccess(uploadedFile, response);
        },
        onError: (error) => {
          handleUploadError(fileItem, error, updatedFileList);
        },
        onProgress: (percent, uploadingFile) => {
          handleUploadProgress(uploadingFile, percent);
        },
      });
    } else {
      // 默认上传逻辑(模拟)
      simulateUpload(fileItem, updatedFileList);
    }
  }, [customRequest, handleUploadError, handleUploadProgress, handleUploadSuccess, simulateUpload]);

  // 处理文件选择
  const handleFileSelect = useCallback(async (files: FileList | File[]) => {
    if (disabled) return;

    const fileArray = Array.from(files);

    // 验证文件数量
    if (!validateFileCount(fileArray)) return;

    const validFiles: File[] = [];

    // 验证每个文件
    for (const file of fileArray) {
      // 验证文件大小
      if (!validateFileSize(file)) continue;

      // 执行 beforeUpload 钩子
      let shouldUpload = true;
      if (beforeUpload) {
        try {
          shouldUpload = await beforeUpload(file, fileArray);
        } catch (error) {
          console.error('beforeUpload error:', error);
          shouldUpload = false;
        }
      }

      if (shouldUpload !== false) {
        validFiles.push(file);
      }
    }

    if (validFiles.length === 0) return;

    // 创建文件列表项
    const newFileListItems: CustomUploadFile[] = validFiles.map((file: RcFile, index) => ({
      fileId: `${Date.now()}-${index}`,
      fileName: file.name,
      size: file.size,
      type: file.type,
      status: 'uploading',
      percent: 0,
      originFileObj: file,
    }));

    // 更新文件列表
    const updatedFileList = [...fileList, ...newFileListItems];
    setFileList(updatedFileList);

    // 开始上传
    newFileListItems.forEach(fileItem => {
      uploadFile(fileItem, updatedFileList);
    });
  }, [disabled, validateFileCount, fileList, validateFileSize, beforeUpload, onChange, uploadFile]);

  /**
   * @description 处理文件输入变化
   * @param {React.ChangeEvent<HTMLInputElement>} eFile
   * @returns {Function} null
   * @version 1.0.0
   * @author 2209150234
   * @date 2025-09-04 20:15:34
   */
  const handleFileInputChange = useCallback((eFile: React.ChangeEvent<HTMLInputElement>) => {
    const { files } = eFile.target;
    if (files && files.length > 0) {
      handleFileSelect(files);
      // 重置 input 值以便再次选择相同文件
      if (fileInputRef.current) {
        fileInputRef.current.value = '';
      }
    }
  }, [handleFileSelect]);

  /** 拖拽事件处理 */
  const handleDragEnter = useCallback((eFile: React.DragEvent) => {
    eFile.preventDefault();
    if (!disabled) {
      setIsDragOver(true);
    }
  }, [disabled]);

  /** 拖拽离开事件 */
  const handleDragLeave = useCallback((eFile: React.DragEvent) => {
    eFile.preventDefault();
    setIsDragOver(false);
  }, []);

  /** 拖拽结束 */
  const handleDragOver = useCallback((eFile: React.DragEvent) => {
    eFile.preventDefault();
  }, []);

  const handleDrop = useCallback((eFile: React.DragEvent) => {
    eFile.preventDefault();
    setIsDragOver(false);

    if (disabled) return;

    const { files } = eFile.dataTransfer;
    if (files && files.length > 0) {
      handleFileSelect(files);
    }
  }, [disabled, handleFileSelect]);

  // 暴露方法给父组件
  useImperativeHandle(ref, () => ({
    upload: (file: File) => {
      handleFileSelect([file]);
    },
    clear: () => {
      setFileList([]);
    },
    getFileList: () => [...fileList],
  }));

  /** 默认拖拽内容区域 */
  const defaultDragContent = (
    <div className="custom-upload-drag-content">

      <Button icon={
        <UploadOutlined
          style={{ fontSize: '18px', color: 'var(--primary-color)' }}
        />}
        type='text'
      >{i18nValue('点击选择文件或将文件拖拽到此处')}</Button>
      <p className="ant-upload-hint">{i18nValue('支持单个或批量上传')}</p>
    </div>
  );


  /**
   * @fileoverview 默认文件列表渲染
   * @author 2209150234
   * @date 2025-09-04
   * @version 1.0.0
   */
  const renderDefaultFileList = () => {
    if (!showUploadList || fileList.length === 0) return null;

    return (
      <div className="custom-upload-list">
        {fileList.map(file => (
          <div key={file.fileName} className="custom-upload-list-item">
            <FileOutlined style={{ marginRight: 8, color: 'var(--primary-color)' }} />
            <span className="custom-upload-file-name" title={file.fileName}>
              {file.fileName}
            </span>
            {/* { ['uploading','error].includes(file.status) && (
              <span className="custom-upload-progress">
                {file.percent!== 100 && <LoadingOutlined style={{ marginRight: 8 }} />}
                {file.percent && file.percent!== 100 ? `${(file.percent)}%` : ''}
              </span>
            )} */}
            <EyeOutlined
              size={16}
              title={i18nValue('预览')}
              style={{ marginLeft: 8, cursor: 'pointer', color: 'var(--primary-color)' }}
            />
            <DownloadOutlined
              size={16}
              title={i18nValue('下载')}
              style={{ marginLeft: 8, cursor: 'pointer', color: 'var(--primary-color)' }}
            />
            <DeleteOutlined
              className="custom-upload-delete"
              size={16}
              title={i18nValue('删除')}
              onClick={() => handleRemoveFile(file)}
              style={{ marginLeft: 8, cursor: 'pointer', color: '#ff4d4f' }}
            />
          </div>
        ))}
      </div>
    );
  };


  return (
    <div className="custom-drag-upload">
      {/* 隐藏的文件输入框 */}
      <input
        ref={fileInputRef}
        type="file"
        accept={accept}
        multiple={multiple}
        disabled={disabled}
        onChange={handleFileInputChange}
        style={{ display: 'none' }}
      />

      {/* 拖拽区域 */}
      {isDragger && <Spin spinning={loading} tip={i18nValue('附件上传中')} >
        <div
          className={`custom-upload-drag-area ${isDragOver ? 'drag-over' : ''} ${(disabled || loading) ? 'disabled' : ''}`}
          onClick={triggerFileSelect}
          onDragEnter={handleDragEnter}
          onDragOver={handleDragOver}
          onDragLeave={handleDragLeave}
          onDrop={handleDrop}
        >
          {dragContent || defaultDragContent}
        </div></Spin>}

      {/* 上传按钮 */}
      {!isDragger && <div className="custom-upload-button" style={{ marginTop: 16 }}>
        <Button
          type="primary"
          icon={<UploadOutlined />}
          onClick={triggerFileSelect}
          loading={loading}
          disabled={disabled || loading}
          {
          ...uploadBtnProps
          }
        >
          {uploadBtnProps?.uploadTitle || i18nValue('选择文件')}
        </Button>
      </div>}

      {/* 文件列表 */}
      {
        showUploadList && (!fileListRender) && renderDefaultFileList()
      }
      {/* 自定义文件列表 */}
      {
        showUploadList && fileListRender && fileListRender()
      }
    </div>
  );
});

export default CustomizeUpload;
  • 类型注释部分
import { ButtonProps, UploadFile } from 'antd';
import { RcFile } from 'antd/es/upload';

// 文件状态类型
export type UploadFileStatus = 'uploading' | 'done' | 'error' | 'removed';

type PropertiesToOmit = 'uid' | 'name';
// 自定义文件类型扩展
export interface CustomUploadFile extends Omit<UploadFile, PropertiesToOmit> {
  /** 文件状态 */
  status?: UploadFileStatus;
  /** 文件上传进度 */
  percent?: number;
  /** 文件 */
  originFileObj?: RcFile;
  /** 文件ID */
  fileId: string;
  /** 文件名称 */
  fileName: string;
}


enum FileSizeLimits {
  MAX_SIZE = 150,
  MAX_COUNT = 50,
}

// 组件属性接口
export interface CustomUploadProps {
  /** 文件列表 */
  defaultFileList?: any[];
  /** 是否支持多选文件 */
  multiple?: boolean;
  /** 接受的文件类型 */
  accept?: string;
  /** 是否禁用 */
  disabled?: boolean;
  /** 最大文件数量 */
  maxCount?: FileSizeLimits.MAX_SIZE;
  /** 文件大小限制(MB) */
  maxSize?: FileSizeLimits.MAX_SIZE;
  /** 是否显示上传按钮 */
  showUploadList?: boolean;
  /** 自定义文件列表渲染 */
  fileListRender?: () => React.ReactNode;
  /** 上传前的回调函数 */
  beforeUpload?: (file: File, fileList: File[]) => boolean | Promise<boolean>;
  /** 自定义上传请求 */
  customRequest?: (options: {
    file: File;
    onSuccess: (response: any, file: CustomUploadFile) => void;
    onError: (error: Error) => void;
    onProgress: (percent: number, file: CustomUploadFile) => void;
  }) => void;
  /** 上传成功失败,删除附件回调 */
  onChange: (info: ChangeInfoProps) => void;
  /** 移除文件回调 */
  onRemove?: (file: CustomUploadFile) => void | boolean | Promise<void | boolean>;
  /** 拖拽区域自定义内容 */
  dragContent?: React.ReactNode;
  /** 按钮区域自定义内容 */
  buttonContent?: React.ReactNode;
  /** 是否拖拽上传 */
  isDragger: boolean;
  /** 上传按钮PROPS */
  uploadBtnProps?: UploadBtnProps;
}

type OtherBtnProps = {
  /** 上传按钮名称 */
  uploadTitle: string;
};

/** 上传Change事件回调参数 */
export type ChangeInfoProps = { type: uploadStatus;file: CustomUploadFile; fileList: CustomUploadFile[] };

/** 上传Change事件回调参数类型值 */
type uploadStatus = 'success' | 'error' | 'remove';

type UploadBtnProps = OtherBtnProps & Omit<ButtonProps, 'loading' | 'onClick' | 'icon'>;

// 组件暴露的方法接口
export interface CustomUploadRef {
  /** 手动上传文件 */
  upload: (file: File) => void;
  /** 清空文件列表 */
  clear: () => void;
  /** 获取当前文件列表 */
  getFileList: () => CustomUploadFile[];
}

  • 样式文件
.custom-drag-upload {
  width: 100%;
}

.custom-upload-drag-area {
  border: 1px dashed var(--upload-border-color);
  border-radius: 6px;
  padding: 20px;
  text-align: center;
  cursor: pointer;
  transition: border-color 0.3s;
  background-color: var(--upload-bg-color);
}

.custom-upload-drag-area:hover:not(.disabled) {
  border-color: var(--primary-color);
}

.custom-upload-drag-area.drag-over {
  border-color: var(--primary-color);
  background-color: var(--upload-hover-bg-color);
}

.custom-upload-drag-area.disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

.custom-upload-button .ant-btn {
  transition: all 0.3s;
}

.custom-upload-list {
  margin-top: 10px;
}

.custom-upload-list-item {
  display: flex;
  align-items: flex-start;
  align-items: center;
  padding: 2px 12px 2px 10px;
  border: 1px solid var(--upload-border-color);
  border-radius: 2px;
  margin-bottom: 2px;
  color: var(--text-color);
  background-color: var(--bg-primary);

  &:hover {
    color: var(--primary-color);
  }
}

.custom-upload-file-name {
  flex: 1;
  text-align: left;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.custom-upload-progress,
.custom-upload-error {
  margin-left: 8px;
  font-size: 12px;
}

.ant-btn-disabled {
  cursor: not-allowed;
}
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值