STAY HUNGRY , STAY FOOLISH.

求知若饥,虚心若愚。

       浏览:

使用Ant Design UI库实现类似百度网盘

最近项目里涉及到一些功能点,难度系数比较大,完成后想统一总结一下。
话不多说,先看效果及其功能点:

效果如下:

tree3


功能一:实现不同用户资源管理展示,默认展示根目录下的文件及文件夹

icloud_show


功能二:实现上传功能,支持单个、多个文件的上传,支持整个文件夹上传

icloud_show


功能三:实现点击目录可进入子目录,点击面包屑切换到对应目录层级

icloud_show


功能四:实现创建文件夹功能

icloud_show


在这四个大功能里面,难度最大的当属文件夹上传,因为需要递归去操作一整套上传流程,下面会详细讲到。


1.资源管理展示

icloud_show

涉及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类型,自己判断并展示。

icloud_show1


请求根目录数据,并渲染:

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.实现点击目录可进入子目录,点击面包屑切换到对应目录层级

icloud_show

涉及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.实现创建文件夹功能

icloud_show3

涉及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的数据,否则会出来多个输入框。

icloud_input

具体实现:
新建文件夹按钮添加事件:

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
// 判断表格数据中是否有id为0的数据,没有则添加
if (children.every(child => child.id !== 0)) {
dispatch({
type: 'tree/saveNewFolder',
payload: {},
})
}
}
...
namespace: 'tree',
reducers: {
saveNewFolder(state) {
const { folder, folder: { children } } = state
// 添加行的数据有,文件类型图标、名称、时间,及文件大小0KB
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
// 删除表格数据中id为0的数据,也就是数组前面第一个
children.shift()
folder.children = children
return {
...state,
folder,
}
},
}

4.实现上传功能

icloud_show

这是该文章的最大难点模块,因为会涉及到不同的上传情况,根据上传情况,我划分成单个文件上传多个文件上传整个文件夹上传三个小模块。

4.1.单个文件上传

upload_file

简单说下文件上传流程:选择文件,走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
// 第二步 ali-oss
uploadFile(OSSAddress, postData, file).then((data) => {
if (data.url) {
console.log('上传oss 成功:', data.url)
// 获取到oss返回的url
// 第三步 ajax请求
dispatch({
type: 'tree/createFile',
payload: {
// 传给后端的文件名、文件大小、文件路径,及文件夹id
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';

// 命名oss缓存的key
const local_data_key = 'upload_oss_token_data';

// 判断token是否过期
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;
}

// 获取缓存中的token
function getOSSToken(tokenUrl, postData) {
return new Promise((resolve, reject) => {
let ossTokenData = get_local_storage();
if (ossTokenData) {
// 如果没过期,直接取缓存中的token
resolve(ossTokenData);
} else {
// 如果过期,则去调用公司内部获取oss token接口,并将其放入缓存中
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) => {
// 使用 FileReader 的 readAsArrayBuffer 方法
const reader = new FileReader();
// 将file转成arrayBuffer
reader.readAsArrayBuffer(file);
// 转成arrayBuffer成功后,进入onload回调
reader.onload = event => {
// 获取 ali-oss STS token
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}`);
}

// 配置,生产oss对象
const client = new OSS({
// region: endpoint,
accessKeyId: AccessKeyId,
accessKeySecret: AccessKeySecret,
stsToken: SecurityToken,
bucket: bucketName,
});
// 文件名命名规范是时间戳+(1~10)随机数字
const timestamp = new Date().getTime();
const rnumber = Math.floor(Math.random() * 10 + 1);
// 拼装上传路径,objectKey是前面的路径信息
// 路径 + 文件夹名 + 后缀 生成最终的全路径
// 类似:partner/file/26/15874546541662.png
const filePath = `${objectKey}${timestamp}${rnumber}${suffix}`;

// 将arrayBuffer转buffer
const buffer = new OSS.Buffer(event.target.result);
// 上传
client
.put(filePath, buffer)
.then(result => {
// console.log('上传oss 成功:', result);
resolve(result);
})
.catch(ex => {
// console.log('上传oss 失败: ');
resolve(ex);
});
});
};
});
};

4.2.多个文件上传

upload_mul

重复走单个文件上传流程,就实现多个文件上传。和单个文件上传比,唯一的区别就是在选择文件上。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.文件夹上传

upload_folder


upload_dir

终于开始说到文件夹上传的实现,也是本篇文章的精华之处。
先说下多个文件上传文件夹上传的区别:
区别一:
前者是在同一级目录下进行多次单个文件的上传操作
后者是在不同级目录下进行多次单个文件的上传操作,且每一层目录都需要进行多次单个文件的上传操作。

区别二:
前者是异步处理,上传多个文件的时候,大小不同,上传文件的前后顺序可能会不同;
后者是同步处理,文件夹上传,以计算机操作题.txt举例,要想实现这个文件的上传,需要怎么做呢?
首先需要创建img_test文件夹,获取到img_test文件夹的id后,才能继续创建child2子文件夹,获取到child2文件夹的id后,才能继续创建child2_1子文件夹,获取到child2_1文件夹的id后,才能开始走单个文件上传流程,最终上传计算机操作题.txt成功。
这还只是一个文件呢?多个文件?多个目录?层层创建文件夹,层层上传?

那么,如何才能实现文件夹上传呢?

思路分析:
文件夹上传就是把所有文件夹下的文件进行上传,只是在上传之前需要进行依次创建或读取文件夹id的操作。

文件夹上传 = 依次创建/读取文件夹 + 多个文件上传

换句话说就是上传每一个文件的时候,通过webkitRelativePath属性,可以获得该文件的整个路径。然后递归它前面的目录,调用getOrCreateFolder接口,该接口第一次创建目录,后面就是查询该目录,返回目录id。

file_dirs


上图会有特别多的请求:
上传.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('/')
// 获取该文件前面的目录
// 举例:路径:img_test2/child2/child2_1/计算机操作题.txt
// mkdirNames值为:["img_test2", "child2", "child2_1"]
mkdirNames.pop()
dispatch({
type: 'tree/createFolder',
payload: {
mkdirNames,
num: 0, // 递归的标志,每创建文件夹+1
folder_id: folder.id,
},
}).then(folderId => {
// 递归结束后,获取到最后的目录id,准备上传文件
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') {
// 创建文件夹成功,递归标志+1
num++;
// 如果递归的次数小于该文件前面的目录的总数,则继续创建下一层文件夹
if (i < mkdirNames.length) {
// 需要return继续创建文件夹,同时目录id是刚创建完的id
return yield put({
type: 'createFolder',
payload: {
mkdirNames,
num,
folder_id: response.data.id,
},
})
}
// 该文件前面的目录都创建完毕后,返回最后的目录id
return response.data.id
}
notification.error({ message: response.description });
},
...
// 继续第四步创建文件的操作
// 需要在上传完所有文件后,给与 所有文件上传成功 的提示,该怎么实现呢?
// 加个files状态,在创建文件的地方记录一下。通过与上传前的fileList的数量做对比,数量一样,确定所有文件都上传成功。
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库实现异步加载树形结构文件夹管理》。