Ant Design Transfer 双树穿梭框“造轮子” - zptime/blog GitHub Wiki
工作不息,代码不止呀,我又双叒叕来了。
之前实现了列表穿梭框和单树穿梭框,这一次主要是实现完整的树形穿梭框,即穿梭框左侧和右侧都是树形结构,而且父子节点有联动关系,还是用于城市选择。
实现效果如图所示:
主要功能点:
- 穿梭框左侧、右侧都是树形结构数据
- 父子节点有联动关系,可进行全选、半选、全不选
- 已被选择的数据会移动到右侧,在左侧中不显示
- 本质上是对数据进行处理
- 用于城市选择,虽然展示数据结构,但实际返回还是城市数据
以下代码展示中,穿梭框左右侧处理基本是一样的,仅以左侧源数据框为例
UI 基本上是模仿的官网的样式,样式名也是直接借鉴官网的,方便快速。。。
- 源数据框:头部栏(checkbox),搜索栏(input),数据栏(tree)
- 目标数据框:头部栏(checkbox),搜索栏(input),数据栏(tree)
- 操作栏:左移按钮(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>
实现效果如图所示:
穿梭框左右侧的展示,本质上是对数据进行处理,接下来重点讲一下数据的处理方式。
- 传入参数:主要是数据源
dataSource
和目标数据框的targetKeys
集合(此处targetKeys
仅为城市 id,不包含省份 id) - 回调参数:
targetKeys
(右侧选中 key 集合)
参数名 | 类型 | 是否必传 | 备注 |
---|---|---|---|
dataSource | Array | Y | 数据源 |
targetKeys | Array | Y | 右侧框数据的 key 集合 |
titles | Array | N | 头部标题,默认["源列表", "目标列表"] |
locale | Object | N | 配置项 |
filter | Boolean | N | 是否显示搜索框 |
replaceFields | Object | N | 替换 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: "", // 目标数据筛选
};
}
- 数据过滤的处理
根据
target_keys
和filterFrom
对数据进行过滤。 (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;
}
}
- 处理全选、半选或者未选的状态
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;
}
}
}
- 全部选中:遍历获取所有的 key
- 全部取消: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);
}
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);
已经被选中的数据才会在目标数据框中出现
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;
之前的函数filterSourceTree
,filterTargetTree
同时对关键词和目标数据进行了过滤处理,比较粗糙,而且当时只针对了二级树结构,扩展性不强。接下来对其进行了改造优化。
具体函数可见:前端树结构数据常用操作汇总
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;
},
}
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;
},
}