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'

const props = defineProps({
  // 数据相关
  data: { type: Array, default: () => [] }, // 全局数据
  checkedData: { type: Array, default: () => [] }, // 选中数据
  rightShowData: { type: Array, default: () => [] }, // 右侧显示数据
  valueKey: { type: String, default: 'key' }, // value的key
  labelKey: { type: String, default: 'label' }, // label的key
  showTitleKey: { type: String, default: 'label' }, // 鼠标移上去显示的key
  rightMaxCount: { type: [Number, null], default: null }, // 最大选择数量

  // 显示控制
  showHeader: { type: Boolean, default: false }, // 是否显示头部
  showLeftCheckbox: { type: Boolean, default: false }, // 左侧是否显示checkbox
  showRightCheckbox: { type: Boolean, default: false }, // 右侧是否显示checkbox
  showLeftHeaderCheckbox: { type: Boolean, default: false }, // 左侧是否显示头部的checkbox
  showRightHeaderCheckbox: { type: Boolean, default: false }, // 右侧是否显示头部的checkbox
  showHeaderCount: { type: Boolean, default: false }, // 是否显示头部的count
  showCheckBg: { type: Boolean, default: false }, // 是否显示选中时的背景颜色
  showCheckBorder: { type: Boolean, default: false }, // 是否显示选中时的边框颜色
  filterable: { type: Boolean, default: false }, // 是否显示搜索框
  filterPlaceholder: { type: String, default: '请输入搜索内容' }, // 搜索框的placeholder
  flexDirection: { type: String, default: 'horizontal', validator: (v: string) => ['vertical', 'horizontal'].includes(v) }, // 整个transfer的排列方向
  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的宽度
  panelItemHeight: { type: String, default: '40px' }, // 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(() => props.rightShowData.length ? props.data.filter((item: any) => props.rightShowData.includes(item[props.valueKey])) : 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]
    // 限制右侧数据数量不超过maxItem
    if (props.rightMaxCount !== null) {
      // 修改:确保右侧保留最新的数据,将超出的数据放回左侧
      const newData = [...moved, ...target]
      if (newData.length > props.rightMaxCount) {
        rightData.value = newData.slice(0, props.rightMaxCount)
        const overflow = newData.slice(props.rightMaxCount)
        leftData.value = [...overflow, ...leftData.value]
      } else {
        rightData.value = newData
      }
    } else {
      rightData.value = [...moved, ...target]
    }
  } else {
    rightData.value = [...remaining]
    leftData.value = [...moved, ...target]
  }
  leftChecked.value = []
  rightChecked.value = []
  emitChange("move_" + direction)
}

/**
 * 全部左右移动
 */
const moveAll = (direction: string) => {
  if (direction === 'right') {
    // 限制右侧数据数量不超过maxItem
    if (props.rightMaxCount !== null) {
      // 修改:确保右侧保留最新的数据,将超出的数据放回左侧
      const newData = [...leftData.value, ...rightData.value]
      if (newData.length > props.rightMaxCount) {
        rightData.value = newData.slice(0, props.rightMaxCount)
        const overflow = newData.slice(props.rightMaxCount)
        leftData.value = [...overflow]
      } else {
        rightData.value = newData
        leftData.value = []
      }
    } else {
      rightData.value = [...leftData.value, ...rightData.value]
      leftData.value = []
    }
  } else {
    leftData.value = [...rightData.value, ...leftData.value]
    rightData.value = []
  }
  rightChecked.value = []
  leftChecked.value = []
  emitChange("move_all_" + direction)
}

/**
 * 全选
 */
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 = (duration: string) => {
  emit('dataChange', leftData.value, rightData.value, duration)
}

/**
 * 过滤数据 左
 */
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]))
  } else {
    leftData.value = []
    rightData.value = []
  }
})

/**
 * 初始化数据
 */
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]))
})

const resetLeftChecked = () => {
  leftChecked.value = []
}

const resetRightChecked = () => {
  rightChecked.value = []
}

defineExpose({
  resetRightChecked,
  resetLeftChecked
})
</script>

<template>
  <div class="basic-transfer" :style="{ flexDirection: flexDirection === 'horizontal' ? 'row' : 'column' }">
    <!-- 禁用状态 -->
    <div v-if="disabled" class="basic-transfer__disabled"></div>
    <!-- 左侧面板 -->
    <div class="transfer-panel" :style="{ width: panelWidth }">
      <!-- 额外头部内容 -->
      <div v-if="$slots['left-header-ex']" class="transfer-panel__header-ex">
        <slot name="left-header-ex"></slot>
      </div>
      <!-- 主要内容 -->
      <div class="transfer-panel__main-container">
        <!-- 头部 -->
        <div v-if="showHeader" class="transfer-panel__header">
          <div class="transfer-panel__header-title" :style="{ width: showHeaderCount ? '92%' : '100%' }">
            <el-checkbox v-if="showLeftHeaderCheckbox" v-model="leftCheckedAll" :indeterminate="leftIndeterminate"
              :disabled="disabled" :class="headerCheckboxClass" />
            <div :style="{ display: !showLeftHeaderCheckbox && showLeftCheckbox ? 'block' : 'none', width: '14px' }">
               
            </div>
            <span class="transfer-panel__header-text">
              {{ (!showLeftHeaderCheckbox && showLeftCheckbox) || showLeftHeaderCheckbox ? ' ' : '' }}
              <slot name="left-title">
                {{ 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"
              :max="rightMaxCount !== null ? rightMaxCount : 9999" :min="0">
              <el-checkbox v-for="item in leftFilteredData" :key="item[valueKey]" :value="item[valueKey]"
                :class="['transfer-panel__item', transferItemClass, !showLeftCheckbox ? 'no-before-check' : '']" :style="{
                  backgroundColor: showCheckBg && isLeftChecked(item) ? checkedBgColor : '',
                  color: showCheckBg && isLeftChecked(item) ? checkedTextColor : '',
                  borderColor: showCheckBorder && isLeftChecked(item) ? checkedBorderColor : '',
                  width: panelItemWidth,
                  height: panelItemHeight
                }">
                <div :style="{ width: showHeaderCount ? '92%' : '100%' }">
                  <slot name="left-item" :item="item">
                    <span class="item-text" :title="item[showTitleKey]">{{ item[labelKey] }}</span>
                  </slot>
                </div>
                <div></div>
              </el-checkbox>
            </el-checkbox-group>
          </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>

    <!-- 操作按钮 -->
    <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="$slots['right-header-ex']" class="transfer-panel__header-ex">
        <slot name="right-header-ex"></slot>
      </div>
      <!-- 主要内容 -->
      <div class="transfer-panel__main-container">
        <!-- 头部 -->
        <div v-if="showHeader" class="transfer-panel__header">
          <div class="transfer-panel__header-title" :style="{ width: showHeaderCount ? '92%' : '100%' }">
            <el-checkbox v-if="showRightHeaderCheckbox" v-model="rightCheckedAll" :indeterminate="rightIndeterminate"
              :disabled="disabled" :class="headerCheckboxClass" />
            <div :style="{ display: !showRightHeaderCheckbox && showRightCheckbox ? 'block' : 'none', width: '14px' }">
               
            </div>
            <span class="transfer-panel__header-text">
              {{ (!showRightHeaderCheckbox && showRightCheckbox) || showRightHeaderCheckbox ? ' ' : '' }}
              <slot name="right-title">
                {{ 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"
              :max="rightMaxCount !== null ? rightMaxCount : 9999" :min="0">
              <el-checkbox v-for="item in rightFilteredData" :key="item[valueKey]" :value="item[valueKey]"
                :class="['transfer-panel__item', transferItemClass, !showRightCheckbox ? 'no-before-check' : '']"
                :style="{
                  backgroundColor: showCheckBg && isRightChecked(item) ? checkedBgColor : '',
                  color: showCheckBg && isRightChecked(item) ? checkedTextColor : '',
                  borderColor: showCheckBorder && isRightChecked(item) ? checkedBorderColor : '',
                  width: panelItemWidth,
                  height: panelItemHeight
                }">
                <div :style="{ width: showHeaderCount ? '92%' : '100%' }">
                  <slot name="right-item" :item="item">
                    <span class="item-text" :title="item[showTitleKey]">{{ item[labelKey] }}</span>
                  </slot>
                </div>
                <div></div>
              </el-checkbox>
            </el-checkbox-group>
          </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>
  </div>
</template>

<style scoped>
.basic-transfer {
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
}

.transfer-panel {
  width: 250px;
}

.transfer-panel__main-container {
  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__header-text {
    display: flex;
    align-items: center;
    width: 100%;
  }
}

.transfer-panel__header-count {
  width: 8%;
  font-size: 12px;
}

.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 {
  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;
  line-height: 15px;
}

.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 {
  width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  display: inline-block;
  height: 20px;
  line-height: 20px;
}

.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;
}

.transfer-panel__footer {
  border-top: 1px solid #DEE4EC;
  background-color: #F5F7FA;
}

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

    .el-checkbox__label {
      color: unset !important;
      width: calc(100% - 14px);
    }
  }

  .no-before-check {
    .el-checkbox__input {
      display: none;
    }

    .el-checkbox__label {
      color: unset !important;
      width: 100%;
    }
  }
}
</style>

属性

属性名 类型 默认值 释义
data Array [] 全局数据
checkedData Array [] 初始选中数据
valueKey String key value的key
labelKey String label label的key
showTitleKey String label 鼠标移上去显示的key
rightMaxCount [Number, null] null 右侧最大选择数量
showHeader Boolean false 是否显示头部
showLeftCheckbox Boolean false 左侧是否显示checkbox
showRightCheckbox Boolean false 右侧是否显示checkbox
showLeftHeaderCheckbox Boolean false 左侧是否显示头部的checkbox
showRightHeaderCheckbox Boolean false 右侧是否显示头部的checkbox
showHeaderCount Boolean false 是否显示头部的count
showCheckBg Boolean false 是否显示选中时的背景颜色
showCheckBorder Boolean false 是否显示选中时的边框颜色
filterable Boolean false 是否显示搜索框
filterPlaceholder String 请输入搜索内容 搜索框的placeholder
flexDirection String vertical | horizontal 整个transfer的排列方向
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-header-ex 额外头部内容-左
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-header-ex 额外头部内容-右
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>

更新

  • 2025-09-17 全部使用 checkgroup 作为列表子项,通过设置 rightMaxCount 控制左侧可选数量,优化样式。