前言
上篇文章我简单介绍 React-uploady(@rpldy/uploady)的部分内容和功能的使用方法;文章链接
这篇文章我将结合antd Procli 创建的项目以及结合 procomponents 中的 EditableProTable 可编辑表格来当作 @rpldy/uploady 的自定义预览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;
总结
今天只有代码,没有总结。