有点小九九
简单的事情认真做

上传组件:

// ChunkUpload.jsx
import React, { 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 [abortResult, 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;
    });
  };

  // ---- listeners (most are kept as your original components) ----
  const ChunkUploadStartListenerComponent = () => {
    useChunkStartListener(async (data) => { /* nothing special here */ });
    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[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;

页面组件 --> antdProComponents

// 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 [editableKeys, setEditableRowKeys] = useState([]);
  const [chooseFile, setChooseFile] = useState(false);

  const materialRef = useRef();
  const uploadRef = useRef();
  const preSendDataRef = useRef({});

  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)) {
          map.set(item.id, { ...(map.get(item.id)), ...item });
        } else {
          map.set(item.id, { ...item });
        }
      });
      return Array.from(map.values());
    });
    // 打开编辑态让用户填写
    setEditableRowKeys(prev => [...prev, ...flatItems.map(it => it.id)]);
  };

  // 上传进度回调
  const handleItemProgress = (item) => {
    // item: { id, loaded, total, completed? }
    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;
    }));
  };

  // 上传状态回调 (TODO 有BUG待完善)
  const handleItemFinalize = (item) => {
    setBatchesMap(prev => prev.map(row => {
      if (row.id === item.id) {
        // 如果 finalize 时 completed === 100 认为成功
        const completed = item.completed ?? row.completed;
        const newState = item.state ?? (completed === 100 && 'finished');
        return { ...row, completed, state: newState };
      }
      return row;
    }));
  };

  // 保存编辑回调(当用户手动点击保存)
  const submitForm = async (row) => {
    // row 已包含 file_id / file_name 等字段
    setBatchesMap(prev => prev.map(item => item.id === row.id ? { ...item, ...row } : item));
    return true;
  };

  // 当点击开始上传
  const handleStartUpload = async () => {
    var resultMap;
    try {

      // 1) validateFields 以确保表单校验通过并获取最新字段
      const validateValues = await form.validateFields();

      // validateValues值的结构为 { id: { file_id, file_name } }
      const resultArr = Object.entries(validateValues).map(([id, obj]) => ({ id, ...obj }));
      // 确认是否经过第1步的校验获取到数据
      if (resultArr.length) {
        resultMap = Object.fromEntries(resultArr.map(r => [r.id, r]));
      } else {
        resultMap = Object.fromEntries(batchesMap.map(r => [r.id, r]));
      }

      // 2) 合并到 batchesMap
      setBatchesMap(prev => prev.map(item => ({ ...item, ...(resultMap[item.id] || {}) })));

      // 3) 保存所有编辑行
      for (const key of editableKeys) {
        if (materialRef.current?.saveEditable) {
          await materialRef.current.saveEditable(key);
        }
      }

      // 4) 设置 requestPreSend(每个 item 上传时根据 item.id 找对应的参数)
      preSendDataRef.current = resultMap;

      // 5) 退出编辑态并开始上传
      setEditableRowKeys([]);

      uploadRef.current.startUpload();
      // message.success('开始上传');
    } catch (error) {
      // console.error(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));
    uploadRef.current && uploadRef.current.retryItem(id);
  };
  // 清除并停止所有上传
  const clearAll = () => {
    if (uploadRef.current) {
      uploadRef.current.stopAll()
      setBatchesMap([]);
      setEditableRowKeys([]);
    }
  };

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

  // 表格列(你现有的列,略微调整 renderText)
  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: 'file',
    //   editable: false,
    //   render: (colData) => colData?.name || '',
    // },
    {
      title: '操作',
      dataIndex: 'option',
      valueType: 'option',
      width: 180,
      render: (text, record, _, action) => {
        const UPLOAD_RETRY = record.state !== 'pending' && record.state !== 'finished' && record.state !== 'uploading';// 等待上传、上传成功或者正在上传不可以重试
        const UPLOAD_ABORT = record.state === 'uploading';// 上传中可以取消
        return [
          <a key="editable" onClick={() => action?.startEditable?.(record.id)}>编辑</a>,
          UPLOAD_RETRY && (<a key="retry" onClick={async () => { retryUpload(record.id); await action?.saveEditable?.(record.id); }}>重试</a>),
          UPLOAD_ABORT && (<a key="abort" onClick={() => { abortUpload(record.id); action?.saveEditable?.(record.id); }}>取消上传</a>),

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

    },
  ];

  return (
    <>
      <EditableProTable
        scroll={{ x: 800 }}
        columns={columns}
        actionRef={materialRef}
        headerTitle="视频素材列表"
        rowKey="id"
        search={false}
        value={batchesMap}
        onChange={(value) => setBatchesMap(value)}
        recordCreatorProps={false}
        revalidateOnFocus={false}
        pagination={{ pageSize: 10 }}
        toolBarRender={() => [
          // <Button
          //   type="primary"
          //   danger
          //   key="danger"
          //   onClick={() => clearAll()}
          //   icon={<StopOutlined />}
          // >
          //   全部停止
          // </Button>,
          <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: (props, defaultDoms) => {
            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;

 

posted on 2025-09-11 17:11  有点小九九  阅读(18)  评论(0)    收藏  举报