通用的部门选择器

作者 zhufenfen 日期 2022-06-29
通用的部门选择器

最近分到了一个严峻的任务,接到这个任务时一筹莫展。技术方案做了一周也觉得不是我能做得了的事,好难呀好想放弃,仿佛自己在风口浪尖上,进退两难。只能咬咬牙,坚持一天算一天。浅浅记录一下~

需求背景

项目中部门父子联动关系不满足当前某些使用场景(比如只想选中父部门下数据,或者取消选中子部门不会影响父部门选中状态)现需开发一个可以自由配置每个节点联动关系的部门组件,并实现节点的异步加载。组件开发完之后,会逐步替换项目里原有的部门。大概长这个样子,现有的组件库显然是不能拿来即用的,难办了😅

dropdown的内容大概如下,触发器可以是select或filter或其它button

设计场景

这是要封装一个通用的部门组件呀,冷静一下,分析下逻辑场景,最终和UI确认是要实现这样的一个效果。

  • 懒加载,首次加载两级部门,随后每次点击包含子部门的展开标识只加载一级部门。如果父节点含下级,则新展开的节点也默认勾选。
  • 鼠标悬浮在被选中的部门节点上(包括叶子节点),右侧会根据当前部门的含/不含下级状态,展示含/不含下级按钮。
  • 勾选部门,此时当前部门及所有下级部门全被勾选且都是含下级状态。
  • 取消勾选部门,此时当前部门及所有下级部门全被取消勾选。所有祖先部门都调整为不含下级状态。
  • 点击含下级按钮,此时当前部门的所有下级部门全被勾选且都是含下级状态。
  • 点击不含下级按钮,此时当前部门的所有下级部门都被取消勾选。所有祖先部门都调整为不含下级状态。
  • Select框的tag标签,包含所有不含下级的被选中部门以及含下级的顶层头部部门。Select框的tag标签对应着部门树选中数据,部门树数据变动,Select框的tag标签也随之变动,Select框的数据变动,部门树数据也随之变动。
  • 勾选显示已停用部门,清空之前被勾选的数据,重新请求数据。已停用部门后会新增标识。(前端不做筛选原因:默认不展示已停用部门,如果某个部门的下级部门全是已停用部门,由于后端haveSub标识为true,则在页面点击展开框会再请求加载一次,却没得到部门数据。)
  • 搜索时做防抖处理,部门树搜索内容做高亮处理 还有等等......总之,之前部门选择器的功能这里只能多,不能少...

技术方案

需求开发前,当然要做技术方案啦。害,工作两三年第一次做技术方案,还好有模版可以照葫芦画瓢。组件首先需要考虑字段的一些设计,组件接收的prop以及对外传递的数据。花了蛮久的时间研究整理了下,可能还是会有遗漏… 现有的组件props阔以通过selectProp和filterProp传递进来,算是留一个入口吧。

用TS定义了组件的部门树数据以及前后端和组件对外传递的数据格式,在组件内部主要工作是对这些数据进行逻辑判断处理。由于组件select展示的数据和tree选中的数据不一致,所以会维护两套数据。一个是所有勾选的keys,另一个是部门回显数据格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 部门树数据
export interface DepartTreeData {
orgId: number;
name: string;
haveUsed?: HAVE_USED;
hasAuth?: boolean;
haveSub?: boolean;
path?: string;
children?: DepartTreeData[];
[key: string]: unknown;
}

export interface DepartValue {
orgId: number;
name: string;
includeSub?: boolean;
}

// 前后端传递的数据格式
export type AsyncDepartmentValue<M> = (M extends true ? DepartValue[] : DepartValue) | undefined;

export type AsyncDepartmentValueOption<M> =
| (M extends true ? DepartTreeData[] : DepartTreeData)
| undefined;

// 所有勾选的keys
export type CheckedKeys<M> = (M extends true ? number[] : number) | null | undefined;

// 部门回显数据格式
export type CheckValueMap = Map<number, { name: string; includeSub?: boolean }>;

具体实现

接下来就是具体实现过程啦,也蛮煎熬的,仿佛在考验一个前端同学的算法能力,记得刚入职的时候面算法,就不太会,面试官夸我诚实😄

话说回来,到底怎么实现呢。其实主要是取消勾选/勾选或点击含/不含下级的一些联动逻辑处理,需要处理两套数据。一点一点慢慢琢磨其中的奥妙,终于有一天茅塞顿开,咔咔咔一顿写,仿佛实现了,哈哈!这里浅浅记录一下使用的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
/**
* 取消勾选/勾选祖先节点时,map数据清除子节点
*/
const deleteChildrenMap = (changeValueMap: CheckValueMap, childrenKeys: number[] | undefined) => {
childrenKeys?.forEach((item) => {
if (changeValueMap.has(item)) {
changeValueMap.delete(item);
}
});
return changeValueMap;
};

/**
* 取消勾选/勾选祖先节点时,children数组联动(所有下级部门全被取消勾选/勾选,禁用节点除外)
*/
const linkageChildren = (
treeEntities: TreeEntities,
checkedKeysSet: Set<number>,
childrenKeys: number[] | undefined,
operate: ValueOf<typeof OPERATE> = OPERATE.ADD
) => {
childrenKeys?.forEach((item) => {
if (treeEntities.get(item)?.rawData.hasAuth) {
checkedKeysSet[operate](item);
}
});
return checkedKeysSet;
};

/**
* 收集禁用节点
*/
const gatherDisableNode = (
parentItem: number,
isIncludeSubMode: boolean,
treeDataEntities: TreeEntities,
changeValueMap: CheckValueMap,
disabledNode: number[]
) => {
// 直接子级都调整为含下级
const directChildrenKeys = treeDataEntities.get(parentItem)?.directChildrenKeys;
directChildrenKeys?.forEach((child) => {
changeValueMap.set(
child,
isIncludeSubMode
? {
name: treeDataEntities.get(child)?.rawData.name as string,
includeSub: true,
}
: { name: treeDataEntities.get(child)?.rawData.name as string }
);
if (!treeDataEntities.get(child)?.rawData.hasAuth) {
disabledNode.push(child);
}
});
};

/**
* 取消节点时,部门树包含禁用节点的逻辑处理
*/
const disableNodeHandle = (
disabledNode: number[],
changeValueMap: CheckValueMap,
checkedKeysSet: Set<number>,
treeDataEntities: TreeEntities,
isIncludeSubMode: boolean
) => {
disabledNode.forEach((item) => {
changeValueMap.delete(item);
const showChild = (directChildren: number[] | undefined) => {
directChildren?.forEach((child) => {
if (checkedKeysSet.has(child)) {
changeValueMap.set(
child,
isIncludeSubMode
? {
name: treeDataEntities.get(child)?.rawData.name as string,
includeSub: true,
}
: {
name: treeDataEntities.get(child)?.rawData.name as string,
}
);
} else if (!treeDataEntities.get(child)?.rawData.hasAuth) {
showChild(treeDataEntities.get(child)?.directChildrenKeys);
}
});
};
showChild(treeDataEntities.get(item)?.directChildrenKeys);
});
};

/**
* 含下级模式下,取消勾选节点时,changeValue中含下级的祖先部门都调整为不含下级状态,同时邻居节点都调整为含下级状态。
*/
const linkageParents = (
changeValueMap: CheckValueMap,
parentKeys: number[] | undefined,
checkedKeysSet: Set<number>,
treeDataEntities: TreeEntities
) => {
const disabledNode: number[] = [];
parentKeys?.forEach((item) => {
if (changeValueMap.has(item) && changeValueMap.get(item)?.includeSub) {
changeValueMap.set(item, {
name: changeValueMap.get(item)?.name as string,
includeSub: false,
});
gatherDisableNode(item, true, treeDataEntities, changeValueMap, disabledNode);
}
});
disableNodeHandle(disabledNode, changeValueMap, checkedKeysSet, treeDataEntities, true);
return changeValueMap;
};

/**
* 普通模式下,取消勾选节点时,changeValue和checkedKeys中的祖先部门都清除,changeValue中添加选中的邻居节点。
*/
const defaultLinkParents = (
changeValueMap: CheckValueMap,
checkedKeysSet: Set<number>,
parentKeys: number[] | undefined,
treeDataEntities: TreeEntities
) => {
const disabledNode: number[] = [];
parentKeys?.forEach((item) => {
if (changeValueMap.has(item)) {
changeValueMap.delete(item);
gatherDisableNode(item, false, treeDataEntities, changeValueMap, disabledNode);
}
if (checkedKeysSet.has(item)) {
checkedKeysSet.delete(item);
}
});
disableNodeHandle(disabledNode, changeValueMap, checkedKeysSet, treeDataEntities, false);
return [changeValueMap, checkedKeysSet];
};

/**
* 普通模式下,勾选节点时,判断父节点是否应该被勾选
*/
const judgeParentChecked = (
directChildren: number[] | undefined,
checkedKeysSet: Set<number>,
treeDataEntities: TreeEntities
): boolean | undefined => {
return directChildren?.every((child) => {
if (checkedKeysSet.has(child)) {
return true;
} else if (!treeDataEntities.get(child)?.rawData.hasAuth) {
const childs = treeDataEntities.get(child)?.directChildrenKeys;
return judgeParentChecked(childs, checkedKeysSet, treeDataEntities);
}
return false;
});
};

组件内部,把用户的行为操作放在一个方法里面,虽然多了些判断条件,但至少代码不会显得那么冗余

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
/**
* 节点状态改变
* @param node 改变的节点
* @param status 节点添加或者删除
* @param type 勾选取消节点或者点击不含/含下级
*/
const nodeStatusChange = (
node: DepartTreeData | DepartValue | undefined,
status: boolean | undefined,
type: ValueOf<typeof Check_Or_Include>
) => {
let checkedKeysSet = new Set<number>((node ? checkedKeys : []) as CheckedKeys<true>);
let changeValueMap = new Map(node ? changeValue : null);
if (node) {
// 单个操作
const operate = status ? OPERATE.DELETE : OPERATE.ADD;
if (type === Check_Or_Include.CHECK) {
checkedKeysSet[operate](node.orgId);
}
const childrenKeys = treeDataEntities.get(node.orgId)?.childrenKeys;
checkedKeysSet = linkageChildren(treeDataEntities, checkedKeysSet, childrenKeys, operate);
if (status) {
// 取消勾选节点或者点击不含下级
const parentKeys = treeDataEntities.get(node.orgId)?.parentKeys;
if (isIncludeSubMode) {
changeValueMap = linkageParents(
changeValueMap,
type === Check_Or_Include.CHECK ? parentKeys : parentKeys?.concat(node.orgId),
checkedKeysSet,
treeDataEntities
);
} else {
[changeValueMap, checkedKeysSet] = defaultLinkParents(
changeValueMap,
checkedKeysSet,
parentKeys,
treeDataEntities
) as [CheckValueMap, Set<number>];
}
if (type === Check_Or_Include.CHECK) {
changeValueMap.delete(node.orgId);
} else {
changeValueMap.set(node.orgId, { name: node.name, includeSub: false });
}
} else {
// 勾选节点或者点击含下级
if (isIncludeSubMode) {
changeValueMap.set(node.orgId, { name: node.name, includeSub: true });
} else {
changeValueMap.set(node.orgId, { name: node.name });
checkedKeysSet[operate](node.orgId);
const parentKeys = treeDataEntities.get(node.orgId)?.parentKeys.reverse();
parentKeys?.forEach((item) => {
if (!treeDataEntities.get(item)?.rawData.hasAuth) {
return;
}
const directChildrenKeys = treeDataEntities.get(item)?.directChildrenKeys;
const equalAllChecked = judgeParentChecked(
directChildrenKeys,
checkedKeysSet,
treeDataEntities
);
if (equalAllChecked) {
checkedKeysSet[operate](item);
changeValueMap.set(item, {
name: treeDataEntities.get(item)?.rawData.name as string,
});
const deleteChilds = treeDataEntities.get(item)?.childrenKeys;
changeValueMap = deleteChildrenMap(changeValueMap, deleteChilds);
}
});
}
}
changeValueMap = deleteChildrenMap(changeValueMap, childrenKeys);
}
setCheckedKeys([...checkedKeysSet] as CheckedKeys<M>);
mapChangeArr(changeValueMap);
setIsNeedChange(false);
};

/**
* 点击节点
*/
const nodeSelectChange = (
keys: number | null,
node: DepartTreeData,
{ checkboxStatus }: { checkboxStatus?: boolean | undefined }
) => {
if (multiple) {
nodeStatusChange(node, checkboxStatus, Check_Or_Include.CHECK);
} else {
const changeValueMap = new Map();
if (keys || keys === 0) {
changeValueMap.set(node.orgId, { name: node.name });
setCheckedKeys(node.orgId as CheckedKeys<M>);
} else {
setCheckedKeys(null);
}
mapChangeArr(changeValueMap);
// 搜索选中后组件blur
selectRef.current?.blur();
}
};

/**
* 点击不含/含下级
*/
const handleDepartLink = (
e: React.MouseEvent,
node: DepartTreeData,
includeSub: boolean | undefined
) => {
e.stopPropagation();
nodeStatusChange(node, includeSub, Check_Or_Include.INCLUDE);
};

这些真是写的头大呀,还是看下最终实现效果开心一下吧。哈哈~

SelectTree

FilterTree