最近项目里涉及到一些功能点,难度系数比较大,完成后想统一总结一下。
话不多说,先看效果及其功能点:
效果如下:
功能一:实现不同用户资源管理展示,默认展示根目录下的文件及文件夹
功能二:实现上传功能,支持单个、多个文件的上传,支持整个文件夹上传
功能三:实现点击目录可进入子目录,点击面包屑切换到对应目录层级
功能四:实现创建文件夹功能
在这四个大功能里面,难度最大的当属文件夹上传
,因为需要递归去操作一整套上传流程,下面会详细讲到。
1.资源管理展示
涉及Antd UI组件:Menu菜单项、下拉菜单Dropdown、按钮Button、图标Icon、表格Table
绘制上传、新建文件夹按钮:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| const UploadMenu = (props) => ( <Menu> <Menu.Item>上传文件</Menu.Item> <Menu.Item>上传文件夹</Menu.Item> </Menu> ); ... <Dropdown overlay={<UploadMenu/>} placement="bottomCenter"> <Button type="primary"><Icon type="upload" />上传</Button> </Dropdown> <Button> <Icon type="folder-add" />新建文件夹 </Button>
|
绘制资源管理列表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| columns = [ { dataIndex: 'name', title: '文件名称', render: (_, record) => ( <Fragment> <img src={record.icon_url} /> <span>{record.name}</span> </Fragment> ), }, { dataIndex: 'update_time', title: '修改时间' }, { dataIndex: 'file_size_str',title: '大小' }, ] ... <Table columns={this.columns} scroll={{ y: document.body.clientHeight - 300 }} rowKey="id" pagination={false} ></Table>
|
值得注意的是渲染该列表时,名称前面的图标会根据文件不同的类型展示出不同的icon,这个逻辑后端已经帮忙处理,假如没处理的话,会返回file_type类型,自己判断并展示。
请求根目录数据,并渲染:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| componentDidMount() { this.fetchGetFile(0) } ... fetchGetFile = (folder_id) => { const { dispatch, } = this.props dispatch({ type: 'tree/getFolderList', payload: { folder_id, }, }) } ... namespace: 'tree', effects:{ * getFolderList({ payload }, { call, put }) { const url = '/partner/folder/get' const response = yield call(sendPostRequest, url, payload) if (response && response.retcode === 'success') { const folder = response.data yield put({ type: 'saveFolder', payload: { folder, }, }) } else { notification.error({ message: response.description }); } }, } ... @connect(({ tree, loading, }) => ({ tree, fetchLoading: loading.effects['tree/getFolderList'], })) class CloudResource extends PureComponent { render() { const { tree: { folder: { children } }, } = this.props; return ( <Fragment> <Table dataSource={children || []} loading={fetchLoading} ></Table> </Fragment> ) } }
|
2.实现点击目录可进入子目录,点击面包屑切换到对应目录层级
涉及Antd UI组件:表格Table、面包屑Breadcrumb
面包屑指的:全部文件/img_test1/child2/child2_1
思路分析:
默认展示根目录,每进入一个子目录,将该子目录信息放入面包屑中且获取该目录数据;
然后切换的时候,将选择的子目录其后面的目录都从面包屑中移除且获取该目录数据。
具体实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
| <Table onRow={record => ({ onClick: () => { if (record.obj_type === 'folder') { this.fetchGetFile(record.id) const { breadValue } = this.state breadValue.push(record) this.setState({ breadValue, }) } }, })} ></Table> ... constructor(props) { super(props) this.state = { breadValue: [], } } ... render() { const { breadValue } = this.state; return ( <Fragment> <Breadcrumb> <Breadcrumb.Item> <a onClick={this.selectBreadFirst}>全部文件</a> </Breadcrumb.Item> {breadValue.map((item, index) => ( <Breadcrumb.Item key={index}> <a onClick={() => { this.selectBread(index) }}>{item.name}</a> </Breadcrumb.Item> ))} </Breadcrumb> </Fragment> ) } ... selectBreadFirst = () => { this.fetchGetFile(0); this.setState({ breadValue: [], }) } selectBread = (index) => { const { breadValue } = this.state; this.fetchGetFile(breadValue[index].id) breadValue.splice(index + 1, breadValue.length); this.setState({ breadValue, }) };
|
3.实现创建文件夹功能
涉及Antd UI组件:表格Table
表格Table组件尽管支持可编辑单元格、可编辑行,但没有像这样只编辑一行一个单元格,因此需要自己自定义来实现。
而且为了一个输入框,没必要使用复杂的const {Provider, Consumer} = React.createContext(defaultValue)
来实现。
思路分析:
方案一:使用原生DOM直接去操作该Table,在它前面新增个tr
方案二:通过其封装的dataSource属性实现
两种方案都试过,只能使用方案二去实现,老老实实地使用Antd提供的组件属性和方法
。
方案一试过直接去操作table,会出现失去点击事件等问题,毕竟事件是在初始化table时注册的,自己操作DOM新增一行tr,该tr是无法被事件捕获的,而且即使添加上去,也无法使用当前react组件类下的某个方法。
Antd组件自身封装一系列DOM事件及属性,不按照它的规则去写,很难实现相应业务,除非你不用它去写业务。
确定思路:
点击新增文件夹时,在dataSource数组前面放入一条id为0的数据,在渲染的一行的时候去判断,如果id为0,则文件名称这个单元格内,出现输入框,确认、取消按钮。
点击确认,发起创建文件夹请求;点击取消,则将dataSource数组前面id为0的数据移除。
值得注意的是当点击完新建文件夹按钮后,出来新建文件夹输入框后,再次点击新建文件夹按钮,不应该再添加id为0的数据,否则会出来多个输入框。
具体实现:
新建文件夹按钮添加事件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| <Button onClick={this.addFolder} > <Icon type="folder-add" />新建文件夹 </Button> ... addFolder = () => { const { dispatch, tree: { folder: { children } } } = this.props if (children.every(child => child.id !== 0)) { dispatch({ type: 'tree/saveNewFolder', payload: {}, }) } } ... namespace: 'tree', reducers: { saveNewFolder(state) { const { folder, folder: { children } } = state children.unshift({ id: 0, icon_url: 'http://file-icon.cn/folder.png', name: '新建文件夹', file_size_str: '--', update_time: moment().format('YYYY-MM-DD HH:mm:ss'), }) folder.children = children return { ...state, folder, } }, }
|
表格table加判断:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
| constructor(props) { super(props) this.state = { folder_name: '新建文件夹', } } ... columns = [ { dataIndex: 'name', title: '文件名称', render: (_, record) => ( <Fragment> <img src={record.icon_url} /> {record.id === 0 ? <Fragment> <Input value={this.state.folder_name} onChange={this.changeFolderName} /> <img src={sure} onClick={this.createFolder} /> <img src={cancle} onClick={this.cancleFolder} /> </Fragment> : <span>{record.name}</span> } </Fragment> ), }, { dataIndex: 'update_time', title: '修改时间' }, { dataIndex: 'file_size_str',title: '大小' }, ] ... <Table columns={this.columns} ></Table> ...
changeFolderName = (e) => { this.setState({ folder_name: e.target.value, }) }
createFolder = () => { const { dispatch } = this.props const { folder_name } = this.state dispatch({ type: 'tree/createFolder', payload: { folder_id: localStorage.getItem('folder_id'), name: folder_name, }, }) this.setState({ folder_name: '新建文件夹', }) }
cancleFolder = () => { const { dispatch } = this.props dispatch({ type: 'tree/cancleNewFolder', payload: {}, }) } ... namespace: 'tree', reducers: { cancleNewFolder(state) { const { folder, folder: { children } } = state children.shift() folder.children = children return { ...state, folder, } }, }
|
4.实现上传功能
这是该文章的最大难点
模块,因为会涉及到不同的上传情况,根据上传情况,我划分成单个文件上传
、多个文件上传
、整个文件夹上传
三个小模块。
4.1.单个文件上传
简单说下文件上传流程:选择文件,走ali-oss
,得到文件真实url
后,将url请求创建文件接口,实现单个文件的上传。
选择文件 -> ali-oss -> ajax请求 -> 完成
具体流程:
1.选择文件
通过<input type="file">
进行文件选择,选择后将浏览器获取到的file对象
转成ArrayBuffer二进制缓冲区对象
,后面实现阿里对象存储OSS需要用到。
2.ali-oss
得到二进制对象后,需使用oss-js-sdk实现阿里对象存储的上传,地址:https://github.com/ali-sdk/ali-osss。
首先去获取公司内部的oss token
,如果token已过期,则重新获取;未过期,则直接从缓存获取。
获取到token等信息后,创建oss对象,将arrayBuffer对象
转成buffer对象
,最后将其传入oss的存储空间bucket,上传文件成功后,返回文件路径url。(例如:http://bucket.oss-cn-hangzhou.aliyuncs.com/partner/file/26/15874546541662.png)
3.ajax请求
调用创建文件的接口,将真实的url和文件夹id进行绑定,形成记录。
4.完成
记录完成后,需要刷新当前列表页,至此文件上传所有流程结束。
涉及Antd UI组件:上传Upload
具体实现:
上传按钮的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| <Upload name='file' showUploadList={false} withCredentials beforeUpload={this.beforeUpload} >上传文件</Upload> ... beforeUpload = (file) => { const { OSSAddress, postData, tree: { folder }, } = this.props uploadFile(OSSAddress, postData, file).then((data) => { if (data.url) { console.log('上传oss 成功:', data.url) dispatch({ type: 'tree/createFile', payload: { name: file.name, file_size: file.size, file_url: data.url, folder_id: folder.id, }, }); this.fetchGetFile(folder.id) } else { console.log('上传oss 失败: '); } }).catch(err => console.error(err)) } ... namespace: 'tree', effects: { * createFile({ payload }, { call, put }) { const url = `/partner/file/create` const response = yield call(sendPostRequest, url, payload) if (response && response.retcode === 'success') { notification.success({ message: response.description }); } else { notification.error({ message: response.description }); } }, }
|
uploadFile是外面导出的方法,统一完成第二步ali-oss的操作。
uploadFile的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115
| import OSS from 'ali-oss'; import request from '@/utils/request';
const local_data_key = 'upload_oss_token_data';
function get_local_storage() { try { let local_data = localStorage.getItem(local_data_key); if (local_data) { let dataObj = JSON.parse(local_data); if (new Date(dataObj.Expiration).getTime() > new Date().getTime()) { return dataObj; } } } catch (e) { console.error('get local storage error!'); } return null; }
function getOSSToken(tokenUrl, postData) { return new Promise((resolve, reject) => { let ossTokenData = get_local_storage(); if (ossTokenData) { resolve(ossTokenData); } else { request(tokenUrl, { method: 'POST', data: postData, }).then(({ data, retcode }) => { if (retcode !== 'success') { console.error( 'Retrieve ali oss STS token error, errcode: ', ); reject({}); } else { localStorage.setItem(local_data_key, JSON.stringify(data)); resolve(data); } }); } }); }
export default (tokenUrl, postData, file) => { const suffix = file.name.slice(file.name.lastIndexOf('.')); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.readAsArrayBuffer(file); reader.onload = event => { getOSSToken(tokenUrl, postData).then(ossToken => { const { endpoint, AccessKeyId, AccessKeySecret, objectKey, SecurityToken, bucketName, } = ossToken; if ( !( endpoint && AccessKeyId && AccessKeySecret && objectKey && SecurityToken && bucketName ) ) { return reject(`OSS token invalid, ${ossToken}`); }
const client = new OSS({ accessKeyId: AccessKeyId, accessKeySecret: AccessKeySecret, stsToken: SecurityToken, bucket: bucketName, }); const timestamp = new Date().getTime(); const rnumber = Math.floor(Math.random() * 10 + 1); const filePath = `${objectKey}${timestamp}${rnumber}${suffix}`;
const buffer = new OSS.Buffer(event.target.result); client .put(filePath, buffer) .then(result => { resolve(result); }) .catch(ex => { resolve(ex); }); }); }; }); };
|
4.2.多个文件上传
重复走单个文件上传流程
,就实现多个文件上传
。和单个文件上传比,唯一的区别就是在选择文件
上。Antd Upload组件,默认是单个上传,只需要增加一个multiple
属性即可实现多个文件上传。
1 2 3 4 5 6 7
| <Upload multiple name='file' showUploadList={false} withCredentials beforeUpload={this.beforeUpload} >上传文件</Upload>
|
在多个文件上传的时候,beforeUpload
方法可以有第二个参数fileList,表示选择上传的文件集合,file表示当前上传的文件对象。
1 2 3
| beforeUpload = (file, fileList) => { ... }
|
4.3.文件夹上传
终于开始说到文件夹上传
的实现,也是本篇文章的精华之处。
先说下多个文件上传
和文件夹上传
的区别:
区别一:
前者是在同一级目录下进行多次单个文件的上传操作
;
后者是在不同级目录下进行多次单个文件的上传操作
,且每一层目录都需要进行多次单个文件的上传操作。
区别二:
前者是异步处理
,上传多个文件的时候,大小不同,上传文件的前后顺序可能会不同;
后者是同步处理
,文件夹上传,以计算机操作题.txt
举例,要想实现这个文件的上传,需要怎么做呢?
首先需要创建img_test
文件夹,获取到img_test文件夹的id后,才能继续创建child2
子文件夹,获取到child2文件夹的id后,才能继续创建child2_1
子文件夹,获取到child2_1文件夹的id后,才能开始走单个文件上传流程,最终上传计算机操作题.txt
成功。
这还只是一个文件呢?多个文件?多个目录?层层创建文件夹,层层上传?
那么,如何才能实现文件夹上传呢?
思路分析:
文件夹上传就是把所有文件夹下的文件
进行上传,只是在上传之前需要进行依次创建或读取文件夹id
的操作。
文件夹上传 = 依次创建/读取文件夹 + 多个文件上传
换句话说就是上传每一个文件的时候,通过webkitRelativePath
属性,可以获得该文件的整个路径。然后递归它前面的目录,调用getOrCreateFolder
接口,该接口第一次创建目录,后面就是查询该目录,返回目录id。
上图会有特别多的请求:
上传.DS_store
前,会创建img_test2文件夹,
上传直播课程内容导入模板.xlsx
前,会获取img_test2文件夹,
…
上传pdf2.png
前,会获取img_test2文件夹,然后创建child文件夹,
…
上传.DS_store
前,会获取img_test2文件夹,然后创建child2文件夹,
…
上传计算机操作题
前,会获取img_test2文件夹,然后获取child2文件夹,然后创建child_2_1文件夹
…
这个地方值得注意的是需要递归异步请求
,必须等待上一个getOrCreateFolder接口请求后,才能继续下一个getOrCreateFolder请求,不然到最后上传文件的时候,传的目录id会有问题,导致文件放的目录层级不对。
思路清楚后,看下具体实现:
首先需要控制input file只能上传文件夹,Antd Upload组件提供支持,只需增加一个directory
属性即可支持文件夹上传。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111
| <Upload directory multiple name='file' showUploadList={false} withCredentials beforeUpload={this.beforeUpload} >上传文件</Upload> ... beforeUpload = (file, fileList) => { const { OSSAddress, postData, tree: { folder }, } = this.props const mkdirNames = file.webkitRelativePath.split('/') mkdirNames.pop() dispatch({ type: 'tree/createFolder', payload: { mkdirNames, num: 0, folder_id: folder.id, }, }).then(folderId => { if(folderId) { uploadFile(OSSAddress, postData, file).then((data) => { if (data.url) { console.log('上传oss 成功:', data.url) dispatch({ type: 'tree/createFile', payload: { name: file.name, file_size: file.size, file_url: data.url, folder_id: folderId, }, }); } else { console.log('上传oss 失败: '); } }).catch(err => console.error(err)) } }) } ...
namespace: 'tree', * createFolder({ payload }, { call, put }) { const { mkdirNames } = payload let { num } = payload const url = `/partner/folder/goc` const response = yield call(sendPostRequest, url, { folder_id: payload.folder_id, name: mkdirNames[i], }) if (response && response.retcode === 'success') { num++; if (i < mkdirNames.length) { return yield put({ type: 'createFolder', payload: { mkdirNames, num, folder_id: response.data.id, }, }) } return response.data.id } notification.error({ message: response.description }); }, ...
constructor(props) { super(props) this.files = [] } beforeUpload = (file, fileList) => { const { files } = this ... uploadFile(OSSAddress, postData, file).then((data) => { if (data.url) { ... files.push(file) if (files.length === fileList.length) { console.log('所有文件上传完成,给与提示') this.files = [] } } }) }
|
至此,使用Ant Design UI库实现类似百度网盘的基本功能结束,下一篇文章会讲解更具难度的功能点,《使用Ant Design UI库实现异步加载树形结构文件夹管理》。