Ant Design Transfer 双树穿梭框“造轮子”

4,228 阅读5分钟

工作不息,代码不止呀,我又双叒叕来了。

之前实现了列表穿梭框和单树穿梭框,这一次主要是实现完整的树形穿梭框,穿梭框左侧和右侧都是树形结构,而且父子节点有联动关系,还是用于城市选择。

实现效果如图所示:

ic_transfer_1.png

主要功能点:

  1. 穿梭框左侧、右侧都是树形结构数据
  2. 父子节点有联动关系,可进行全选、半选、全不选
  3. 已被选择的数据会移动到右侧,在左侧中不显示
  4. 本质上是对数据进行处理
  5. 用于城市选择,虽然展示数据结构,但实际返回还是城市数据

以下代码展示中,穿梭框左右侧处理基本是一样的,仅以左侧源数据框为例

UI 完成

UI 基本上是模仿的官网的样式,样式名也是直接借鉴官网的,方便快速。。。

  1. 源数据框:头部栏(checkbox),搜索栏(input),数据栏(tree)
  2. 目标数据框:头部栏(checkbox),搜索栏(input),数据栏(tree)
  3. 操作栏:左移按钮(button),右移按钮(button)

实现代码如下:

<template>
  <div class="tree-transfer ant-transfer ant-transfer-customize-list">
    <!-- 源数据框 -->
    <div class="ant-transfer-list">
      <!-- 头部栏 -->
      <div class="ant-transfer-list-header">
        <a-checkbox
          :indeterminate="from_is_indeterminate"
          v-model="from_check_all"
          @change="fromAllBoxChange"
        />
        <span class="ant-transfer-list-header-selected">
          <span
            >{{ from_check_keys.length || 0 }}/{{ from_all_keys.length }} {{
            locale.itemUnit }}</span
          >
          <span class="ant-transfer-list-header-title">{{ fromTitle }}</span>
        </span>
      </div>
      <!-- 主体内容 -->
      <div class="ant-transfer-list-body ant-transfer-list-body-with-search">
        <!-- 搜索框 -->
        <div v-if="filter" class="ant-transfer-list-body-search-wrapper">
          <div>
            <a-input
              v-model="filterFrom"
              :placeholder="locale.searchPlaceholder"
              class="ant-transfer-list-search"
            />
            <a class="ant-transfer-list-search-action">
              <a-icon
                type="close-circle"
                theme="filled"
                v-if="filterFrom && filterFrom.length > 0"
                @click="filterFrom = ''"
              />
              <a-icon type="search" v-else />
            </a>
          </div>
        </div>
        <!-- 树列表 -->
        <div class="ant-transfer-list-body-customize-wrapper">
          <a-tree
            ref="from-tree"
            class="tt-tree from-tree"
            blockNode
            checkable
            :checked-keys="from_check_keys"
            :expanded-keys="from_expand_keys"
            :tree-data="self_from_data"
            @check="fromTreeChecked"
            @expand="fromTreeExpanded"
            :style="{ height: treeHeight + 'px' }"
          />
        </div>
      </div>
    </div>

    <!-- 操作栏 -->
    <div class="ant-transfer-operation">
      <a-button
        type="primary"
        @click="addToAims(true)"
        shape="circle"
        :disabled="from_disabled"
        icon="right"
      ></a-button>
      <a-button
        type="primary"
        @click="removeToSource"
        shape="circle"
        :disabled="to_disabled"
        icon="left"
      ></a-button>
    </div>

    <!-- 目标数据框 -->
    <!-- 此处省略,和源数据基本一样 -->
  </div>
</template>

实现效果如图所示:

ic_transfer_2.png

数据处理

穿梭框左右侧的展示,本质上是对数据进行处理,接下来重点讲一下数据的处理方式。

  1. 传入参数:主要是数据源dataSource和目标数据框的 targetKeys集合(此处targetKeys仅为城市 id,不包含省份 id)
  2. 回调参数:targetKeys(右侧选中 key 集合)

传入参数(props)

参数名类型是否必传备注
dataSourceArrayY数据源
targetKeysArrayY右侧框数据的 key 集合
titlesArrayN头部标题,默认["源列表", "目标列表"]
localeObjectN配置项
filterBooleanN是否显示搜索框
replaceFieldsObjectN替换 treeData 中对应的字段

组件内部参数

data() {
  return {
    data_source: [...this.dataSource], // 数据源
    target_keys: [], // 右侧框数据的 key 集合
    from_is_indeterminate: false, // 源数据是否半选
    from_check_all: false, // 源数据是否全选
    to_is_indeterminate: false, // 目标数据是否半选
    to_check_all: false, // 目标数据是否全选
    from_disabled: true, // 添加按钮是否禁用
    to_disabled: true, // 移除按钮是否禁用
    from_check_keys: [], // 源数据选中key数组 以此属性关联穿梭按钮,总全选、半选状态
    to_check_keys: [], // 目标数据选中key数组 以此属性关联穿梭按钮,总全选、半选状态
    from_expand_keys: [], // 源数据展开key数组
    to_expand_keys: [], // 目标数据展开key数组
    from_all_keys: [], // 源数据所有key
    to_all_keys: [], // 目标数据所有key
    filterFrom: "", // 源数据筛选
    filterTo: "", // 目标数据筛选
  };
}

数据处理

  1. 数据过滤的处理 根据 target_keysfilterFrom对数据进行过滤。 (1)源数据过滤掉包含target_keys的数据。 (2)目标数据仅保留包含target_keys的数据
computed: {
  // 源数据
  self_from_data() {
    // 源数据过滤
    let from_array = filterSourceTree(
      this.data_source,
      this.target_keys,
      this.filterFrom,
      this.replaceFields
    );

    // === 用于全选、半选的状态的处理 ====
    // 获取源数据所有的key集合
    this.from_all_keys = this.getAllKeys(from_array);
    // 获取源数据所有选中key集合
    this.from_check_keys = this.from_check_keys.filter((key) =>
      this.from_all_keys.includes(key)
    );
    return from_array;
  },
  // 源数据菜单名
  fromTitle() {
    let [text] = this.titles;
    return text;
  }
}
  1. 处理全选、半选或者未选的状态
watch: {
  /* 左侧 状态监测 */
  from_check_keys(val) {
    if (val.length > 0) {
      // 穿梭按钮是否禁用
      this.from_disabled = false;
      // 总半选是否开启
      this.from_is_indeterminate = true;

      // 总全选是否开启:根据选中节点中为根节点的数量是否和源数据长度相等
      // 获取所有省份key集合
      let allParentKeys = this.self_from_data.map(
        (item) => item[this.replaceFields.key]
      );
      // 获取所有选中的key集合
      let allCheck = val.filter((item) => allParentKeys.includes(item));
      // 1. 相等时显示全选
      if (allCheck.length == this.self_from_data.length) {
        // 关闭半选 开启全选
        this.from_is_indeterminate = false;
        this.from_check_all = true;
      } else {
        // 2. 否则,部分选
        this.from_is_indeterminate = true;
        this.from_check_all = false;
      }
    } else {
      // 3. 没有选中时,未选状态
      this.from_disabled = true;
      this.from_is_indeterminate = false;
      this.from_check_all = false;
    }
  }
}

事件定义

全选事件

  1. 全部选中:遍历获取所有的 key
  2. 全部取消:key 置空
/* 源数据 总全选checkbox */
fromAllBoxChange(val) {
  if (this.self_from_data.length == 0) {
    return;
  }
  if (val.target.checked) {
    this.from_check_keys = this.getAllKeys(this.self_from_data);
  } else {
    this.from_check_keys = [];
  }
  this.$emit("left-check-change", this.from_check_all);
}

获取数据所有 key

getAllKeys(data) {
  let result = [];
  data.forEach((item) => {
    result.push(item[this.replaceFields.key]);
    if (item.children && item.children.length) {
      item.children.forEach((o) => {
        result.push(o[this.replaceFields.key]);
      });
    }
  });
  return result;
}

复选框事件

点击复选框时触发,直接用Tree@check事件处理

fromTreeChecked(checkedKeys, e) {
  this.from_check_keys = checkedKeys;
}

展开/收起事件

展开/收起节点时触发,直接用Tree@expand事件处理

fromTreeExpanded(expandedKeys) {
  this.from_expand_keys = expandedKeys;
}

模拟数据处理

基础数据

[
  {
    id: "1000",
    pid: "0",
    value: "湖北省",
    label: "湖北省",
    children: [
      { id: "1001", pid: "1000", label: "武汉" },
      { id: "1020", pid: "1000", label: "咸宁" },
      { id: "1022", pid: "1000", label: "孝感" },
      { id: "1034", pid: "1000", label: "襄阳" },
      { id: "1003", pid: "1000", label: "宜昌" },
    ],
  },
  {
    id: "1200",
    pid: "0",
    value: "江苏省",
    label: "江苏省",
    children: [
      { id: "1201", pid: "1200", label: "南京" },
      { id: "1202", pid: "1200", label: "苏州" },
      { id: "1204", pid: "1200", label: "扬州" },
    ],
  },
];

源数据处理

已经被选中的数据不会在源数据框出现,将其过滤掉就行

const filterSourceTree = (
  tree = [],
  targetKeys = [],
  keyword = "",
  replaceFields
) => {
  if (!tree.length) {
    return [];
  }
  const result = [];
  for (let item of tree) {
    if (item[replaceFields.title].includes(keyword)) {
      if (item.children && item.children.length) {
        let ele = { ...item, children: [] };
        for (let o of item.children) {
          if (targetKeys.includes(o[replaceFields.key])) continue;
          ele.children.push(o);
        }
        if (ele.children.length) {
          result.push(ele);
        }
      }
    } else {
      if (item.children && item.children.length) {
        let node = { ...item, children: [] };
        for (let o of item.children) {
          if (
            !(
              !targetKeys.includes(o[replaceFields.key]) &&
              o[replaceFields.title].includes(keyword)
            )
          )
            continue;
          node.children.push(o);
        }
        if (node.children.length) {
          result.push(node);
        }
      }
    }
  }
  return result;
};

let leftSource = filterSourceTree(
  this.provinceData,
  // 武汉,咸宁,南京
  ["1001", "1020", "1201"],
  "",
  this.replaceFields
);
console.log(leftSource);

ic_transfer_3.png

目标数据处理

已经被选中的数据才会在目标数据框中出现

const filterTargetTree = (
  tree = [],
  targetKeys = [],
  keyword = "",
  replaceFields
) => {
  if (!tree.length) {
    return [];
  }
  const result = [];
  for (let item of tree) {
    if (item[replaceFields.title].includes(keyword)) {
      if (item.children && item.children.length) {
        let ele = { ...item, children: [] };
        for (let o of item.children) {
          if (!targetKeys.includes(o[replaceFields.key])) continue;
          ele.children.push(o);
        }
        if (ele.children.length) {
          result.push(ele);
        }
      }
    } else {
      if (item.children && item.children.length) {
        let node = { ...item, children: [] };
        for (let o of item.children) {
          if (
            !(
              targetKeys.includes(o[replaceFields.key]) &&
              o[replaceFields.title].includes(keyword)
            )
          )
            continue;
          node.children.push(o);
        }
        if (node.children.length) {
          result.push(node);
        }
      }
    }
  }
  return result;
};

// 目标数据处理
let rightSource = filterTargetTree(
  this.provinceData,
  ["1001", "1020", "1201"],
  "",
  this.replaceFields
);
console.log(rightSource);
this.provinceData = rightSource;

ic_transfer_4.png

函数改造

之前的函数filterSourceTreefilterTargetTree同时对关键词和目标数据进行了过滤处理,比较粗糙,而且当时只针对了二级树结构,扩展性不强。接下来对其进行了改造优化。

具体函数可见:前端树结构数据常用操作汇总

源数据处理

computed: {
  // 源数据
  self_from_data() {
    // 选中数据过滤
    let from_array = filterSourceTreeFn(this.data_source, this.target_keys);
    // 关键词过滤
    if (this.filterFrom) {
      from_array = filterKeywordTreeFn(from_array, this.filterFrom);
    }
    return from_array;
  },
}

ic_transfer_8.png

目标数据处理

computed: {
  // 目标数据
  self_to_data() {
    // 选中数据保留
    let to_array = filterTargetTreeFn(this.data_source, this.target_keys);
    // 关键词过滤
    if (this.filterTo) {
      to_array = filterKeywordTreeFn(to_array, this.filterTo);
    }
    return to_array;
  },
}