cl-tree.uvue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. <template>
  2. <view class="cl-tree" :class="[pt.className]">
  3. <cl-tree-item
  4. v-for="item in data"
  5. :key="item.id"
  6. :item="item"
  7. :level="0"
  8. :pt="props.pt"
  9. ></cl-tree-item>
  10. </view>
  11. </template>
  12. <script lang="ts" setup>
  13. import { computed, watch, ref, type PropType } from "vue";
  14. import type { ClTreeItem, ClTreeNodeInfo } from "../../types";
  15. import { first, isEmpty, isEqual, parsePt } from "@/cool";
  16. defineOptions({
  17. name: "cl-tree"
  18. });
  19. const props = defineProps({
  20. pt: {
  21. type: Object as PropType<any>,
  22. default: () => ({})
  23. },
  24. // 绑定值
  25. modelValue: {
  26. type: [Array, String, Number] as PropType<any | null>,
  27. default: null
  28. },
  29. // 树形结构数据
  30. list: {
  31. type: Array as PropType<ClTreeItem[]>,
  32. default: () => []
  33. },
  34. // 节点图标
  35. icon: {
  36. type: String,
  37. default: "arrow-right-s-fill"
  38. },
  39. // 展开图标
  40. expandIcon: {
  41. type: String,
  42. default: "arrow-down-s-fill"
  43. },
  44. // 是否严格的遵循父子不互相关联
  45. checkStrictly: {
  46. type: Boolean,
  47. default: false
  48. },
  49. // 是否可以选择节点
  50. checkable: {
  51. type: Boolean,
  52. default: true
  53. },
  54. // 是否允许多选
  55. multiple: {
  56. type: Boolean,
  57. default: false
  58. }
  59. });
  60. const emit = defineEmits(["update:modelValue", "change"]);
  61. // 定义透传类型
  62. type PassThrough = {
  63. className?: string;
  64. };
  65. // 计算样式穿透对象
  66. const pt = computed(() => parsePt<PassThrough>(props.pt));
  67. // 树数据
  68. const data = ref<ClTreeItem[]>(props.list);
  69. /**
  70. * 优化的节点搜索算法,使用Map缓存提升查找性能
  71. * 创建节点索引映射,O(1)时间复杂度查找节点
  72. */
  73. const nodeMap = computed(() => {
  74. // 创建一个Map用于存储节点信息,key为节点id,value为节点信息对象
  75. const map = new Map<string | number, ClTreeNodeInfo>();
  76. // 递归遍历所有节点,构建节点与父节点的映射关系
  77. function buildMap(nodes: ClTreeItem[], parent: ClTreeItem | null = null): void {
  78. for (let i: number = 0; i < nodes.length; i++) {
  79. const node = nodes[i]; // 当前节点
  80. // 将当前节点的信息存入map,包含节点本身、父节点、在父节点中的索引
  81. map.set(node.id, { node, parent, index: i } as ClTreeNodeInfo);
  82. // 如果当前节点有子节点,则递归处理子节点
  83. if (node.children != null && node.children.length > 0) {
  84. buildMap(node.children, node);
  85. }
  86. }
  87. }
  88. // 从根节点开始构建映射
  89. buildMap(data.value);
  90. return map; // 返回构建好的Map
  91. });
  92. /**
  93. * 根据key查找节点信息
  94. * @param key 节点id
  95. * @returns 节点信息对象或null
  96. */
  97. function findNodeInfo(key: string | number): ClTreeNodeInfo | null {
  98. const result = nodeMap.value.get(key); // 从Map中查找节点信息
  99. return result != null ? result : null; // 找到则返回,否则返回null
  100. }
  101. /**
  102. * 获取指定节点的所有祖先节点
  103. * @param key 节点id
  104. * @returns 祖先节点数组(从根到最近父节点顺序)
  105. */
  106. function getAncestors(key: string | number): ClTreeItem[] {
  107. const result: ClTreeItem[] = []; // 用于存储祖先节点
  108. let nodeInfo = findNodeInfo(key); // 当前节点信息
  109. // 循环查找父节点,直到根节点
  110. while (nodeInfo != null && nodeInfo.parent != null) {
  111. result.unshift(nodeInfo.parent); // 将父节点插入到数组前面
  112. nodeInfo = findNodeInfo(nodeInfo.parent.id); // 查找父节点信息
  113. }
  114. return result; // 返回祖先节点数组
  115. }
  116. /**
  117. * 更新所有节点的选中状态(用于批量操作后的状态同步)
  118. */
  119. function updateAllCheckStates(): void {
  120. // 递归更新每个节点的选中和半选状态
  121. function updateNodeStates(nodes: ClTreeItem[]): void {
  122. for (let i: number = 0; i < nodes.length; i++) {
  123. const node = nodes[i]; // 当前节点
  124. const children = node.children != null ? node.children : []; // 子节点数组
  125. if (children.length == 0) {
  126. // 叶子节点,重置半选状态
  127. node.isHalfChecked = false;
  128. continue; // 跳过后续处理
  129. }
  130. // 先递归处理子节点
  131. updateNodeStates(children);
  132. // 统计子节点的选中和半选数量
  133. let checkedCount = 0; // 选中数量
  134. let halfCheckedCount = 0; // 半选数量
  135. for (let j = 0; j < children.length; j++) {
  136. if (children[j].isChecked == true) {
  137. checkedCount++;
  138. } else if (children[j].isHalfChecked == true) {
  139. halfCheckedCount++;
  140. }
  141. }
  142. // 根据子节点状态更新当前节点状态
  143. if (checkedCount == children.length) {
  144. // 全部选中
  145. node.isChecked = true;
  146. node.isHalfChecked = false;
  147. } else if (checkedCount > 0 || halfCheckedCount > 0) {
  148. // 部分选中或有半选
  149. node.isChecked = false;
  150. node.isHalfChecked = true;
  151. } else {
  152. // 全部未选中
  153. node.isChecked = false;
  154. node.isHalfChecked = false;
  155. }
  156. }
  157. }
  158. // 从根节点开始递归更新
  159. updateNodeStates(data.value);
  160. }
  161. /**
  162. * 更新指定节点的所有祖先节点的选中状态
  163. * @param key 节点id
  164. */
  165. function updateAncestorsCheckState(key: string | number): void {
  166. const ancestors = getAncestors(key); // 获取所有祖先节点
  167. // 从最近的父节点开始向上更新
  168. for (let i = ancestors.length - 1; i >= 0; i--) {
  169. const ancestor = ancestors[i]; // 当前祖先节点
  170. const children = ancestor.children != null ? ancestor.children : ([] as ClTreeItem[]); // 子节点数组
  171. if (children.length == 0) continue; // 没有子节点则跳过
  172. let checkedCount = 0; // 选中数量
  173. let halfCheckedCount = 0; // 半选数量
  174. // 统计子节点的选中和半选数量
  175. for (let j = 0; j < children.length; j++) {
  176. if (children[j].isChecked == true) {
  177. checkedCount++;
  178. } else if (children[j].isHalfChecked == true) {
  179. halfCheckedCount++;
  180. }
  181. }
  182. // 根据子节点状态更新当前祖先节点状态
  183. if (checkedCount == children.length) {
  184. // 全部选中
  185. ancestor.isChecked = true;
  186. ancestor.isHalfChecked = false;
  187. } else if (checkedCount > 0 || halfCheckedCount > 0) {
  188. // 部分选中或有半选
  189. ancestor.isChecked = false;
  190. ancestor.isHalfChecked = true;
  191. } else {
  192. // 全部未选中
  193. ancestor.isChecked = false;
  194. ancestor.isHalfChecked = false;
  195. }
  196. }
  197. }
  198. /**
  199. * 获取指定节点的所有子孙节点
  200. * 优化:使用队列实现广度优先遍历,避免递归栈溢出
  201. * @param key 节点id
  202. * @returns 子孙节点数组
  203. */
  204. function getDescendants(key: string | number): ClTreeItem[] {
  205. const nodeInfo = findNodeInfo(key); // 查找节点信息
  206. if (nodeInfo == null || nodeInfo.node.children == null) {
  207. return []; // 节点不存在或无子节点,返回空数组
  208. }
  209. // 存储所有子孙节点
  210. const result: ClTreeItem[] = [];
  211. // 队列用于广度优先遍历
  212. const queue: ClTreeItem[] = [];
  213. // 将子节点添加到队列中
  214. for (let i = 0; i < nodeInfo.node.children.length; i++) {
  215. queue.push(nodeInfo.node.children[i]);
  216. }
  217. // 广度优先遍历所有子孙节点
  218. while (queue.length > 0) {
  219. const node = queue.shift(); // 取出队首节点
  220. if (node == null) break; // 队列为空则结束
  221. result.push(node); // 将当前节点加入结果数组
  222. // 如果有子节点,继续加入队列
  223. if (node.children != null && node.children.length > 0) {
  224. for (let i = 0; i < node.children.length; i++) {
  225. queue.push(node.children[i]);
  226. }
  227. }
  228. }
  229. return result; // 返回所有子孙节点
  230. }
  231. /**
  232. * 清空所有节点的选中状态
  233. */
  234. function clearChecked(): void {
  235. // 遍历所有节点,将 isChecked 和 isHalfChecked 设为 false
  236. nodeMap.value.forEach((info: ClTreeNodeInfo) => {
  237. info.node.isChecked = false;
  238. info.node.isHalfChecked = false;
  239. });
  240. }
  241. /**
  242. * 设置指定节点的选中状态
  243. * @param key 节点id
  244. * @param flag 是否选中
  245. */
  246. function setChecked(key: string | number, flag: boolean): void {
  247. const nodeInfo = findNodeInfo(key); // 查找节点信息
  248. if (nodeInfo == null) return; // 节点不存在则返回
  249. // 非多选模式下,清空所有选中状态
  250. if (!props.multiple) {
  251. clearChecked();
  252. }
  253. // 设置当前节点选中状态
  254. nodeInfo.node.isChecked = flag;
  255. // 多选模式下处理
  256. if (props.multiple) {
  257. // 非严格模式下处理父子联动
  258. if (!props.checkStrictly) {
  259. // 设置所有子孙节点的选中状态
  260. const descendants = getDescendants(key);
  261. for (let i = 0; i < descendants.length; i++) {
  262. descendants[i].isChecked = flag;
  263. }
  264. // 更新所有祖先节点的状态
  265. updateAncestorsCheckState(key);
  266. }
  267. }
  268. }
  269. /**
  270. * 批量设置节点选中状态
  271. * @param keys 需要设置为选中的节点id数组
  272. */
  273. function setCheckedKeys(keys: (string | number)[]): void {
  274. // 遍历所有需要选中的节点
  275. for (let i = 0; i < keys.length; i++) {
  276. const key: string | number = keys[i];
  277. const nodeInfo = findNodeInfo(key); // 查找节点信息
  278. if (nodeInfo != null) {
  279. nodeInfo.node.isChecked = true; // 设置为选中
  280. // 非严格模式下同时设置所有子孙节点为选中状态
  281. if (!props.checkStrictly) {
  282. const descendants = getDescendants(key);
  283. for (let j = 0; j < descendants.length; j++) {
  284. descendants[j].isChecked = true;
  285. }
  286. }
  287. }
  288. }
  289. // 非严格模式下更新所有相关节点的状态
  290. if (!props.checkStrictly) {
  291. updateAllCheckStates();
  292. }
  293. }
  294. /**
  295. * 获取所有选中节点的keys
  296. * @returns 选中节点id数组
  297. */
  298. function getCheckedKeys(): (string | number)[] {
  299. const result: (string | number)[] = []; // 存储选中节点id
  300. /**
  301. * 递归收集所有选中节点的id
  302. * @param nodes 当前遍历的节点数组
  303. */
  304. function collectCheckedKeys(nodes: ClTreeItem[]): void {
  305. for (let i = 0; i < nodes.length; i++) {
  306. const node = nodes[i];
  307. if (node.isChecked == true) {
  308. result.push(node.id); // 收集选中节点id
  309. }
  310. if (node.children != null) {
  311. collectCheckedKeys(node.children); // 递归处理子节点
  312. }
  313. }
  314. }
  315. collectCheckedKeys(data.value); // 从根节点开始收集
  316. return result; // 返回所有选中节点id
  317. }
  318. /**
  319. * 获取所有半选中节点的keys
  320. * @returns 半选节点id数组
  321. */
  322. function getHalfCheckedKeys(): (string | number)[] {
  323. const result: (string | number)[] = []; // 存储半选节点id
  324. /**
  325. * 递归收集所有半选节点的id
  326. * @param nodes 当前遍历的节点数组
  327. */
  328. function collectHalfCheckedKeys(nodes: ClTreeItem[]): void {
  329. for (let i = 0; i < nodes.length; i++) {
  330. const node = nodes[i];
  331. if (node.isHalfChecked == true) {
  332. result.push(node.id); // 收集半选节点id
  333. }
  334. if (node.children != null) {
  335. collectHalfCheckedKeys(node.children); // 递归处理子节点
  336. }
  337. }
  338. }
  339. collectHalfCheckedKeys(data.value); // 从根节点开始收集
  340. return result; // 返回所有半选节点id
  341. }
  342. /**
  343. * 设置指定节点的展开状态
  344. * @param key 节点id
  345. * @param flag 是否展开
  346. */
  347. function setExpanded(key: string | number, flag: boolean): void {
  348. const nodeInfo = findNodeInfo(key); // 查找节点信息
  349. if (nodeInfo == null) return; // 节点不存在则返回
  350. nodeInfo.node.isExpand = flag; // 设置节点的展开状态
  351. }
  352. /**
  353. * 批量设置节点展开状态
  354. * @param keys 需要展开的节点id数组
  355. */
  356. function setExpandedKeys(keys: (string | number)[]): void {
  357. // 设置指定节点为展开状态
  358. for (let i = 0; i < keys.length; i++) {
  359. const nodeInfo = findNodeInfo(keys[i]);
  360. if (nodeInfo != null) {
  361. nodeInfo.node.isExpand = true;
  362. }
  363. }
  364. }
  365. /**
  366. * 获取所有展开节点的keys
  367. * @returns 展开节点id数组
  368. */
  369. function getExpandedKeys(): (string | number)[] {
  370. const result: (string | number)[] = []; // 存储展开节点id
  371. /**
  372. * 递归收集所有展开节点的id
  373. * @param nodes 当前遍历的节点数组
  374. */
  375. function collectExpandedKeys(nodes: ClTreeItem[]): void {
  376. for (let i = 0; i < nodes.length; i++) {
  377. const node = nodes[i];
  378. if (node.isExpand == true) {
  379. result.push(node.id); // 收集展开节点id
  380. }
  381. if (node.children != null) {
  382. collectExpandedKeys(node.children); // 递归处理子节点
  383. }
  384. }
  385. }
  386. collectExpandedKeys(data.value); // 从根节点开始收集
  387. return result; // 返回所有展开节点id
  388. }
  389. /**
  390. * 展开所有节点
  391. */
  392. function expandAll(): void {
  393. // 遍历所有节点,如果有子节点则设置为展开
  394. nodeMap.value.forEach((info: ClTreeNodeInfo) => {
  395. if (info.node.children != null && info.node.children.length > 0) {
  396. info.node.isExpand = true;
  397. }
  398. });
  399. }
  400. /**
  401. * 收起所有节点
  402. */
  403. function collapseAll() {
  404. // 遍历所有节点,将isExpand设为false
  405. nodeMap.value.forEach((info: ClTreeNodeInfo) => {
  406. info.node.isExpand = false;
  407. });
  408. }
  409. /**
  410. * 同步绑定值
  411. */
  412. /**
  413. * 同步绑定值到外部
  414. * 当内部选中状态变化时,更新外部的modelValue,并触发change事件
  415. */
  416. function syncModelValue() {
  417. // 如果树数据为空,则不更新绑定值
  418. if (isEmpty(data.value)) {
  419. return;
  420. }
  421. // 获取当前所有选中的key
  422. const checkedKeys = getCheckedKeys();
  423. // 如果外部modelValue为null,或当前选中key与外部modelValue不一致,则更新
  424. if (props.modelValue == null || !isEqual(checkedKeys, props.modelValue!)) {
  425. // 如果多选,直接传递数组;否则只传第一个选中的key
  426. const value = props.multiple ? checkedKeys : first(checkedKeys);
  427. emit("update:modelValue", value); // 通知外部更新modelValue
  428. emit("change", value); // 触发change事件
  429. }
  430. }
  431. /**
  432. * 同步外部modelValue到内部选中状态
  433. * 当外部modelValue变化时,更新内部选中状态,并保持与外部一致
  434. */
  435. function syncCheckedState() {
  436. // 如果外部modelValue为null,则不处理
  437. if (props.modelValue == null) {
  438. return;
  439. }
  440. // 获取当前所有选中的key
  441. const checkedKeys = getCheckedKeys();
  442. // 如果当前选中key与外部modelValue不一致,则进行同步
  443. if (!isEqual(checkedKeys, props.modelValue!)) {
  444. if (Array.isArray(props.modelValue)) {
  445. setCheckedKeys(props.modelValue!); // 多选时,设置所有选中key
  446. } else {
  447. setChecked(props.modelValue!, true); // 单选时,设置单个选中key
  448. }
  449. }
  450. syncModelValue(); // 同步绑定值到外部
  451. }
  452. // 监听props.list变化,同步到内部数据
  453. watch(
  454. computed(() => props.list),
  455. (val: ClTreeItem[]) => {
  456. data.value = val;
  457. // 检查选中状态
  458. syncCheckedState();
  459. },
  460. { immediate: true }
  461. );
  462. // 监听modelValue变化
  463. watch(
  464. computed(() => [props.modelValue ?? 0]),
  465. () => {
  466. syncCheckedState();
  467. },
  468. { immediate: true, deep: true }
  469. );
  470. // 监听树数据变化
  471. watch(
  472. data,
  473. () => {
  474. // 自动更新选中状态
  475. if (!props.checkStrictly && props.multiple) {
  476. updateAllCheckStates();
  477. }
  478. // 更新绑定值
  479. syncModelValue();
  480. },
  481. { deep: true }
  482. );
  483. defineExpose({
  484. icon: computed(() => props.icon),
  485. expandIcon: computed(() => props.expandIcon),
  486. checkStrictly: computed(() => props.checkStrictly),
  487. checkable: computed(() => props.checkable),
  488. multiple: computed(() => props.multiple),
  489. clearChecked,
  490. setChecked,
  491. setCheckedKeys,
  492. getCheckedKeys,
  493. getHalfCheckedKeys,
  494. setExpanded,
  495. setExpandedKeys,
  496. getExpandedKeys,
  497. expandAll,
  498. collapseAll
  499. });
  500. </script>