最近心血来潮,打算自己捣腾个web app来练练手(虽然大概率会半路弃坑……),其中有一部分是关于文件上传的,在实现的过程中遇到了一些坑,于是打算把血泪教训都记录下来。
前端的部分采用了Antd,不得不说真是香,至少比我司内部的前端库香了1000倍……事半功倍。后端部分则主要通过multer来实现,目测应该是一种比较通用的做法?
1、捯饬前端
首先我们新建一个upload_file文件夹,在里面放我们的前端后端各种东西。
而后老规矩,通过create-react-app uf_ui来创建前端部分。npm install antd来安装一下antd。
然后我想要实现的功能是页面上一个按钮,点击按钮会弹出对话框(Modal),在其中上传文件,并可以加入一些其他的备注等等。那么我们先来准备一下对话框的部分。
我们在src目录下新建components,然后新建2个文件,ModalContainer.js作为对话框容器,而UploadFile.js负责对话框内部的内容:
ModalContainer.js的内容比较简单:
1 import React, { useState } from 'react'; 2 import { Modal, Button } from 'antd'; 3 4 export default function ModalContainer(props) { 5 const [visible, setVisible] = useState(false); //是否显示模态框 6 7 const showModal = () => { 8 setVisible(true); //显示 9 }; 10 11 const handleCancel = () => { 12 setVisible(false); //隐藏 13 }; 14 15 return ( 16 <> 17 <Button 18 type={props.buttonType} 19 icon={props.icon} 20 onClick={showModal}> 21 {props.title} 22 </Button> 23 24 <Modal 25 title={props.title} 26 visible={visible} 27 footer={null} 28 onCancel={handleCancel} 29 {...props} 30 > 31 {React.cloneElement(props.content, 32 { handleCancel: handleCancel })} 33 </Modal> 34 </> 35 ) 36 }
其中Button是页面载入后会显示的按钮,点击就会弹出对话框;而Modal则负责对话框本体,我们将footer设置成null来去掉默认的取消和确认按钮(也就是生成一个没有按钮的对话框)。此外,我们设置了ModalContainer将使用content参数来传入一个组件以显示内部的内容。
但是,由于我们去掉了取消和确认按钮,我们必须把关闭对话框的任务也交给content传入的组件来做(也就是说content的组件会包含取消和确认按钮),因此,我们必须把handleCancel这个函数传入content组件中,作为其props的一部分,React.cloneElement就实现了这一点,它将完整地复制content组件,并将handleCancel也注入它的props中。这样一来,我们在写content组件时,就能通过调用props.handleCancel来关闭对话框了。
接下来,我们把组件加入到App.js中:
1 import './App.css'; 2 import { UploadOutlined } from '@ant-design/icons'; 3 import ModalContainer from "./components/ModalContainer"; 4 import UploadFile from "./components/UploadFile"; 5 6 function App() { 7 return ( 8 <div className="App"> 9 <ModalContainer 10 icon={<UploadOutlined/>} 11 title="上传文件" 12 width={600} 13 content={<UploadFile/>}/> 14 </div> 15 ); 16 } 17 18 export default App;
可以看到我们将UploadFile组件传入了content,作为对话框的内容。由于我们还没写任何东西,所以弹出后会是一片空白。此外,注意antd的图标库和antd本体在import时是分开的(虽然不用单独安装)。此外,想使用antd的样式的话,可以在App.css第一行前加入一句:@import 'antd/dist/antd.css';
npm start之后可以看到一个按钮,点击后一篇空白。
接下来我们来写一下UploadFile.js,对于UploadFile,我希望它是一个表单,有一个上传文件的按钮,有一个输入框来负责自定义文件名称,另一个用来写一些备注,我们先把这个表单整出来:
1 import { 2 Upload, 3 Form, 4 Button, 5 Input } from 'antd'; 6 import { UploadOutlined } from '@ant-design/icons'; 7 8 const layout = { 9 labelCol: { span: 6 }, 10 wrapperCol: { span: 18 }, 11 }; //表单的整体布局 12 const tailLayout = { 13 wrapperCol: { offset: 16, span: 8 }, 14 }; //取消和确定按钮的布局 15 16 const { TextArea } = Input; 17 18 export default function UploadFile(props) { 19 const [form] = Form.useForm(); //用于之后取数据 20 21 return ( 22 <> 23 <Form 24 {...layout} 25 form={form} 26 name="upload_file"> 27 28 <Form.Item 29 label="文件名称" 30 name="name" 31 rules={[{ required: true, 32 message: "请输入文件名" }]}> 33 <Input/> 34 </Form.Item> 35 36 <Form.Item 37 label="备注" 38 name="desc" 39 rules={[{ required: false, 40 message: "请输入备注" }]}> 41 <TextArea rows={4}/> 42 </Form.Item> 43 44 <Form.Item 45 label="上传文件" 46 name="file" 47 rules={[{ required: true, 48 message: "请上传文件" }]}> 49 <Upload> 50 <Button icon={<UploadOutlined />}>开始上传</Button> 51 </Upload> 52 </Form.Item> 53 54 </Form> 55 </> 56 ) 57 }
表单中一共3样东西,name对应文件名的输入,desc对应备注,file对应一个上传文件的按钮。此时我们再看页面,点击“上传文件”按钮,会看到表单弹出:
点击其中的开始上传,就可以选择文件了。随便上传一个试试,发现失败(红色):
那是因为我们还没有配置上传的request。此外,我们发现对话框没有确认和取消按钮,只能靠大叉退出,我们可以先通过<Form.Item>加上:
1 import { 2 Upload, 3 Form, 4 Button, 5 Input, 6 Space } from 'antd'; 7 import { UploadOutlined } from '@ant-design/icons'; 8 9 const layout = { 10 labelCol: { span: 6 }, 11 wrapperCol: { span: 18 }, 12 }; //表单的整体布局 13 const tailLayout = { 14 wrapperCol: { offset: 16, span: 8 }, 15 }; //取消和确定按钮的布局 16 17 const { TextArea } = Input; 18 19 export default function UploadFile(props) { 20 const [form] = Form.useForm(); //用于之后取数据 21 22 return ( 23 <> 24 <Form 25 {...layout} 26 form={form} 27 name="upload_file"> 28 29 <Form.Item 30 label="文件名称" 31 name="name" 32 rules={[{ required: true, 33 message: "请输入文件名" }]}> 34 <Input/> 35 </Form.Item> 36 37 <Form.Item 38 label="备注" 39 name="desc" 40 rules={[{ required: false, 41 message: "请输入备注" }]}> 42 <TextArea rows={4}/> 43 </Form.Item> 44 45 <Form.Item 46 label="上传文件" 47 name="file" 48 rules={[{ required: true, 49 message: "请上传文件" }]}> 50 <Upload> 51 <Button icon={<UploadOutlined />}>开始上传</Button> 52 </Upload> 53 </Form.Item> 54 55 <Form.Item 56 {...tailLayout}> 57 <Space> 58 <Button type="primary" htmlType="submit"> 59 确认 60 </Button> 61 <Button onClick={props.handleCancel}> 62 取消 63 </Button> 64 </Space> 65 </Form.Item> 66 </Form> 67 </> 68 ) 69 }
可以看到取消按钮的onClick会调用props.handleCancel,也就是我们刚才在ModalContainer中所做的注入。现在对话框里应该多了2个按钮,点击“取消”会关闭对话框,而点击确认则会提示表单还没填完(因为这两项我们设置了required: true):
除此之外,我们还需要有个函数来负责点击“确认”按钮后的一系列步骤,我们自然希望点击确认后会提交表单,然后关闭窗口我们可以用axios来负责处理相关的request,但更重要的问题是,我们如何获得表单内填写的内容呢?antd的表单提供了getFieldValue和getFieldsValue这两个函数,但在实际使用过程中总是无法正确地取到值(感觉这俩更像是获取props而非states),不过我们可以通过自己写一个handleSubmit函数,并使之与表单的onFinish挂钩来完成取值过程。
… export default function UploadFile(props) { const [form] = Form.useForm(); //用于之后取数据 const handleSubmit = values => { console.log(values) props.handleCancel(); } return ( <> <Form {...layout} form={form} name="upload_file" onFinish={handleSubmit}> … </Form> </> ) }
当我们提交表单时,会触发onFinish,而它会调用handleSubmit来记录下表单的值,并且关闭对话框,我们来测试一下看看:
OK,我们成功地记录下了表单的值(虽然我们的文件并没有真正地传到后台)。
2、动态获取文件名
接下来我想做的一件事是,我每次只上传一个文件,当上传文件时,文件名称一栏会自动改成该文件的名称(不包括扩展名),这样一来我可以直接采用默认的文件名来提交表单了。
要实现这一点,我们首先得需要将上传的文件限制在1个,为此我们需要一个文件列表,并给Upload组件的onChange传入一个handleChange函数,在每次上传新文件时,新的文件会顶掉文件列表中的旧文件,再把文件列表传回给Upload组件的fileList,从而实现实时更新,并限制在1个文件:
import React, { useState } from 'react'; … export default function UploadFile(props) { const [form] = Form.useForm(); //用于之后取数据 const [fileList, setFileList] = useState(null); //文件列表 console.log(fileList) … const handleChange = (info) => { let files = [...info.fileList]; files = files.slice(-1); setFileList(files); } return ( <> <Form {...layout} form={form} name="upload_file" onFinish={handleSubmit}> … <Form.Item label="上传文件" name="file" rules={[{ required: true, message: "请上传文件" }]}> <Upload onChange={handleChange} fileList={fileList}> <Button icon={<UploadOutlined />}>开始上传</Button> </Upload> </Form.Item> … </Form> </> ) }
我们可以看到每次文件上传后,新的会替换旧的,且列表内永远只有1个文件:
那接下来就是获取文件名了,同样,我们用一个变量负责记录文件名,同样在handleChange中完成提取文件名和变更该变量,最后我们用表单的setFieldsValue和useEffect来实时更新文件名称一栏:
import React, { useState, useEffect } from 'react'; … export default function UploadFile(props) { const [form] = Form.useForm(); //用于之后取数据 const [fileList, setFileList] = useState(null); //文件列表 const [fileName, setFileName] = useState(null); //文件名 console.log(fileName) … const handleChange = (info) => { let files = [...info.fileList]; files = files.slice(-1); setFileList(files); if (files && files.length > 0) { const [fname, fextname] = files[0]["name"].split(/\.(?=[^\.]+$)/); //分割文件名 setFileName(fname); } } useEffect(() => { //实时更新 form.setFieldsValue({ name: fileName, }); }, [fileName]); return ( <> <Form {...layout} form={form} name="upload_file" onFinish={handleSubmit}> … </Form> </> ) }
我们可以看到,每次上传都会自动改变文件名称了:
至此,前端部分初步完成了。
3、捯饬后端
后端相对简单,新建一个uf_api文件夹作为后端部分,cd进入,npm init来进行初始化。另外我们再新建一个dest文件夹,作为存放我们上传的文件的地方。
我们可以按照上一篇中第2部分的方法(见https://www.cnblogs.com/silence-gtx/p/14092286.html)来搭建后端以及配置tsconfig.json,由于暂时不需要数据库,所以其他部分可以暂时忽略。
我们在src目录下新建controllers文件夹,并新建一个UploadController.ts来作为处理上传功能的controller:
接下来,我们会使用multer来负责处理上传的文件,并将其储存到硬盘上的指定位置,还有fs负责创建文件夹的操作。我们还需要tsoa来帮助我们更方便地写controller(全部npm install 一下)。
1 import { 2 Controller, 3 Request, 4 Post, 5 Route 6 } from 'tsoa'; 7 import express from 'express'; 8 const multer = require('multer'); 9 const fs = require('fs'); 10 11 const storagePath = "F:/node_project/upload_file/dest"; //设置储存路径 12 13 @Route("upload") 14 export class UploadController extends Controller { 15 @Post("/") 16 public async uploadFile( 17 @Request() request: express.Request 18 ): Promise<any> { 19 await this.handleUpload(request); 20 return true; 21 } 22 23 private async createFolder (folder: string) { 24 //创建文件夹,若文件夹已存在,则跳过 25 try { 26 fs.accessSync(folder); 27 console.log('目标文件夹已创建') 28 } catch (error) { 29 fs.mkdirSync(folder); 30 console.log('创建目标文件夹') 31 } 32 }; 33 34 private async handleUpload ( 35 request: express.Request 36 ): Promise<void> { 37 this.createFolder(storagePath); 38 39 var storage = multer.diskStorage({ 40 destination: function ( 41 req: express.Request, 42 file: any, 43 callback:any) { 44 callback(null, storagePath) 45 }, //负责处理路径 46 filename: function ( 47 req: express.Request, 48 file: any, 49 callback:any 50 ) { 51 console.log(file) 52 callback(null, file.originalname) 53 } //负责处理文件名,originalname为你上传文件的名称 54 }); 55 56 var upload = multer({ storage: storage }); 57 58 const multerSingle = upload.single("file"); 59 // 前端传过来的form-data应该将上传的文件放在file下,即form-data包含 {"file": 你的文件} 60 // antd的Upload组件会自动使用"file" 61 62 return new Promise((resolve, reject) => { 63 multerSingle(request, undefined, async (error: any) => { 64 if (error) { 65 reject(error); 66 } 67 resolve(); 68 console.log("文件已上传") 69 }); 70 }); 71 } 72 }
UploadController包含3个函数,公有函数负责暴露接口并调用handleUpload来处理文件的上传,私有函数createFolder负责创建目标文件夹(如果已经有了就自动忽略),私有函数handleUpload负责具体的文件上传逻辑(注意,返回的类型必须是void,否则会报错)。
接下来,我们在uf_api根目录下创建tsoa的配置文件tsoa.json,并在src下新建routes,然后,和上一期一样,在cmd中执行yarn run tsoa routes生成路由:
1 { 2 "entryFile": "src/app.ts", 3 "noImplicitAdditionalProperties": "throw-on-extras", 4 "controllerPathGlobs": ["src/**/*Controller.ts"], 5 "spec": { 6 "outputDirectory": "src/routes", 7 "specVersion": 3 8 }, 9 "routes": { 10 "routesDir": "src/routes" 11 } 12 }
同样地,在app.ts中加入路由(另外,别忘了cors,否则一会儿就没法跟前端连通了):
1 import express from 'express'; 2 import bodyParser from 'body-parser'; 3 var cors = require('cors'); 4 import { RegisterRoutes } from "./routes/routes"; 5 6 // 创建一个express实例 7 const app: express.Application = express(); 8 9 var corsOptions = { 10 credentials:true, 11 origin:'http://localhost:3000', 12 optionsSuccessStatus:200 13 }; 14 app.use(cors(corsOptions)); 15 16 app.use( 17 bodyParser.urlencoded({ 18 extended: true, 19 }) 20 ); 21 app.use(bodyParser.json()); 22 23 RegisterRoutes(app); // 添加路由 24 25 app.listen(5000, ()=> { 26 console.log('Example app listening on port 5000!'); 27 });
接着把package.json中的start的值改为tsoa spec-and-routes && tsc && node ./build/app.js,然后我们npm start试试:
接下来,我们用Postman测试一下看看,输入Controller对应的地址,调成POST请求之后,在下方的Body中选择form-data,然后在KEY一栏就可以选择是Text还是File,测试上传文件的话我们就选File:
回忆一下刚才controller的注释中有提到我们的文件需要对应”file”,因此,我们在KEY这儿填上file,VALUE就上传一个文件,点击Send,成功的话应该会返回一个true:
我们可以看看console里的内容:
再到dest下看看:
文件已经成功上传,跑通了。接下来就可以试着贯通前后端了。
4、连通与改进
我们再回到前端的UploadFile.js,将后端的地址加入到Upload的action中去:
1 import React, { useState, useEffect } from 'react'; 2 import { 3 Upload, 4 Form, 5 Button, 6 Input, 7 Space } from 'antd'; 8 import { UploadOutlined } from '@ant-design/icons'; 9 10 const layout = { 11 labelCol: { span: 6 }, 12 wrapperCol: { span: 18 }, 13 }; //表单的整体布局 14 const tailLayout = { 15 wrapperCol: { offset: 16, span: 8 }, 16 }; //取消和确定按钮的布局 17 18 const { TextArea } = Input; 19 20 export default function UploadFile(props) { 21 const [form] = Form.useForm(); //用于之后取数据 22 const [fileList, setFileList] = useState(null); //文件列表 23 const [fileName, setFileName] = useState(null); //文件名 24 console.log(fileName) 25 26 const handleSubmit = values => { 27 console.log(values) 28 props.handleCancel(); 29 } 30 31 const handleChange = (info) => { 32 let files = [...info.fileList]; 33 files = files.slice(-1); 34 setFileList(files); 35 36 if (files && files.length > 0) { 37 const [fname, fextname] = files[0]["name"].split(/\.(?=[^\.]+$)/); //分割文件名 38 setFileName(fname); 39 } 40 } 41 42 useEffect(() => { 43 //实时更新 44 form.setFieldsValue({ 45 name: fileName, 46 }); 47 }, [fileName]); 48 49 return ( 50 <> 51 <Form 52 {...layout} 53 form={form} 54 name="upload_file" 55 onFinish={handleSubmit}> 56 57 <Form.Item 58 label="文件名称" 59 name="name" 60 rules={[{ required: true, 61 message: "请输入文件名" }]}> 62 <Input/> 63 </Form.Item> 64 65 <Form.Item 66 label="备注" 67 name="desc" 68 rules={[{ required: false, 69 message: "请输入备注" }]}> 70 <TextArea rows={4}/> 71 </Form.Item> 72 73 <Form.Item 74 label="上传文件" 75 name="file" 76 rules={[{ required: true, 77 message: "请上传文件" }]}> 78 <Upload 79 action="http://localhost:5000/upload" 80 onChange={handleChange} 81 fileList={fileList}> 82 <Button icon={<UploadOutlined />}>开始上传</Button> 83 </Upload> 84 </Form.Item> 85 86 <Form.Item 87 {...tailLayout}> 88 <Space> 89 <Button type="primary" htmlType="submit"> 90 确认 91 </Button> 92 <Button onClick={props.handleCancel}> 93 取消 94 </Button> 95 </Space> 96 </Form.Item> 97 </Form> 98 </> 99 ) 100 }
我们试一试:
前后端的工作都毫无问题,成功!到此,我们已经初步完成了上传文件的功能。
但是转念一想,每次我们选择文件后,文件都会被直接上传到目标文件夹,并且直接覆盖同名文件。可是万一我只是手滑怎么办呢?为了避免这种现象,我想到的是单独再做一个上传按钮,每次选择文件后并不直接上传,需要点击按钮才能完成上传,即手动上传。此外,我还可以重命名我要上传的文件(比如用文件名称一栏的值代替原文件名再上传)。这样就可以一定程度上避免手滑的问题(当然,更保险一点应该做个文件已存在提示,不过本文没有做……)。
我们先来实现手动上传功能,我们要先去掉Upload的action以阻止默认上传行为的发生,然后自己用axios写一个来实现上传功能,此外再加个按钮以及一个handleUpload函数来触发上传功能(可以参考antd的手动上传案例)。
先从axios开始吧,新建一个UploadSvc.js:
1 import axios from 'axios'; 2 3 const api = "http://localhost:5000"; //后段地址 4 5 class UploadSvc { 6 uploadDataset(file) { 7 let config = { 8 headers: { 9 "Content-Type": "multipart/form-data" 10 } 11 } // 我们上传的是form-data 12 return new Promise((resolve) => resolve(axios.post(`${api}/upload`, file, config))); 13 } 14 } 15 16 export default new UploadSvc();
uploadDataset将接受一个file,然后以form-data的形式发送后端的相应路径。
接下来我们将UploadSvc引入UploadFile.js中,并加上其他东西:
1 import React, { useState, useEffect } from 'react'; 2 import { 3 Upload, 4 Form, 5 Button, 6 Input, 7 Space, 8 message } from 'antd'; 9 import { UploadOutlined } from '@ant-design/icons'; 10 import UploadSvc from "./UploadSvc"; 11 12 const layout = { 13 labelCol: { span: 6 }, 14 wrapperCol: { span: 18 }, 15 }; //表单的整体布局 16 const tailLayout = { 17 wrapperCol: { offset: 16, span: 8 }, 18 }; //取消和确定按钮的布局 19 20 const { TextArea } = Input; 21 22 export default function UploadFile(props) { 23 const [form] = Form.useForm(); //用于之后取数据 24 const [fileList, setFileList] = useState(null); //文件列表 25 const [fileName, setFileName] = useState(null); //文件名 26 const [uploadName, setUploadName] = useState(null); //文件全名(包含扩展名) 27 const [uploading, setUploading] = useState(false); //是否在上传中 28 29 console.log(uploadName) 30 31 const handleSubmit = values => { 32 //处理表单提交 33 console.log(values) 34 props.handleCancel(); 35 } 36 37 const handleChange = (info) => { 38 //处理文件名一栏根据上传文件改变自动变化 39 let files = [...info.fileList]; 40 files = files.slice(-1); 41 setFileList(files); 42 43 if (files && files.length > 0) { 44 const [fname, fextname] = files[0]["name"].split(/\.(?=[^\.]+$)/); //分割文件名 45 setFileName(fname); 46 setUploadName(files[0]["name"]); //设置全名 47 } 48 } 49 50 const handleUpload = () => { 51 //处理手动上传 52 const formData = new FormData(); 53 if (fileList && fileName !== "") { 54 let file = fileList[0] 55 formData.append('file', file.originFileObj, uploadName); //一定要用file.originFileObj!!! 56 setUploading(true) //设置状态为上传中 57 58 UploadSvc.uploadDataset(formData) 59 .then(response => { 60 message.success(`文件 ${uploadName} 已上传`, 2) //成功的提示消息将持续2秒 61 setUploading(false) //重置上传状态 62 }) 63 } 64 } 65 66 useEffect(() => { 67 //实时更新 68 form.setFieldsValue({ 69 name: fileName, 70 }); 71 }, [fileName]); 72 73 return ( 74 <> 75 <Form 76 {...layout} 77 form={form} 78 name="upload_file" 79 onFinish={handleSubmit}> 80 81 <Form.Item 82 label="文件名称" 83 name="name" 84 rules={[{ required: true, 85 message: "请输入文件名" }]}> 86 <Input/> 87 </Form.Item> 88 89 <Form.Item 90 label="备注" 91 name="desc" 92 rules={[{ required: false, 93 message: "请输入备注" }]}> 94 <TextArea rows={4}/> 95 </Form.Item> 96 97 <Form.Item 98 label="上传文件" 99 name="file" 100 rules={[{ required: true, 101 message: "请上传文件" }]}> 102 <Upload 103 onChange={handleChange} 104 fileList={fileList}> 105 <Button icon={<UploadOutlined />}>开始上传</Button> 106 </Upload> 107 <Button 108 type="primary" 109 onClick={handleUpload} 110 disabled={fileList === null || fileList.length === 0} 111 loading={uploading} 112 style={{ marginTop: 16 }} 113 > 114 {uploading ? "上传中..." : "开始上传"} 115 </Button> 116 </Form.Item> 117 118 <Form.Item 119 {...tailLayout}> 120 <Space> 121 <Button type="primary" htmlType="submit"> 122 确认 123 </Button> 124 <Button onClick={props.handleCancel}> 125 取消 126 </Button> 127 </Space> 128 </Form.Item> 129 </Form> 130 </> 131 ) 132 }
这里千万要注意的是,我们使用formData.append给formData添加要上传的文件时,一定要使用file.originFileObj而不是file本身(卡了我几个小时的一个点)!因为antd默认去除的这个file并不是文件本体,而是包含了一些metadata的一个Object,其中的originFileObj才是真正的文件本体。
现在我们来试试,选择文件,不点击“开始上传”按钮:
可以看到Upload默认的上传已经由于缺少action而失败,此时后端的console是没有任何反应的,说明上传没有发生。然后我们点击“开始上传”:
可以看到这次弹出了上传成功的消息,查看一下后端:
上传确实成功了。
然后,我们还发现,即使我们修改了文件名称一栏,上传后的文件名依然没有改变,为了实现这一点,我们需要Form.Provider:
import React, { useState, useEffect } from 'react'; import { Upload, Form, Button, Input, Space, message } from 'antd'; import { UploadOutlined } from '@ant-design/icons'; import UploadSvc from "./UploadSvc"; … export default function UploadFile(props) { const [form] = Form.useForm(); //用于之后取数据 const [fileList, setFileList] = useState(null); //文件列表 const [fileName, setFileName] = useState(null); //文件名 const [extName, setExtName] = useState(null); //扩展名 const [uploadName, setUploadName] = useState(null); //文件全名(包含扩展名) const [uploading, setUploading] = useState(false); //是否在上传中 console.log(uploadName) … const handleChange = (info) => { //处理文件名一栏根据上传文件改变自动变化 let files = [...info.fileList]; files = files.slice(-1); setFileList(files); if (files && files.length > 0) { const [fname, fextname] = files[0]["name"].split(/\.(?=[^\.]+$)/); //分割文件名 setFileName(fname); //设置文件名 setExtName(fextname); //设置扩展名 setUploadName(files[0]["name"]); //设置全名 } } … return ( <> <Form.Provider onFormChange={ () => uploadName && setUploadName(form.getFieldValue('name') + '.' + extName) }> <Form {...layout} form={form} name="upload_file" onFinish={handleSubmit}> … </Form> </Form.Provider> </> ) }
我们通过它的onFormChange,来实现每次表单内容更新时,更新uploadName这个变量。现在我们可以试试我们的功能有没有成功:
看一下dest,发现成功上传了新的文件:
最后,加上一点细节:
1 import React, { useState, useEffect } from 'react'; 2 import { 3 Upload, 4 Form, 5 Button, 6 Input, 7 Space, 8 message } from 'antd'; 9 import { UploadOutlined } from '@ant-design/icons'; 10 import UploadSvc from "./UploadSvc"; 11 12 const layout = { 13 labelCol: { span: 6 }, 14 wrapperCol: { span: 18 }, 15 }; //表单的整体布局 16 const tailLayout = { 17 wrapperCol: { offset: 16, span: 8 }, 18 }; //取消和确定按钮的布局 19 20 const { TextArea } = Input; 21 22 export default function UploadFile(props) { 23 const [form] = Form.useForm(); //用于之后取数据 24 const [fileList, setFileList] = useState(null); //文件列表 25 const [fileName, setFileName] = useState(null); //文件名 26 const [extName, setExtName] = useState(null); //扩展名 27 const [uploadName, setUploadName] = useState(null); //文件全名(包含扩展名) 28 const [uploading, setUploading] = useState(false); //是否在上传中 29 30 console.log(uploadName) 31 32 const handleSubmit = values => { 33 //处理表单提交 34 console.log(values) 35 props.handleCancel(); 36 } 37 38 const handleChange = (info) => { 39 //处理文件名一栏根据上传文件改变自动变化 40 let files = [...info.fileList]; 41 files = files.slice(-1); 42 setFileList(files); 43 44 if (files && files.length > 0) { 45 const [fname, fextname] = files[0]["name"].split(/\.(?=[^\.]+$)/); //分割文件名 46 setFileName(fname); //设置文件名 47 setExtName(fextname); //设置扩展名 48 setUploadName(files[0]["name"]); //设置全名 49 } 50 } 51 52 const handleUpload = () => { 53 //处理手动上传 54 const formData = new FormData(); 55 if (fileList && fileName !== "") { 56 let file = fileList[0] 57 formData.append('file', file.originFileObj, uploadName); //一定要用file.originFileObj!!! 58 setUploading(true) //设置状态为上传中 59 60 UploadSvc.uploadDataset(formData) 61 .then(response => { 62 message.success(`文件 ${uploadName} 已上传`, 2) //成功的提示消息将持续2秒 63 setUploading(false) //重置上传状态 64 form.setFieldsValue({ 65 file: true //满足data的required: true 66 }) 67 }) 68 } 69 } 70 71 useEffect(() => { 72 //实时更新 73 form.setFieldsValue({ 74 name: fileName, 75 }); 76 }, [fileName]); 77 78 return ( 79 <> 80 <Form.Provider 81 onFormChange={ 82 () => uploadName && setUploadName(form.getFieldValue('name') + '.' + extName) 83 }> 84 <Form 85 {...layout} 86 form={form} 87 name="upload_file" 88 onFinish={handleSubmit}> 89 90 <Form.Item 91 label="文件名称" 92 name="name" 93 rules={[{ required: true, 94 message: "请输入文件名" }]}> 95 <Input/> 96 </Form.Item> 97 98 <Form.Item 99 label="备注" 100 name="desc" 101 rules={[{ required: false, 102 message: "请输入备注" }]}> 103 <TextArea rows={4}/> 104 </Form.Item> 105 106 <Form.Item 107 label="上传文件" 108 name="file" 109 rules={[{ required: true, 110 message: "请上传文件" }]}> 111 <Upload 112 onChange={handleChange} 113 beforeUpload={file => { 114 fileList && setFileList([...fileList, file]) 115 return false; 116 }} 117 fileList={fileList}> 118 <Button icon={<UploadOutlined />}>开始上传</Button> 119 </Upload> 120 <Button 121 type="primary" 122 onClick={handleUpload} 123 disabled={fileList === null || fileList.length === 0} 124 loading={uploading} 125 style={{ marginTop: 16 }} 126 > 127 {uploading ? "上传中..." : "开始上传"} 128 </Button> 129 </Form.Item> 130 131 <Form.Item 132 {...tailLayout}> 133 <Space> 134 <Button type="primary" htmlType="submit"> 135 确认 136 </Button> 137 <Button onClick={props.handleCancel}> 138 取消 139 </Button> 140 </Space> 141 </Form.Item> 142 </Form> 143 </Form.Provider> 144 </> 145 ) 146 }
通过给Upload添加beforeUpload来避免触发默认上传行为(Not Found报错,文件名发红)。
此外,由于上传文件(file部分)的required是true,也就是说是表单中必须要填写的内容,如果我们上传完就此提交,会报错,导致无法提交表单:
我们需要在handleUpload中给Form的file部分设置一个值来满足required的要求。这样一来,我们就可以顺利提交了:
一套比较完整的文件上传就做好了。
代码见:
https://github.com/SilenceGTX/upload_file