vue3 实现组织架构选中 禁用 移动到另外的列表

4 阅读7分钟
  1. 整体结构
  2. 采用 Dialog (弹窗) 包裹 组织架构树列表 和 列表 的模式。由于标准穿梭框难以满足复杂的树形交互,采用自定义布局,将左右两侧区域分别封装为独立的逻辑模块。(Dialog 复用原有组件)

● 左侧容器: 组件,用于展示组织架构及人员。 左侧容器上有添加按钮
● 右侧容器: 自定义列表,用于展示已选人员。 右侧按钮 有删除按钮 显示容器内人数 2.2 数据结构定义

树形结构节点

// 树节点数据结构
interface TreeNode {
  id: number;
  label: string; // 团队名称或人员姓名
  type: 'dept' | 'user'; // 节点类型
  departmentName?: string; // 人员所属部门名(用于左侧展示)
  children?: TreeNode[]; // 子节点
  disabled?: boolean; // 是否禁用
}

右侧

// 目标数据结构
interface TargetUser {
  id: number;
  name: string;
  phoneSuffix: string; // 手机后四位
  department: string; // 所属团队
  rawNode: TreeNode; // 原始节点引用(用于去重和状态管理)
}

过滤逻辑:在前端对返回的树结构进行深度遍历,对已选中的list 进行递归,属性设置为disable

树形配置

show-checkbox:开启复选框。

check-strictly:关闭(false),以实现父子节点的级联勾选。

default-expanded-keys:根据数据量决定是否默认展开全部,若数据量大则仅展开根节点。(数据较大) 全部展开

按钮状态控制 

添加按钮:绑定 leftCheckedNodes.length > 0 

删除按钮:绑定 rightSelectedIds.length > 0

添加操作 

  1. 获取数据:通过 tree.getCheckedNodes() 获取左侧勾选的节点。 2. 数据转换:将勾选的节点转换为 TargetUser 格式。 3. 去重合并: ○ 使用 Map 或 Set 以 id 为键,确保右侧列表中无重复人员。 ○ 若右侧已存在该人员,跳过此次添加。

  2. 更新状态:将合并后的数据赋值给右侧列表数据源 targetData,并将勾选的数据置为disable

删除操作 

  1. 从 targetData 中过滤掉当前右侧勾选的人员ID。 2. 更新 targetData,同步更新左侧组织架构树,将删除的id 对应的不可选状态转换为可选
 <el-dialog title="月度业绩名单" v-model="openStaff" width="1200px" append-to-body>
      <div class="tree-transfer">
        <!-- 穿梭框左侧:树形结构 -->
        <div class="tree-transfer-left">
          <div class="transfer-panel-title">
            <span>人员清单</span>
            <div style="display: flex; flex-direction: row">
              <div>{{ checkedLeafCount }}人</div>
              <el-button link type="primary" text @click="moveToRight">添加</el-button>
            </div>
          </div>
          <el-tree ref="treeLeftRef" :data="dataLeft" show-checkbox node-key="id" :props="defaultProps"
            default-expand-all :filter-node-method="filterNode" @check="handleCheckChange">

            <template #default="{ node, data }">
              <span class="custom-tree-node">
                <span v-if="data?.type === 'dept'" class="dept-tree-user">
                  {{ data.label }}
                </span>
                <span v-else class="dept-tree-user">
                  {{ data.namePhoneN }}
                </span>
              </span>
            </template>
          </el-tree>
        </div>

        <!-- 穿梭框右侧:树形结构 -->
        <div class="tree-transfer-right">
          <div class="transfer-panel-title">
            <span>已选人员</span>
            <div style="display: flex; flex-direction: row">
              <div>{{ checkedRightCount }}人</div>
              <el-button link type="primary" text @click="moveToLeft">移除</el-button>
            </div>
          </div>
          <el-tree ref="treeRightRef" :data="dataRight" show-checkbox node-key="id" :props="defaultProps"
            default-expand-all :filter-node-method="filterNode" @check="handleCheckRightChange">
            <template #default="{ node, data }">
              <span class="custom-tree-node">
                <span class="dept-tree-user">{{ data.namePhoneN }}</span>
                <span class="dept-tree-label" v-show="data.orgName">
                  {{ data.orgName }}
                </span>
              </span>
            </template>
          </el-tree>
        </div>
      </div>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitStaff" :loading="confirmbtLoading">确 定</el-button>
          <el-button @click="cancel">取 消</el-button>
        </div>
      </template>
    </el-dialog>
    
    
    ///js
    // 数据
const dataList = ref([]);
const dateValue = ref(new Date());

// 组织架构树
// 定义树组件的配置
const defaultProps = {
  children: "children",
  label: "label",
};

// 左右两侧的树数据
const dataLeft = ref([]);
const checkedLeafCount = ref(0); // 左侧勾选数量
const defaultDisableList = ref([9, 10]); // 左侧disable的list
const dataRight = ref([]);
const checkedRightCount = ref(0);

// 获取模板引用 (用于调用树的方法)
const treeLeftRef = ref(null);
const treeRightRef = ref(null);
const confirmbtLoading = ref(false);
const setLoading = ref(false);
// 左侧容器勾选
const handleCheckChange = (node, checked) => {
  // 确保 treeLeft 引用已挂载
  if (!treeLeftRef.value) return;

  // 关键点:第二个参数传 true,表示只获取叶子节点
  const leafNodes = treeLeftRef.value.getCheckedNodes(true) || [];

  // 2. 过滤:type 类型不为 dept 的节点
  // 注意:这里逻辑与你原代码一致 (node.type !== "dept"),即排除 dept 类型
  const filteredNodes = leafNodes.filter((node) => node.type !== "dept");

  // 更新响应式数据
  checkedLeafCount.value = filteredNodes.length;

  // 如果你需要在控制台查看数据,可以在这里打印
  // console.log("当前选中的有效叶子节点数:", checkedLeafCount.value);
};

// 右侧容器勾选
const handleCheckRightChange = (node, checked) => {
  // 关键点:第二个参数传 true,表示只获取叶子节点
  const rightKeys = treeRightRef?.value?.getCheckedKeys(true) || [];
  checkedRightCount.value = rightKeys.length;
};
// 过滤节点方法
const filterNode = (value, data) => {
  if (!value) return true;
  return data.label.includes(value);
};

// 1. 移动到右侧主函数
const moveToRight = () => {
  checkedLeafCount.value = 0;

  // 先保存右侧当前的勾选状态,以便更新数据后恢复
  const prevRightKeys = treeRightRef.value?.getCheckedKeys(true) || [];

  // 1. 获取选中节点
  const checkedNodes = treeLeftRef.value?.getCheckedNodes() || [];
  if (!checkedNodes.length) return;

  // 2. 递归收集 user 类型节点 (逻辑不变)
  const collectUsers = (node) => {
    let res = [];
    if (node.type === "user") res.push(node);
    if (node.children?.length) {
      node.children.forEach((child) => {
        res = res.concat(collectUsers(child));
      });
    }
    return res;
  };

  // 3. 汇总所有选中节点中的 user
  let users = [];
  checkedNodes.forEach((node) => {
    users = users.concat(collectUsers(node));
  });

  if (!users.length) {
    treeLeftRef.value?.setCheckedKeys([]);
    return;
  }

  // 4. 去重并过滤右侧已存在的
  const userMap = new Map();
  users.forEach((u) => {
    if (!userMap.has(u.id)) {
      userMap.set(u.id, {
        id: u.id,
        label: u.label,
        namePhoneN: u.namePhoneN,
        orgName: u.orgName,
        managerId: u.managerId,
      });
    }
  });

  const rightIdSet = new Set(dataRight.value.map((u) => u.id));
  const newUsers = Array.from(userMap.values()).filter(
    (u) => !rightIdSet.has(u.id),
  );

  if (newUsers.length) {
    // 更新右侧数据
    dataRight.value = dataRight.value.concat(newUsers);

    // 保持右侧数据顺序与左侧一致:
    const flattenLeft = (nodes, arr = []) => {
      nodes.forEach(n => {
        if (n.type === 'user') arr.push(n.id);
        if (n.children && n.children.length) flattenLeft(n.children, arr);
      });
      return arr;
    };
    const leftOrder = flattenLeft(dataLeft.value);
    const orderMap = new Map();
    leftOrder.forEach((id, idx) => orderMap.set(id, idx));
    dataRight.value.sort((a, b) => {
      const ia = orderMap.has(a.id) ? orderMap.get(a.id) : Infinity;
      const ib = orderMap.has(b.id) ? orderMap.get(b.id) : Infinity;
      return ia - ib;
    });

    // 收集需要禁用的 ID
    const toDisableIds = new Set(newUsers.map((u) => u.id));

    // 5. 标记禁用逻辑 (关键修改点)
    // Vue3 不需要 this.$set,直接赋值即可触发响应式
    const markDisabledRecursive = (nodes) => {
      let anyMatched = false;
      nodes.forEach((node) => {
        let childMatched = false;
        if (node.children && node.children.length) {
          childMatched = markDisabledRecursive(node.children);
        }
        const selfMatched = toDisableIds.has(node.id);
        if (selfMatched || childMatched) {
          // --- 核心修改:移除 this.$set ---
          node.disabled = true;
          // --- 核心修改:直接操作数组 ---
          if (!defaultDisableList.value.includes(node.id)) {
            defaultDisableList.value.push(node.id);
          }
          anyMatched = true;
        }
      });
      return anyMatched;
    };

    markDisabledRecursive(dataLeft.value);

    // 注意:Vue3 中,如果 dataLeft 是响应式引用,修改其内部属性
    // 通常不需要强制重新赋值 JSON.parse (除非为了强制触发生命周期)
    // 如果树组件没有自动刷新,再开启下面这行
    // dataLeft.value = JSON.parse(JSON.stringify(dataLeft.value));

    // 恢复右侧的勾选状态
    nextTick(() => {
      treeRightRef.value?.setCheckedKeys(prevRightKeys);
    });
  }

  // 6. 清空左侧勾选
  treeLeftRef.value?.setCheckedKeys([]);
};

// 将右侧选中节点移回左侧
const moveToLeft = () => {
  // 保留左侧当前勾选,以便最后恢复
  const prevLeftKeys = treeLeftRef.value?.getCheckedKeys(true) || [];

  // 1. 获取右侧选中节点的 Key
  const checkedKeys = treeRightRef.value?.getCheckedKeys() || [];
  if (!checkedKeys.length) return;

  // 1) 从右侧数据源中移除选中的节点
  // 注意:你需要确保 removeNodesById 函数是纯函数,不修改原数组
  dataRight.value = removeNodesById(dataRight.value, checkedKeys);

  // 2) 在左侧树中找到对应节点并启用
  const idsToEnable = new Set(checkedKeys);

  const markEnabledRecursive = (nodes) => {
    let anyMatched = false;
    nodes.forEach((node) => {
      let childMatched = false;
      if (node.children && node.children.length) {
        childMatched = markEnabledRecursive(node.children);
      }
      const selfMatched = idsToEnable.has(node.id);

      if (selfMatched || childMatched) {
        // --- 核心修改:直接赋值,无需 this.$set ---
        node.disabled = false;

        // --- 核心修改:直接操作数组 ---
        const idx = defaultDisableList.value.indexOf(node.id);
        if (idx !== -1) {
          defaultDisableList.value.splice(idx, 1);
        }
        anyMatched = true;
      }
    });
    return anyMatched;
  };

  markEnabledRecursive(dataLeft.value);

  // --- 优化建议 ---
  // 在 Vue 3 中,由于响应式系统是基于 Proxy 的,
  // 直接修改 node.disabled 通常会触发视图更新。
  // 只有当组件(如 Element Plus Tree)没有监听深层属性变化时,才需要强制刷新。
  // 如果界面正常,可以尝试注释掉下面这行以提高性能。
  // dataLeft.value = JSON.parse(JSON.stringify(dataLeft.value));

  // 4) 清空右侧勾选并恢复左侧状态
  treeRightRef.value?.setCheckedKeys([]);
  checkedRightCount.value = 0;

  nextTick(() => {
    treeLeftRef.value?.setCheckedKeys(prevLeftKeys);
    // 更新左侧计数
    const leaf = treeLeftRef.value?.getCheckedNodes(true) || [];
    checkedLeafCount.value = leaf.filter((n) => n.type !== "dept").length;
  });
};

// 定义函数(移除了 this,改为普通函数或 const)
const removeNodesById = (data, keys) => {
  return data
    .filter((node) => !keys.includes(node.id))
    .map((node) => {
      if (node.children && node.children.length > 0) {
        return {
          ...node,
          children: removeNodesById(node.children, keys), // 直接递归调用
        };
      }
      return node;
    });
};

const sortAbs = computed(() => {
  return function (sort) {
    return Math.abs(sort);
  };
});

const getList = () => {
  loading.value = true;
  getPerformance({
    startDate: getMonthOne(dateValue.value),
    endDate: currentMonthLast(dateValue.value),
  })
    .then((res) => {
      dataList.value = res.data;
    })
    .finally(() => {
      loading.value = false;
    });
};
const getOrgTreeData = async () => {
  const res = await getOrgTree();
  return res.data;
};
const toOpen = async () => {
  setLoading.value = true;
  try {
    const result = await getOrgTreeData();
    // 接口失败是否打开弹框
    if (+result.code === 200) {
      openStaff.value = true;
      checkedLeafCount.value = 0;
      checkedRightCount.value = 0;
      const leftData = result.data.deptList;

      const rightData = result.data.selectedUsers;

      const idArray = rightData.map((user) => user.id);
      const resdLeftDate = processTreeData(leftData, idArray);
      dataLeft.value = resdLeftDate;
      dataRight.value = rightData;
    } else {
      instance.proxy.msgFail("网络异常,无法打开人员设置,请稍后再试");
    }
  } catch (error) {
    instance.proxy.msgFail("网络异常,无法打开人员设置,请稍后再试");
  } finally {
    setLoading.value = false;
  }
};

const submitStaff = () => {
  const staffIds = dataRight.value.map((item) => ({
    managerId: item.managerId,
  }));
  // 如果数据为空,不允许提交,给予用户提示
  if (staffIds.length === 0) {
    instance.proxy.msgFail("请至少选择一个人员");
    return;
  }
  confirmbtLoading.value = true;
  updateStaffs({
    type: "winnersList",
    markedIDs: staffIds.map((item) => item.managerId),
  }).then((res) => {
    instance.proxy.msgSuccess("更新成功");
    openStaff.value = false;
    dataLeft.value = [];
    dataRight.value = [];
    getList();
  }).catch((error) => {
    instance.proxy.msgFail("更新失败,请稍后再试");
  }).finally(() => {
    confirmbtLoading.value = false;

  });
};

const cancel = () => {
  openStaff.value = false;
  dataLeft.value = [];
  dataRight.value = [];
};


// 自动化处理禁用逻辑 代码


// utils/treeUtils.ts
import {cloneDeep} from 'lodash';

// --- 以下是之前的接口定义 ---
interface TreeNode {
  id: number | string;
  type: 'dept' | 'user' | string;
  disabled?: boolean;
  children?: TreeNode[];
  [key: string]: any;
}

type TreeData = TreeNode[];

/**
 * 处理树形数据:根据 disableIdList 禁用节点,并根据子节点状态更新父节点状态
 * @param {TreeData} treeData - 原始树形数据
 * @param {(number | string)[]} disableIdList - 接口返回的需要禁用的 ID 列表
 * @returns {TreeData} 处理后的树形数据
 */
export function processTreeData(
  treeData: TreeData,
  disableIdList: (number | string)[]
): TreeData {
  // 1. 深拷贝,避免修改原数据
  const data: TreeData = cloneDeep(treeData);

  // 2. 使用栈来模拟深度优先遍历 (避免递归导致的栈溢出)
  // 栈中的每一项是一个节点,以及一个标志位表示它是否已经被“处理过”(即它的子节点已经处理完了)
  const stack: { node: TreeNode; processed: boolean }[] = [];

  // 初始化:将根节点推入栈,并标记为未处理
  data.forEach((node) => {
    stack.push({ node, processed: false });
  });

  // 用于存储节点的处理结果(是否被禁用),以便父节点查询
  // Map<节点ID, 该节点及其所有子节点是否都被禁用>
  const nodeDisabledStatus = new Map<any, boolean>();

  while (stack.length > 0) {
    const { node, processed } = stack.pop()!;

    // 如果节点是叶子节点,直接计算状态
    if (!node.children || node.children.length === 0) {
      const isDisabled = disableIdList.includes(node.id);
      node.disabled = isDisabled;
      nodeDisabledStatus.set(node.id, isDisabled);
      continue;
    }

    if (!processed) {
      // 第一次访问该节点:先将自己重新推入栈,标记为已处理
      stack.push({ node, processed: true });

      // 然后将所有子节点推入栈,标记为未处理
      // 注意:为了保持顺序,需要倒序推入
      for (let i = node.children.length - 1; i >= 0; i--) {
        stack.push({ node: node.children[i], processed: false });
      }
    } else {
      // 第二次访问该节点(所有子节点已经处理完毕)
      // 检查所有子节点是否都被禁用
      const allChildrenDisabled = node.children.every((child) =>
        nodeDisabledStatus.get(child.id)
      );

      let shouldDisableNode = false;

      if (disableIdList.includes(node.id)) {
        shouldDisableNode = true;
      } else if (node.type === 'dept' && allChildrenDisabled) {
        shouldDisableNode = true;
      } else {
        shouldDisableNode = false;
      }

      node.disabled = shouldDisableNode;
      nodeDisabledStatus.set(node.id, shouldDisableNode);
    }
  }

  return data;
}