- 整体结构
- 采用 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
添加操作
-
获取数据:通过 tree.getCheckedNodes() 获取左侧勾选的节点。 2. 数据转换:将勾选的节点转换为 TargetUser 格式。 3. 去重合并: ○ 使用 Map 或 Set 以 id 为键,确保右侧列表中无重复人员。 ○ 若右侧已存在该人员,跳过此次添加。
-
更新状态:将合并后的数据赋值给右侧列表数据源 targetData,并将勾选的数据置为disable
删除操作
- 从 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;
}