Refine v5 多文件上传实战:基于 Mantine 的 Multipart/form-data 上传方案
【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine
导读
在 Refine v5 中实现文件上传有多种形态,其中multipart/form-data(Multipart Upload)是后端接口最通用的上传协议。本文以仓库中的upload-mantine-multipart示例为主体,讲解如何在 Refine 的useForm表单流程中接入 MantineDropzone,手动构造FormData上传文件、回填表单字段并展示预览,覆盖新建与编辑两个完整场景,最终交付一个可复制的 React 后台管理文件上传方案。
示例概览与运行方式
关联文档 documentation/docs/examples/upload/mantine/multipart.md 指向一个完整的可运行示例upload-mantine-multipart。示例采用的技术栈如下:
- UI 框架:Mantine(
@mantine/core、@mantine/dropzone) - Refine 包:
@refinedev/core、@refinedev/mantine、@refinedev/react-table、@refinedev/simple-rest - 路由:
@refinedev/react-router+react-router - HTTP 客户端:
axios - Markdown 编辑器:
@uiw/react-md-editor
按示例 README.md 的说明,可以通过 Refine CLI 在本地直接创建该示例工程:
npm create refine-app@latest -- --example upload-mantine-multipart应用入口 App.tsx 使用simple-rest数据提供器连接https://api.fake-rest.refine.dev,并注册了posts资源的 list / create / edit / show 四个页面路由:
const API_URL = "https://api.fake-rest.refine.dev"; <Refine routerProvider={routerProvider} dataProvider={dataProvider(API_URL)} notificationProvider={useNotificationProvider} resources={[ { name: "posts", list: "/posts", create: "/posts/create", edit: "/posts/edit/:id", show: "/posts/show/:id", }, ]} >Multipart 上传的核心思路:手动构造 FormData
与 Ant Design 版本(Upload组件自带action直传)不同,Mantine 版本的Dropzone只负责收集文件,不负责网络请求。上传动作需要开发者自行完成,这也让它成为理解multipart/form-data协议本质的最佳示例。
整个流程可以拆解为四步:
- 用户把文件拖入
Dropzone,触发onDrop回调; - 在回调中把文件对象
append进FormData,字段名为file; - 用
axios.post把FormData发送到上传端点/media/upload,拿到返回的{ url }; - 将带
url的文件对象写入表单字段(images),提交表单时随记录一并入库。
FormData是浏览器原生 API,axios 在检测到请求体为FormData时,会自动设置Content-Type: multipart/form-data并生成正确的 boundary,无需手动指定。
Create 表单:拖拽上传 + 预览回填
新建页完整实现位于 create.tsx,关键代码如下:
export const PostCreate: React.FC = () => { const [files, setFiles] = useState<FileWithURL[]>([]); const [isUploadLoading, setIsUploadLoading] = useState(false); const { saveButtonProps, getInputProps, setFieldValue, errors } = useForm< IPost, HttpError, FormValues >({ initialValues: { title: "", status: "", category: { id: "" }, content: "", images: [], }, validate: { title: (value) => (value.length < 2 ? "Too short title" : null), status: (value) => (value.length <= 0 ? "Status is required" : null), category: { id: (value) => (value.length <= 0 ? "Category is required" : null), }, content: (value) => (value.length < 10 ? "Too short content" : null), }, }); const apiUrl = useApiUrl(); const handleOnDrop = (files: FileWithPath[]) => { try { setIsUploadLoading(true); files.map(async (file) => { const formData = new FormData(); formData.append("file", file); const res = await axios.post<{ url: string }>( `${apiUrl}/media/upload`, formData, { withCredentials: false, headers: { "Access-Control-Allow-Origin": "*", }, }, ); setFiles( (prev) => [...prev, { url: res.data.url, ...file }] as FileWithURL[], ); }); setIsUploadLoading(false); } catch (error) { setIsUploadLoading(false); } }; useEffect(() => { setFieldValue("images", files); }, [files]); // ... <Dropzone accept={IMAGE_MIME_TYPE} onDrop={handleOnDrop} loading={isUploadLoading}> <Text align="center">Drop images here</Text> </Dropzone>要点逐条说明:
useForm的泛型参数:useForm<IPost, HttpError, FormValues>中第三个泛型FormValues是表单的实际值类型,其中images字段被定义为FileWithURL[],即带url的文件对象数组。useApiUrl():来自@refinedev/core,返回当前数据提供器配置的 API 根地址,避免把 URL 硬编码在页面组件里。这里的apiUrl即https://api.fake-rest.refine.dev。FileWithURL类型:示例定义了interface FileWithURL extends FileWithPath { url?: string },在@mantine/dropzone的FileWithPath基础上扩展出上传成功后服务端返回的url字段(见 interfaces/index.d.ts 同目录类型定义)。setFieldValue同步表单:Dropzone与 Refine 表单没有直接绑定,示例通过useEffect监听本地files状态,再调用useForm返回的setFieldValue("images", files)把文件列表写入表单值,提交时随记录一起发送。- 预览渲染:
files.map生成<Image src={file.url} />列表,再用SimpleGrid以四列网格展示;编辑页则从values.images读取已有图片,实现已上传图片的回显。 IMAGE_MIME_TYPE:@mantine/dropzone导出的图片 MIME 类型常量,限定只接受图片文件。
Edit 表单:已有图片回显
编辑页 edit.tsx 与新建页几乎一致,差异主要体现在两处:
const { saveButtonProps, getInputProps, setFieldValue, values, errors, refineCore: { query: queryResult }, } = useForm<IPost, HttpError, FormValues>({ // initialValues 与 validate 同 Create }); // 分类下拉框默认值取自当前记录 const { selectProps } = useSelect<ICategory>({ resource: "categories", defaultValue: queryResult?.data?.data.category.id, pagination: { mode: "server" }, }); // 预览直接读表单值中的已有图片 const previews = values.images?.map((file, index) => { return <Image key={index} src={file.url} />; });- 通过
refineCore.query拿到当前编辑记录的数据,用于初始化分类等关联字段; - 图片预览改为读取
values.images(表单当前值),因此编辑页进入时即可回显记录中已保存的图片; - 新拖入文件后,
handleOnDrop与setFieldValue的逻辑与新建页完全复用,新旧文件会合并进images字段。
编辑提交时,Refine 会以PATCH/PUT请求把包含images(含url)的完整表单值发送到/posts/:id。
上传端点的接口契约
无论前端如何实现,multipart 上传的成功都依赖后端端点遵守统一契约。参考 multipart-upload.md 中给出的规范:
请求方向:
[POST] https://api.fake-rest.refine.dev/media/upload { "file": "binary" }该端点必须是Content-Type: multipart/form-data,且表单字段名为file,值为文件二进制数据。
响应方向(示例中axios.post<{ url: string }>的泛型即对应此结构):
{ "url": "https://example.com/uploaded-file.jpeg" }前端拿到url后与文件对象合并存储。最终随posts记录提交到 API 的image/images字段形态大致如下(以 Ant Design 版本教程文档展示的数据为例,Mantine 示例的images数组语义相同,均以url为核心字段):
{ "title": "Test", "images": [ { "name": "greg-bulla-6RD0mcpY8f8-unsplash.jpg", "url": "https://refine.ams3.digitaloceanspaces.com/78c82c0b2203e670d77372f4c20fc0e2", "type": "image/jpeg", "size": 70922 } ] }与 Ant Design 版本实现方式的对比
同一主题在 Refine 仓库中还有 Ant Design 实现(multipart-upload.md 教程主体基于@refinedev/antd,示例为upload-antd-multipart),两者对比能帮助理解不同 UI 库下的接入差异:
| 环节 | Mantine 版本(本文) | Ant Design 版本 |
|---|---|---|
| 文件选择组件 | @mantine/dropzone的Dropzone | antd的Upload.Dragger |
| 上传动作 | 手动axios.post+FormData | 组件action属性直传端点 |
| 表单接入 | onDrop回调 +setFieldValue | getValueFromEvent转换事件为UploadFile数组 |
| 上传中状态 | 本地isUploadLoading控制Dropzoneloading | useFileUploadState()返回isLoading/onChange,可禁用保存按钮 |
其中 Ant Design 版本的useFileUploadState是 Refine 为上传场景提供的便捷 Hook,通过saveButtonProps.disabled = isLoading在文件上传过程中禁用"保存"按钮,避免表单在图片尚未传完时被提交。Mantine 版本由于上传逻辑完全自控,等价地使用本地isUploadLoading状态即可达到相同效果。
从源码看数据流:文件如何进入 Refine 表单
综合 create.tsx 与 edit.tsx 的实现,可以梳理出一条完整的数据链路:
Dropzone.onDrop(files) └─> 每个 file append 进 FormData("file", file) └─> axios.post(`${apiUrl}/media/upload`, formData) └─> 响应 { url } 与 file 合并为 FileWithURL └─> setFiles(prev => [...prev, {url, ...file}]) └─> useEffect 触发 setFieldValue("images", files) └─> 表单 values.images 更新 └─> 提交时随记录发送 /posts └─> 编辑页回显 values.images -> <Image src={url} />这一链路体现了 RefineuseForm的一个核心特性:表单值与 UI 组件之间通过getInputProps/setFieldValue双向桥接,第三方组件(如Dropzone、MDEditor)无需依赖 Refine 内部实现,只要把数据写入表单值即可无缝参与saveButtonProps触发的提交流程。上传过程与表单提交解耦,也是保证"先传文件、后存记录"顺序正确性的关键。
延伸阅读
- 完整理论教程:multipart-upload.md,含 Ant Design 版本的逐步讲解与
useFileUploadState用法 - Mantine 示例工程:examples/upload-mantine-multipart,包含新建、编辑、列表页完整代码
- 同主题 Base64 上传对比:documentation/docs/examples/upload/mantine/base64.md
【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考