Vue3+Ant-design-vue 实现树形穿梭框

1.需求:实现树形结构的穿梭框,并且可以左右来回穿梭,穿梭箭头也是跟着左右俩侧树形结构选中状态而高亮(也就是左侧树形结构选完后 穿梭向右箭头要高亮 相反 右侧树形结构选完后 穿梭左箭头要高亮),左侧树形结构穿梭后 左侧选中节点置灰

2.数据格式 与后端同学确认好 可以以我这个为例子

javascript 复制代码
{
    "code": 0,
    "level": null,
    "msg": "操作成功",
    "ok": true,
    "data": [
        {
            "departmentId": 4237,
            "departmentName": "最外层一级",
            "parentDepartmentId": 157,
            "departmentType": "1001",
            "haveFlag": true,
            "children": [
                {
                    "departmentId": 4245,
                    "departmentName": "里层一级",
                    "parentDepartmentId": 4237,
                    "departmentType": "1002",
                    "haveFlag": true,
                    "children": [
                        {
                            "departmentId": 4116,
                            "departmentName": "里层二级",
                            "parentDepartmentId": 4245,
                            "departmentType": "1",
                            "haveFlag": true,
                            "children": []
                        }
                    ]
                }
            ]
        }
    ],
    "dataType": 1
}

3.具体代码

javascript 复制代码
现在我是把这个封装成组件了 以下会介绍具体封装及使用

1.先介绍具体组件封装
  1-1 先上代码 完了再拆开细讲里面逻辑包括数据封装
      后端返回的格式应该是不满足组件自带的数据格式 所以需要对数据进行封装
      如果后端返回的满足组件自带数据格式 那就不需要封装
  
  1-2 代码
  <template>
  <div ref="roleDataScope">
    <a-transfer
      v-model:target-keys="targetKeys"
      @update:target-keys="handleTargetKeysChange"
      class="tree-transfer"
      :data-source="dataSource"
      :render="(item) => item.title"
      :show-select-all="false"
    >
      <template #children="{ direction, selectedKeys, onItemSelect }">
        <div class="look_css" v-if="direction === 'left'">
          <div class="txt_css">查看数据</div>
          <a-input-search v-model:value="value" class="search_css" @search="handleSearch" placeholder="点击搜索有结果" />
        </div>

        <template v-if="direction === 'left'">
          <template v-if="filteredTreeData.length > 0">
            <a-tree
              block-node
              checkable
              check-strictly
              style="height: 500px; overflow-y: scroll"
              :checked-keys="[...selectedKeys, ...targetKeys]"
              :tree-data="filteredTreeData"
              @check="
                (_, props) => {
                  onChecked(props, [...selectedKeys, ...targetKeys], onItemSelect);
                }
              "
              @select="
                (_, props) => {
                  onChecked(props, [...selectedKeys, ...targetKeys], onItemSelect);
                }
              "
            />
          </template>
          <a-empty v-else description="未找到相关数据" />
        </template>
        <a-tree
          v-else-if="targetKeys.length > 0"
          block-node
          checkable
          check-strictly
          :checked-keys="[...selectedKeys]"
          :tree-data="findSelectedNodes(tData, targetKeys)"
          @check="
            (_, props) => {
              onChecked(props, [...selectedKeys], onItemSelect);
            }
          "
          @select="
            (_, props) => {
              onChecked(props, [...selectedKeys], onItemSelect);
            }
          "
        />
      </template>
    </a-transfer>
  </div>
</template>
<script setup>
  import { computed, watch, ref, inject, onMounted } from 'vue';
  import { departmentApi } from '/@/api/system/department-api';
  const selectRoleId = inject('selectRoleId');
  const emit = defineEmits(['update:selectedData']);
  // 转换接口数据到树形组件需要的格式
  function convertDepartmentData(data, selectedKeys = []) {
    return data.map((item) => {
      const newNode = {
        key: item.departmentId.toString(),
        title: item.departmentName,
        type: item.departmentType,
        ...(item.haveFlag && { checked: true }),
      };
      if (item.haveFlag) {
        selectedKeys.push(item.departmentId.toString());
      }
      if (item.children && item.children.length > 0) {
        newNode.children = convertDepartmentData(item.children, selectedKeys);
      }
      return newNode;
    });
  }

  const value = ref('');
  const tData = ref([]);
  const transferDataSource = ref([]);

  const targetKeys = ref([]);

  function flatten(list = []) {
    list.forEach((item) => {
      transferDataSource.value.push(item);
      flatten(item.children);
    });
  }

  function isChecked(selectedKeys, eventKey) {
    return selectedKeys.indexOf(eventKey) !== -1;
  }

  function handleTreeData(treeNodes, targetKeys = []) {
    return treeNodes.map(({ children, ...props }) => ({
      ...props,
      disabled: targetKeys.includes(props.key),
      children: handleTreeData(children ?? [], targetKeys),
    }));
  }
  // 查找已选节点并构建树形数据
  function findSelectedNodes(nodes, selectedKeys) {
    return nodes
      .map((node) => {
        const newNode = { ...node };
        if (node.children && node.children.length > 0) {
          newNode.children = findSelectedNodes(node.children, selectedKeys);
        }
        const isNodeSelected = selectedKeys.includes(node.key);
        const hasSelectedChild = newNode.children && newNode.children.length > 0;
        if (isNodeSelected || hasSelectedChild) {
          return newNode;
        }
        return null;
      })
      .filter(Boolean);
  }

  const dataSource = ref(transferDataSource.value);

  const treeData = computed(() => {
    return handleTreeData(tData.value, targetKeys.value);
  });
  // 过滤树形数据的函数
  function filterTree(nodes, keyword) {
    if (!keyword) return nodes;
    return nodes
      .map((node) => {
        const newNode = { ...node };
        if (node.children) {
          newNode.children = filterTree(node.children, keyword);
        }
        if (node.title.includes(keyword) || (newNode.children && newNode.children.length > 0)) {
          return newNode;
        }
        return null;
      })
      .filter(Boolean);
  }
  function handleSearch(val) {
    value.value = val;
  }
  // 计算属性,用于获取过滤后的树形数据
  const filteredTreeData = computed(() => {
    return filterTree(treeData.value, value.value);
  });

  // 收集所有子节点的key
  function collectChildKeys(node, keys = []) {
    if (node.key) {
      keys.push(node.key);
    }
    if (node.children && node.children.length > 0) {
      node.children.forEach((child) => collectChildKeys(child, keys));
    }
    return keys;
  }

  const onChecked = (e, checkedKeys, onItemSelect) => {
    const { node } = e;
    const isChecked = !checkedKeys.includes(node.key);

    // 收集当前节点及其所有子节点的key
    const allKeys = collectChildKeys(node);

    // 批量更新选中状态
    allKeys.forEach((key) => {
      onItemSelect(key, isChecked);
    });
  };

  function collectParentKeys(nodes, targetKeys, parentKeys = []) {
    for (const node of nodes) {
      if (targetKeys.includes(node.key)) {
        parentKeys.push(node.key);
      }
      if (node.children && node.children.length > 0) {
        const childParentKeys = collectParentKeys(node.children, targetKeys, [...parentKeys]);
        if (childParentKeys.length > parentKeys.length) {
          if (!parentKeys.includes(node.key)) {
            parentKeys.push(node.key);
          }
        }
      }
    }
    return parentKeys;
  }

  // 辅助函数:构建目标格式数据
  function buildTargetFormat(selectedNodes) {
    console.log('selectedNodes:', selectedNodes);
    // 收集所有节点的 key 映射
    const nodeMap = {};
    selectedNodes.forEach((node) => {
      nodeMap[node.key] = node;
    });

    // 将 collectChildKeys 函数声明移动到函数体根位置
    function collectChildKeys(children, unitIdList) {
      children.forEach((child) => {
        if (selectedNodes.some((n) => n.key === child.key)) {
          // console.log('child:', child);
          if (child.type != '1002') {
            unitIdList.push(child.key);
          }

          if (child.children && child.children.length > 0) {
            collectChildKeys(child.children, unitIdList);
          }
        }
      });
    }

    const result = [];
    selectedNodes.forEach((node) => {
      if (node.children && node.children.length > 0) {
        // 有子级的节点,收集子级 key
        const unitIdList = [];
        collectChildKeys(node.children, unitIdList);
        console.log('node', node);
        if (node.type == '1001') {
          result.push({
            companyId: node.key,
            unitIdList: [...new Set(unitIdList)], // 去重
          });
        }
      } else if (!selectedNodes.some((n) => n.children && n.children.some((c) => c.key === node.key))) {
        // 没有父级引用的叶子节点
        result.push({
          companyId: node.key,
          unitIdList: [],
        });
      }
    });
    return result;
  }

  const handleTargetKeysChange = (newTargetKeys) => {
    targetKeys.value = newTargetKeys;
    // 收集所有父级节点的 key
    const allKeys = [...new Set([...newTargetKeys, ...collectParentKeys(tData.value, newTargetKeys)])];
    // 筛选出包含父级节点的数据
    const selectedData = dataSource.value.filter((item) => allKeys.includes(item.key));
    // 转换为目标格式
    const formattedData = buildTargetFormat(selectedData);
    emit('update:selectedData', formattedData);
  };

  // 初始化数据
  function getInit() {
    const formData = new FormData();
    formData.append('roleId', selectRoleId.value);
    departmentApi.queryDepartmentDataPermissionTree(formData).then((res) => {
      if (res.ok) {
        const selectedKeys = [];
        tData.value = convertDepartmentData(res.data, selectedKeys);
        targetKeys.value = selectedKeys;
        transferDataSource.value = [];
        flatten(tData.value);
        dataSource.value = transferDataSource.value;
      }
    });
  }
  watch(
    () => selectRoleId.value,
    () => getInit()
  );
  onMounted(() => {
    getInit();
    handleTargetKeysChange(targetKeys.value);
  });
</script>
<style scoped>
  .tree-transfer .ant-transfer-list:first-child {
    width: 50%;
    flex: none;
  }
  .look_css {
    display: flex;
    flex-direction: row;
    justify-content: space-between;
    align-items: center;
    margin-top: 10px;
    .txt_css {
      margin-left: 20px;
    }
    .search_css {
      width: 200px;
      margin-right: 20px;
    }
  }
</style>

2.再介绍如何具体使用
<RoleDataScope ref="roleDataScopeRef" v-model:selectedData="rightSideData" />
import RoleDataScope from '../role-data-scope/index.vue';
const rightSideData = ref([]);
因为我这个保存按钮是在父组件里面 所以要把对应的值传过来
这个根据实际情况而定 如果你的保存按钮就在子组件里写 那就不用传了

4.有问题 随时欢迎大家来交流