Showing
1 changed file
with
138 additions
and
13 deletions
| @@ -129,6 +129,27 @@ | @@ -129,6 +129,27 @@ | ||
| 129 | color: var(--text-muted); | 129 | color: var(--text-muted); |
| 130 | } | 130 | } |
| 131 | 131 | ||
| 132 | + .search-group { | ||
| 133 | + display: flex; | ||
| 134 | + align-items: center; | ||
| 135 | + gap: 10px; | ||
| 136 | + flex: 1; | ||
| 137 | + max-width: 560px; | ||
| 138 | + } | ||
| 139 | + | ||
| 140 | + .search-actions { | ||
| 141 | + display: flex; | ||
| 142 | + align-items: center; | ||
| 143 | + gap: 6px; | ||
| 144 | + } | ||
| 145 | + | ||
| 146 | + .search-status { | ||
| 147 | + min-width: 44px; | ||
| 148 | + text-align: center; | ||
| 149 | + font-size: 0.8rem; | ||
| 150 | + color: var(--text-muted); | ||
| 151 | + } | ||
| 152 | + | ||
| 132 | /* 统计信息 */ | 153 | /* 统计信息 */ |
| 133 | .stats { | 154 | .stats { |
| 134 | display: flex; | 155 | display: flex; |
| @@ -444,6 +465,7 @@ | @@ -444,6 +465,7 @@ | ||
| 444 | 刷新 | 465 | 刷新 |
| 445 | </button> | 466 | </button> |
| 446 | 467 | ||
| 468 | + <div class="search-group"> | ||
| 447 | <div class="search-box"> | 469 | <div class="search-box"> |
| 448 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | 470 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| 449 | <circle cx="11" cy="11" r="8"/> | 471 | <circle cx="11" cy="11" r="8"/> |
| @@ -451,6 +473,13 @@ | @@ -451,6 +473,13 @@ | ||
| 451 | </svg> | 473 | </svg> |
| 452 | <input type="text" id="searchInput" placeholder="搜索节点..."> | 474 | <input type="text" id="searchInput" placeholder="搜索节点..."> |
| 453 | </div> | 475 | </div> |
| 476 | + <div class="search-actions"> | ||
| 477 | + <button class="btn" id="searchBtn" title="搜索">搜索</button> | ||
| 478 | + <button class="btn" id="searchPrevBtn" title="上一个">↑</button> | ||
| 479 | + <button class="btn" id="searchNextBtn" title="下一个">↓</button> | ||
| 480 | + <span class="search-status" id="searchStatus">0/0</span> | ||
| 481 | + </div> | ||
| 482 | + </div> | ||
| 454 | 483 | ||
| 455 | <div class="stats" id="statsContainer"> | 484 | <div class="stats" id="statsContainer"> |
| 456 | <div class="stat-item"> | 485 | <div class="stat-item"> |
| @@ -590,6 +619,9 @@ | @@ -590,6 +619,9 @@ | ||
| 590 | let reportId = {{ report_id | tojson if report_id else 'null' }}; | 619 | let reportId = {{ report_id | tojson if report_id else 'null' }}; |
| 591 | let graphReady = false; | 620 | let graphReady = false; |
| 592 | let graphPollTimer = null; | 621 | let graphPollTimer = null; |
| 622 | + let graphSearchResults = []; | ||
| 623 | + let graphSearchIndex = -1; | ||
| 624 | + let graphSearchKeyword = ''; | ||
| 593 | const GRAPH_POLL_INTERVAL = 4000; | 625 | const GRAPH_POLL_INTERVAL = 4000; |
| 594 | 626 | ||
| 595 | // 初始化 | 627 | // 初始化 |
| @@ -620,7 +652,13 @@ | @@ -620,7 +652,13 @@ | ||
| 620 | allEdges = data.graph.edges; | 652 | allEdges = data.graph.edges; |
| 621 | 653 | ||
| 622 | updateStats(data.graph.stats); | 654 | updateStats(data.graph.stats); |
| 655 | + resetGraphSearchState(); | ||
| 623 | renderGraph(); | 656 | renderGraph(); |
| 657 | + const input = document.getElementById('searchInput'); | ||
| 658 | + const currentKeyword = (input && input.value) ? input.value : ''; | ||
| 659 | + if (currentKeyword) { | ||
| 660 | + runGraphSearch(currentKeyword); | ||
| 661 | + } | ||
| 624 | showLoading(false); | 662 | showLoading(false); |
| 625 | showEmpty(false); | 663 | showEmpty(false); |
| 626 | graphReady = true; | 664 | graphReady = true; |
| @@ -769,6 +807,13 @@ | @@ -769,6 +807,13 @@ | ||
| 769 | network.once('stabilizationIterationsDone', () => { | 807 | network.once('stabilizationIterationsDone', () => { |
| 770 | network.fit({ animation: true }); | 808 | network.fit({ animation: true }); |
| 771 | }); | 809 | }); |
| 810 | + | ||
| 811 | + // 如果已有搜索关键词,重新聚焦当前匹配;否则更新状态显示 | ||
| 812 | + if (graphSearchKeyword) { | ||
| 813 | + runGraphSearch(graphSearchKeyword); | ||
| 814 | + } else { | ||
| 815 | + updateGraphSearchStatus(); | ||
| 816 | + } | ||
| 772 | } | 817 | } |
| 773 | 818 | ||
| 774 | // 显示节点详情 | 819 | // 显示节点详情 |
| @@ -812,6 +857,76 @@ | @@ -812,6 +857,76 @@ | ||
| 812 | document.getElementById('nodeDetail').style.display = 'none'; | 857 | document.getElementById('nodeDetail').style.display = 'none'; |
| 813 | } | 858 | } |
| 814 | 859 | ||
| 860 | + function resetGraphSearchState() { | ||
| 861 | + graphSearchResults = []; | ||
| 862 | + graphSearchIndex = -1; | ||
| 863 | + graphSearchKeyword = ''; | ||
| 864 | + updateGraphSearchStatus(); | ||
| 865 | + } | ||
| 866 | + | ||
| 867 | + function updateGraphSearchStatus() { | ||
| 868 | + const statusEl = document.getElementById('searchStatus'); | ||
| 869 | + const prevBtn = document.getElementById('searchPrevBtn'); | ||
| 870 | + const nextBtn = document.getElementById('searchNextBtn'); | ||
| 871 | + const hasResults = graphSearchResults.length > 0 && graphSearchIndex >= 0; | ||
| 872 | + if (statusEl) { | ||
| 873 | + statusEl.textContent = hasResults ? `${graphSearchIndex + 1}/${graphSearchResults.length}` : '0/0'; | ||
| 874 | + statusEl.style.visibility = hasResults ? 'visible' : 'hidden'; | ||
| 875 | + } | ||
| 876 | + if (prevBtn) prevBtn.disabled = !hasResults; | ||
| 877 | + if (nextBtn) nextBtn.disabled = !hasResults; | ||
| 878 | + } | ||
| 879 | + | ||
| 880 | + function runGraphSearch(keyword) { | ||
| 881 | + if (!network) return; | ||
| 882 | + const term = (keyword || '').trim(); | ||
| 883 | + graphSearchKeyword = term; | ||
| 884 | + | ||
| 885 | + if (!term) { | ||
| 886 | + resetGraphSearchState(); | ||
| 887 | + network.selectNodes([]); | ||
| 888 | + return; | ||
| 889 | + } | ||
| 890 | + | ||
| 891 | + const lower = term.toLowerCase(); | ||
| 892 | + const nodesDataset = network.body && network.body.data && network.body.data.nodes ? network.body.data.nodes.get() : []; | ||
| 893 | + graphSearchResults = nodesDataset.filter(n => (n.label || '').toLowerCase().includes(lower)); | ||
| 894 | + graphSearchResults.sort((a, b) => { | ||
| 895 | + const aLabel = (a.label || '').toLowerCase(); | ||
| 896 | + const bLabel = (b.label || '').toLowerCase(); | ||
| 897 | + if (aLabel === bLabel) { | ||
| 898 | + return String(a.id).localeCompare(String(b.id), 'zh'); | ||
| 899 | + } | ||
| 900 | + return aLabel.localeCompare(bLabel, 'zh'); | ||
| 901 | + }); | ||
| 902 | + graphSearchIndex = graphSearchResults.length ? 0 : -1; | ||
| 903 | + | ||
| 904 | + if (!graphSearchResults.length) { | ||
| 905 | + network.selectNodes([]); | ||
| 906 | + hideNodeDetail(); | ||
| 907 | + updateGraphSearchStatus(); | ||
| 908 | + return; | ||
| 909 | + } | ||
| 910 | + | ||
| 911 | + focusGraphSearchIndex(graphSearchIndex); | ||
| 912 | + } | ||
| 913 | + | ||
| 914 | + function focusGraphSearchIndex(index) { | ||
| 915 | + if (!network || !graphSearchResults.length) return; | ||
| 916 | + const total = graphSearchResults.length; | ||
| 917 | + graphSearchIndex = ((index % total) + total) % total; | ||
| 918 | + const target = graphSearchResults[graphSearchIndex]; | ||
| 919 | + network.selectNodes([target.id]); | ||
| 920 | + network.focus(target.id, { animation: true, scale: 1.4 }); | ||
| 921 | + showNodeDetail(target._data || target); | ||
| 922 | + updateGraphSearchStatus(); | ||
| 923 | + } | ||
| 924 | + | ||
| 925 | + function stepGraphSearch(delta) { | ||
| 926 | + if (!graphSearchResults.length) return; | ||
| 927 | + focusGraphSearchIndex(graphSearchIndex + delta); | ||
| 928 | + } | ||
| 929 | + | ||
| 815 | // 更新统计 | 930 | // 更新统计 |
| 816 | function updateStats(stats) { | 931 | function updateStats(stats) { |
| 817 | document.getElementById('nodeCount').textContent = stats.total_nodes || 0; | 932 | document.getElementById('nodeCount').textContent = stats.total_nodes || 0; |
| @@ -888,22 +1003,32 @@ | @@ -888,22 +1003,32 @@ | ||
| 888 | }); | 1003 | }); |
| 889 | 1004 | ||
| 890 | // 搜索 | 1005 | // 搜索 |
| 891 | - document.getElementById('searchInput').addEventListener('input', (e) => { | ||
| 892 | - const query = e.target.value.toLowerCase(); | ||
| 893 | - if (!query) { | ||
| 894 | - if (network) network.selectNodes([]); | ||
| 895 | - return; | 1006 | + const searchInput = document.getElementById('searchInput'); |
| 1007 | + const searchBtn = document.getElementById('searchBtn'); | ||
| 1008 | + const searchPrevBtn = document.getElementById('searchPrevBtn'); | ||
| 1009 | + const searchNextBtn = document.getElementById('searchNextBtn'); | ||
| 1010 | + if (searchInput) { | ||
| 1011 | + searchInput.addEventListener('keydown', (e) => { | ||
| 1012 | + if (e.key === 'Enter') { | ||
| 1013 | + runGraphSearch(searchInput.value); | ||
| 896 | } | 1014 | } |
| 897 | - | ||
| 898 | - const matchedIds = allNodes | ||
| 899 | - .filter(n => n.label.toLowerCase().includes(query)) | ||
| 900 | - .map(n => n.id); | ||
| 901 | - | ||
| 902 | - if (network && matchedIds.length > 0) { | ||
| 903 | - network.selectNodes(matchedIds); | ||
| 904 | - network.focus(matchedIds[0], { animation: true, scale: 1.5 }); | 1015 | + }); |
| 1016 | + searchInput.addEventListener('input', () => { | ||
| 1017 | + if (!searchInput.value) { | ||
| 1018 | + resetGraphSearchState(); | ||
| 1019 | + if (network) network.selectNodes([]); | ||
| 905 | } | 1020 | } |
| 906 | }); | 1021 | }); |
| 1022 | + } | ||
| 1023 | + if (searchBtn) { | ||
| 1024 | + searchBtn.addEventListener('click', () => runGraphSearch(searchInput ? searchInput.value : '')); | ||
| 1025 | + } | ||
| 1026 | + if (searchPrevBtn) { | ||
| 1027 | + searchPrevBtn.addEventListener('click', () => stepGraphSearch(-1)); | ||
| 1028 | + } | ||
| 1029 | + if (searchNextBtn) { | ||
| 1030 | + searchNextBtn.addEventListener('click', () => stepGraphSearch(1)); | ||
| 1031 | + } | ||
| 907 | 1032 | ||
| 908 | // 筛选 | 1033 | // 筛选 |
| 909 | document.querySelectorAll('.filter-item input[type="checkbox"]').forEach(cb => { | 1034 | document.querySelectorAll('.filter-item input[type="checkbox"]').forEach(cb => { |
-
Please register or login to post a comment