React实战 - 文件上传插件 React-uploady(@rpldy/uploady) 结合AntdProCli可编辑表格自定义展示

@rpldy/uploady文件上传插件实战

前言

上篇文章我简单介绍 React-uploady(@rpldy/uploady)的部分内容和功能的使用方法;文章链接

这篇文章我将结合antd Procli 创建的项目以及结合 procomponents 中的 EditableProTable 可编辑表格来当作 @rpldy/uploady 的自定义预览UI

不多说,直接贴代码


一、上传组件代码

页面展示:

点击选择文件后,上传文件的UI弹框

// ChunkUpload.jsx
import { useCallback, forwardRef, useState, useImperativeHandle, useEffect, useRef } from 'react';
import { UploadDropZone } from '@rpldy/upload-drop-zone';
import {
  ChunkedUploady,
  useUploady,
  useChunkStartListener,
  useChunkFinishListener,
  useRequestPreSend,
  useAbortItem,
  useItemAbortListener,
  useAbortAll,
  useAbortBatch,
  useBatchAbortListener,
  useBatchAddListener,
  useAllAbortListener,
  useBatchStartListener,
  useBatchFinishListener,
} from '@rpldy/chunked-uploady';
import retryEnhancer, { useRetry } from "@rpldy/retry-hooks";
import { useItemProgressListener, useItemFinalizeListener } from '@rpldy/uploady';
import { Button } from "antd";
import { asUploadButton } from "@rpldy/upload-button";

const ChunkUpload = forwardRef((props, ref) => {
  const [activeBatches, setActiveBatches] = useState([]);
  const [_, setAbortResult] = useState();
  const abortItemBtnRef = useRef();

  const flattenBatchItems = (batch) => {
    if (!batch || !batch.items) return [];
    const parentData = { ...batch };
    delete parentData.items;
    return batch.items.map(item => {
      const newItem = { ...item };
      Object.keys(parentData).forEach(key => {
        newItem[`parent_${key}`] = parentData[key];
      });
      newItem[`file_name`] = item.file?.name || item.fileName || '';
      newItem[`file_id`] = '';
      return newItem;
    });
  };

  const ChunkUploadStartListenerComponent = () => {
    useChunkStartListener(async (data) => { });
    return null;
  };
  const ChunkUploadFinishListenerComponent = () => {
    useChunkFinishListener(({ item, chunk, uploadData }) => { });
    return null;
  };

  // 当 batch 被添加时触发(立即通知父组件)
  const ChunkUploadAddListener = ({ onBatchStart }) => {
    useBatchAddListener((batch) => {
      onBatchStart(batch);
    });
    return null;
  };

  const ChunkedUploadAbortAllListener = () => {
    useAllAbortListener(() => {
      // console.log("调用了abortAll,全部停止上传");
    });
    return null;
  };
  const BatchUploadStartListener = () => {
    useBatchStartListener((batch) => { });
    return null;
  };
  const BatchFinishListener = ({ onBatchFinish }) => {
    useBatchFinishListener((batch) => {
      onBatchFinish(batch);
    });
    return null;
  };
  const BatchAbortListener = ({ onBatchAbort }) => {
    useBatchAbortListener((batch) => {
      onBatchAbort(batch);
    });
    return null;
  };
  const UploadAbortItemListener = ({ onAbortItem }) => {
    useItemAbortListener((item) => {
      onAbortItem(item);
    });
    return null;
  };

  // 给可拖拽项添加点击上传
  const MyClickableDropZone = forwardRef((props, ref) => {
    const { onClick, ...buttonProps } = props;
    const onZoneClick = useCallback(e => { if (onClick) onClick(e); }, [onClick]);
    return (
      <UploadDropZone
        {...buttonProps}
        ref={ref}
        onDragOverClassName="drag-active"
        extraProps={{ onClick: onZoneClick }}
        grouped
        maxGroupSize={10}
      />
    );
  });
  const DropZoneButton = asUploadButton(MyClickableDropZone);

  // 停止单个上传按钮(隐藏测试用)
  const UploadAbortItemButton = forwardRef((props, ref) => {
    const abort = useAbortItem();
    useImperativeHandle(ref, () => ({ abort: (id) => abort(id) }));
    return (
      <Button className="w-16 h-6 border rounded-sm p-6" style={{ display: "none" }}>
        取消上传
      </Button>
    );
  });

  const handleAbortItem = (item) => {
    setAbortResult(item);
  };
  const handleBatchAbort = (batch) => {
    let abortBatchResult = batch.items.map(item => item.id);
    setAbortResult(abortBatchResult);
    setActiveBatches(prevActiveBatches => prevActiveBatches.filter((it) => it.id !== batch.id));
  };
  const onBatchFinish = (batch) => {
    setActiveBatches(prevActiveBatches => prevActiveBatches.filter((it) => it.id !== batch.id));
  };

  // InnerUploader — 暴露控制接口给父组件(并支持 setRequestPreSend)
  const InnerUploader = forwardRef(({ activeBatches, preSendDataRef }, ref) => {
    const uploader = useUploady();
    const abortAll = useAbortAll();
    const abortBatch = useAbortBatch();
    const abortItem = useAbortItem();
    const preSendRef = useRef(null);
    const retryItem = useRetry();

    useRequestPreSend(({ options, items }) => {
      const itemId = items?.[0]?.id;
      const match = preSendDataRef.current.get(itemId) || {};

      return {
        options: {
          ...options,
          params: {
            ...options.params,
            video_id: match.file_id,
            name: match.file_name,
          },
        },
      };
    });

    useImperativeHandle(ref, () => ({
      setRequestPreSend: (cb) => { preSendRef.current = cb; },
      startUpload: () => uploader.processPending(),
      stopAll: () => abortAll(),
      stopBatch: (batchId) => abortBatch(batchId),
      stopItem: (itemId) => abortItem(itemId),
      retryItem: (itemId) => retryItem(itemId),
      getBatches: () => activeBatches,
    }), [activeBatches, uploader, abortBatch, abortItem]);

    return null;
  });

  // 文件上传进度监听
  const ItemProgressListener = ({ onItemProgress }) => {
    useItemProgressListener((item) => {
      onItemProgress?.(item);
    });
    return null;
  };
  // 文件上传完成监听
  const ItemFinalizeListener = ({ onItemFinalize }) => {
    useItemFinalizeListener((item) => {
      onItemFinalize?.(item);
    });
    return null;
  };

  // 当 batch 添加 -> 保存 activeBatches 并立即通知父组件(持久化由父组件完成)
  const handleBatchStart = (batch) => {
    setActiveBatches(prev => [...prev, batch]);
    // 立刻扁平化并发回父组件,父组件负责去重/持久化
    const flat = flattenBatchItems(batch);
    props.onBatchAdd?.(flat); // <-- 父组件接收并持久化
  };

  useEffect(() => {
    props.onBatchesChange?.(activeBatches);
  }, [activeBatches]);

  return (
    <ChunkedUploady
      autoUpload={false}
      accept='video/*'
      chunked={false}
      destination={{
        url: '/api/uploads',
        headers: { 'Authorization': localStorage.getItem('token') }
      }}
      sendWithFormData={true}
      concurrent
      maxConcurrent={5}
      parallel={1}
      retries={5}
      enhancer={retryEnhancer}
      {...props}
      multiple
    >
      <ChunkedUploadAbortAllListener />
      <ChunkUploadAddListener onBatchStart={handleBatchStart} />
      <ChunkUploadStartListenerComponent />
      <ChunkUploadFinishListenerComponent />
      <BatchUploadStartListener />
      <BatchAbortListener onBatchAbort={handleBatchAbort} />
      <UploadAbortItemListener onAbortItem={handleAbortItem} />
      <BatchFinishListener onBatchFinish={onBatchFinish} />

      <UploadAbortItemButton ref={abortItemBtnRef} />
      {/* 本地 Item 进度/完成监听,透传给父组件 */}
      <ItemProgressListener onItemProgress={props.onItemProgress} />
      <ItemFinalizeListener onItemFinalize={props.onItemFinalize} />

      <InnerUploader ref={ref} activeBatches={activeBatches} preSendDataRef={props.preSendDataRef} />

      <DropZoneButton>
        <div className='w-full h-auto flex flex-col justify-center items-center p-4 rounded-md border border-inherit cursor-pointer select-none' >
          <div className='text-lg' style={{ marginBottom: '1rem' }}>单击或拖动文件到此区域进行上传</div>
          <div className='text-sm text-gray-500'>支持单个文件上传</div>
        </div>
      </DropZoneButton>
    </ChunkedUploady>
  );
});

export default ChunkUpload;

二、页面使用

页面展示:
选择完文件后,可动态编辑上传参数
点击上传:支持取消重试编辑删除
在这里插入图片描述

// index.jsx
import { useRef, useState } from 'react';
import { EditableProTable, ModalForm } from '@ant-design/pro-components';
import { Button, Form, message, Popconfirm } from "antd";
import {
  SelectOutlined,
  UploadOutlined,
  LoadingOutlined,
  ClockCircleOutlined,
  CheckCircleOutlined,
  CloseCircleOutlined,
} from "@ant-design/icons";
import ChunkUpload from "../components/ChunkUpload";

const VideoMaterial = () => {
  const materialRef = useRef();
  const [editableKeys, setEditableRowKeys] = useState([]);
  const [chooseFile, setChooseFile] = useState(false);
  const uploadRef = useRef();
  const preSendDataRef = useRef(new Map());
  const [batchesMap, setBatchesMap] = useState([]);
  const [form] = Form.useForm();

  // 父端接收 ChunkUpload 的批次添加事件
  const handleBatchAdd = (flatItems) => {
    setBatchesMap(prev => {
      const map = new Map(prev.map(i => [i.id, i]));
      flatItems.forEach(item => {
        if (map.has(item.id)) {
          if (item.file_id) {
            map.set(item.id, { ...(map.get(item.id)), ...item });
          } else {
            map.set(item.id, { ...item, ...(map.get(item.id)) });
          }
        } else {
          map.set(item.id, { ...item });
        }
      });

      return Array.from(map.values());
    });

    setEditableRowKeys(prev => {
      const newIds = flatItems
        .filter(it => !batchesMap.some(old => old.id === it.id)) // 只取新增
        .map(it => it.id);
      return [...prev, ...newIds];
    });
  };

  // 上传进度回调
  const handleItemProgress = (item) => {
    setBatchesMap(prev => prev.map(row => {
      if (row.id === item.id) {
        const completed = item.completed ?? Math.round((item.loaded / Math.max(item.total || 1, 1)) * 100);
        return { ...row, completed, state: 'uploading' };
      }
      return row;
    }));
  };

  // 上传状态回调
  const handleItemFinalize = (item) => {
    setBatchesMap(prev => prev.map(row => {
      if (row.id === item.id) {
        const completed = item.completed ?? row.completed;
        const newState = item.state ?? (completed === 100 && 'finished');
        return { ...row, completed, state: newState };
      }
      return row;
    }));
  };

  // 保存编辑回调(当用户手动点击保存)
  const submitForm = async (row) => {
    setBatchesMap(prev => prev.map(item => item.id === row.id ? { ...item, ...row } : item));
    preSendDataRef.current.set(row.id, row)
    return true;
  };
  // 当点击开始上传
  const handleStartUpload = async () => {
    var resultMap;
    try {
      const validateValues = await form.validateFields();
      const resultArr = Object.entries(validateValues).map(([id, obj]) => ({ id, ...obj }));
      if (resultArr.length) {
        resultMap = Object.fromEntries(resultArr.map(r => [r.id, r]));
      } else {
        resultMap = Object.fromEntries(batchesMap.map(r => [r.id, r]));
      }
      setBatchesMap(prev => prev.map(item => ({ ...item, ...(resultMap[item.id] || {}) })));
      for (const key of editableKeys) {
        if (materialRef.current?.saveEditable) {
          await materialRef.current.saveEditable(key);
        }
      }

      Object.entries(resultMap).map(([id, item]) => {
        preSendDataRef.current.set(id, item)
      })

      setEditableRowKeys([]);
      uploadRef.current.startUpload();
    } catch (error) {
      message.error('请先填写并保存所有必填字段');
    }
  };

  // 删除/取消上传
  const abortUpload = (id) => {
    uploadRef.current.stopItem(id);
    setBatchesMap(prev => prev.map(it => it.id === id ? { ...it, state: 'error' } : it));
  };
  // 重试上传
  const retryUpload = async (id) => {
    setBatchesMap(prev => prev.map(it => it.id === id ? { ...it, state: 'reloading' } : it));
    preSendDataRef.current.set(id, Object.assign(preSendDataRef.current.get(id), { state: 'reloading' }))
    uploadRef.current && uploadRef.current.retryItem(id);
  };

  const confirm = (id) => {
    abortUpload(id)
    setBatchesMap(prev => prev.filter(item => item.id !== id))
  };

  // 表格列
  const columns = [
    {
      title: '序列ID',
      dataIndex: 'id',
      width: 150,
      align: 'center',
      editable: false,
    },
    {
      title: '视频ID',
      dataIndex: 'file_id',
      width: 100,
      align: 'center',
      editable: true,
      trigger: ['onBlur'],
      formItemProps: { rules: [{ required: true, message: '请填写视频ID' }] },
    },
    {
      title: '视频名称',
      dataIndex: 'file_name',
      editable: true,
      width: 230,
      trigger: ['onBlur'],
      formItemProps: { rules: [{ required: true, message: '请填写视频名称' }] },
    },
    {
      title: '状态',
      dataIndex: 'state',
      editable: false,
      width: 130,
      render: (text, rowData) => {
        switch (rowData.state) {
          case 'pending':
            return <><ClockCircleOutlined style={{ color: '#faad14' }} /> 等待上传</>;
          case 'uploading':
            return <><LoadingOutlined style={{ color: '#1890ff' }} spin /> 上传中</>;
          case 'reloading':
            return <><LoadingOutlined style={{ color: '#faad14' }} /> 重新上传</>
          case 'aborted':
            return <><CloseCircleOutlined style={{ color: '#faad14' }} /> 已取消</>;
          case 'finished':
            return <><CheckCircleOutlined style={{ color: '#52c41a' }} /> 上传成功</>;
          case 'error':
            return <><CloseCircleOutlined style={{ color: '#ff4d4f' }} /> 上传失败</>;
          default: return rowData.state;
        }
      }
    },
    {
      title: '上传进度',
      dataIndex: 'completed',
      valueType: 'progress',
      editable: false,
      width: 150,
      renderText: (val) => Math.max(0, Math.min(100, Math.round(val || 0))),
    },
    {
      title: '操作',
      dataIndex: 'option',
      valueType: 'option',
      width: 180,
      render: (text, record, _, action) => {
        const UPLOAD_ABORT = record.state === 'uploading';// 上传中可以取消
        const UPLOAD_RETRY = !(record.state === 'pending' || record.state === 'finished' || record.state === 'uploading');// 等待上传、上传成功或者正在上传不可以重试
        const UPLOAD_FINISH = !(record.state === 'finished' || record.state === 'uploading');// 上传成功或者正在上传不可编辑
        return [
          UPLOAD_FINISH && (<a key="editable" onClick={() => action?.startEditable?.(record.id)}>编辑</a>), ,
          UPLOAD_RETRY && (<a key="retry" onClick={(e) => retryUpload(record.id)}>重试</a>),
          UPLOAD_ABORT && (<a key="abort" onClick={() => abortUpload(record.id)}>取消上传</a>),

          <Popconfirm
            key="delete"
            title="确定删除吗?"
            onConfirm={() => confirm(record.id)}
            onCancel={() => { }}
            okText="确定"
            cancelText="取消"
          >
            <a>删除</a>
          </Popconfirm>
          ,
        ]
      }

    },
  ];

  return (
    <>
      <EditableProTable
        scroll={{ x: 800 }}
        columns={columns}
        actionRef={materialRef}
        headerTitle="素材列表"
        rowKey="id"
        search={false}
        value={batchesMap}
        onChange={setBatchesMap}
        recordCreatorProps={false}
        revalidateOnFocus={false}
        pagination={{ pageSize: 10 }}
        toolBarRender={() => [
          <Button
            type="primary"
            key="primary"
            disabled={batchesMap.length === 0}
            onClick={handleStartUpload}
            icon={<UploadOutlined />}
          >
            开始上传
          </Button>,
          <Button
            type='default'
            key="choose"
            onClick={() => setChooseFile(true)}
            icon={<SelectOutlined />}
          >
            选择素材
          </Button>
        ]}
        editable={{
          type: 'multiple',
          form,
          editableKeys,
          onSave: async (rowKey, data) => {
            await submitForm(data);
            // 返回 true 表示保存成功
            return true;
          },
          onDelete: (rowKey, data) => {
            abortUpload(data.id);
          },
          onChange: setEditableRowKeys,
          actionRender: (row, config, dom) => [dom.save, dom.delete]
        }}
      />

      <ModalForm
        title={'选择视频素材'}
        width={800}
        open={chooseFile}
        onOpenChange={setChooseFile}
        submitter={{
          render: () => {
            return [
              <Button
                key="confirm"
                type='primary'
                onClick={() => {
                  setChooseFile(false);
                }}
              >
                确认
              </Button>,
            ];
          },
        }}
      >
        <ChunkUpload
          ref={uploadRef}
          destination={{ url: '/localApi/upload/simple' }}
          onBatchAdd={handleBatchAdd}                    // 新增批次时父端持久化
          onBatchesChange={() => { }}                    // 保持兼容(可选)
          onItemProgress={handleItemProgress}            // 进度回调
          onItemFinalize={handleItemFinalize}            // 完成回调
          preSendDataRef={preSendDataRef}
        />
      </ModalForm>
    </>
  );
};

export default VideoMaterial;


总结

今天只有代码,没有总结。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值