Blog Logo

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

写于2020-04-22 02:40 阅读耗时21分钟 阅读量


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

效果如下:

tree3


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

icloud_show


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

icloud_show


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

icloud_show


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

icloud_show


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


1.资源管理展示

icloud_show

涉及Antd UI组件:Menu菜单项、下拉菜单Dropdown、按钮Button、图标Icon、表格Table

绘制上传、新建文件夹按钮:

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>

绘制资源管理列表:

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


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

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 思路分析: 默认展示根目录,每进入一个子目录,将该子目录信息放入面包屑中且获取该目录数据; 然后切换的时候,将选择的子目录其后面的目录都从面包屑中移除且获取该目录数据。 具体实现:

<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

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

<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加判断:

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 具体实现: 上传按钮的实现:

// 第一步:选择文件
<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的实现:

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属性即可实现多个文件上传。

<Upload
  multiple
  name='file'
  showUploadList={false}
  withCredentials
  beforeUpload={this.beforeUpload}
>上传文件</Upload>

在多个文件上传的时候,beforeUpload方法可以有第二个参数fileList,表示选择上传的文件集合,file表示当前上传的文件对象。

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属性即可支持文件夹上传。

// 第一步:选择文件夹
<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库实现异步加载树形结构文件夹管理》。

Headshot of Maxi Ferreira

怀着敬畏之心,做好每一件事。