使用e-tree开发树形穿梭框

一、废话不多话,直接上效果图

二、废话依旧不多说,直接上源代码,觉得主包够豪爽的点个赞奥!

html 复制代码
<template>
    <div style="margin: 100px;width: 1000px;height: 500px;border:1px solid #e4e7ed;border-radius:8px;box-shadow:0 2px 12px 0 rgba(0,0,0,0.08);">
        <el-form :model="form" ref="formRef" label-width="100px">
            <div class="tree-transfer">
                <!-- 左侧可选资源树 -->
                <div class="transfer-panel">
                    <div class="transfer-header">可选资源</div>
                    <el-input
                        v-model="leftFilterText"
                        placeholder="搜索资源"
                        class="tree-search"
                        clearable
                    />
                    <el-tree
                        ref="leftTree"
                        node-key="id"
                        :data="leftTreeData"
                        :props="treeProps"
                        :default-expand-all="true"
                        :show-checkbox="true"
                        :filter-node-method="filterNode"
                        :check-strictly="false"
                        @check-change="handleLeftCheckChange"
                    />
                </div>

                <!-- 中间操作按钮 -->
                <div class="transfer-buttons">
                    <el-button
                        type="primary"
                        :icon="ArrowRight"
                        @click="addToRight"
                        :disabled="!leftCheckedKeys.length"
                    />
                    <el-button
                        type="primary"
                        :icon="ArrowLeft"
                        @click="removeFromRight"
                        :disabled="!rightCheckedKeys.length"
                    />
                    <el-button
                        type="primary"
                        :icon="DArrowRight"
                        @click="addAllToRight"
                    />
                    <el-button
                        type="primary"
                        :icon="DArrowLeft"
                        @click="removeAllFromRight"
                    />
                </div>

                <!-- 右侧已选资源树 -->
                <div class="transfer-panel">
                    <div class="transfer-header">已选资源</div>
                    <el-input
                        v-model="rightFilterText"
                        placeholder="搜索资源"
                        class="tree-search"
                        clearable
                    />
                    <el-tree
                        ref="rightTree"
                        node-key="id"
                        :data="rightTreeData"
                        :props="treeProps"
                        :default-expand-all="true"
                        :show-checkbox="true"
                        :filter-node-method="filterNode"
                        :check-strictly="false"
                        @check-change="handleRightCheckChange"
                    />
                </div>
            </div>
        </el-form>
    </div>
</template>
<script setup>
// 1. vue基础API
import { ref, reactive, computed, watch, nextTick, onMounted } from 'vue'
// 2. Element Plus 消息提示
import { ElMessage } from 'element-plus'
// 3. 箭头图标
import { ArrowRight, ArrowLeft, DArrowRight, DArrowLeft } from '@element-plus/icons-vue'

onMounted(() => {
  getResourceTree()
})

/** 表单数据 */
const form = reactive({
  resourceIds: []
})

/** 表单引用 */
const formRef = ref()

const allResourceTree = ref([])
/** 树形穿梭框状态 */
const leftTree = ref()
const rightTree = ref()
const leftFilterText = ref('')
const rightFilterText = ref('')
const leftCheckedKeys = ref([])
const rightCheckedKeys = ref([])

/** 树配置 */
const treeProps = {
  label: 'resourceName',
  children: 'children',
  disabled: 'disabled'
}


/**
 * 获取所有子节点ID
 * @param tree 树形数据
 * @param nodeId 节点ID
 * @returns {Array}
 */
const getAllChildrenIds = (tree, nodeId) => {
  const result = []
  const findNode = (nodes, targetId) => {
    for (const node of nodes) {
      if (node.id === targetId) {
        if (node.children && node.children.length > 0) {
          const collectChildren = (children) => {
            for (const child of children) {
              result.push(child.id)
              if (child.children && child.children.length > 0) {
                collectChildren(child.children)
              }
            }
          }
          collectChildren(node.children)
        }
        return
      }
      if (node.children && node.children.length > 0) {
        findNode(node.children, targetId)
      }
    }
  }
  findNode(tree, nodeId)
  return result
}

/**
 * 过滤树节点
 * @param value 过滤文本
 * @param data 节点数据
 * @returns {boolean}
 */
const filterNode = (value, data) => {
  if (!value) return true
  return data.resourceName.toLowerCase().includes(value.toLowerCase())
}

/**
 * 从树中移除指定节点
 * @param tree 树形数据
 * @param ids 要移除的ID列表
 * @returns {Array}
 */
const removeNodesByIds = (tree, ids) => {
  const result = []
  for (const node of tree) {
    if (!ids.includes(node.id)) {
      const newNode = { ...node }
      if (node.children && node.children.length > 0) {
        newNode.children = removeNodesByIds(node.children, ids)
      }
      result.push(newNode)
    } else {
      // 如果父节点被选中,但子节点未被选中,保留父节点结构并显示未被选中的子节点
      if (node.children && node.children.length > 0) {
        const remainingChildren = removeNodesByIds(node.children, ids)
        if (remainingChildren.length > 0) {
          // 创建一个临时父节点,标记为已选中状态,显示未被选中的子节点
          result.push({
            ...node,
            disabled: true, // 已选中父节点无法再选
            children: remainingChildren,
            isSelectedParent: true // 标记这是一个已选中的父节点
          })
        }
      }
    }
  }
  return result
}

/**
 * 获取树中所有叶子节点ID
 * @param tree 树形数据
 * @returns {Array}
 */
const getAllLeafIds = (tree) => {
  const result = []
  const collectLeafs = (nodes) => {
    for (const node of nodes) {
      // 可选1、只收集叶子节点ID
      // if (!node.children || node.children.length === 0) {
      //   result.push(node.id)
      // } else {
      //   collectLeafs(node.children)
      // }

      // 可选2、收集所有节点ID(包括父节点)
      result.push(node.id)
      if (node.children && node.children.length > 0) {
        collectLeafs(node.children)
      } 
    }
  }
  collectLeafs(tree)
  return result
}

/**
 * 根据ID列表构建子树
 * @param tree 原始树形数据
 * @param ids 选中的ID列表
 * @returns {Array}
 */
const buildSubTreeByIds = (tree, ids) => {
  const result = []
  for (const node of tree) {
    if (ids.includes(node.id)) {
      const newNode = { ...node }
      if (node.children && node.children.length > 0) {
        newNode.children = buildSubTreeByIds(node.children, ids)
      }
      result.push(newNode)
    } else if (node.children && node.children.length > 0) {
      const childResult = buildSubTreeByIds(node.children, ids)
      if (childResult.length > 0) {
        result.push({
          ...node,
          children: childResult
        })
      }
    }
  }
  return result
}


// ===================== API 方法 =====================
/**
 * 获取资源树列表
 */
const getResourceTree = () => {
  request.get('/tree').then(res => {
    if (res.code === '200' || res.code === 200) {
      allResourceTree.value = res.data || []
      nextTick(() => {
        syncTreeData()
      })
    }
  }).catch(() => {
    ElMessage.error('获取资源列表失败')
  })
}

/**
 * 同步左右树数据
 */
const syncTreeData = () => {
  if (leftTree.value) {
    leftTree.value.setCheckedKeys([])
  }
  if (rightTree.value) {
    rightTree.value.setCheckedKeys([])
  }
  leftCheckedKeys.value = []
  rightCheckedKeys.value = []
}

/**
 * 获取左侧树数据(排除已选)
 */
const leftTreeData = computed(() => {
  if (!allResourceTree.value.length || !form.resourceIds.length) {
    return allResourceTree.value
  }
  return removeNodesByIds(allResourceTree.value, form.resourceIds)
})

/**
 * 获取右侧树数据(已选资源)
 */
const rightTreeData = computed(() => {
  if (!allResourceTree.value.length || !form.resourceIds.length) {
    return []
  }
  return buildSubTreeByIds(allResourceTree.value, form.resourceIds)
})


/**
 * 左侧树勾选变化处理
 */
const handleLeftCheckChange = (data, checked, indeterminate) => {
  const childIds = getAllChildrenIds(allResourceTree.value, data.id)
  const allIds = [data.id, ...childIds]
  
  if (checked) {
    leftCheckedKeys.value = [...new Set([...leftCheckedKeys.value, ...allIds])]
  } else {
    leftCheckedKeys.value = leftCheckedKeys.value.filter(id => !allIds.includes(id))
  }
}

/**
 * 右侧树勾选变化处理
 */
const handleRightCheckChange = (data, checked, indeterminate) => {
  const childIds = getAllChildrenIds(allResourceTree.value, data.id)
  const allIds = [data.id, ...childIds]
  
  if (checked) {
    rightCheckedKeys.value = [...new Set([...rightCheckedKeys.value, ...allIds])]
  } else {
    rightCheckedKeys.value = rightCheckedKeys.value.filter(id => !allIds.includes(id))
  }
}

/**
 * 添加选中项到右侧
 */
const addToRight = () => {
  if (leftCheckedKeys.value.length === 0) return
  
  form.resourceIds = [...new Set([...form.resourceIds, ...leftCheckedKeys.value])]
  
  if (leftTree.value) {
    leftTree.value.setCheckedKeys([])
  }
  leftCheckedKeys.value = []
}

/**
 * 从右侧移除选中项
 */
const removeFromRight = () => {
  if (rightCheckedKeys.value.length === 0) return
  
  form.resourceIds = form.resourceIds.filter(id => !rightCheckedKeys.value.includes(id))
  
  if (rightTree.value) {
    rightTree.value.setCheckedKeys([])
  }
  rightCheckedKeys.value = []
}

/**
 * 添加全部到右侧
 */
const addAllToRight = () => {
  const leafIds = getAllLeafIds(leftTreeData.value)
  form.resourceIds = [...new Set([...form.resourceIds, ...leafIds])]
}

/**
 * 移除全部
 */
const removeAllFromRight = () => {
  form.resourceIds = []
  rightCheckedKeys.value = []
  if (rightTree.value) {
    rightTree.value.setCheckedKeys([])
  }
}

/**
 * 监听左侧过滤文本变化
 */
watch(leftFilterText, (val) => {
  if (leftTree.value) {
    leftTree.value.filter(val)
  }
})

/**
 * 监听右侧过滤文本变化
 */
watch(rightFilterText, (val) => {
  if (rightTree.value) {
    rightTree.value.filter(val)
  }
})

// ============ 模拟request对象(mock专用)============
const request = {
  get: (url) => {
    // 匹配你请求的 /tree 接口
    if (url === '/tree') {
      // 返回模拟Promise,结构和后端一致 {code, data, msg}
      return new Promise((resolve) => {
        // 模拟接口延迟200ms
        setTimeout(() => {
          const mockTreeData = [
            {
              id: 1,
              resourceName: '系统管理',
              children: [
                { id: 11, resourceName: '用户管理', children: [] },
                { id: 12, resourceName: '角色管理', children: [] },
                {
                  id: 13,
                  resourceName: '菜单权限',
                  children: [
                    { id: 131, resourceName: '新增菜单', children: [] },
                    { id: 132, resourceName: '编辑菜单', children: [] }
                  ]
                }
              ]
            },
            {
              id: 2,
              resourceName: '订单模块',
              children: [
                { id: 21, resourceName: '全部订单', children: [] },
                { id: 22, resourceName: '退款订单', children: [] }
              ]
            },
            { id: 3, resourceName: '财务中心', children: [] }
          ]
          resolve({
            code: '200',
            data: mockTreeData,
            msg: '查询成功'
          })
        }, 200)
      })
    }
    // 其他接口可继续扩展
    return Promise.reject({ msg: '接口不存在' })
  }
}

</script>

<style scoped>
/* 树形穿梭框样式 */
.tree-transfer {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  width: 100%;
}

.transfer-panel {
  flex: 0 0 45%;
  /* flex: 1; */
  border: 1px solid #e4e7ed;
  border-radius: 4px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.transfer-header {
  padding: 12px 15px;
  background-color: #f5f7fa;
  border-bottom: 1px solid #e4e7ed;
  font-weight: 500;
}

.tree-search {
  padding: 10px;
  border-bottom: 1px solid #e4e7ed;
}

.transfer-panel :deep(.el-tree) {
  flex: 1;
  max-height: 400px;
  overflow-y: auto;
}

.transfer-buttons {
  /* 垂直居中核心 */
  align-self: center;
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 10px;
}

.transfer-buttons :deep(.el-button) {
  width: 40px;
  height: 40px;
  padding: 0;
}
.transfer-buttons :deep(.el-button + .el-button) {
  margin-left: 0;
}
</style>
相关推荐
lang201509281 小时前
Java SAX 流式解析全解:从原理到 EasyExcel 实战
java·前端·javascript
VidDown1 小时前
视频协议传输全解析:从 HTTP/HTTPS 到 HLS/DASH 的完整旅程
javascript·网络·http·https·编辑器·音视频·视频编解码
独泪了无痕2 小时前
Vue集成uuid生成唯一标识实践指南
前端·vue.js
yuanyxh10 小时前
Mac 软件推荐
前端·javascript·程序员
万少10 小时前
AtomCode开发微信小程序《谁去呀》 全流程
前端·javascript·后端
学Linux的语莫13 小时前
Vue 3 入门教程
前端·javascript·vue.js
怕浪猫13 小时前
第一章、Chrome DevTools Protocol (CDP) 详解
前端·javascript·chrome
qq43569470115 小时前
Vue04
前端·vue.js
Yeats_Liao16 小时前
Feed流系统设计(三):数据模型与存储设计,从表结构到Redis收件箱
java·javascript·redis