Ant Design Transfer 穿梭框组件“造轮子” - zptime/blog GitHub Wiki
人的思想是千变万化的,不可能一套组件满足所有的需求,前端开发总避免不了要“造轮子”的。这次做的穿梭框就是模仿官方来写的,功能基本一样,但不可能完全一样,会在官方的基础上优化,样式也更好看了哟。
官方基础的穿梭框如下,左右两边都是列表形式,还有搜索框。
具体使用可以查看官方文档,带搜索框的穿梭框
重写穿梭框的原因:
- 样式肯定是不满足 UI 要求的
- 功能麻烦了一点,操作完数据之后总要点一下“左移”或者“右移”才能完成
- 当数据很多时,组件渲染会很卡顿,反正我公司的上千条数据都会有点卡了
主要实现功能点:
- 头部全选
- 搜索功能
- 搜索关键词高亮显示
- 自动穿梭功能
- 虚拟滚动列表实现
自定义参数:考虑对外暴露的参数,参数的作用,属性等 自定义事件:考虑暴露出去的回调事件
// 自定义参数
export default {
props: {
dataSource: {
// 数据源
type: Array,
default: () => [],
},
targetKeys: {
// 右侧框数据的 key 集合
type: Array,
default: () => [],
},
disabledKeys: {
// 禁用 key 集合
type: Array,
default: () => [],
},
titles: {
// 标题
type: Array,
default: () => ["可选", "已选"],
},
locale: {
// 语言配置
type: Object,
default: () => {
return {
itemUnit: "项",
sourceEmptyText: "暂无可选数据",
targetEmptyText: "暂无已选数据",
};
},
},
searchPlaceholder: {
type: String,
default: "请输入搜索关键字",
},
showSelectAll: {
// 是否展示全选勾选框
type: Boolean,
default: true,
},
},
};
// 自定义事件
// onChange事件:返回显示在目标框数据的 key 集合和 数据列表
this.$emit("on-change", this.sourceTargetKeys, this.targetData);
整体结构是源数据框+目标数据框,而每个数据框由头部、搜索框、数据源列表三部分组成
<template>
<!-- 基础穿梭框 -->
<div class="m-single-common-transfer">
<!-- 源数据展示 -->
<div class="mct-transfer-list">
<!-- 头部 -->
<div class="mct-transfer-list-header"></div>
<!-- 主体内容 -->
<div class="mct-transfer-list-body">
<!-- 搜索框 -->
<div class="mct-transfer-list-search"></div>
<!-- 源列表 -->
<div class="mct-transfer-list-source"></div>
</div>
</div>
<!-- 目标数据展示 -->
<div class="mct-transfer-list">
<!-- 头部 -->
<div class="mct-transfer-list-header"></div>
<!-- 主体内容 -->
<div class="mct-transfer-list-body">
<!-- 搜索框 -->
<div class="mct-transfer-list-search"></div>
<!-- 目标列表 -->
<div class="mct-transfer-list-source"></div>
</div>
</div>
</div>
</template>
头部主要分为 3 个部分,如下以源数据框为例,目标数据框原理一样:
- 全选框:可进行全选和全不选的切换事件(
handleSourceSelectedAll
),可表示全选、部分选、全不选(sourceIsIndeterminate
,sourceCheckedAll
)三种状态 - 计数项:可以统计数据源所有项和已选项个数(
sourceCheckedKeys
,sourceAllKeys
) - 标题:可自定义展示内容(
sourceTitle
)
<template>
<div class="mct-transfer-list-header">
<!-- showSelectAll:是否展示全选勾选框和计数项 -->
<template v-if="showSelectAll">
<!-- sourceCheckedAll:是否全选; sourceIsIndeterminate:是否半选 -->
<a-checkbox
:indeterminate="sourceIsIndeterminate"
v-model="sourceCheckedAll"
@change="handleSourceSelectedAll"
class="mct-transfer-list-header-checkbox"
/>
<!-- sourceAllKeys:全部key;sourceCheckedKeys:选中key -->
<div class="mct-transfer-list-header-selected">
<span v-if="sourceCheckedKeys.length"
>{{ sourceCheckedKeys.length }}/{{ sourceAllKeys.length }} {{
locale.itemUnit }}</span
>
<span v-else>{{ sourceAllKeys.length }} {{ locale.itemUnit }}</span>
</div>
</template>
<div class="mct-transfer-list-header-title">{{ sourceTitle }}</div>
</div>
</template>
<script>
import * as R from "ramda";
export default {
computed: {
// 源数据菜单名
sourceTitle() {
let [text] = this.titles;
return text;
},
// 目标数据菜单名
targetTitle() {
let [, text] = this.titles;
return text;
},
},
watch: {
// 左侧 状态监测
sourceCheckedKeys(val) {
if (val && val.length > 0) {
// 总半选是否开启
this.sourceIsIndeterminate = true;
// 总全选是否开启 - 根据选中节点的数量是否和源数据长度相等
let allKeys = this.getAllKeys(this.sourceData);
let allCheck = R.filter((o) => R.includes(o.key, allKeys), val);
if (R.length(allKeys) === R.length(allCheck)) {
// 关闭半选 开启全选
this.sourceIsIndeterminate = false;
this.sourceCheckedAll = true;
} else {
// 开启半选
this.sourceIsIndeterminate = true;
this.sourceCheckedAll = false;
}
} else {
// 未选
this.sourceIsIndeterminate = false;
this.sourceCheckedAll = false;
}
},
},
methods: {
// 源数据全选事件
handleSourceSelectedAll(e) {
if (this.sourceData.length === 0) {
return;
}
if (e.target.checked) {
this.sourceTargetKeys = R.uniq(
R.concat(this.sourceTargetKeys, this.getAllKeys(this.sourceData))
);
} else {
this.sourceTargetKeys = R.difference(
this.sourceTargetKeys,
this.getAllKeys(this.sourceData)
);
}
},
},
};
</script>
搜索框其实就挺简单了,主要是一个图标的变化。空值时是放大镜图标;有值时时删除图标,可以清空搜索框值
<div class="mct-transfer-list-search">
<a-input
v-model="sourceKeyword"
:placeholder="searchPlaceholder"
class="mct-transfer-list-search-input"
@pressEnter="handleSourceSearch"
>
<a-icon
slot="suffix"
type="close-circle"
theme="filled"
v-if="sourceKeyword && sourceKeyword.length > 0"
@click="sourceKeyword = ''"
/>
<a-icon v-else slot="suffix" type="search" />
</a-input>
</div>
源数据列表和目标数据列表有相同点,也有不同点:
- 相同点:
- 都采用了虚拟列表:RecycleScroller
- 搜索关键词高亮显示:substr 截取标题显示
- 数据源过滤:数据源根据关键词
sourceKeyword
过滤
- 不同点:
- 源数据项包括选择框和标题
- 源数据禁用处理:
disabledKeys
,sourceTargetKeys
- 目标数据显示标题和删除按钮
<template>
<a-list
:data-source="sourceData"
:locale="{ emptyText: locale.sourceEmptyText }"
class="mct-list"
>
<RecycleScroller
class="mct-list-recycle"
:items="sourceData"
:item-size="36"
key-field="key"
>
<a-list-item
slot-scope="{ item, index }"
:key="`source-${index}`"
class="mct-list-item"
>
<a-checkbox
:checked="item.checked"
:disabled="item.disabled"
class="mct-list-item-checkbox"
@change="(e) => handleSourceSelect(e, index, item.key)"
></a-checkbox>
<div :class="['mct-list-item-title', { disabled: item.disabled }]">
<!-- 搜索关键词高亮显示 -->
<template v-if="sourceKeyword && item.title.includes(sourceKeyword)">
<div class="title title-other">
<span
>{{ item.title.substr(0, item.title.indexOf(sourceKeyword))
}}</span
>
<span class="active">{{ sourceKeyword }}</span>
<span
>{{ item.title.substr( item.title.indexOf(sourceKeyword) +
sourceKeyword.length ) }}</span
>
</div>
</template>
</div>
</a-list-item>
</RecycleScroller>
</a-list>
</template>
<script>
// 虚拟数据列表 RecycleScroller
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
computed: {
// 源数据列表
sourceData() {
let filterKeys =
this.disabledKeys && this.disabledKeys.length
? R.concat(this.disabledKeys, this.sourceTargetKeys)
: this.sourceTargetKeys;
// 禁用数据处理
let result = R.map(
(o) => {
if (R.includes(o.key, filterKeys)) {
o.disabled = true;
o.checked = true;
o.title = `${o.title}(已配置)`;
} else {
o.disabled = false;
o.checked = false;
}
return o;
},
// 数据源过滤
this.sourceKeyword
? R.filter(
(r) => R.includes(this.sourceKeyword, r.title),
R.clone(this.dataSource)
)
: R.clone(this.dataSource)
);
// 全选,已选处理
this.sourceAllKeys = this.getAllKeys(result);
this.sourceCheckedKeys = R.filter(
(o) => R.includes(o, this.sourceAllKeys),
this.sourceTargetKeys
);
return result;
}
}
</script>
<template>
<single-transfer
:data-source="singleDataSource"
:target-keys="singleTargetKeys"
@on-change="handleSingleChange"
/>
</template>
<script>
import * as R from "ramda";
import SingleTransfer from "@cms/component/transfer/SingleTransfer.vue";
const mapIndexed = R.addIndex(R.map);
const singleDataSource = mapIndexed((o, i) => {
return {
key: i + 1,
title:
i === 0 ? `选项哈哈哈哈哈哈哈哈哈哈哈哈哈嘿嘿` : `${o.title}${i + 1}`,
};
}, R.repeat({ key: 1, title: "选项" }, 20));
export default {
data() {
return {
singleDataSource,
singleTargetKeys: [1, 3],
};
},
components: {
SingleTransfer,
},
methods: {
handleSingleChange(targetKeys, data) {
console.log("singleTransfer", targetKeys, data);
},
},
};
</script>
<style lang="scss"></style>