最後活躍 1 week ago

tree-selector.html 原始檔案
1<!DOCTYPE html>
2<html lang="zh-CN">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6 <title>移动端树形多选列表</title>
7 <style>
8 * {
9 margin: 0;
10 padding: 0;
11 box-sizing: border-box;
12 }
13
14 body {
15 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
16 background-color: #f5f5f5;
17 padding: 10px;
18 }
19
20 .container {
21 max-width: 100%;
22 margin: 0 auto;
23 }
24
25 .search-box {
26 position: sticky;
27 top: 0;
28 background: white;
29 padding: 10px;
30 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
31 z-index: 100;
32 border-radius: 8px;
33 margin-bottom: 10px;
34 }
35
36 .search-input {
37 width: 100%;
38 padding: 12px 15px;
39 border: 1px solid #ddd;
40 border-radius: 20px;
41 font-size: 16px;
42 outline: none;
43 transition: all 0.3s;
44 }
45
46 .search-input:focus {
47 border-color: #007AFF;
48 box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2);
49 }
50
51 .result-info {
52 padding: 10px;
53 color: #666;
54 font-size: 14px;
55 }
56
57 .tree-container {
58 background: white;
59 border-radius: 8px;
60 overflow: hidden;
61 box-shadow: 0 1px 3px rgba(0,0,0,0.1);
62 height: calc(100vh - 150px);
63 overflow-y: auto;
64 position: relative;
65 }
66
67 .tree-node {
68 display: flex;
69 align-items: center;
70 padding: 12px 15px;
71 border-bottom: 1px solid #eee;
72 transition: background-color 0.2s;
73 position: absolute;
74 width: 100%;
75 box-sizing: border-box;
76 }
77
78 .tree-node:last-child {
79 border-bottom: none;
80 }
81
82 .tree-node.indent-1 { padding-left: 30px; }
83 .tree-node.indent-2 { padding-left: 45px; }
84 .tree-node.indent-3 { padding-left: 60px; }
85 .tree-node.indent-4 { padding-left: 75px; }
86 .tree-node.indent-5 { padding-left: 90px; }
87 .tree-node.indent-6 { padding-left: 105px; }
88 .tree-node.indent-7 { padding-left: 120px; }
89 .tree-node.indent-8 { padding-left: 135px; }
90 .tree-node.indent-9 { padding-left: 150px; }
91 .tree-node.indent-10 { padding-left: 165px; }
92
93 .node-toggle {
94 width: 24px;
95 height: 24px;
96 display: flex;
97 align-items: center;
98 justify-content: center;
99 margin-right: 8px;
100 color: #999;
101 font-size: 18px;
102 transform: rotate(0deg);
103 transition: transform 0.2s;
104 }
105
106 .node-toggle.expanded {
107 transform: rotate(90deg);
108 }
109
110 .node-toggle.empty {
111 visibility: hidden;
112 }
113
114 .node-content {
115 flex: 1;
116 font-size: 16px;
117 color: #333;
118 }
119
120 .node-checkbox {
121 width: 20px;
122 height: 20px;
123 border: 2px solid #ccc;
124 border-radius: 4px;
125 display: flex;
126 align-items: center;
127 justify-content: center;
128 margin-left: 10px;
129 position: relative;
130 }
131
132 .node-checkbox.checked {
133 background-color: #007AFF;
134 border-color: #007AFF;
135 }
136
137 .node-checkbox.indeterminate::after {
138 content: "";
139 position: absolute;
140 width: 10px;
141 height: 2px;
142 background-color: white;
143 }
144
145 .node-checkbox.checked::after {
146 content: "✓";
147 color: white;
148 font-size: 14px;
149 position: absolute;
150 }
151
152 .node-children {
153 overflow: hidden;
154 transition: max-height 0.3s ease-out;
155 }
156
157 .search-result {
158 background-color: #e8f4ff;
159 }
160
161 .no-results {
162 padding: 20px;
163 text-align: center;
164 color: #999;
165 }
166
167 .loading {
168 padding: 20px;
169 text-align: center;
170 color: #999;
171 }
172
173 .selected-count {
174 position: fixed;
175 bottom: 20px;
176 right: 20px;
177 background: #007AFF;
178 color: white;
179 width: 50px;
180 height: 50px;
181 border-radius: 50%;
182 display: flex;
183 align-items: center;
184 justify-content: center;
185 font-weight: bold;
186 box-shadow: 0 2px 10px rgba(0,0,0,0.3);
187 cursor: pointer;
188 z-index: 1000;
189 }
190
191 .breadcrumb {
192 display: flex;
193 padding: 10px 15px;
194 background: #f9f9f9;
195 border-bottom: 1px solid #eee;
196 overflow-x: auto;
197 white-space: nowrap;
198 }
199
200 .breadcrumb-item {
201 font-size: 14px;
202 color: #007AFF;
203 cursor: pointer;
204 padding: 0 5px;
205 }
206
207 .breadcrumb-item:not(:last-child)::after {
208 content: " > ";
209 color: #999;
210 }
211
212 .breadcrumb-item:last-child {
213 color: #333;
214 font-weight: bold;
215 }
216
217 .node-path {
218 color: #888;
219 font-size: 12px;
220 margin-top: 3px;
221 }
222
223 .search-mode .tree-node:not(.search-result) .node-path {
224 display: none;
225 }
226
227 .virtual-container {
228 position: relative;
229 }
230
231 .node-icon {
232 margin-right: 8px;
233 font-size: 16px;
234 }
235
236 .folder-icon::before {
237 content: "📁";
238 }
239
240 .file-icon::before {
241 content: "📄";
242 }
243
244 /* 弹窗样式 */
245 .modal {
246 display: none;
247 position: fixed;
248 top: 0;
249 left: 0;
250 width: 100%;
251 height: 100%;
252 background-color: rgba(0, 0, 0, 0.5);
253 z-index: 2000;
254 justify-content: center;
255 align-items: center;
256 }
257
258 .modal-content {
259 background-color: white;
260 border-radius: 10px;
261 width: 90%;
262 max-width: 500px;
263 max-height: 80vh;
264 overflow: hidden;
265 display: flex;
266 flex-direction: column;
267 }
268
269 .modal-header {
270 padding: 15px 20px;
271 border-bottom: 1px solid #eee;
272 display: flex;
273 justify-content: space-between;
274 align-items: center;
275 }
276
277 .modal-title {
278 font-size: 18px;
279 font-weight: bold;
280 color: #333;
281 }
282
283 .modal-close {
284 background: none;
285 border: none;
286 font-size: 24px;
287 cursor: pointer;
288 color: #999;
289 }
290
291 .modal-body {
292 padding: 10px 20px;
293 overflow-y: auto;
294 flex: 1;
295 }
296
297 .selected-item {
298 display: flex;
299 justify-content: space-between;
300 align-items: center;
301 padding: 12px 0;
302 border-bottom: 1px solid #f0f0f0;
303 }
304
305 .selected-item:last-child {
306 border-bottom: none;
307 }
308
309 .item-info {
310 flex: 1;
311 }
312
313 .item-name {
314 font-size: 16px;
315 color: #333;
316 }
317
318 .item-path {
319 font-size: 12px;
320 color: #888;
321 margin-top: 3px;
322 }
323
324 .deselect-btn {
325 background: #ff3b30;
326 color: white;
327 border: none;
328 border-radius: 4px;
329 padding: 6px 12px;
330 font-size: 14px;
331 cursor: pointer;
332 }
333
334 .modal-footer {
335 padding: 15px 20px;
336 border-top: 1px solid #eee;
337 display: flex;
338 justify-content: space-between;
339 }
340
341 .clear-all-btn {
342 background: #ff3b30;
343 color: white;
344 border: none;
345 border-radius: 4px;
346 padding: 10px 20px;
347 font-size: 16px;
348 cursor: pointer;
349 }
350
351 .confirm-btn {
352 background: #007AFF;
353 color: white;
354 border: none;
355 border-radius: 4px;
356 padding: 10px 20px;
357 font-size: 16px;
358 cursor: pointer;
359 }
360
361 .empty-selected {
362 text-align: center;
363 padding: 30px;
364 color: #999;
365 }
366
367 /* 父节点样式调整 */
368 .parent-node .node-content {
369 flex: 1;
370 }
371
372 .parent-node .node-checkbox {
373 display: none;
374 }
375 </style>
376</head>
377<body>
378 <div class="container">
379 <div class="search-box">
380 <input type="text" class="search-input" id="searchInput" placeholder="输入名称搜索...">
381 </div>
382
383 <div class="breadcrumb" id="breadcrumb"></div>
384
385 <div class="result-info" id="resultInfo"></div>
386
387 <div class="tree-container" id="treeContainer">
388 <div class="loading" id="loading">加载中...</div>
389 </div>
390 </div>
391
392 <div class="selected-count" id="selectedCount">0</div>
393
394 <!-- 已选项弹窗 -->
395 <div class="modal" id="selectedModal">
396 <div class="modal-content">
397 <div class="modal-header">
398 <div class="modal-title">已选项</div>
399 <button class="modal-close" id="modalClose">&times;</button>
400 </div>
401 <div class="modal-body" id="modalBody">
402 <!-- 已选项将在这里显示 -->
403 </div>
404 <div class="modal-footer">
405 <button class="clear-all-btn" id="clearAllBtn">清空所有</button>
406 <button class="confirm-btn" id="confirmBtn">确认</button>
407 </div>
408 </div>
409 </div>
410
411 <script>
412 // 模拟大量树形数据
413 class TreeNode {
414 constructor(id, name, parentId = null) {
415 this.id = id;
416 this.name = name;
417 this.parentId = parentId;
418 this.children = [];
419 this.checked = false;
420 this.indeterminate = false;
421 }
422 }
423
424 // 生成大量测试数据,最多5-6级深度
425 function generateTreeData() {
426 console.time('数据生成耗时');
427 const rootNode = new TreeNode('0', '根节点');
428 const nodeMap = new Map();
429 nodeMap.set('0', rootNode);
430
431 // 生成5-6级深度的树结构
432 // 第1级: 10个节点
433 for (let i = 1; i <= 10; i++) {
434 const parentId = '0';
435 const nodeId = i.toString();
436 const nodeName = `一级节点 ${i}`;
437 const node = new TreeNode(nodeId, nodeName, parentId);
438 nodeMap.set(nodeId, node);
439 nodeMap.get(parentId).children.push(node);
440 }
441
442 // 第2级: 每个一级节点下有20个二级节点
443 for (let i = 1; i <= 10; i++) {
444 for (let j = 1; j <= 20; j++) {
445 const parentId = i.toString();
446 const nodeId = `${i}-${j}`;
447 const nodeName = `二级节点 ${i}-${j}`;
448 const node = new TreeNode(nodeId, nodeName, parentId);
449 nodeMap.set(nodeId, node);
450 nodeMap.get(parentId).children.push(node);
451 }
452 }
453
454 // 第3级: 每个二级节点下有15个三级节点
455 for (let i = 1; i <= 10; i++) {
456 for (let j = 1; j <= 20; j++) {
457 for (let k = 1; k <= 15; k++) {
458 const parentId = `${i}-${j}`;
459 const nodeId = `${i}-${j}-${k}`;
460 const nodeName = `三级节点 ${i}-${j}-${k}`;
461 const node = new TreeNode(nodeId, nodeName, parentId);
462 nodeMap.set(nodeId, node);
463 nodeMap.get(parentId).children.push(node);
464 }
465 }
466 }
467
468 // 第4级: 每个三级节点下有10个四级节点
469 for (let i = 1; i <= 10; i++) {
470 for (let j = 1; j <= 20; j++) {
471 for (let k = 1; k <= 15; k++) {
472 for (let l = 1; l <= 10; l++) {
473 const parentId = `${i}-${j}-${k}`;
474 const nodeId = `${i}-${j}-${k}-${l}`;
475 const nodeName = `四级节点 ${i}-${j}-${k}-${l}`;
476 const node = new TreeNode(nodeId, nodeName, parentId);
477 nodeMap.set(nodeId, node);
478 nodeMap.get(parentId).children.push(node);
479 }
480 }
481 }
482 }
483
484 // 第5级: 每个四级节点下有5个五级节点
485 for (let i = 1; i <= 10; i++) {
486 for (let j = 1; j <= 20; j++) {
487 for (let k = 1; k <= 15; k++) {
488 for (let l = 1; l <= 10; l++) {
489 for (let m = 1; m <= 5; m++) {
490 const parentId = `${i}-${j}-${k}-${l}`;
491 const nodeId = `${i}-${j}-${k}-${l}-${m}`;
492 const nodeName = `五级节点 ${i}-${j}-${k}-${l}-${m}`;
493 const node = new TreeNode(nodeId, nodeName, parentId);
494 nodeMap.set(nodeId, node);
495 nodeMap.get(parentId).children.push(node);
496 }
497 }
498 }
499 }
500 }
501
502 // 第6级: 部分五级节点下有六级节点
503 for (let i = 1; i <= 10; i++) {
504 for (let j = 1; j <= 20; j++) {
505 for (let k = 1; k <= 15; k++) {
506 for (let l = 1; l <= 10; l++) {
507 for (let m = 1; m <= 5; m++) {
508 // 只为部分节点添加六级节点
509 if (Math.random() > 0.7) {
510 for (let n = 1; n <= 3; n++) {
511 const parentId = `${i}-${j}-${k}-${l}-${m}`;
512 const nodeId = `${i}-${j}-${k}-${l}-${m}-${n}`;
513 const nodeName = `六级节点 ${i}-${j}-${k}-${l}-${m}-${n}`;
514 const node = new TreeNode(nodeId, nodeName, parentId);
515 nodeMap.set(nodeId, node);
516 nodeMap.get(parentId).children.push(node);
517 }
518 }
519 }
520 }
521 }
522 }
523 }
524
525 console.timeEnd('数据生成耗时');
526 console.log(rootNode);
527 return rootNode;
528 }
529
530 // 树形列表组件
531 class TreeSelector {
532 constructor(container, rootNode) {
533 this.container = container;
534 this.rootNode = rootNode;
535 this.currentNode = rootNode;
536 this.path = [rootNode];
537 this.selectedNodes = new Set();
538 this.searchResults = new Set();
539 this.searchMode = false;
540 this.searchTerm = '';
541
542 // 虚拟滚动相关
543 this.nodeHeight = 50; // 每个节点的高度
544 this.buffer = 15; // 缓冲区节点数
545 this.scrollTop = 0;
546 this.containerHeight = 0;
547
548 // 创建节点ID到节点的映射以提高查找性能
549 this.nodeMap = new Map();
550 this.buildNodeMap(rootNode);
551 console.log('节点ID到节点数量',this.nodeMap.size)
552
553 // 构建父节点映射以提高查找性能
554 this.parentMap = new Map();
555 this.buildParentMap(rootNode);
556
557 // 构建节点路径映射
558 this.nodePathMap = new Map();
559 this.buildNodePathMap(rootNode, []);
560
561 // 初始化虚拟滚动
562 this.initVirtualScroll();
563
564 this.render();
565 this.bindEvents();
566 this.updateSelectedCount();
567 this.initModal();
568 }
569
570 // 构建节点路径映射
571 buildNodePathMap(node, path) {
572 const currentPath = [...path, node.name];
573 this.nodePathMap.set(node.id, currentPath);
574
575 if (node.children) {
576 node.children.forEach(child => {
577 this.buildNodePathMap(child, currentPath);
578 });
579 }
580 }
581
582 // 初始化弹窗
583 initModal() {
584 const modal = document.getElementById('selectedModal');
585 const selectedCount = document.getElementById('selectedCount');
586 const modalClose = document.getElementById('modalClose');
587 const clearAllBtn = document.getElementById('clearAllBtn');
588 const confirmBtn = document.getElementById('confirmBtn');
589
590 // 点击已选数量按钮打开弹窗
591 selectedCount.addEventListener('click', () => {
592 this.showSelectedModal();
593 });
594
595 // 点击关闭按钮关闭弹窗
596 modalClose.addEventListener('click', () => {
597 this.closeSelectedModal();
598 });
599
600 // 点击清空所有按钮
601 clearAllBtn.addEventListener('click', () => {
602 this.clearAllSelections();
603 });
604
605 // 点击确认按钮
606 confirmBtn.addEventListener('click', () => {
607 this.confirmSelections();
608 });
609
610 // 点击遮罩层关闭弹窗
611 modal.addEventListener('click', (e) => {
612 if (e.target === modal) {
613 this.closeSelectedModal();
614 }
615 });
616 }
617
618 // 显示已选项弹窗
619 showSelectedModal() {
620 const modal = document.getElementById('selectedModal');
621 const modalBody = document.getElementById('modalBody');
622
623 // 获取所有选中的节点
624 const selectedNodes = [];
625 for (const [id, node] of this.nodeMap) {
626 if (node.checked) {
627 selectedNodes.push(node);
628 }
629 }
630
631 // 清空弹窗内容
632 modalBody.innerHTML = '';
633
634 // 显示选中项
635 if (selectedNodes.length === 0) {
636 modalBody.innerHTML = '<div class="empty-selected">暂无选中项</div>';
637 } else {
638 selectedNodes.forEach(node => {
639 const itemElement = document.createElement('div');
640 itemElement.className = 'selected-item';
641
642 const itemInfo = document.createElement('div');
643 itemInfo.className = 'item-info';
644
645 const itemName = document.createElement('div');
646 itemName.className = 'item-name';
647 itemName.textContent = node.name;
648
649 const itemPath = document.createElement('div');
650 itemPath.className = 'item-path';
651 itemPath.textContent = this.nodePathMap.get(node.id).join(' > ');
652
653 itemInfo.appendChild(itemName);
654 itemInfo.appendChild(itemPath);
655
656 const deselectBtn = document.createElement('button');
657 deselectBtn.className = 'deselect-btn';
658 deselectBtn.textContent = '取消';
659 deselectBtn.addEventListener('click', () => {
660 this.deselectNode(node);
661 });
662
663 itemElement.appendChild(itemInfo);
664 itemElement.appendChild(deselectBtn);
665
666 modalBody.appendChild(itemElement);
667 });
668 }
669
670 // 显示弹窗
671 modal.style.display = 'flex';
672 }
673
674 // 关闭已选项弹窗
675 closeSelectedModal() {
676 const modal = document.getElementById('selectedModal');
677 modal.style.display = 'none';
678 }
679
680 // 取消选中节点
681 deselectNode(node) {
682 node.checked = false;
683 node.indeterminate = false;
684
685 // 更新子节点状态
686 this.updateChildren(node, false);
687
688 // 更新父节点状态
689 this.updateParents(node);
690
691 // 更新选中计数
692 this.updateSelectedCount();
693
694 // 更新UI
695 this.updateCheckboxUI(node);
696
697 // 重新渲染弹窗
698 this.showSelectedModal();
699 }
700
701 // 清空所有选择
702 clearAllSelections() {
703 for (const [id, node] of this.nodeMap) {
704 if (node.checked) {
705 node.checked = false;
706 node.indeterminate = false;
707 this.updateCheckboxUI(node);
708 }
709 }
710
711 // 更新选中计数
712 this.updateSelectedCount();
713
714 // 重新渲染弹窗
715 this.showSelectedModal();
716 }
717
718 // 确认选择并打印到控制台
719 confirmSelections() {
720 const selectedNodes = [];
721 for (const [id, node] of this.nodeMap) {
722 if (node.checked) {
723 selectedNodes.push({
724 id: node.id,
725 name: node.name,
726 path: this.nodePathMap.get(node.id).join(' > ')
727 });
728 }
729 }
730
731 console.log('已选项:', selectedNodes);
732 alert(`已选择 ${selectedNodes.length} 项,详情请查看控制台`);
733
734 // 关闭弹窗
735 this.closeSelectedModal();
736 }
737
738 // 初始化虚拟滚动
739 initVirtualScroll() {
740 let ticking = false;
741
742 const handleScroll = () => {
743 this.scrollTop = this.container.scrollTop;
744 if (!ticking) {
745 requestAnimationFrame(() => {
746 this.render();
747 ticking = false;
748 });
749 ticking = true;
750 }
751 };
752
753 this.container.addEventListener('scroll', handleScroll);
754
755 // 获取容器高度
756 this.containerHeight = this.container.clientHeight;
757 }
758
759 // 构建节点ID到节点的映射
760 buildNodeMap(node) {
761 this.nodeMap.set(node.id, node);
762 if (node.children) {
763 node.children.forEach(child => this.buildNodeMap(child));
764 }
765 }
766
767 // 构建父节点映射
768 buildParentMap(node) {
769 if (node.children) {
770 node.children.forEach(child => {
771 this.parentMap.set(child.id, node);
772 this.buildParentMap(child);
773 });
774 }
775 }
776
777 // 渲染树形结构
778 render() {
779 if (this.searchMode) {
780 this.renderSearchResults();
781 } else {
782 this.renderTree();
783 }
784
785 this.renderBreadcrumb();
786 this.updateResultInfo();
787 }
788
789 // 渲染普通树结构(虚拟滚动)
790 renderTree() {
791 // 获取当前路径下的所有节点
792 const allNodes = this.flattenTree(this.currentNode, 0);
793
794 // 计算可见节点范围
795 const startIndex = Math.max(0, Math.floor(this.scrollTop / this.nodeHeight) - this.buffer);
796 const endIndex = Math.min(
797 allNodes.length,
798 Math.ceil((this.scrollTop + this.containerHeight) / this.nodeHeight) + this.buffer
799 );
800
801 // 获取可见节点
802 const visibleNodes = allNodes.slice(startIndex, endIndex);
803
804 // 创建或获取虚拟容器
805 let virtualContainer = this.container.querySelector('.virtual-container');
806 if (!virtualContainer) {
807 virtualContainer = document.createElement('div');
808 virtualContainer.className = 'virtual-container';
809 this.container.innerHTML = '';
810 this.container.appendChild(virtualContainer);
811 }
812
813 // 设置虚拟容器高度
814 virtualContainer.style.height = `${allNodes.length * this.nodeHeight}px`;
815
816 // 获取所有现有的节点元素
817 const existingNodes = Array.from(virtualContainer.querySelectorAll('.tree-node'));
818
819 // 创建一个映射来跟踪哪些节点已经存在
820 const existingNodeMap = new Map();
821 existingNodes.forEach(nodeEl => {
822 existingNodeMap.set(nodeEl.dataset.id, nodeEl);
823 });
824
825 // 移除不再需要的节点
826 const visibleNodeIds = new Set(visibleNodes.map(item => item.node.id));
827 existingNodes.forEach(nodeEl => {
828 if (!visibleNodeIds.has(nodeEl.dataset.id)) {
829 nodeEl.remove();
830 }
831 });
832
833 // 更新或创建可见节点
834 visibleNodes.forEach((item, index) => {
835 const nodeId = item.node.id;
836 let nodeElement = existingNodeMap.get(nodeId);
837
838 if (!nodeElement) {
839 // 创建新节点
840 nodeElement = this.createNodeElement(item.node, item.depth);
841 virtualContainer.appendChild(nodeElement);
842 }
843
844 // 更新节点位置
845 nodeElement.style.top = `${(index + startIndex) * this.nodeHeight}px`;
846 });
847 }
848
849 // 展平树结构
850 flattenTree(node, depth) {
851 const result = [{ node, depth }];
852
853 if (node.expanded && node.children && node.children.length > 0) {
854 node.children.forEach(child => {
855 result.push(...this.flattenTree(child, depth + 1));
856 });
857 }
858
859 return result;
860 }
861
862 // 创建节点元素
863 createNodeElement(node, depth) {
864 // 判断是否为父节点(有子节点)
865 const isParent = node.children && node.children.length > 0;
866
867 const nodeElement = document.createElement('div');
868 nodeElement.className = `tree-node ${isParent ? 'parent-node' : ''} indent-${Math.min(depth, 10)}`;
869 nodeElement.dataset.id = node.id;
870
871 // 展开/收起按钮
872 const toggleElement = document.createElement('div');
873 toggleElement.className = `node-toggle ${!isParent ? 'empty' : ''} ${node.expanded ? 'expanded' : ''}`;
874 toggleElement.innerHTML = '▶';
875 nodeElement.appendChild(toggleElement);
876
877 // 节点内容
878 const contentElement = document.createElement('div');
879 contentElement.className = 'node-content';
880 contentElement.textContent = node.name;
881 nodeElement.appendChild(contentElement);
882
883 // 只为叶节点添加复选框
884 if (!isParent) {
885 const checkboxElement = document.createElement('div');
886 checkboxElement.className = `node-checkbox ${node.checked ? 'checked' : ''} ${node.indeterminate && !node.checked ? 'indeterminate' : ''}`;
887 nodeElement.appendChild(checkboxElement);
888 }
889
890 return nodeElement;
891 }
892
893 // 渲染搜索结果(扁平列表,带虚拟滚动)
894 renderSearchResults() {
895 if (this.searchResults.size === 0) {
896 const noResultsElement = document.createElement('div');
897 noResultsElement.className = 'no-results';
898 noResultsElement.textContent = '未找到匹配结果';
899 this.container.innerHTML = '';
900 this.container.appendChild(noResultsElement);
901 return;
902 }
903
904 // 将搜索结果转换为数组并排序
905 const resultsArray = Array.from(this.searchResults).sort((a, b) => {
906 return a.name.localeCompare(b.name);
907 });
908
909 // 计算可见节点范围
910 const startIndex = Math.max(0, Math.floor(this.scrollTop / this.nodeHeight) - this.buffer);
911 const endIndex = Math.min(
912 resultsArray.length,
913 Math.ceil((this.scrollTop + this.containerHeight) / this.nodeHeight) + this.buffer
914 );
915
916 // 获取可见节点
917 const visibleResults = resultsArray.slice(startIndex, endIndex);
918
919 // 创建或获取虚拟容器
920 let virtualContainer = this.container.querySelector('.virtual-container');
921 if (!virtualContainer) {
922 virtualContainer = document.createElement('div');
923 virtualContainer.className = 'virtual-container';
924 this.container.innerHTML = '';
925 this.container.appendChild(virtualContainer);
926 }
927
928 // 设置虚拟容器高度
929 virtualContainer.style.height = `${resultsArray.length * this.nodeHeight}px`;
930
931 // 获取所有现有的节点元素
932 const existingNodes = Array.from(virtualContainer.querySelectorAll('.tree-node'));
933
934 // 创建一个映射来跟踪哪些节点已经存在
935 const existingNodeMap = new Map();
936 existingNodes.forEach(nodeEl => {
937 existingNodeMap.set(nodeEl.dataset.id, nodeEl);
938 });
939
940 // 移除不再需要的节点
941 const visibleNodeIds = new Set(visibleResults.map(node => node.id));
942 existingNodes.forEach(nodeEl => {
943 if (!visibleNodeIds.has(nodeEl.dataset.id)) {
944 nodeEl.remove();
945 }
946 });
947
948 // 更新或创建可见节点
949 visibleResults.forEach((node, index) => {
950 let nodeElement = existingNodeMap.get(node.id);
951
952 if (!nodeElement) {
953 // 创建新节点
954 nodeElement = this.createSearchResultElement(node);
955 virtualContainer.appendChild(nodeElement);
956 }
957
958 // 更新节点位置
959 nodeElement.style.top = `${(index + startIndex) * this.nodeHeight}px`;
960 });
961 }
962
963 // 创建搜索结果元素
964 createSearchResultElement(node) {
965 // 判断是否为父节点(有子节点)
966 const isParent = node.children && node.children.length > 0;
967
968 const nodeElement = document.createElement('div');
969 nodeElement.className = `tree-node search-result ${isParent ? 'parent-node' : ''}`;
970 nodeElement.dataset.id = node.id;
971
972 // 节点图标(文件夹或文件)
973 const iconElement = document.createElement('div');
974 iconElement.className = `node-icon ${isParent ? 'folder-icon' : 'file-icon'}`;
975 nodeElement.appendChild(iconElement);
976
977 // 节点内容
978 const contentElement = document.createElement('div');
979 contentElement.className = 'node-content';
980 contentElement.textContent = node.name;
981 nodeElement.appendChild(contentElement);
982
983 // 只为叶节点添加复选框
984 if (!isParent) {
985 const checkboxElement = document.createElement('div');
986 checkboxElement.className = `node-checkbox ${node.checked ? 'checked' : ''} ${node.indeterminate && !node.checked ? 'indeterminate' : ''}`;
987 nodeElement.appendChild(checkboxElement);
988 }
989
990 return nodeElement;
991 }
992
993 // 渲染面包屑导航
994 renderBreadcrumb() {
995 const breadcrumb = document.getElementById('breadcrumb');
996 if (!breadcrumb) return;
997
998 breadcrumb.innerHTML = '';
999
1000 this.path.forEach((node, index) => {
1001 const item = document.createElement('div');
1002 item.className = 'breadcrumb-item';
1003 item.textContent = node.name;
1004 item.onclick = () => this.navigateTo(index);
1005 breadcrumb.appendChild(item);
1006 });
1007 }
1008
1009 // 更新结果信息
1010 updateResultInfo() {
1011 const info = document.getElementById('resultInfo');
1012 if (!info) return;
1013
1014 if (this.searchMode) {
1015 info.textContent = `找到 ${this.searchResults.size} 个匹配结果`;
1016 } else {
1017 info.textContent = '';
1018 }
1019 }
1020
1021 // 绑定事件
1022 bindEvents() {
1023 // 搜索事件
1024 const searchInput = document.getElementById('searchInput');
1025 if (!searchInput) return;
1026
1027 let searchTimeout;
1028 searchInput.addEventListener('input', (e) => {
1029 clearTimeout(searchTimeout);
1030 searchTimeout = setTimeout(() => {
1031 this.search(e.target.value);
1032 }, 300);
1033 });
1034
1035 // 节点点击事件代理
1036 this.container.addEventListener('click', (e) => {
1037 const nodeElement = e.target.closest('.tree-node');
1038 if (!nodeElement) return;
1039
1040 const nodeId = nodeElement.dataset.id;
1041 const node = this.nodeMap.get(nodeId);
1042 if (!node) return;
1043
1044 // 在搜索模式下,点击节点行为不同
1045 if (this.searchMode) {
1046 // 点击复选框
1047 if (e.target.classList.contains('node-checkbox')) {
1048 this.toggleCheckbox(node);
1049 return;
1050 }
1051
1052 // 点击节点内容 - 进入浏览模式并导航到该节点
1053 this.enterNodeFromSearch(node);
1054 return;
1055 }
1056
1057 // 在浏览模式下保持原有行为
1058 // 点击展开/收起按钮
1059 if (e.target.classList.contains('node-toggle')) {
1060 this.toggleNode(node);
1061 return;
1062 }
1063
1064 // 点击复选框
1065 if (e.target.classList.contains('node-checkbox')) {
1066 this.toggleCheckbox(node);
1067 return;
1068 }
1069
1070 // 点击节点内容(如果是父节点)
1071 if (e.target.classList.contains('node-content') || e.target.closest('.node-content')) {
1072 if (node.children && node.children.length > 0) {
1073 this.enterNode(node);
1074 } else {
1075 // 点击叶节点时切换选中状态
1076 this.toggleCheckbox(node);
1077 }
1078 return;
1079 }
1080 });
1081 }
1082
1083 // 从搜索结果进入节点浏览
1084 enterNodeFromSearch(node) {
1085 // 构建从根节点到目标节点的路径
1086 const path = [];
1087 let current = node;
1088
1089 // 向上遍历到根节点
1090 while (current) {
1091 path.unshift(current);
1092 current = this.parentMap.get(current.id);
1093 }
1094
1095 // 设置路径并切换到浏览模式
1096 this.path = path;
1097 this.currentNode = node;
1098 this.searchMode = false;
1099 this.searchTerm = '';
1100 const searchInput = document.getElementById('searchInput');
1101 if (searchInput) {
1102 searchInput.value = '';
1103 }
1104
1105 // 展开目标节点
1106 node.expanded = true;
1107
1108 this.scrollTop = 0;
1109 this.container.scrollTop = 0;
1110 this.render();
1111 }
1112
1113 // 搜索功能 - 优化版本
1114 search(term) {
1115 console.time('搜索耗时');
1116 this.searchTerm = term.trim().toLowerCase();
1117 this.searchMode = this.searchTerm !== '';
1118
1119 if (!this.searchMode) {
1120 this.searchResults.clear();
1121 this.scrollTop = 0;
1122 this.container.scrollTop = 0;
1123 this.render();
1124 console.timeEnd('搜索耗时');
1125 return;
1126 }
1127
1128 this.searchResults.clear();
1129
1130 // 使用优化的搜索算法
1131 // 遍历所有节点查找匹配项
1132 for (const [id, node] of this.nodeMap) {
1133 // 检查节点名称是否匹配
1134 if (node.name.toLowerCase().includes(this.searchTerm)) {
1135 this.searchResults.add(node);
1136 }
1137 }
1138
1139 this.scrollTop = 0;
1140 this.container.scrollTop = 0;
1141 console.timeEnd('搜索耗时');
1142 this.render();
1143 }
1144
1145 // 展开/收起节点
1146 toggleNode(node) {
1147 node.expanded = !node.expanded;
1148 this.render();
1149 }
1150
1151 // 进入节点(用于浏览模式)
1152 enterNode(node) {
1153 // 检查节点是否已经在路径中,避免重复添加
1154 const nodeIndex = this.path.findIndex(n => n.id === node.id);
1155 if (nodeIndex !== -1) {
1156 // 如果节点已存在,截断路径到该节点位置
1157 this.path = this.path.slice(0, nodeIndex + 1);
1158 } else {
1159 // 否则添加到路径末尾
1160 this.path.push(node);
1161 }
1162 this.currentNode = node;
1163 this.scrollTop = 0;
1164 this.container.scrollTop = 0;
1165 this.render();
1166 }
1167
1168 // 导航到指定路径层级
1169 navigateTo(index) {
1170 this.path = this.path.slice(0, index + 1);
1171 this.currentNode = this.path[this.path.length - 1];
1172 this.scrollTop = 0;
1173 this.container.scrollTop = 0;
1174 this.render();
1175 }
1176
1177 // 切换复选框状态
1178 toggleCheckbox(node) {
1179 node.checked = !node.checked;
1180 node.indeterminate = false;
1181
1182 // 更新子节点状态
1183 this.updateChildren(node, node.checked);
1184
1185 // 更新父节点状态
1186 this.updateParents(node);
1187
1188 // 更新选中计数
1189 this.updateSelectedCount();
1190
1191 // 更新UI
1192 this.updateCheckboxUI(node);
1193 }
1194
1195 // 更新子节点状态
1196 updateChildren(node, checked) {
1197 if (node.children) {
1198 node.children.forEach(child => {
1199 child.checked = checked;
1200 child.indeterminate = false;
1201 this.updateChildren(child, checked);
1202 });
1203 }
1204 }
1205
1206 // 更新父节点状态
1207 updateParents(node) {
1208 const parent = this.parentMap.get(node.id);
1209 if (!parent) return;
1210
1211 // 检查是否所有子节点都被选中
1212 const allChecked = parent.children.every(child => child.checked);
1213 const someChecked = parent.children.some(child => child.checked || child.indeterminate);
1214
1215 parent.checked = allChecked;
1216 parent.indeterminate = !allChecked && someChecked;
1217
1218 // 更新UI
1219 this.updateCheckboxUI(parent);
1220
1221 // 递归更新祖先节点
1222 this.updateParents(parent);
1223 }
1224
1225 // 更新复选框UI状态
1226 updateCheckboxUI(node) {
1227 // 更新所有匹配的节点元素(可能在不同视图中存在)
1228 const nodeElements = this.container.querySelectorAll(`.tree-node[data-id="${node.id}"]`);
1229 nodeElements.forEach(nodeElement => {
1230 // 只为叶节点更新复选框
1231 if (!nodeElement.classList.contains('parent-node')) {
1232 const checkbox = nodeElement.querySelector('.node-checkbox');
1233 if (checkbox) {
1234 checkbox.className = `node-checkbox ${node.checked ? 'checked' : ''} ${node.indeterminate && !node.checked ? 'indeterminate' : ''}`;
1235 }
1236 }
1237 });
1238 }
1239
1240 // 更新选中计数
1241 updateSelectedCount() {
1242 const count = document.getElementById('selectedCount');
1243 if (!count) return;
1244
1245 const selectedCount = this.getSelectedCount();
1246 count.textContent = selectedCount;
1247 }
1248
1249 // 获取选中节点数量
1250 getSelectedCount() {
1251 let count = 0;
1252
1253 // 使用Map遍历提高性能
1254 for (const [id, node] of this.nodeMap) {
1255 if (node.checked) {
1256 count++;
1257 }
1258 }
1259
1260 return count;
1261 }
1262 }
1263
1264 // 初始化应用
1265 document.addEventListener('DOMContentLoaded', () => {
1266 const treeContainer = document.getElementById('treeContainer');
1267 const loadingElement = document.getElementById('loading');
1268
1269 // 模拟数据加载
1270 setTimeout(() => {
1271 if (loadingElement) {
1272 loadingElement.style.display = 'none';
1273 }
1274
1275 const rootNode = generateTreeData();
1276 const treeSelector = new TreeSelector(treeContainer, rootNode);
1277
1278 // 保存引用以便调试
1279 window.treeSelector = treeSelector;
1280 }, 500);
1281 });
1282 </script>
1283</body>
1284</html>