zptime / blog

个人博客
0 stars 0 forks source link

双树穿梭框(treeTransfer 优化后) #16

Open zptime opened 3 years ago

zptime commented 3 years ago
<template>
  <!-- 依托于ant design vue完成,必须先安装该组件库 -->
  <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
          v-if="self_from_data && self_from_data.length > 0"
          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 v-else class="ant-transfer-list-body-not-found">
          {{ locale.notFoundContent }}
        </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 class="ant-transfer-list">
      <!-- 头部 -->
      <div class="ant-transfer-list-header">
        <a-checkbox
          :indeterminate="to_is_indeterminate"
          v-model="to_check_all"
          @change="toAllBoxChange"
        />
        <span class="ant-transfer-list-header-selected">
          <span
            >{{ to_check_keys.length || 0 }}/{{ to_all_keys.length }}
            {{ locale.itemsUnit }}</span
          >
          <span class="ant-transfer-list-header-title">{{ toTitle }}</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="filterTo"
              :placeholder="locale.searchPlaceholder"
              class="ant-transfer-list-search"
            />
            <a class="ant-transfer-list-search-action">
              <a-icon
                type="close-circle"
                theme="filled"
                v-if="filterTo && filterTo.length > 0"
                @click="filterTo = ''"
              />
              <a-icon type="search" v-else />
            </a>
          </div>
        </div>
        <!-- 树列表 -->
        <div
          v-if="self_to_data && self_to_data.length > 0"
          class="ant-transfer-list-body-customize-wrapper"
        >
          <a-tree
            class="tt-tree to-tree"
            ref="to-tree"
            blockNode
            checkable
            :checked-keys="to_check_keys"
            :expanded-keys="to_expand_keys"
            :tree-data="self_to_data"
            @check="toTreeChecked"
            @expand="toTreeExpanded"
            :style="{ height: treeHeight + 'px' }"
          />
        </div>
        <!-- 无数据显示 -->
        <div v-else class="ant-transfer-list-body-not-found">
          {{ locale.notFoundContent }}
        </div>
      </div>
    </div>
  </div>
</template>

<script>
// 关键词过滤
const filterKeywordTreeFn = (tree = [], keyword = "") => {
  if (!(tree && tree.length)) {
    return [];
  }
  if (!keyword) {
    return tree;
  }

  return R.filter((o) => {
    // 1. 父节点满足条件,直接返回
    if (o.title.includes(keyword)) {
      return true;
    }
    if (o.children?.length) {
      // 2. 否则,存在子节点时,递归处理
      o.children = filterKeywordTreeFn(o.children, keyword);
    }
    // 3. 子节点满足条件时,返回
    return o.children && o.children.length;
    // 避免修改原数据,此处用R.clone()处理一下
  }, R.clone(tree));
};

// 选中节点过滤:源数据
const filterSourceTreeFn = (tree = [], targetKeys = [], result = []) => {
  // 父节点和子节点都存在才返回
  R.forEach((o) => {
    // 1. 判断当前节点是否含符合条件的数据:是-继续;否-过滤
    if (targetKeys.indexOf(o.id) < 0) {
      // 2. 判断是否含有子节点:是-继续;否-直接返回
      if (o.children?.length) {
        // 3. 子节点递归处理
        o.children = filterSourceTreeFn(o.children, targetKeys);
        // 4. 存在子节点,且子节点也有符合条件的子节点,直接返回
        if (o.children.length) result.push(o);
      } else {
        result.push(o);
      }
    }
  }, R.clone(tree));
  return result;
};

// 选中节点保留:目标数据
const filterTargetTreeFn = (tree = [], targetKeys = []) => {
  if (!(tree && tree.length)) {
    return [];
  }
  if (!targetKeys?.length) {
    return tree;
  }
  return R.filter((o) => {
    // 存在子节点时,递归处理
    if (o.children?.length) {
      o.children = filterTargetTreeFn(o.children, targetKeys);
    }
    return (
      R.indexOf(o.id, targetKeys) > -1 || (o.children && o.children.length)
    );
  }, R.clone(tree));
};

export default {
  name: "treeTransfer",
  props: {
    dataSource: {
      // 数据源
      type: Array,
      default: () => [],
    },
    targetKeys: {
      // 右侧框数据的 key 集合
      type: Array,
      default: () => [],
    },
    // 标题
    titles: {
      type: Array,
      default: () => ["源列表", "目标列表"],
    },
    // 各种语言
    locale: {
      type: Object,
      default: () => {
        return {
          itemUnit: "项",
          itemsUnit: "项",
          notFoundContent: "暂无数据",
          searchPlaceholder: "请输入城市名称",
        };
      },
    },
    // 是否启用筛选
    filter: {
      type: Boolean,
      default: true,
    },
    // 替换 treeData 中对应的字段
    replaceFields: {
      type: Object,
      default: () => {
        return {
          children: "children",
          title: "title",
          key: "key",
        };
      },
    },
  },
  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: "", // 目标数据筛选
      fullHeight: document.documentElement.clientHeight, // 网页可见区域高度
    };
  },
  mounted() {
    this.getBodyHeight();
  },
  computed: {
    // 源数据
    self_from_data() {
      let from_array = filterSourceTreeFn(this.data_source, this.target_keys);
      if (this.filterFrom) {
        from_array = filterKeywordTreeFn(from_array, this.filterFrom);
      }
      this.from_all_keys = this.getAllKeys(from_array);
      this.from_check_keys = this.from_check_keys.filter((key) =>
        this.from_all_keys.includes(key)
      );
      return from_array;
    },
    // 目标数据
    self_to_data() {
      let to_array = filterTargetTreeFn(this.data_source, this.target_keys);
      if (this.filterTo) {
        to_array = filterKeywordTreeFn(to_array, this.filterTo);
      }
      this.to_all_keys = this.getAllKeys(to_array);
      this.to_check_keys = this.to_check_keys.filter((key) =>
        this.to_all_keys.includes(key)
      );
      return to_array;
    },
    // 源数据菜单名
    fromTitle() {
      let [text] = this.titles;
      return text;
    },
    // 目标数据菜单名
    toTitle() {
      let [, text] = this.titles;
      return text;
    },
    treeHeight() {
      return this.fullHeight - 350;
    },
  },
  watch: {
    targetKeys: {
      handler: function (val) {
        this.target_keys = val;
        // 过滤数据清除
        this.filterFrom = "";
        this.filterTo = "";
        // 滚动条回到顶部
        this.$nextTick(() => {
          if (this.$el.querySelector(".from-tree")) {
            this.$el.querySelector(".from-tree").scrollTop = 0;
          }
          if (this.$el.querySelector(".to-tree")) {
            this.$el.querySelector(".to-tree").scrollTop = 0;
          }
        });
        // tree数据清除
        this.clearChecked();
      },
      deep: true,
      immediate: true,
    },
    /* 左侧 状态监测 */
    from_check_keys(val) {
      if (val.length > 0) {
        // 穿梭按钮是否禁用
        this.from_disabled = false;
        // 总半选是否开启
        this.from_is_indeterminate = true;

        // 总全选是否开启 - 根据选中节点中为根节点的数量是否和源数据长度相等
        let allParentKeys = this.self_from_data.map(
          (item) => item[this.replaceFields.key]
        );
        let allCheck = val.filter((item) => allParentKeys.includes(item));
        if (allCheck.length == this.self_from_data.length) {
          // 关闭半选 开启全选
          this.from_is_indeterminate = false;
          this.from_check_all = true;
        } else {
          this.from_is_indeterminate = true;
          this.from_check_all = false;
        }
      } else {
        this.from_disabled = true;
        this.from_is_indeterminate = false;
        this.from_check_all = false;
      }
    },
    /* 右侧 状态监测 */
    to_check_keys(val) {
      if (val.length > 0) {
        // 穿梭按钮是否禁用
        this.to_disabled = false;
        // 总半选是否开启
        this.to_is_indeterminate = true;

        // 总全选是否开启 - 根据选中节点中为根节点的数量是否和源数据长度相等
        let allParentKeys = this.self_to_data.map(
          (item) => item[this.replaceFields.key]
        );
        let allCheck = val.filter((item) => allParentKeys.includes(item));
        if (allCheck.length == this.self_to_data.length) {
          // 关闭半选 开启全选
          this.to_is_indeterminate = false;
          this.to_check_all = true;
        } else {
          this.to_is_indeterminate = true;
          this.to_check_all = false;
        }
      } else {
        this.to_disabled = true;
        this.to_is_indeterminate = false;
        this.to_check_all = false;
      }
    },
  },
  methods: {
    /**
     * 清空选中节点
     * type:string left左边 right右边 all全部 默认all
     */
    clearChecked(type = "all") {
      if (type === "left") {
        this.from_check_keys = [];
        this.from_expand_keys = [];
        this.from_is_indeterminate = false;
        this.from_check_all = false;
      } else if (type === "right") {
        this.to_check_keys = [];
        this.to_expand_keys = [];
        this.to_is_indeterminate = false;
        this.to_check_all = false;
      } else {
        this.from_check_keys = [];
        this.to_check_keys = [];
        this.from_expand_keys = [];
        this.to_expand_keys = [];
        this.from_is_indeterminate = false;
        this.from_check_all = false;
        this.to_is_indeterminate = false;
        this.to_check_all = false;
      }
    },
    /* 添加到右边 */
    addToAims(emit) {
      // 右侧添加选中数据
      let targetKeys = [...this.target_keys, ...this.from_check_keys];
      this.target_keys = [...new Set(targetKeys)];

      // 左侧删掉选中数据
      this.from_check_keys = [];

      // 传递信息给父组件
      emit &&
        this.$emit(
          "handleChange",
          this.self_from_data,
          this.self_to_data,
          this.target_keys
        );
    },
    /* 移除到左边 */
    removeToSource() {
      // 右侧删除选中数据
      let targetKeys = this.target_keys.filter(
        (item) => !this.to_check_keys.includes(item)
      );
      this.target_keys = [...new Set(targetKeys)];

      // 左侧添加选中数据
      this.to_check_keys = [];

      // 解决右侧数据筛选后提交,数据为空的问题
      this.filterTo = "";

      // 传递信息给父组件
      this.$emit(
        "handleChange",
        this.self_from_data,
        this.self_to_data,
        this.target_keys
      );
    },
    /* 源树选中事件 - 是否禁用穿梭按钮 */
    fromTreeExpanded(expandedKeys) {
      this.from_expand_keys = expandedKeys;
    },
    /* 目标树选中事件 - 是否禁用穿梭按钮 */
    toTreeExpanded(expandedKeys) {
      this.to_expand_keys = expandedKeys;
    },
    /* 源树选中事件 - 是否禁用穿梭按钮 */
    fromTreeChecked(checkedKeys, e) {
      this.from_check_keys = checkedKeys;
    },
    /* 目标树选中事件 - 是否禁用穿梭按钮 */
    toTreeChecked(checkedKeys, e) {
      this.to_check_keys = checkedKeys;
    },
    /* 源数据 总全选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);
    },
    /* 目标数据 总全选checkbox */
    toAllBoxChange(val) {
      if (this.self_to_data.length == 0) {
        return;
      }
      if (val.target.checked) {
        this.to_check_keys = this.getAllKeys(this.self_to_data);
      } else {
        this.to_check_keys = [];
      }
      this.$emit("right-check-change", this.to_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;
    },
    // 动态获取浏览器高度
    getBodyHeight() {
      const _this = this;
      window.onresize = () => {
        return (() => {
          window.fullHeight = document.documentElement.clientHeight;
          _this.fullHeight = window.fullHeight;
        })();
      };
    },
  },
};
</script>
<style lang="scss" scoped>
$px: 1px;
.tree-transfer {
  .ant-transfer-list {
    flex: 1;
  }
  .tt-tree {
    max-height: 400 * $px;
    overflow-y: auto;
    overflow-x: hidden;
    height: 100%;
  }
}
</style>