Vue3 Transfer
Vue3基于element-plus自建Transfer组件

<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
// 当使用checkbox时,不需要传panelItemWidth的宽,其余的都需要传panelItemWidth
const props = defineProps({
// 数据相关
data: { type: Array, default: () => [] }, // 全局数据
checkedData: { type: Array, default: () => [] }, // 选中数据
valueKey: { type: String, default: 'key' }, // value的key
labelKey: { type: String, default: 'label' }, // label的key
// 显示控制
showHeader: { type: Boolean, default: true }, // 是否显示头部
showCheckbox: { type: Boolean, default: true }, // 是否显示checkbox
showHeaderCheckbox: { type: Boolean, default: true }, // 是否显示头部的checkbox
showHeaderCount: { type: Boolean, default: true }, // 是否显示头部的count
showCheckBg: { type: Boolean, default: true }, // 是否显示选中时的背景颜色
showCheckBorder: { type: Boolean, default: true }, // 是否显示选中时的边框颜色
filterable: { type: Boolean, default: true }, // 是否显示搜索框
filterPlaceholder: { type: String, default: '请输入搜索内容' }, // 搜索框的placeholder
buttonDirection: { type: String, default: 'vertical', validator: (v: string) => ['vertical', 'horizontal'].includes(v) }, // 按钮的排列方向
showAddAll: { type: Boolean, default: true }, // 是否显示全部按钮
disabled: { type: Boolean, default: false }, // 是否禁用整个Transfer
// 样式自定义
checkedBgColor: { type: String, default: '#F5F7FA' }, // 选中时的背景颜色
checkedBorderColor: { type: String, default: '#409EFF' }, // 选中时的边框颜色
checkedTextColor: { type: String, default: '#666666' }, // 选中时的文字颜色
headerCheckboxClass: String, // 头部checkbox的class
transferItemClass: String, // transferItem的class
filterClass: String, // 搜索框的class
scrollbarClass: String, // scrollbar的class
buttonTexts: {
type: Array,
default: () => ['右移', '左移', '全部右移', '全部左移']
}, // 按钮的文字
titles: { type: Array, default: () => ['列表1', '列表2'] }, // 标题
emptyTexts: { type: Array, default: () => ['无数据', '无数据'] }, // 空状态的文字
panelItemWidth: { type: String, default: '100%' }, // transferItem的宽度
panelWidth: { type: String, default: '250px' }, // transferPanel的宽度
panelBodyHeight: { type: String, default: '300px' } // transferPanel的body高度
})
// 事件
const emit = defineEmits(['dataChange', 'checkedChange', 'filterChange'])
// 数据状态
const leftData = ref<any>([])
const rightData = ref<any>([])
const leftChecked = ref<any>([])
const rightChecked = ref<any>([])
const leftQuery = ref('')
const rightQuery = ref('')
// 计算属性
const leftFilteredData = computed(() => filterData(leftData.value, leftQuery.value, 'left'))
const rightFilteredData = computed(() => filterData(rightData.value, rightQuery.value, 'right'))
/**
* 左侧checkbox全选状态
*/
const leftCheckedAll = computed({
get: () => leftChecked.value.length !== 0 && leftChecked.value.length === leftFilteredData.value.length,
set: val => val ? selectAll('left') : leftChecked.value = []
})
/**
* 右侧checkbox全选状态
*/
const rightCheckedAll = computed({
get: () => rightChecked.value.length !== 0 && rightChecked.value.length === rightFilteredData.value.length,
set: val => val ? selectAll('right') : rightChecked.value = []
})
/**
* 左侧checkbox半选状态
*/
const leftIndeterminate = computed(() => leftChecked.value.length && !leftCheckedAll.value)
/**
* 右侧checkbox半选状态
*/
const rightIndeterminate = computed(() => rightChecked.value.length > 0 && !rightCheckedAll.value)
/**
* 过滤数据
*/
const filterData = (data: Array<any>, query: string, direction: string) => {
if (!query) return data
direction === 'left' ? leftChecked.value = [] : rightChecked.value = []
return data.filter(item =>
item[props.labelKey].toLowerCase().includes(query.toLowerCase())
)
}
/**
* 左右移动
*/
const moveTo = (direction: string) => {
const source = direction === 'right' ? leftChecked.value : rightChecked.value
const target = direction === 'right' ? rightData.value : leftData.value
const sourceData = direction === 'right' ? leftData.value : rightData.value
const moved = sourceData.filter(item =>
source.includes(item[props.valueKey])
)
const remaining = sourceData.filter(item =>
!source.includes(item[props.valueKey])
)
if (direction === 'right') {
leftData.value = [...remaining]
rightData.value = [...target, ...moved]
} else {
rightData.value = [...remaining]
leftData.value = [...target, ...moved]
}
leftChecked.value = []
rightChecked.value = []
emitChange()
}
/**
* 全部左右移动
*/
const moveAll = (direction: string) => {
if (direction === 'right') {
rightData.value = [...rightData.value, ...leftData.value]
leftData.value = []
} else {
leftData.value = [...leftData.value, ...rightData.value]
rightData.value = []
}
rightChecked.value = []
leftChecked.value = []
emitChange()
}
/**
* 全选
*/
const selectAll = (direction: string) => {
const keys = direction === 'left'
? leftFilteredData.value.map(i => i[props.valueKey])
: rightFilteredData.value.map(i => i[props.valueKey])
if (direction === 'left') {
leftChecked.value = [...new Set([...leftChecked.value, ...keys])]
emit('checkedChange', leftChecked.value, 'left')
} else {
rightChecked.value = [...new Set([...rightChecked.value, ...keys])]
emit('checkedChange', rightChecked.value, 'right')
}
}
/**
* 判断是否选中 左
*/
const isLeftChecked = (item: object) => {
return leftChecked.value.includes(item[props.valueKey])
}
/**
* 判断是否选中 右
*/
const isRightChecked = (item: object) => {
return rightChecked.value.includes(item[props.valueKey])
}
/**
* 点击transferItem
*/
const handleItemClick = (item: object, direction: string, event: any) => {
if (props.disabled) return
event.stopPropagation() // 防止事件冒泡
const key = item[props.valueKey]
const isChecked = direction === 'left'
? leftChecked.value.includes(key)
: rightChecked.value.includes(key)
if (isChecked) {
// 如果已选中,则移除
if (direction === 'left') {
leftChecked.value = leftChecked.value.filter(k => k !== key)
} else {
rightChecked.value = rightChecked.value.filter(k => k !== key)
}
} else {
// 如果未选中,则添加
if (direction === 'left') {
leftChecked.value = [...leftChecked.value, key]
} else {
rightChecked.value = [...rightChecked.value, key]
}
}
emit('checkedChange', direction === 'left' ? leftChecked.value : rightChecked.value, direction)
}
/**
* 触发change事件
*/
const emitChange = () => {
emit('dataChange', leftData.value, rightData.value)
}
/**
* 过滤数据 左
*/
const handleLeftFilter = () => {
emit('filterChange', 'left', leftQuery.value)
}
/**
* 过滤数据 右
*/
const handleRightFilter = () => {
emit('filterChange', 'right', rightQuery.value)
}
watch(() => props.data, (newData) => {
if (newData && newData.length > 0) {
leftData.value = newData.filter((item: any) => !props.checkedData.includes(item[props.valueKey]))
rightData.value = newData.filter((item: any) => props.checkedData.includes(item[props.valueKey]))
}
})
/**
* 初始化数据
*/
onMounted(() => {
leftData.value = props.data.filter((item: any) => !props.checkedData.includes(item[props.valueKey]))
rightData.value = props.data.filter((item: any) => props.checkedData.includes(item[props.valueKey]))
})
</script>
<template>
<div class="basic-transfer">
<!-- 禁用状态 -->
<div v-if="disabled" class="basic-transfer__disabled"></div>
<!-- 左侧面板 -->
<div class="transfer-panel" :style="{ width: panelWidth }">
<!-- 头部 -->
<div v-if="showHeader" class="transfer-panel__header">
<div class="transfer-panel__header-title">
<el-checkbox v-if="showHeaderCheckbox" v-model="leftCheckedAll" :indeterminate="leftIndeterminate"
:disabled="disabled" :class="headerCheckboxClass" />
<span class="transfer-panel__header-text">
<slot name="left-title">
{{ showHeaderCheckbox ? ' ' : '' }}
{{ titles[0] }}
</slot>
</span>
</div>
<div class="transfer-panel__header-count" v-if="showHeaderCount">
<slot name="left-header-count" :count="leftData.length" :checked-count="leftChecked.length">
{{ leftChecked.length }} / {{ leftData.length }}
</slot>
</div>
</div>
<!-- 内容区域 -->
<div class="transfer-panel__body" :style="{ height: panelBodyHeight }">
<!-- 搜索框 -->
<div v-if="filterable" class="transfer-panel__filter">
<el-input v-model="leftQuery" :placeholder="filterPlaceholder" :class="['flter-default', filterClass]"
:disabled="disabled" clearable @input="handleLeftFilter">
<template #prefix>
<slot name="left-filter-prefix">
<el-icon>
<search />
</el-icon>
</slot>
</template>
</el-input>
</div>
<!-- 列表内容 -->
<el-scrollbar v-if="leftFilteredData.length > 0" class="transfer-panel__list"
:wrap-class="`scrollbar-wrapper ${scrollbarClass}`">
<el-checkbox-group v-model="leftChecked" :disabled="disabled" v-if="showCheckbox">
<el-checkbox v-for="item in leftFilteredData" :key="item[valueKey]" :label="item[labelKey]"
:value="item[valueKey]" class="transfer-panel__item" :class="transferItemClass" :style="{
backgroundColor: showCheckBg && isLeftChecked(item) ? checkedBgColor : '',
color: showCheckBg && isLeftChecked(item) ? checkedTextColor : '',
borderColor: showCheckBorder && isLeftChecked(item) ? checkedBorderColor : '',
width: panelItemWidth
}">
<slot name="left-item" :item="item">
<span class="item-text" :title="item[labelKey]">{{ item[labelKey] }}</span>
</slot>
</el-checkbox>
</el-checkbox-group>
<div v-else v-for="item in leftFilteredData" :key="item[valueKey]" class="transfer-panel__item"
:class="transferItemClass" :style="{
backgroundColor: showCheckBg && isLeftChecked(item) ? checkedBgColor : '',
color: showCheckBg && isLeftChecked(item) ? checkedTextColor : '',
borderColor: showCheckBorder && isLeftChecked(item) ? checkedBorderColor : '',
width: panelItemWidth
}" @click="(e) => handleItemClick(item, 'left', e)">
<span class="item-text" :title="item[labelKey]">
<slot name="left-item" :item="item">{{ item[labelKey] }}</slot>
</span>
</div>
</el-scrollbar>
<!-- 空状态 -->
<div v-else class="transfer-panel__empty">
<slot name="left-empty">
<img src="@/assets/image/public/d_noData.png" style="width: 90px; height: 90px;" class="inline-block"
alt="{{ emptyTexts[0] }}" />
<div>{{ emptyTexts[0] }}</div>
</slot>
</div>
<!-- 底部插槽 -->
<div v-if="$slots['left-footer']" class="transfer-panel__footer">
<slot name="left-footer"></slot>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="transfer-buttons" :class="buttonDirection">
<el-button type="primary" :disabled="disabled || leftChecked.length === 0" @click="moveTo('right')">
<slot name="right-button">{{ buttonTexts[0] }}</slot>
</el-button>
<el-button type="primary" :disabled="disabled || rightChecked.length === 0" @click="moveTo('left')">
<slot name="left-button">{{ buttonTexts[1] }}</slot>
</el-button>
<el-button v-if="showAddAll" type="primary" :disabled="disabled || leftData.length === 0" @click="moveAll('right')">
<slot name="right-all-button">{{ buttonTexts[2] }}</slot>
</el-button>
<el-button v-if="showAddAll" type="primary" :disabled="disabled || rightData.length === 0" @click="moveAll('left')">
<slot name="left-all-button">{{ buttonTexts[3] }}</slot>
</el-button>
</div>
<!-- 右侧面板 -->
<div class="transfer-panel" :style="{ width: panelWidth }">
<!-- 头部 -->
<div v-if="showHeader" class="transfer-panel__header">
<div class="transfer-panel__header-title">
<el-checkbox v-if="showHeaderCheckbox" v-model="rightCheckedAll" :indeterminate="rightIndeterminate"
:disabled="disabled" :class="headerCheckboxClass" />
<span class="transfer-panel__header-text">
<slot name="right-title">
{{ showHeaderCheckbox ? ' ' : '' }}
{{ titles[1] }}
</slot>
</span>
</div>
<div class="transfer-panel__header-count" v-if="showHeaderCount">
<slot name="right-header-count" :count="rightData.length" :checked-count="rightChecked.length">
{{ rightChecked.length }} / {{ rightData.length }}
</slot>
</div>
</div>
<!-- 内容区域 -->
<div class="transfer-panel__body" :style="{ height: panelBodyHeight }">
<!-- 搜索框 -->
<div v-if="filterable" class="transfer-panel__filter">
<el-input v-model="rightQuery" :placeholder="filterPlaceholder" :class="['flter-default', filterClass]"
:disabled="disabled" clearable @input="handleRightFilter">
<template #prefix>
<slot name="right-filter-prefix">
<el-icon>
<search />
</el-icon>
</slot>
</template>
</el-input>
</div>
<!-- 列表内容 -->
<el-scrollbar v-if="rightFilteredData.length > 0" class="transfer-panel__list"
:wrap-class="`scrollbar-wrapper ${scrollbarClass}`">
<el-checkbox-group v-model="rightChecked" :disabled="disabled" v-if="showCheckbox">
<el-checkbox v-for="item in rightFilteredData" :key="item[valueKey]" :label="item[labelKey]"
:value="item[valueKey]" class="transfer-panel__item" :class="transferItemClass" :style="{
backgroundColor: showCheckBg && isRightChecked(item) ? checkedBgColor : '',
color: showCheckBg && isRightChecked(item) ? checkedTextColor : '',
borderColor: showCheckBorder && isRightChecked(item) ? checkedBorderColor : '',
width: panelItemWidth
}">
<slot name="right-item" :item="item">
<span class="item-text" :title="item[labelKey]">{{ item[labelKey] }}</span>
</slot>
</el-checkbox>
</el-checkbox-group>
<div v-else v-for="item in rightFilteredData" :key="item[valueKey]" class="transfer-panel__item"
:class="transferItemClass" :style="{
backgroundColor: showCheckBg && isRightChecked(item) ? checkedBgColor : '',
color: showCheckBg && isRightChecked(item) ? checkedTextColor : '',
borderColor: showCheckBorder && isRightChecked(item) ? checkedBorderColor : '',
width: panelItemWidth
}" @click="(e) => handleItemClick(item, 'right', e)">
<span class="item-text" :title="item[labelKey]">
<slot name="right-item" :item="item">{{ item[labelKey] }}</slot>
</span>
</div>
</el-scrollbar>
<!-- 空状态 -->
<div v-else class="transfer-panel__empty">
<slot name="right-empty">
<img src="@/assets/image/public/d_noData.png" style="width: 90px; height: 90px;" class="inline-block"
alt="{{ emptyTexts[1] }}" />
<div>{{ emptyTexts[1] }}</div>
</slot>
</div>
<!-- 底部插槽 -->
<div v-if="$slots['right-footer']" class="transfer-panel__footer">
<slot name="right-footer"></slot>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.basic-transfer {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
.transfer-panel {
width: 250px;
border: 1px solid #DEE4EC;
border-radius: 4px;
background: #FFF;
box-sizing: border-box;
}
.transfer-panel__header {
height: 40px;
padding: 0 15px;
background: #F5F7FA;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #DEE4EC;
}
.transfer-panel__header-title {
display: flex;
align-items: center;
}
.transfer-panel__body {
display: flex;
flex-direction: column;
}
.transfer-panel__filter {
padding: 10px;
}
.flter-default {
width: 100%;
}
.transfer-panel__list {
flex: 1;
overflow: auto;
}
.transfer-panel__item {
height: 40px;
padding: 4px 15px;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: flex-start;
box-sizing: border-box;
border: 1px solid transparent;
}
.transfer-panel__item:hover {
background-color: #f5f7fa;
}
.transfer-panel__item.is-checked {
/* background-color: v-bind(checkedBgColor);
border: 1px solid v-bind(checkedBorderColor); */
box-sizing: border-box;
}
.item-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.transfer-buttons {
padding: 0 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.transfer-buttons.horizontal {
flex-direction: row;
align-items: center;
}
.transfer-panel__empty {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #909399;
}
.basic-transfer__disabled {
background-color: rgba(255, 255, 255, .5);
cursor: not-allowed;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
:deep() {
.vertical,
.horizontal {
.el-button {
margin-left: 0px;
}
}
.el-checkbox {
margin-right: 0px;
}
}
</style>
属性
属性名 |
类型 |
默认值 |
释义 |
data |
Array |
[] |
全局数据 |
checkedInitData |
Array |
[] |
初始选中数据 |
valueKey |
String |
key |
value的key |
labelKey |
String |
label |
label的key |
showHeader |
Boolean |
true |
是否显示头部 |
showCheckbox |
Boolean |
true |
是否显示checkbox |
showHeaderCheckbox |
Boolean |
true |
是否显示头部的checkbox |
showHeaderCount |
Boolean |
true |
是否显示头部的count |
showCheckBg |
Boolean |
true |
是否显示选中时的背景颜色 |
showCheckBorder |
Boolean |
true |
是否显示选中时的边框颜色 |
filterable |
Boolean |
true |
是否显示搜索框 |
filterPlaceholder |
String |
请输入搜索内容 |
搜索框的placeholder |
buttonDirection |
String |
vertical | horizontal |
按钮的排列方向 |
showAddAll |
Boolean |
true |
是否显示全部按钮 |
disabled |
Boolean |
false |
是否禁用整个Transfer |
checkedBgColor |
String |
#F5F7FA |
选中时的背景颜色 |
checkedBorderColor |
String |
#409EFF |
选中时的边框颜色 |
checkedTextColor |
String |
#666666 |
选中时的文字颜色 |
headerCheckboxClass |
String |
- |
头部checkbox的class |
transferItemClass |
String |
- |
transferItem的class |
filterClass |
String |
- |
搜索框的class |
scrollbarClass |
String |
- |
scrollbar的class |
buttonTexts |
Array |
['右移', '左移', '全部右移', '全部左移'] |
按钮的文字 |
titles |
Array |
['列表1', '列表2'] |
标题 |
emptyTexts |
Array |
['无数据', '无数据'] |
空状态的文字 |
panelItemWidth |
String |
100% |
transferItem的宽度 |
panelWidth |
String |
250px |
transferPanel的宽度 |
panelBodyHeight |
String |
300px |
transferPanel的body高度 |
事件
事件名 |
参数 |
释义 |
dataChange |
(leftValue: Array, rightValue: Array) |
当左右框中的数据变化时触发 |
checkedChange |
(checkData: Array<String>, direction: 'left' | 'right') |
当左右框中选择数据项时触发,值为选择的valueKey |
filterChange |
(direction: 'left' | 'right', queryString: string) |
当左右的搜索框查询值变化时触发 |
插槽
插槽 |
释义 |
left-title |
标题-左 |
left-header-count |
计数-左 |
left-filter-prefix |
筛选-左 |
left-item |
数据项-左 |
left-empty |
空内容-左 |
left-footer |
底部内容-左 |
right-button |
选择向右 |
left-button |
选择向左 |
right-all-button |
全部右移 |
left-all-button |
全部左移 |
right-title |
标题-右 |
right-header-count |
计数-右 |
right-filter-prefix |
筛选-右 |
right-item |
数据项-右 |
right-empty |
空内容-右 |
right-footer |
底部内容-右 |
引用
<script setup>
import { ref } from 'vue'
import BasicTransfer from './BasicTransfer.vue'
const transferData = ref([
{ key: '1', label: '选项111111111111111111111111111111', value: 'A' },
{ key: '2', label: '选项2', value: 'B' },
{ key: '3', label: '选项3', value: 'C' },
{ key: '4', label: '选项4', value: 'D' },
{ key: '5', label: '选项4', value: 'D' },
{ key: '6', label: '选项4', value: 'D' },
{ key: '7', label: '选项4', value: 'D' },
{ key: '8', label: '选项4', value: 'D' },
{ key: '9', label: '选项4', value: 'D' },
{ key: '10', label: '选项4', value: 'D' },
{ key: '11', label: '选项4', value: 'D' },
{ key: '12', label: '选项4', value: 'D' },
{ key: '13', label: '选项4', value: 'D' },
]);
</script>
<template>
<BasicTransfer :data="transferData" :checkedInitData="['2', '4']" leftTitle="待选数据" rightTitle="已选数据"
:showHeaderCheckbox="false" :showCheckbox="false" panelItemWidth="300px" :buttonTexts="['>', '<', '>>', '<<']"
panelWidth="300px" @change="(data1, data2) => console.log(data1, data2)">
<!-- 自定义左侧面板的数据项展示内容 -->
<!-- <template #left-item="{ item }">
<span>{{ item.label }} - {{ item.value }}</span>
</template> -->
<!-- 自定义右侧面板的数据项展示内容 -->
<template #right-item="{ item }">
<span>{{ item.label }} (选中)</span>
</template>
</BasicTransfer>
</template>
<style scoped>
</style>
评论