不可思议el-tree与el-table两个组合肝完这个奇葩需求,被自己的能力吓坏了

6,934

前言

Hello 大家好,我是虚竹,前段时间忙着寻找大哥和三弟的踪迹,耽误了技术文章输出,趁中途栖息时间赶紧补上一篇水文。今天给各位看官讲一讲我的刨坑之旅,因为最近开发了一个项目业务上遇到一个感觉有点啪啪的复杂需求(目录树+复选框+分页+检索)。多数大牛们看了这个功能需求,都会立马跳出来说,不难不难,这不就是开发一个网盘么,类似百度网盘那种。非也非也,先看看再说!

1.jpg

当时有两种选择方案要么用el-tree,要么用el-table,简单的过了一下这两个UI组件的用法就动手了,并根据实际业务需求,也为了偷个懒就采用ElementUI中的el-table,中途开发很顺利,做着做着却发现不对劲,于是乎推倒重来,我的天哪,程序猿就是这样磨练出来的,加油!通过深思熟虑研究一番后,业务组件最终选用el-tree和el-table两者结合使用,经过不断折腾,摸索多次尝试,已经可以满足大部分业务需求了。做出来后有的需求感觉不是很合理,就跟产品后端沟通一下做了一些业务实现上的调整。

应用场景

1.png

1.png

3.png

根据项目实际业务需求自己做了一个Demo简约版,演示地址👉:http://106.55.168.13:8083/

2.png

欢迎各位大佬一起讨论交流,多多给点建议或其他最优解决方案,也可提些BUG,提些各种奇葩的需求,看我是否能实现,哈哈。🤟

目前有个新需求想寻求大佬们指点迷津,是这样的总共分两级目录嵌套,一二级目录列表需分页展示含检索功能,并保证二级目录也可以分页(加载更多)。👋

功能需求描述

  • 目录树结构列表显示(后端不支持分页)
  • 目录树默认全部展开
  • 处理时间日期JS库 Day.js
  • 根据文件后缀显示不同图标
  • 配置全局管道方法
  • 文件大小单位换上
  • 目录树复选框单选
  • 全选反选
  • 鼠标移入图标文件名提示
  • 递归算法统计文件总数
  • 快速检索目录或文件(后端不支持批量下载和分享)
  • 批量下载、分享
  • 单个下载、分享

此功能开发涉及到递归算法,我们一起先来简单普及一下什么是递归算法,他会出现在什么场景,能解决什么问题。

递归的概念

就是函数自己调用自己本身,或者在自己函数调用的下级函数中调用自己。

递归的步骤

  • 假设递归函数已经写好
  • 寻找递推关系
  • 将递推关系的结构转换为递归体
  • 将临界条件加入到递归体中

介绍递归的文章随处可见,小编推荐几篇看看:

代码实现

采用技术栈:Vue2.6 + Vue-router + ElementUI + Less + Flex布局

el-tree 组件

采用ElementUI中的el-tree组件进行二次开发,自定义做成表格形式。 特点是:非常清晰的层级结构嵌套展示信息,可选择、懒加载、可展开与折叠或可拖拽节点。比如选中某父节点会同时选中其下所有子节点,选中子节点同时会保留父节点被选中总的来说可以满足大部分业务需求。唯一不足的地方是el-tree不合适做分页结合关键词检索功能。

template代码如下:

<div class="tree-box">
  <div class="tree-nav">
    <div class="item">
      <el-checkbox
        v-model="isCheckedAll"
        :indeterminate="isIndeterminate"
        :disabled="treeData.length === 0"
        class="checkbox-style"
        @change="handleCheckAllChange"
      >
      </el-checkbox
      >名称
    </div>
    <div class="item">大小</div>
    <div class="item">修改时间</div>
    <div class="item">上传时间</div>
    <div class="item">加密级别</div>
    <div class="item">下载级别</div>
    <div class="item">操作</div>
  </div>
  <div v-loading="loading" class="tree-content">
    <el-tree
      ref="tree"
      :data="treeData"
      node-key="directoryId"
      :props="props"
      show-checkbox
      default-expand-all
      @check="handleCheckChange"
      @check-change="handleCurChange"
    >
      <span slot-scope="{ node, data }" class="custom-tree-node">
        <template>
          <div v-if="data.directoryType === 1" class="node_div">
            <span class="name-box">
              <el-tooltip effect="dark" placement="left">
                <div slot="content">
                  {{ node.label }}
                </div>
                <i class="file-icon icon-folder"></i>
              </el-tooltip>
              {{ node.label }}
            </span>
          </div>
          <div v-if="data.directoryType === 2" class="node_div">
            <span class="name-box" :title="node.label">
              <el-tooltip effect="dark" placement="left">
                <div slot="content">
                  {{ node.label }}
                </div>
                <i :class="node.label | getIcon"></i>
              </el-tooltip>
              {{ node.label }}
            </span>
            <span class="size-box">
              {{ data.size | renderSize }}
            </span>
            <span class="time-box">
              {{ $DayTime(data.gmtUpdate).format("YYYY-MM-DD HH:mm") }}
            </span>
            <span class="upload-box">
              {{ $DayTime(data.gmtUpload).format("YYYY-MM-DD HH:mm") }}
            </span>
            <span class="secret-box">
              {{ data.secretType | secretType }}
            </span>
            <span class="download-box">
              {{ data.downloadType | downloadStatus }}
            </span>
            <span class="operate-box">
              <el-button
                v-if="data.downloadType === 1 && data.directoryType === 2"
                type="text"
                size="small"
                @click="() => handleDownload(data, 2)"
                >下载</el-button
              >
              <el-button
                v-if="data.directoryType === 2"
                type="text"
                size="small"
                @click="() => handleShare(data, 2)"
                >分享</el-button
              >
            </span>
          </div>
        </template>
      </span>
    </el-tree>
  </div>
</div>

data代码如下:

data() {
    return {
        isCheckedAll: false, // 是否全选状态
        isIndeterminate: false, // 是否半选状态
        isMultipleDownload: true, // 批量下载按钮是否禁用
        isDownloadFile: true, // 选中的所有文件是否可下载
        isDownloadFileBtn: true, // 文件是否可下载
        newTreeArray: [], // 过滤保留被选中的新数组
        totalNum: 0, // 统计文件总数
        selectTotalNum: 0, // 选中文件总数
        props: { // 配置选项
          children: "children",
          label: "name",
          isLeaf: "leaf",
        },
        treeData: [ // 初始化目录树列表数据
        {
          directoryId: 1,
          directoryType: 2, // 1:目录 2:文件
          downloadType: 1,
          secretType: 0,
          size: 12367,
          name: "前端大厂面试宝典.pdf",
          gmtUpdate: 1630825270483,
          gmtUpload: 1630825248029,
          children: [],
        },
        {
          directoryId: 2,
          directoryType: 2,
          downloadType: 1,
          secretType: 1,
          size: 5236700,
          name: "前端高级工程师内功秘籍.docx",
          gmtUpdate: 1630825270483,
          gmtUpload: 1630825248029,
          children: [],
        },
        {
          directoryId: 3,
          directoryType: 2,
          downloadType: 0,
          secretType: 1,
          size: 2267,
          name: "前端学习路线图.png",
          gmtUpdate: 1630834889072,
          gmtUpload: 1630825248029,
          children: [],
        },
        {
          directoryId: 4,
          directoryType: 1,
          downloadType: 1,
          secretType: 0,
          name: "前端开源项目汇总",
          gmtUpdate: 1630825270483,
          gmtUpload: 1630825248029,
          children: [
            {
              directoryId: 41,
              directoryType: 2,
              downloadType: 1,
              secretType: 0,
              size: 13200,
              name: "小程序个性简历源码.zip",
              gmtUpdate: 1630825270483,
              gmtUpload: 1630825248029,
              children: [],
            },
            {
              directoryId: 42,
              directoryType: 1,
              downloadType: 1,
              secretType: 0,
              name: "电商网站项目",
              gmtUpdate: 1630825270483,
              gmtUpload: 1630825248029,
              children: [
                {
                  directoryId: 421,
                  directoryType: 2,
                  downloadType: 1,
                  secretType: 0,
                  size: 132008,
                  name: "饿了么H5移动端源码.zip",
                  gmtUpdate: 1630825270483,
                  gmtUpload: 1630825248029,
                  children: [],
                },
              ],
            },
          ],
        },
        {
          directoryId: 5,
          directoryType: 1,
          downloadType: 0,
          secretType: 1,
          name: "前端工程化知识体系",
          gmtUpdate: 1630834889072,
          gmtUpload: 1630834889072,
          children: [
            {
              directoryId: 51,
              directoryType: 2,
              downloadType: 0,
              secretType: 1,
              size: 13200,
              name: "CI/CD项目部署.doc",
              gmtUpdate: 1630834889072,
              gmtUpload: 1630834889072,
              children: [],
            },
            {
              directoryId: 52,
              directoryType: 2,
              downloadType: 0,
              secretType: 1,
              size: 335200,
              name: "前端开发规范秘籍.xlsx",
              gmtUpdate: 1630834889072,
              gmtUpload: 1630834889072,
              children: [],
            },
          ],
        },
      ]
    }
}

script代码如下:

methods: {
    // 是否全选
    async handleCheckAllChange(val) {
      let tree = this.treeData;
      this.isIndeterminate = false;
      if (val) {
        // 全选
        this.isMultipleDownload = tree[0].downloadType === 0;
        this.isDownloadFileBtn = this.isMultipleDownload;
        this.$refs.tree.setCheckedNodes(tree);
      } else {
        // 取消全选
        this.$refs.tree.setCheckedNodes([]);
        this.isMultipleDownload = true;
        this.isDownloadFile = true;
      }
      this.selectTotalNum = 0;
      await this.getRecursion(tree);
      this.newTreeArray = await this.getFilterFile(tree);
    },
    // 当复选框被点击的时候触发
    async handleCheckChange(data, node) {
      let tree = this.treeData;
      this.selectTotalNum = 0;
      await this.getRecursion(tree);
      this.isDownloadFileBtn = data.downloadType === 0 && data.isChecked;
      this.newTreeArray = await this.getFilterFile(tree);
      this.isCheckedAll = this.newTreeArray.length === tree.length;
      this.isIndeterminate =
        this.newTreeArray.length > 0 && this.newTreeArray.length < tree.length;

      if (node.checkedNodes.length > 0) {
        this.isMultipleShare = false;
        this.isMultipleDownload = node.checkedNodes[0].downloadType === 0;
      } else {
        this.isCheckedAll = false;
        this.isIndeterminate = false;
        this.isMultipleDownload = true;
        this.isDownloadFile = true;
        this.newTreeArray = [];
      }
    },
    // 节点选中状态发生变化时的回调
    handleCurChange(data, checked, indeterminate) {
      let isChecked = checked;
      let arr = [];
      arr.push(data);
      this.getCheckedChild(arr, [], isChecked, indeterminate);
    },
    // 递归所有子集设置选中状态isChecked
    async getCheckedChild(data, arr, flag, isParent) {
      return data.map(async (item) => {
        if (flag) {
          item.isChecked = true;
        } else {
          item.isChecked = false;
        }
        if (isParent && item.directoryType === 1) {
          item.isChecked = true;
        }
        if (item.children) {
          await this.getCheckedChild(item.children, arr, flag, isParent);
        }
        return item;
      });
    }
    // 统计已选中所有文件总数
    async getRecursion(tree) {
      this.$nextTick(async () => {
        tree.map(async (item) => {
          if (item.directoryType === 2 && item.isChecked) {
            this.selectTotalNum += 1;
          }
          if (item.children) {
            await this.getRecursion(item.children);
          }
        });
      });
    },
    // 递归过滤保留被选中的目录树数组传给后端
    getFilterFile(tree) {
      return tree
        .filter((item) => item.isChecked === true)
        .map((item) => {
          item = Object.assign({}, item);
          if (item.children) {
            item.children = this.getFilterFile(item.children);
          }
          return item;
        });
    },
}

el-table 组件

采用ElementUI中的el-table组件进行二次开发实现目录树列表结构。

特点是:本身table表格列表样式呈现简约,支持分页检索功能,展示多条结构类似的数据,可对数据进行排序、筛选、对比或其他自定义操作。结合tree-props属性配置选项,通过row-key绑定数据唯一值变量directoryId,很好的支持树类型的数据显示。比如table自带复选框全选反选,选中某父节点同时会选中所有子节点。唯一美中不足的地方是el-table选中子节点没法让父节点一起选中,因为找不到父节点层级嵌套太多就更没辙了,当时绞尽脑汁,怎么也想不出有效的解决方法,如有大佬知道的,请不吝赐教。

template代码如下:

<div class="tree-header">
  <div class="tree-btn">
    <el-button
      type="primary"
      size="small"
      plain
      :disabled="multiple"
      @click="handleDownload(null, 1)"
    >
      批量下载</el-button
    >
    <el-button
      type="primary"
      size="small"
      plain
      :disabled="multiple"
      @click="handleShare(null, 1)"
      >批量分享</el-button
    >
  </div>
  <div class="total-num">共 {{ totalNum }} 个文件</div>
</div>
<div class="tree-box">
  <el-table
    ref="table"
    v-loading="loading"
    :data="tableData"
    class="w100"
    row-key="directoryId"
    default-expand-all
    :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
    @select="selectRow"
    @select-all="selectAll"
    @selection-change="handleSelectionChange"
  >
    <el-table-column
      type="selection"
      :selectable="selectable"
      width="45"
      align="center"
      label="全选"
    ></el-table-column>
    <el-table-column label="名称" show-overflow-tooltip>
      <template slot-scope="scope">
        <el-tooltip effect="dark" placement="left">
          <div slot="content">
            {{ scope.row.name }}
          </div>
          <i
            v-if="scope.row.directoryType === 1"
            class="file-icon icon-folder"
          ></i>
          <i v-else :class="scope.row.name | getIcon"></i>
        </el-tooltip>
        {{ scope.row.name }}
      </template>
    </el-table-column>
    <el-table-column
      prop="size"
      label="大小"
      align="center"
      width="100"
      show-overflow-tooltip
    >
      <template slot-scope="scope" v-if="scope.row.directoryType === 2">
        <span> {{ scope.row.size | renderSize }} </span>
      </template>
    </el-table-column>
    <el-table-column
      prop="gmtUpdate"
      label="修改时间"
      align="center"
      width="200"
      show-overflow-tooltip
    >
      <template slot-scope="scope" v-if="scope.row.directoryType === 2">
        <span>{{
          scope.row.gmtUpdate
            ? $DayTime(scope.row.gmtUpdate).format("YYYY-MM-DD HH:mm")
            : null
        }}</span>
      </template>
    </el-table-column>
    <el-table-column
      label="上传时间"
      align="center"
      width="200"
      show-overflow-tooltip
    >
      <template slot-scope="scope" v-if="scope.row.directoryType === 2">
        <span>{{
          $DayTime(scope.row.gmtUpload).format("YYYY-MM-DD HH:mm")
        }}</span>
      </template>
    </el-table-column>
    <el-table-column
      prop="secretType"
      label="加密级别"
      align="center"
      width="100"
    >
      <template slot-scope="scope" v-if="scope.row.directoryType === 2">
        {{ scope.row.secretType | secretType }}
      </template>
    </el-table-column>
    <el-table-column
      prop="downloadType"
      label="下载级别"
      align="center"
      width="100"
    >
      <template slot-scope="scope" v-if="scope.row.directoryType === 2">
        {{ scope.row.downloadType | downloadStatus }}
      </template>
    </el-table-column>
    <el-table-column label="操作" align="center" width="200">
      <template slot-scope="scope">
        <el-button
          v-if="
            scope.row.downloadType === 1 && scope.row.directoryType === 2
          "
          type="text"
          size="small"
          @click="() => handleDownload(scope.row, 2)"
          >下载</el-button
        >
        <el-button
          v-if="scope.row.directoryType === 2"
          type="text"
          size="small"
          @click="() => handleShare(scope.row, 2)"
          >分享</el-button
        >
      </template>
    </el-table-column>
  </el-table>
</div>

data代码如下:

data() {
    return {
        totalNum: 0, // 统计文件总数
        selectTotalNum: 0, // 选中文件数
        ids: [], // 选中数组
        single: true, // 非单个禁用
        multiple: true, // 非多个禁用
        downloadTypeArr: [],
        tableData: [], // 目录树结构同el-tree一样
    }
}

script代码如下:

methods: {
    // 全选/取消选操作
    selectAll() {
      console.log("全选==", this.tableData);
      this.selectTotalNum = 0;
      this.getRecursion(this.tableData);
      let data = this.tableData;
      this.isAllSelect = !this.isAllSelect;
      this.toggleSelect(data, this.isAllSelect, "all");
    },
    // 选择某行
    selectRow(selection, row) {
      console.log("选择某行===", row);
      this.selectTotalNum = 0;
      this.getRecursion(this.tableData);
      this.$set(row, "isChecked", !row.isChecked);
      this.$nextTick(() => {
        this.isAllSelect = row.isChecked;
        this.toggleSelect(selection, row.isChecked, "tr");
        this.toggleSelect(row, row.isChecked, "tr");
      });
    },
    // 改变选中
    toggleSelection(row, flag) {
      this.$set(row, "isChecked", flag);
      this.$nextTick(() => {
        if (flag) {
          this.$refs.table.toggleRowSelection(row, flag);
        } else {
          this.$refs.table.clearSelection();
        }
      });
    },
    // 递归子级
    toggleSelect(data, flag, type) {
      if (type === "all") {
        if (data.length > 0) {
          data.forEach((item) => {
            this.toggleSelection(item, flag);
            if (item.children && item.children.length > 0) {
              this.toggleSelect(item.children, flag, type);
            }
          });
        }
      } else {
        if (data.children && data.children.length > 0) {
          data.children.forEach((item) => {
            item.isChecked = !item.isChecked;
            this.$refs.table.toggleRowSelection(item, flag);
            this.toggleSelect(item, flag, type);
          });
        }
      }
    },
    // 统计列表总文件数(也可以直接后端返回总文件个数)
    getTotalNum(tree) {
      for (let item of tree) {
        if (item.directoryType === 2) {
          this.totalNum += 1;
        }
        if (item.children) {
          this.getTotalNum(item.children);
        }
      }
    },
    // 统计选中所有文件数
    getRecursion(tree) {
      this.$nextTick(async () => {
        tree.map(async (item) => {
          if (item.directoryType === 2 && item.isChecked) {
            this.selectTotalNum += 1;
          }
          if (item.children) {
            this.getRecursion(item.children);
          }
        });
      });
    },
    // 当选择项发生变化时会触发该事件
    handleSelectionChange(selection) {
      this.ids = selection.map((item) => item.directoryId);
      this.downloadTypeArr = selection.map((item) => item.downloadType);
      this.single = selection.length !== 1;
      this.multiple = !selection.length;
    },
}

结语

写到这里以上是我花了两个通宵实践之后,总结整理出来的一些实操笔记。感觉做完这个给我最大的收获,学会了递归的多种用法,以及树状结构的挖坑填坑,更多是提升了解决各种复杂业务需求的能力。为了想追求尽善尽美(前提不改需求不砍需求),特写了这篇水文,希望能收获更多大佬们的宝贵意见或建议。由于本人功力尚浅,还需闭关修炼。

❤️ 感谢支持

如果本文对你有一丢丢帮助,就点个赞支持下吧,你的「赞」是我创作的动力。

关注我的公众号【懒人码农】,获取更多项目实战经验及各种源码资源。如果你也一样对技术热爱并且为之着迷,欢迎加我的微信【lazycode520】,将会邀请你加入我们的前端实战学习群一起fix问题,一起面向快乐编程~ 🦄