上传组件:
// 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;
浙公网安备 33010602011771号