Vue3 Transfer

Vue3基于element-plus自建Transfer组件

vue-transfer.png

<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>