上传组件:
// 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 }}groupedmaxGroupSize={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 (<ChunkedUploadyautoUpload={false}accept='video/*'chunked={false} // 是否开启分片上传destination={{url: '/api/uploads',headers: { 'Authorization': localStorage.getItem('token') }}}sendWithFormData={true}concurrentmaxConcurrent={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) 合并到 batchesMapsetBatchesMap(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>),<Popconfirmkey="delete"title="确定删除吗?"onConfirm={() => confirm(record.id)}onCancel={cancel}okText="确定"cancelText="取消"><a>删除</a></Popconfirm> ,]}},];return (<><EditableProTablescroll={{ 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>,<Buttontype="primary"key="primary"disabled={batchesMap.length === 0}onClick={handleStartUpload}icon={<UploadOutlined />}>开始上传</Button>,<Buttontype='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]}}/><ModalFormtitle={'选择素材'}width={800}open={chooseFile}onOpenChange={setChooseFile}submitter={{render: (props, defaultDoms) => {return [<Buttonkey="confirm"type='primary'onClick={() => {setChooseFile(false);}}>确认</Button>, ];},}}><ChunkUploadref={uploadRef}destination={{ url: '/localApi/upload/simple' }}onBatchAdd={handleBatchAdd} // 新增批次时父端持久化onBatchesChange={() => { }} // 保持兼容(可选)onItemProgress={handleItemProgress} // 进度回调onItemFinalize={handleItemFinalize} // 完成回调preSendDataRef={preSendDataRef}/></ModalForm></> ); };export default VideoMaterial;