马一丁

Enhance the full-screen viewing style

@@ -6,377 +6,425 @@ @@ -6,377 +6,425 @@
6 <title>知识图谱可视化 - BettaFish</title> 6 <title>知识图谱可视化 - BettaFish</title>
7 <!-- Vis.js --> 7 <!-- Vis.js -->
8 <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script> 8 <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
  9 + <!-- Google Fonts -->
  10 + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
9 <style> 11 <style>
10 :root { 12 :root {
11 - --primary-color: #4F46E5;  
12 - --primary-light: #818CF8;  
13 - --bg-color: #0F172A;  
14 - --card-bg: #1E293B;  
15 - --text-color: #F1F5F9;  
16 - --text-muted: #94A3B8; 13 + --bg-color: #0f172a;
  14 + --sidebar-bg: #1e293b;
  15 + --card-bg: #1e293b;
17 --border-color: #334155; 16 --border-color: #334155;
18 - --success-color: #10B981;  
19 - --warning-color: #F59E0B;  
20 - --error-color: #EF4444; 17 + --primary-color: #6366f1;
  18 + --primary-hover: #4f46e5;
  19 + --text-main: #f8fafc;
  20 + --text-sec: #94a3b8;
  21 + --success: #10b981;
  22 + --warning: #f59e0b;
  23 + --error: #ef4444;
  24 + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
  25 + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
  26 + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
21 } 27 }
22 28
23 * { 29 * {
24 margin: 0; 30 margin: 0;
25 padding: 0; 31 padding: 0;
26 box-sizing: border-box; 32 box-sizing: border-box;
  33 + outline: none;
27 } 34 }
28 35
29 body { 36 body {
30 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; 37 + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
31 background-color: var(--bg-color); 38 background-color: var(--bg-color);
32 - color: var(--text-color);  
33 - min-height: 100vh; 39 + color: var(--text-main);
  40 + height: 100vh;
  41 + overflow: hidden;
  42 + font-size: 14px;
34 } 43 }
35 44
36 - /* 顶部工具栏 */  
37 - .toolbar {  
38 - position: fixed;  
39 - top: 0;  
40 - left: 0;  
41 - right: 0;  
42 - height: 60px;  
43 - background: var(--card-bg);  
44 - border-bottom: 1px solid var(--border-color); 45 + /* 布局容器 */
  46 + .app-container {
45 display: flex; 47 display: flex;
46 - align-items: center;  
47 - padding: 0 20px;  
48 - gap: 16px;  
49 - z-index: 1000; 48 + height: 100vh;
  49 + width: 100vw;
50 } 50 }
51 51
52 - .toolbar h1 {  
53 - font-size: 1.25rem;  
54 - font-weight: 600; 52 + /* 侧边栏 */
  53 + .sidebar {
  54 + width: 320px;
  55 + background: var(--sidebar-bg);
  56 + border-right: 1px solid var(--border-color);
55 display: flex; 57 display: flex;
56 - align-items: center;  
57 - gap: 8px; 58 + flex-direction: column;
  59 + z-index: 20;
  60 + transition: transform 0.3s ease;
  61 + box-shadow: var(--shadow-lg);
58 } 62 }
59 63
60 - .toolbar h1 svg {  
61 - width: 24px;  
62 - height: 24px;  
63 - color: var(--primary-color); 64 + .sidebar.collapsed {
  65 + transform: translateX(-320px);
  66 + position: absolute;
  67 + height: 100%;
64 } 68 }
65 69
66 - .toolbar-divider {  
67 - width: 1px;  
68 - height: 30px;  
69 - background: var(--border-color); 70 + .sidebar-header {
  71 + padding: 20px;
  72 + border-bottom: 1px solid var(--border-color);
  73 + display: flex;
  74 + align-items: center;
  75 + justify-content: space-between;
70 } 76 }
71 77
72 - .btn { 78 + .app-title {
  79 + font-size: 1.1rem;
  80 + font-weight: 600;
73 display: flex; 81 display: flex;
74 align-items: center; 82 align-items: center;
75 - gap: 6px;  
76 - padding: 8px 16px;  
77 - border: 1px solid var(--border-color);  
78 - border-radius: 6px;  
79 - background: transparent;  
80 - color: var(--text-color);  
81 - cursor: pointer;  
82 - font-size: 0.875rem;  
83 - transition: all 0.2s; 83 + gap: 10px;
  84 + color: var(--text-main);
84 } 85 }
85 86
86 - .btn:hover {  
87 - background: var(--primary-color);  
88 - border-color: var(--primary-color); 87 + .app-title svg {
  88 + color: var(--primary-color);
  89 + width: 24px;
  90 + height: 24px;
89 } 91 }
90 92
91 - .btn-primary {  
92 - background: var(--primary-color);  
93 - border-color: var(--primary-color); 93 + /* 侧边栏内容区 */
  94 + .sidebar-content {
  95 + flex: 1;
  96 + overflow-y: auto;
  97 + padding: 0;
94 } 98 }
95 99
96 - .btn svg {  
97 - width: 16px;  
98 - height: 16px; 100 + .section {
  101 + padding: 20px;
  102 + border-bottom: 1px solid var(--border-color);
99 } 103 }
100 104
101 - .search-box {  
102 - flex: 1;  
103 - max-width: 400px; 105 + .section-title {
  106 + font-size: 0.75rem;
  107 + font-weight: 600;
  108 + text-transform: uppercase;
  109 + letter-spacing: 0.05em;
  110 + color: var(--text-sec);
  111 + margin-bottom: 12px;
  112 + display: flex;
  113 + align-items: center;
  114 + justify-content: space-between;
  115 + }
  116 +
  117 + /* 搜索框 */
  118 + .search-wrapper {
104 position: relative; 119 position: relative;
  120 + margin-bottom: 10px;
105 } 121 }
106 122
107 - .search-box input { 123 + .search-input {
108 width: 100%; 124 width: 100%;
109 - padding: 8px 16px 8px 40px; 125 + background: rgba(15, 23, 42, 0.5);
110 border: 1px solid var(--border-color); 126 border: 1px solid var(--border-color);
111 - border-radius: 6px;  
112 - background: var(--bg-color);  
113 - color: var(--text-color);  
114 - font-size: 0.875rem; 127 + padding: 10px 36px 10px 12px;
  128 + border-radius: 8px;
  129 + color: var(--text-main);
  130 + font-size: 0.9rem;
  131 + transition: all 0.2s;
115 } 132 }
116 133
117 - .search-box input:focus {  
118 - outline: none; 134 + .search-input:focus {
119 border-color: var(--primary-color); 135 border-color: var(--primary-color);
  136 + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
120 } 137 }
121 138
122 - .search-box svg { 139 + .search-icon {
123 position: absolute; 140 position: absolute;
124 - left: 12px; 141 + right: 12px;
125 top: 50%; 142 top: 50%;
126 transform: translateY(-50%); 143 transform: translateY(-50%);
127 - width: 16px;  
128 - height: 16px;  
129 - color: var(--text-muted);  
130 - }  
131 -  
132 - .search-group {  
133 - display: flex;  
134 - align-items: center;  
135 - gap: 10px;  
136 - flex: 1;  
137 - max-width: 560px; 144 + color: var(--text-sec);
  145 + pointer-events: none;
138 } 146 }
139 147
140 - .search-actions { 148 + .search-controls {
141 display: flex; 149 display: flex;
  150 + gap: 8px;
142 align-items: center; 151 align-items: center;
143 - gap: 6px; 152 + margin-top: 8px;
144 } 153 }
145 154
146 .search-status { 155 .search-status {
147 - min-width: 44px;  
148 - text-align: center;  
149 font-size: 0.8rem; 156 font-size: 0.8rem;
150 - color: var(--text-muted); 157 + color: var(--text-sec);
  158 + margin-left: auto;
151 } 159 }
152 160
153 - /* 统计信息 */  
154 - .stats { 161 + /* 过滤器 */
  162 + .filter-list {
155 display: flex; 163 display: flex;
156 - gap: 16px;  
157 - margin-left: auto; 164 + flex-direction: column;
  165 + gap: 8px;
158 } 166 }
159 167
160 - .stat-item { 168 + .filter-item {
161 display: flex; 169 display: flex;
162 align-items: center; 170 align-items: center;
163 - gap: 6px;  
164 - font-size: 0.875rem; 171 + padding: 8px 12px;
  172 + background: rgba(15, 23, 42, 0.3);
  173 + border-radius: 6px;
  174 + cursor: pointer;
  175 + transition: background 0.2s;
  176 + border: 1px solid transparent;
165 } 177 }
166 178
167 - .stat-item .label {  
168 - color: var(--text-muted); 179 + .filter-item:hover {
  180 + background: rgba(15, 23, 42, 0.6);
  181 + border-color: var(--border-color);
169 } 182 }
170 183
171 - .stat-item .value {  
172 - font-weight: 600;  
173 - color: var(--primary-light); 184 + .filter-item input {
  185 + display: none;
174 } 186 }
175 187
176 - /* 左侧面板 */  
177 - .sidebar {  
178 - position: fixed;  
179 - top: 60px;  
180 - left: 0;  
181 - width: 300px;  
182 - bottom: 0;  
183 - background: var(--card-bg);  
184 - border-right: 1px solid var(--border-color);  
185 - overflow-y: auto;  
186 - padding: 16px;  
187 - transition: transform 0.3s;  
188 - z-index: 100; 188 + .filter-item.active {
  189 + background: rgba(99, 102, 241, 0.1);
  190 + border-color: rgba(99, 102, 241, 0.3);
189 } 191 }
190 192
191 - .sidebar.collapsed {  
192 - transform: translateX(-100%); 193 + /* 未选中时灰显彩点 */
  194 + .filter-item:not(.active) .filter-dot {
  195 + background-color: var(--text-sec) !important;
  196 + box-shadow: none;
193 } 197 }
194 -  
195 - .sidebar h3 {  
196 - font-size: 0.875rem;  
197 - font-weight: 600;  
198 - color: var(--text-muted);  
199 - text-transform: uppercase;  
200 - letter-spacing: 0.05em;  
201 - margin-bottom: 12px; 198 +
  199 + .filter-item:not(.active) .filter-name,
  200 + .filter-item:not(.active) .filter-count {
  201 + color: var(--text-sec);
  202 + opacity: 0.7;
202 } 203 }
203 204
204 - .filter-group {  
205 - margin-bottom: 20px; 205 + .filter-dot {
  206 + width: 10px;
  207 + height: 10px;
  208 + border-radius: 50%;
  209 + margin-right: 10px;
  210 + box-shadow: 0 0 8px currentColor;
206 } 211 }
207 212
208 - .filter-item {  
209 - display: flex;  
210 - align-items: center;  
211 - gap: 10px;  
212 - padding: 8px 0;  
213 - cursor: pointer; 213 + .filter-name {
  214 + flex: 1;
  215 + font-weight: 500;
214 } 216 }
215 217
216 - .filter-item input[type="checkbox"] {  
217 - width: 16px;  
218 - height: 16px;  
219 - accent-color: var(--primary-color); 218 + .filter-count {
  219 + font-size: 0.75rem;
  220 + color: var(--text-sec);
  221 + background: rgba(255, 255, 255, 0.1);
  222 + padding: 2px 6px;
  223 + border-radius: 4px;
220 } 224 }
221 225
222 - .filter-item .color-dot {  
223 - width: 12px;  
224 - height: 12px;  
225 - border-radius: 50%; 226 + /* 节点详情卡片 */
  227 + .node-details {
  228 + display: none;
  229 + animation: fadeIn 0.3s ease;
226 } 230 }
227 231
228 - .filter-item .count {  
229 - margin-left: auto;  
230 - font-size: 0.75rem;  
231 - color: var(--text-muted); 232 + .detail-card {
  233 + background: rgba(15, 23, 42, 0.3);
  234 + border-radius: 8px;
  235 + padding: 16px;
  236 + border: 1px solid var(--border-color);
232 } 237 }
233 238
234 - /* 节点详情 */  
235 - .node-detail {  
236 - margin-top: 20px;  
237 - padding-top: 20px;  
238 - border-top: 1px solid var(--border-color); 239 + .detail-header {
  240 + margin-bottom: 12px;
  241 + padding-bottom: 12px;
  242 + border-bottom: 1px solid rgba(255, 255, 255, 0.1);
239 } 243 }
240 244
241 - .node-detail .detail-title { 245 + .detail-title {
  246 + font-size: 1rem;
242 font-weight: 600; 247 font-weight: 600;
  248 + color: var(--primary-color);
  249 + margin-bottom: 4px;
  250 + word-break: break-all;
  251 + }
  252 +
  253 + .detail-badge {
  254 + display: inline-block;
  255 + font-size: 0.7rem;
  256 + padding: 2px 8px;
  257 + border-radius: 12px;
  258 + background: var(--border-color);
  259 + color: var(--text-sec);
  260 + }
  261 +
  262 + .prop-row {
243 margin-bottom: 8px; 263 margin-bottom: 8px;
244 - color: var(--primary-light);  
245 } 264 }
246 265
247 - .node-detail .detail-type { 266 + .prop-label {
248 font-size: 0.75rem; 267 font-size: 0.75rem;
249 - color: var(--text-muted);  
250 - margin-bottom: 12px; 268 + color: var(--text-sec);
  269 + margin-bottom: 2px;
251 } 270 }
252 271
253 - .node-detail .detail-props {  
254 - font-size: 0.875rem; 272 + .prop-value {
  273 + font-size: 0.85rem;
  274 + color: var(--text-main);
  275 + word-break: break-word;
  276 + line-height: 1.4;
255 } 277 }
256 278
257 - .node-detail .prop-item {  
258 - padding: 6px 0;  
259 - border-bottom: 1px solid var(--border-color); 279 + /* 统计数据 */
  280 + .stats-grid {
  281 + display: grid;
  282 + grid-template-columns: 1fr 1fr;
  283 + gap: 12px;
260 } 284 }
261 285
262 - .node-detail .prop-key {  
263 - color: var(--text-muted);  
264 - font-size: 0.75rem; 286 + .stat-box {
  287 + background: rgba(15, 23, 42, 0.3);
  288 + padding: 12px;
  289 + border-radius: 8px;
  290 + text-align: center;
  291 + border: 1px solid var(--border-color);
265 } 292 }
266 293
267 - .node-detail .prop-value {  
268 - margin-top: 2px;  
269 - word-break: break-all; 294 + .stat-value {
  295 + font-size: 1.25rem;
  296 + font-weight: 700;
  297 + color: var(--text-main);
270 } 298 }
271 299
272 - /* 图谱容器 */  
273 - .graph-container {  
274 - position: fixed;  
275 - top: 60px;  
276 - left: 300px;  
277 - right: 0;  
278 - bottom: 0;  
279 - transition: left 0.3s; 300 + .stat-label {
  301 + font-size: 0.75rem;
  302 + color: var(--text-sec);
  303 + margin-top: 4px;
280 } 304 }
281 305
282 - .graph-container.fullwidth {  
283 - left: 0; 306 + /* 主画布区 */
  307 + .main-content {
  308 + flex: 1;
  309 + position: relative;
  310 + background: var(--bg-color);
  311 + overflow: hidden;
284 } 312 }
285 313
286 #network { 314 #network {
287 width: 100%; 315 width: 100%;
288 height: 100%; 316 height: 100%;
289 - background: var(--bg-color);  
290 } 317 }
291 318
292 - /* 加载状态 */  
293 - .loading-overlay { 319 + /* 浮动工具栏 */
  320 + .floating-toolbar {
294 position: absolute; 321 position: absolute;
295 - top: 0;  
296 - left: 0;  
297 - right: 0;  
298 - bottom: 0; 322 + background: var(--card-bg);
  323 + border: 1px solid var(--border-color);
  324 + border-radius: 8px;
  325 + padding: 6px;
299 display: flex; 326 display: flex;
  327 + gap: 4px;
  328 + box-shadow: var(--shadow-lg);
  329 + z-index: 10;
  330 + }
  331 +
  332 + .top-right {
  333 + top: 20px;
  334 + right: 20px;
  335 + }
  336 +
  337 + .bottom-right {
  338 + bottom: 20px;
  339 + right: 20px;
300 flex-direction: column; 340 flex-direction: column;
301 - align-items: center;  
302 - justify-content: center;  
303 - background: var(--bg-color);  
304 - z-index: 500; 341 + }
  342 +
  343 + .bottom-left {
  344 + bottom: 20px;
  345 + left: 20px;
  346 + /* 当侧边栏存在时,需要调整left */
  347 + left: 340px;
  348 + transition: left 0.3s ease;
305 } 349 }
306 350
307 - .loading-spinner {  
308 - width: 48px;  
309 - height: 48px;  
310 - border: 4px solid var(--border-color);  
311 - border-top-color: var(--primary-color);  
312 - border-radius: 50%;  
313 - animation: spin 1s linear infinite; 351 + .sidebar.collapsed ~ .main-content .bottom-left {
  352 + left: 20px;
  353 + }
  354 +
  355 + .top-left-toggle {
  356 + position: absolute;
  357 + top: 20px;
  358 + left: 20px;
  359 + z-index: 30;
  360 + display: none; /* 默认隐藏,折叠时显示 */
314 } 361 }
315 362
316 - @keyframes spin {  
317 - to { transform: rotate(360deg); } 363 + .sidebar.collapsed ~ .main-content .top-left-toggle {
  364 + display: block;
318 } 365 }
319 366
320 - .loading-text {  
321 - margin-top: 16px;  
322 - color: var(--text-muted); 367 + /* 按钮样式 */
  368 + .btn-icon {
  369 + width: 36px;
  370 + height: 36px;
  371 + display: flex;
  372 + align-items: center;
  373 + justify-content: center;
  374 + border: 1px solid transparent;
  375 + background: transparent;
  376 + color: var(--text-sec);
  377 + border-radius: 6px;
  378 + cursor: pointer;
  379 + transition: all 0.2s;
323 } 380 }
324 381
325 - /* 空状态 */  
326 - .empty-state {  
327 - position: absolute;  
328 - top: 50%;  
329 - left: 50%;  
330 - transform: translate(-50%, -50%);  
331 - text-align: center;  
332 - color: var(--text-muted); 382 + .btn-icon:hover {
  383 + background: rgba(255, 255, 255, 0.1);
  384 + color: var(--text-main);
333 } 385 }
334 386
335 - .empty-state svg {  
336 - width: 64px;  
337 - height: 64px;  
338 - margin-bottom: 16px;  
339 - opacity: 0.5; 387 + .btn-icon.primary {
  388 + background: var(--primary-color);
  389 + color: white;
  390 + }
  391 +
  392 + .btn-icon.primary:hover {
  393 + background: var(--primary-hover);
340 } 394 }
341 395
342 - /* 提示信息 */  
343 - .toast {  
344 - position: fixed;  
345 - bottom: 20px;  
346 - right: 20px;  
347 - padding: 12px 20px;  
348 - background: var(--card-bg);  
349 - border: 1px solid var(--border-color);  
350 - border-radius: 8px;  
351 - display: none;  
352 - animation: slideIn 0.3s;  
353 - z-index: 2000; 396 + .btn-icon:disabled {
  397 + opacity: 0.5;
  398 + cursor: not-allowed;
354 } 399 }
355 400
356 - @keyframes slideIn {  
357 - from {  
358 - transform: translateX(100%);  
359 - opacity: 0;  
360 - } 401 + .btn-sm {
  402 + padding: 4px 12px;
  403 + height: 32px;
  404 + width: auto;
  405 + font-size: 0.8rem;
361 } 406 }
362 407
363 /* 图例 */ 408 /* 图例 */
364 - .legend {  
365 - position: fixed;  
366 - bottom: 20px;  
367 - left: 320px;  
368 - background: var(--card-bg); 409 + .legend-panel {
  410 + background: rgba(30, 41, 59, 0.9);
  411 + backdrop-filter: blur(8px);
369 border: 1px solid var(--border-color); 412 border: 1px solid var(--border-color);
370 border-radius: 8px; 413 border-radius: 8px;
371 - padding: 12px 16px;  
372 - display: flex;  
373 - gap: 16px;  
374 - z-index: 100;  
375 - transition: left 0.3s; 414 + padding: 12px;
376 } 415 }
377 416
378 - .legend.fullwidth {  
379 - left: 20px; 417 + .legend-title {
  418 + font-size: 0.75rem;
  419 + font-weight: 600;
  420 + color: var(--text-sec);
  421 + margin-bottom: 8px;
  422 + }
  423 +
  424 + .legend-items {
  425 + display: flex;
  426 + gap: 16px;
  427 + flex-wrap: wrap;
380 } 428 }
381 429
382 .legend-item { 430 .legend-item {
@@ -384,729 +432,727 @@ @@ -384,729 +432,727 @@
384 align-items: center; 432 align-items: center;
385 gap: 6px; 433 gap: 6px;
386 font-size: 0.75rem; 434 font-size: 0.75rem;
  435 + color: var(--text-main);
387 } 436 }
388 437
389 - .legend-item .dot {  
390 - width: 10px;  
391 - height: 10px; 438 + .legend-dot {
  439 + width: 8px;
  440 + height: 8px;
  441 + border-radius: 2px;
  442 + }
  443 +
  444 + /* 加载与空状态 */
  445 + .overlay-message {
  446 + position: absolute;
  447 + top: 0;
  448 + left: 0;
  449 + right: 0;
  450 + bottom: 0;
  451 + background: rgba(15, 17, 42, 0.8);
  452 + backdrop-filter: blur(4px);
  453 + display: flex;
  454 + flex-direction: column;
  455 + align-items: center;
  456 + justify-content: center;
  457 + z-index: 50;
  458 + }
  459 +
  460 + .spinner {
  461 + width: 40px;
  462 + height: 40px;
  463 + border: 3px solid rgba(99, 102, 241, 0.3);
392 border-radius: 50%; 464 border-radius: 50%;
  465 + border-top-color: var(--primary-color);
  466 + animation: spin 1s ease-in-out infinite;
  467 + margin-bottom: 16px;
  468 + }
  469 +
  470 + .empty-icon {
  471 + width: 64px;
  472 + height: 64px;
  473 + color: var(--text-sec);
  474 + opacity: 0.5;
  475 + margin-bottom: 16px;
393 } 476 }
394 477
395 - /* 全屏模式 */  
396 - .fullscreen-btn { 478 + .toast {
397 position: fixed; 479 position: fixed;
398 bottom: 20px; 480 bottom: 20px;
399 - right: 20px; 481 + left: 50%;
  482 + transform: translateX(-50%) translateY(100px);
  483 + background: var(--card-bg);
  484 + border: 1px solid var(--primary-color);
  485 + color: var(--text-main);
  486 + padding: 10px 24px;
  487 + border-radius: 50px;
  488 + box-shadow: var(--shadow-lg);
400 z-index: 100; 489 z-index: 100;
  490 + transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
  491 + font-weight: 500;
  492 + display: flex;
  493 + align-items: center;
  494 + gap: 8px;
  495 + }
  496 +
  497 + .toast.show {
  498 + transform: translateX(-50%) translateY(0);
401 } 499 }
402 500
403 - /* 节点类型颜色 */  
404 - .color-topic { background-color: #EF4444; }  
405 - .color-engine { background-color: #F59E0B; }  
406 - .color-section { background-color: #10B981; }  
407 - .color-search_query { background-color: #3B82F6; }  
408 - .color-source { background-color: #8B5CF6; } 501 + @keyframes spin {
  502 + to { transform: rotate(360deg); }
  503 + }
  504 +
  505 + @keyframes fadeIn {
  506 + from { opacity: 0; transform: translateY(5px); }
  507 + to { opacity: 1; transform: translateY(0); }
  508 + }
  509 +
  510 + /* 颜色定义 */
  511 + .color-topic { color: #EF4444; background-color: #EF4444; }
  512 + .color-engine { color: #F59E0B; background-color: #F59E0B; }
  513 + .color-section { color: #10B981; background-color: #10B981; }
  514 + .color-search_query { color: #3B82F6; background-color: #3B82F6; }
  515 + .color-source { color: #8B5CF6; background-color: #8B5CF6; }
  516 +
409 </style> 517 </style>
410 </head> 518 </head>
411 <body> 519 <body>
412 - <!-- 顶部工具栏 -->  
413 - <div class="toolbar">  
414 - <h1>  
415 - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">  
416 - <circle cx="12" cy="5" r="3"/>  
417 - <circle cx="5" cy="19" r="3"/>  
418 - <circle cx="19" cy="19" r="3"/>  
419 - <line x1="12" y1="8" x2="5" y2="16"/>  
420 - <line x1="12" y1="8" x2="19" y2="16"/>  
421 - </svg>  
422 - 知识图谱  
423 - </h1>  
424 -  
425 - <div class="toolbar-divider"></div>  
426 -  
427 - <button class="btn" id="toggleSidebar" title="切换侧边栏">  
428 - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">  
429 - <rect x="3" y="3" width="18" height="18" rx="2"/>  
430 - <line x1="9" y1="3" x2="9" y2="21"/>  
431 - </svg>  
432 - </button>  
433 -  
434 - <button class="btn" id="fitBtn" title="适应视图">  
435 - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">  
436 - <path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>  
437 - </svg>  
438 - 适应  
439 - </button>  
440 -  
441 - <button class="btn" id="zoomInBtn" title="放大">  
442 - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">  
443 - <circle cx="11" cy="11" r="8"/>  
444 - <line x1="21" y1="21" x2="16.65" y2="16.65"/>  
445 - <line x1="11" y1="8" x2="11" y2="14"/>  
446 - <line x1="8" y1="11" x2="14" y2="11"/>  
447 - </svg>  
448 - </button>  
449 -  
450 - <button class="btn" id="zoomOutBtn" title="缩小">  
451 - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">  
452 - <circle cx="11" cy="11" r="8"/>  
453 - <line x1="21" y1="21" x2="16.65" y2="16.65"/>  
454 - <line x1="8" y1="11" x2="14" y2="11"/>  
455 - </svg>  
456 - </button>  
457 -  
458 - <button class="btn" id="manualRefreshBtn" title="手动刷新图谱">  
459 - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">  
460 - <polyline points="23 4 23 10 17 10"/>  
461 - <polyline points="1 20 1 14 7 14"/>  
462 - <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"/>  
463 - <path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"/>  
464 - </svg>  
465 - 刷新  
466 - </button>  
467 -  
468 - <div class="search-group">  
469 - <div class="search-box">  
470 - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">  
471 - <circle cx="11" cy="11" r="8"/>  
472 - <line x1="21" y1="21" x2="16.65" y2="16.65"/>  
473 - </svg>  
474 - <input type="text" id="searchInput" placeholder="搜索节点..."> 520 +
  521 + <div class="app-container">
  522 + <!-- 左侧边栏 -->
  523 + <aside class="sidebar" id="sidebar">
  524 + <div class="sidebar-header">
  525 + <div class="app-title">
  526 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  527 + <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
  528 + <polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
  529 + <line x1="12" y1="22.08" x2="12" y2="12"></line>
  530 + </svg>
  531 + <span>知识图谱</span>
  532 + </div>
  533 + <button class="btn-icon" id="toggleSidebar" title="收起侧边栏">
  534 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  535 + <path d="M15 18l-6-6 6-6"/>
  536 + </svg>
  537 + </button>
475 </div> 538 </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> 539 +
  540 + <div class="sidebar-content">
  541 + <!-- 搜索区域 -->
  542 + <div class="section">
  543 + <div class="section-title">搜索节点</div>
  544 + <div class="search-wrapper">
  545 + <input type="text" id="searchInput" class="search-input" placeholder="输入关键词...">
  546 + <svg class="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  547 + <circle cx="11" cy="11" r="8"></circle>
  548 + <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
  549 + </svg>
  550 + </div>
  551 + <div class="search-controls">
  552 + <button class="btn-icon btn-sm" id="searchPrevBtn" disabled title="上一个">
  553 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"></polyline></svg>
  554 + </button>
  555 + <button class="btn-icon btn-sm" id="searchNextBtn" disabled title="下一个">
  556 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg>
  557 + </button>
  558 + <span class="search-status" id="searchStatus"></span>
  559 + </div>
  560 + </div>
  561 +
  562 + <!-- 节点详情 -->
  563 + <div class="section node-details" id="nodeDetailPanel">
  564 + <div class="section-title">
  565 + <span>选定节点</span>
  566 + <button class="btn-icon btn-sm" onclick="network.unselectAll(); hideNodeDetail();" title="取消选择">
  567 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
  568 + </button>
  569 + </div>
  570 + <div class="detail-card">
  571 + <div class="detail-header">
  572 + <div class="detail-title" id="detailTitle"></div>
  573 + <span class="detail-badge" id="detailType"></span>
  574 + </div>
  575 + <div id="detailProps"></div>
  576 + </div>
  577 + </div>
  578 +
  579 + <!-- 过滤器 -->
  580 + <div class="section">
  581 + <div class="section-title">显示节点</div>
  582 + <div class="filter-list" id="filterList">
  583 + <!-- 动态生成 -->
  584 + <label class="filter-item active">
  585 + <input type="checkbox" checked data-type="topic">
  586 + <span class="filter-dot color-topic"></span>
  587 + <span class="filter-name">主题 (Topic)</span>
  588 + <span class="filter-count" id="count-topic">0</span>
  589 + </label>
  590 + <label class="filter-item active">
  591 + <input type="checkbox" checked data-type="engine">
  592 + <span class="filter-dot color-engine"></span>
  593 + <span class="filter-name">分析引擎 (Engine)</span>
  594 + <span class="filter-count" id="count-engine">0</span>
  595 + </label>
  596 + <label class="filter-item active">
  597 + <input type="checkbox" checked data-type="section">
  598 + <span class="filter-dot color-section"></span>
  599 + <span class="filter-name">报告段落 (Section)</span>
  600 + <span class="filter-count" id="count-section">0</span>
  601 + </label>
  602 + <label class="filter-item active">
  603 + <input type="checkbox" checked data-type="search_query">
  604 + <span class="filter-dot color-search_query"></span>
  605 + <span class="filter-name">搜索词 (Query)</span>
  606 + <span class="filter-count" id="count-search_query">0</span>
  607 + </label>
  608 + <label class="filter-item active">
  609 + <input type="checkbox" checked data-type="source">
  610 + <span class="filter-dot color-source"></span>
  611 + <span class="filter-name">数据来源 (Source)</span>
  612 + <span class="filter-count" id="count-source">0</span>
  613 + </label>
  614 + </div>
  615 + </div>
  616 +
  617 + <!-- 统计信息 -->
  618 + <div class="section">
  619 + <div class="section-title">图谱统计</div>
  620 + <div class="stats-grid">
  621 + <div class="stat-box">
  622 + <div class="stat-value" id="totalNodes">0</div>
  623 + <div class="stat-label">节点总数</div>
  624 + </div>
  625 + <div class="stat-box">
  626 + <div class="stat-value" id="totalEdges">0</div>
  627 + <div class="stat-label">关系总数</div>
  628 + </div>
  629 + </div>
  630 + </div>
481 </div> 631 </div>
482 - </div>  
483 -  
484 - <div class="stats" id="statsContainer">  
485 - <div class="stat-item">  
486 - <span class="label">节点</span>  
487 - <span class="value" id="nodeCount">0</span> 632 + </aside>
  633 +
  634 + <!-- 主画布 -->
  635 + <main class="main-content">
  636 + <!-- 侧边栏展开按钮 -->
  637 + <button class="btn-icon floating-toolbar top-left-toggle" id="expandSidebar" title="展开侧边栏">
  638 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  639 + <path d="M9 18l6-6-6-6"/>
  640 + </svg>
  641 + </button>
  642 +
  643 + <!-- 右上角工具栏 -->
  644 + <div class="floating-toolbar top-right">
  645 + <button class="btn-icon" id="refreshBtn" title="刷新图谱">
  646 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  647 + <path d="M23 4v6h-6"></path><path d="M1 20v-6h6"></path>
  648 + <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
  649 + </svg>
  650 + </button>
  651 + <button class="btn-icon" id="fullscreenBtn" title="全屏模式">
  652 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  653 + <path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2 2v-3M3 16v3a2 2 0 0 0 2 2h3"></path>
  654 + </svg>
  655 + </button>
488 </div> 656 </div>
489 - <div class="stat-item">  
490 - <span class="label">关系</span>  
491 - <span class="value" id="edgeCount">0</span> 657 +
  658 + <!-- 右下角缩放控制 -->
  659 + <div class="floating-toolbar bottom-right">
  660 + <button class="btn-icon" id="zoomInBtn" title="放大">
  661 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  662 + <line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
  663 + </svg>
  664 + </button>
  665 + <button class="btn-icon" id="zoomOutBtn" title="缩小">
  666 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  667 + <line x1="5" y1="12" x2="19" y2="12"></line>
  668 + </svg>
  669 + </button>
  670 + <div style="height: 1px; background: var(--border-color); margin: 4px 2px;"></div>
  671 + <button class="btn-icon" id="fitBtn" title="适应视图">
  672 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  673 + <path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"></path>
  674 + </svg>
  675 + </button>
492 </div> 676 </div>
493 - </div>  
494 - </div>  
495 677
496 - <!-- 左侧面板 -->  
497 - <div class="sidebar" id="sidebar">  
498 - <div class="filter-group">  
499 - <h3>节点类型</h3>  
500 - <label class="filter-item">  
501 - <input type="checkbox" checked data-type="topic">  
502 - <span class="color-dot color-topic"></span>  
503 - <span>主题</span>  
504 - <span class="count" id="count-topic">0</span>  
505 - </label>  
506 - <label class="filter-item">  
507 - <input type="checkbox" checked data-type="engine">  
508 - <span class="color-dot color-engine"></span>  
509 - <span>分析引擎</span>  
510 - <span class="count" id="count-engine">0</span>  
511 - </label>  
512 - <label class="filter-item">  
513 - <input type="checkbox" checked data-type="section">  
514 - <span class="color-dot color-section"></span>  
515 - <span>报告段落</span>  
516 - <span class="count" id="count-section">0</span>  
517 - </label>  
518 - <label class="filter-item">  
519 - <input type="checkbox" checked data-type="search_query">  
520 - <span class="color-dot color-search_query"></span>  
521 - <span>搜索关键词</span>  
522 - <span class="count" id="count-search_query">0</span>  
523 - </label>  
524 - <label class="filter-item">  
525 - <input type="checkbox" checked data-type="source">  
526 - <span class="color-dot color-source"></span>  
527 - <span>数据来源</span>  
528 - <span class="count" id="count-source">0</span>  
529 - </label>  
530 - </div>  
531 -  
532 - <div class="node-detail" id="nodeDetail" style="display: none;">  
533 - <h3>节点详情</h3>  
534 - <div class="detail-title" id="detailTitle"></div>  
535 - <div class="detail-type" id="detailType"></div>  
536 - <div class="detail-props" id="detailProps"></div>  
537 - </div>  
538 - </div> 678 + <!-- 左下角图例 -->
  679 + <div class="floating-toolbar bottom-left legend-panel">
  680 + <div class="legend-title">图例</div>
  681 + <div class="legend-items">
  682 + <div class="legend-item"><span class="legend-dot color-topic"></span>主题</div>
  683 + <div class="legend-item"><span class="legend-dot color-engine"></span>引擎</div>
  684 + <div class="legend-item"><span class="legend-dot color-section"></span>段落</div>
  685 + <div class="legend-item"><span class="legend-dot color-search_query"></span>搜索词</div>
  686 + <div class="legend-item"><span class="legend-dot color-source"></span>来源</div>
  687 + </div>
  688 + </div>
539 689
540 - <!-- 图谱容器 -->  
541 - <div class="graph-container" id="graphContainer" data-report-id="{{ report_id | e if report_id else '' }}">  
542 - <div id="network"></div>  
543 -  
544 - <!-- 加载状态 -->  
545 - <div class="loading-overlay" id="loadingOverlay">  
546 - <div class="loading-spinner"></div>  
547 - <div class="loading-text">正在加载知识图谱...</div>  
548 - </div>  
549 -  
550 - <!-- 空状态 -->  
551 - <div class="empty-state" id="emptyState" style="display: none;">  
552 - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">  
553 - <circle cx="12" cy="12" r="10"/>  
554 - <path d="M8 15h8"/>  
555 - <path d="M9 9h.01"/>  
556 - <path d="M15 9h.01"/>  
557 - </svg>  
558 - <h3>暂无图谱数据</h3>  
559 - <p>请先生成报告以创建知识图谱</p>  
560 - </div>  
561 - </div> 690 + <!-- 网络图容器 -->
  691 + <div id="network" data-report-id="{{ report_id | e if report_id else '' }}"></div>
562 692
563 - <!-- 图例 -->  
564 - <div class="legend" id="legend">  
565 - <div class="legend-item">  
566 - <span class="dot color-topic"></span>  
567 - <span>主题</span>  
568 - </div>  
569 - <div class="legend-item">  
570 - <span class="dot color-engine"></span>  
571 - <span>引擎</span>  
572 - </div>  
573 - <div class="legend-item">  
574 - <span class="dot color-section"></span>  
575 - <span>段落</span>  
576 - </div>  
577 - <div class="legend-item">  
578 - <span class="dot color-search_query"></span>  
579 - <span>搜索词</span>  
580 - </div>  
581 - <div class="legend-item">  
582 - <span class="dot color-source"></span>  
583 - <span>来源</span>  
584 - </div>  
585 - </div> 693 + <!-- 加载遮罩 -->
  694 + <div class="overlay-message" id="loadingOverlay">
  695 + <div class="spinner"></div>
  696 + <div>正在构建知识图谱...</div>
  697 + </div>
586 698
587 - <!-- 全屏按钮 -->  
588 - <button class="btn fullscreen-btn" id="fullscreenBtn" title="全屏">  
589 - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">  
590 - <path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/>  
591 - </svg>  
592 - </button> 699 + <!-- 空状态遮罩 -->
  700 + <div class="overlay-message" id="emptyState" style="display: none;">
  701 + <svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
  702 + <circle cx="12" cy="12" r="10"></circle>
  703 + <line x1="12" y1="8" x2="12" y2="12"></line>
  704 + <line x1="12" y1="16" x2="12.01" y2="16"></line>
  705 + </svg>
  706 + <h3>暂无图谱数据</h3>
  707 + <p style="color: var(--text-sec); margin-top: 8px;">请生成报告以查看关联分析</p>
  708 + <button class="btn-icon primary" style="margin-top: 20px; width: auto; padding: 0 20px;" onclick="loadGraphData({fromManual: true})">
  709 + 重试
  710 + </button>
  711 + </div>
  712 + </main>
  713 + </div>
593 714
594 - <!-- 提示 -->  
595 - <div class="toast" id="toast"></div> 715 + <!-- 消息提示 -->
  716 + <div class="toast" id="toast">
  717 + <div id="toastIcon" style="display: flex; align-items: center;"></div>
  718 + <span id="toastMsg">操作成功</span>
  719 + </div>
596 720
597 <script> 721 <script>
598 - // 配置  
599 - const NODE_COLORS = {  
600 - topic: '#EF4444',  
601 - engine: '#F59E0B',  
602 - section: '#10B981',  
603 - search_query: '#3B82F6',  
604 - source: '#8B5CF6'  
605 - };  
606 -  
607 - const NODE_SHAPES = {  
608 - topic: 'star',  
609 - engine: 'diamond',  
610 - section: 'dot',  
611 - search_query: 'triangle',  
612 - source: 'square' 722 + // 配置常量
  723 + const NODE_CONFIG = {
  724 + topic: { color: '#EF4444', shape: 'dot', size: 30, label: '主题' },
  725 + engine: { color: '#F59E0B', shape: 'diamond', size: 25, label: '引擎' },
  726 + section: { color: '#10B981', shape: 'dot', size: 15, label: '段落' },
  727 + search_query: { color: '#3B82F6', shape: 'triangle', size: 15, label: '搜索词' },
  728 + source: { color: '#8B5CF6', shape: 'square', size: 15, label: '来源' }
613 }; 729 };
614 730
615 - // 全局变量 731 + // 全局状态
616 let network = null; 732 let network = null;
617 - let allNodes = [];  
618 - let allEdges = []; 733 + let graphData = { nodes: [], edges: [] };
619 let reportId = null; 734 let reportId = null;
620 - const graphContainer = document.getElementById('graphContainer');  
621 - if (graphContainer) {  
622 - reportId = graphContainer.dataset.reportId || null;  
623 - }  
624 - let graphReady = false;  
625 - let graphPollTimer = null;  
626 - let graphSearchResults = [];  
627 - let graphSearchIndex = -1;  
628 - let graphSearchKeyword = '';  
629 - const GRAPH_POLL_INTERVAL = 4000; 735 + let isGraphReady = false;
  736 + let pollTimer = null;
  737 +
  738 + // 搜索状态
  739 + let searchState = {
  740 + keyword: '',
  741 + results: [],
  742 + currentIndex: -1
  743 + };
  744 +
  745 + // DOM 元素
  746 + const els = {
  747 + network: document.getElementById('network'),
  748 + sidebar: document.getElementById('sidebar'),
  749 + loading: document.getElementById('loadingOverlay'),
  750 + empty: document.getElementById('emptyState'),
  751 + stats: {
  752 + nodes: document.getElementById('totalNodes'),
  753 + edges: document.getElementById('totalEdges'),
  754 + counts: {}
  755 + },
  756 + search: {
  757 + input: document.getElementById('searchInput'),
  758 + prev: document.getElementById('searchPrevBtn'),
  759 + next: document.getElementById('searchNextBtn'),
  760 + status: document.getElementById('searchStatus')
  761 + },
  762 + detail: {
  763 + panel: document.getElementById('nodeDetailPanel'),
  764 + title: document.getElementById('detailTitle'),
  765 + type: document.getElementById('detailType'),
  766 + props: document.getElementById('detailProps')
  767 + }
  768 + };
630 769
631 // 初始化 770 // 初始化
632 document.addEventListener('DOMContentLoaded', () => { 771 document.addEventListener('DOMContentLoaded', () => {
633 - loadGraphData();  
634 - startGraphPolling(); 772 + // 获取 Report ID
  773 + const container = document.getElementById('network');
  774 + if (container) reportId = container.dataset.reportId || null;
  775 +
  776 + initGraph();
635 setupEventListeners(); 777 setupEventListeners();
  778 + startPolling();
636 }); 779 });
637 780
638 - // 加载图谱数据 781 + // 初始化图谱
  782 + function initGraph() {
  783 + loadGraphData();
  784 + }
  785 +
  786 + // 加载数据
639 async function loadGraphData(options = {}) { 787 async function loadGraphData(options = {}) {
640 - const { fromPoll = false, fromManual = false, allowFallback = true } = options;  
641 - // 仅在首次或未加载成功时展示大遮罩  
642 - if (!graphReady || !fromPoll) {  
643 - showLoading(true);  
644 - } 788 + const { fromPoll = false, fromManual = false } = options;
645 789
  790 + if (!isGraphReady && !fromPoll) showLoading(true);
  791 +
646 try { 792 try {
647 - const fetchGraph = async (id) => { 793 + const fetchUrl = async (id) => {
648 const url = id ? `/api/graph/${id}` : '/api/graph/latest'; 794 const url = id ? `/api/graph/${id}` : '/api/graph/latest';
649 - const response = await fetch(url, { cache: 'no-store' });  
650 - const data = await response.json();  
651 - if (!response.ok || !data.success || !data.graph) {  
652 - return null;  
653 - }  
654 - return data; 795 + const res = await fetch(url, { cache: 'no-store' });
  796 + return await res.json();
655 }; 797 };
656 798
657 - let data = await fetchGraph(reportId);  
658 - let usedFallback = false;  
659 - const allowLatestFallback = allowFallback && !reportId;  
660 - if ((!data || !data.graph) && allowLatestFallback) {  
661 - data = await fetchGraph(null);  
662 - usedFallback = !!(data && data.graph);  
663 - } 799 + // 尝试获取数据
  800 + let data = await fetchUrl(reportId);
664 801
665 - if (data && data.graph) {  
666 - if (data.report_id) {  
667 - reportId = data.report_id;  
668 - }  
669 - allNodes = data.graph.nodes;  
670 - allEdges = data.graph.edges;  
671 -  
672 - updateStats(data.graph.stats);  
673 - resetGraphSearchState();  
674 - renderGraph();  
675 - const input = document.getElementById('searchInput');  
676 - const currentKeyword = (input && input.value) ? input.value : '';  
677 - if (currentKeyword) {  
678 - runGraphSearch(currentKeyword);  
679 - }  
680 - showLoading(false);  
681 - showEmpty(false);  
682 - graphReady = true;  
683 - stopGraphPolling();  
684 - if (fromManual) {  
685 - showToast(usedFallback ? '未找到指定图谱,已切换至最新版本' : '已刷新最新图谱');  
686 - }  
687 - } else {  
688 - showEmpty(true);  
689 - graphReady = false;  
690 - allNodes = [];  
691 - allEdges = [];  
692 - resetGraphSearchState();  
693 - updateStats({  
694 - total_nodes: 0,  
695 - total_edges: 0,  
696 - topic: 0,  
697 - engine: 0,  
698 - section: 0,  
699 - search_query: 0,  
700 - source: 0  
701 - });  
702 - if (network) {  
703 - network.destroy();  
704 - network = null;  
705 - }  
706 - hideNodeDetail();  
707 - showLoading(false);  
708 - if (fromManual) {  
709 - showToast('未找到图谱数据');  
710 - } 802 + // 如果指定ID没有数据,尝试获取最新
  803 + if ((!data || !data.success || !data.graph) && !reportId) {
  804 + data = await fetchUrl(null);
711 } 805 }
712 - } catch (error) {  
713 - console.error('加载图谱失败:', error);  
714 - showToast('加载图谱失败: ' + error.message);  
715 - if (!graphReady) {  
716 - showEmpty(true); 806 +
  807 + if (data && data.success && data.graph) {
  808 + handleGraphSuccess(data, fromManual);
  809 + } else {
  810 + handleGraphEmpty(fromManual);
717 } 811 }
718 - showLoading(false); 812 + } catch (err) {
  813 + console.error("Graph load error:", err);
  814 + if (fromManual) showToast('加载失败: ' + err.message, 'error');
  815 + if (!isGraphReady) showEmpty(true);
  816 + } finally {
  817 + if (!isGraphReady) showLoading(false);
719 } 818 }
720 } 819 }
721 820
722 - function startGraphPolling() {  
723 - if (graphPollTimer) return;  
724 - graphPollTimer = setInterval(() => {  
725 - loadGraphData({ fromPoll: true });  
726 - }, GRAPH_POLL_INTERVAL); 821 + function handleGraphSuccess(data, showMessage) {
  822 + const newNodes = data.graph.nodes || [];
  823 + const newEdges = data.graph.edges || [];
  824 +
  825 + // 简单的Diff检查,避免频繁重绘
  826 + if (isGraphReady && newNodes.length === graphData.nodes.length && newEdges.length === graphData.edges.length) {
  827 + if (showMessage) showToast('已是最新数据');
  828 + return;
  829 + }
  830 +
  831 + graphData = { nodes: newNodes, edges: newEdges };
  832 + if (data.report_id) reportId = data.report_id;
  833 +
  834 + updateStats(data.graph.stats);
  835 + renderNetwork();
  836 +
  837 + // 恢复搜索状态
  838 + if (searchState.keyword) runSearch(searchState.keyword);
  839 +
  840 + isGraphReady = true;
  841 + showEmpty(false);
  842 + showLoading(false);
  843 + stopPolling(); // 成功加载后停止轮询,或者可以继续轮询以获得实时更新
  844 +
  845 + if (showMessage) showToast('图谱已更新');
727 } 846 }
728 847
729 - function stopGraphPolling() {  
730 - if (graphPollTimer) {  
731 - clearInterval(graphPollTimer);  
732 - graphPollTimer = null; 848 + function handleGraphEmpty(showMessage) {
  849 + if (!isGraphReady) {
  850 + showEmpty(true);
  851 + // 清空数据
  852 + graphData = { nodes: [], edges: [] };
  853 + if (network) {
  854 + network.destroy();
  855 + network = null;
  856 + }
  857 + updateStats({});
733 } 858 }
  859 + if (showMessage) showToast('暂无数据');
734 } 860 }
735 861
736 - // 渲染图谱  
737 - function renderGraph() {  
738 - const container = document.getElementById('network');  
739 -  
740 - // 处理节点 862 + // 渲染 Vis Network
  863 + function renderNetwork() {
741 const visibleTypes = getVisibleTypes(); 864 const visibleTypes = getVisibleTypes();
742 - const filteredNodes = allNodes.filter(n => visibleTypes.includes(n.group));  
743 - const filteredNodeIds = new Set(filteredNodes.map(n => n.id));  
744 -  
745 - const nodes = new vis.DataSet(filteredNodes.map(node => ({  
746 - id: node.id,  
747 - label: truncateLabel(node.label, 20),  
748 - title: node.title,  
749 - group: node.group,  
750 - color: {  
751 - background: NODE_COLORS[node.group] || '#6B7280',  
752 - border: NODE_COLORS[node.group] || '#6B7280',  
753 - highlight: {  
754 - background: lightenColor(NODE_COLORS[node.group] || '#6B7280'),  
755 - border: NODE_COLORS[node.group] || '#6B7280'  
756 - }  
757 - },  
758 - shape: NODE_SHAPES[node.group] || 'dot',  
759 - size: node.group === 'topic' ? 30 : (node.group === 'engine' ? 25 : 15),  
760 - font: {  
761 - color: '#F1F5F9',  
762 - size: 12  
763 - },  
764 - // 保存原始数据  
765 - _data: node  
766 - })));  
767 -  
768 - // 处理边  
769 - const edges = new vis.DataSet(allEdges  
770 - .filter(e => filteredNodeIds.has(e.from) && filteredNodeIds.has(e.to))  
771 - .map(edge => ({  
772 - from: edge.from,  
773 - to: edge.to,  
774 - label: edge.label,  
775 - arrows: edge.arrows || 'to',  
776 - color: {  
777 - color: '#475569',  
778 - highlight: '#818CF8'  
779 - },  
780 - font: {  
781 - color: '#94A3B8',  
782 - size: 10,  
783 - strokeWidth: 0  
784 - },  
785 - smooth: {  
786 - type: 'continuous'  
787 - }  
788 - }))  
789 - ); 865 + const nodes = graphData.nodes
  866 + .filter(n => visibleTypes.includes(n.group))
  867 + .map(n => ({
  868 + id: n.id,
  869 + label: truncate(n.label, 20),
  870 + group: n.group,
  871 + title: n.title || n.label, // Tooltip
  872 + _raw: n, // 保存原始数据
  873 + ...getNodeStyle(n.group)
  874 + }));
  875 +
  876 + const nodeIds = new Set(nodes.map(n => n.id));
  877 + const edges = graphData.edges
  878 + .filter(e => nodeIds.has(e.from) && nodeIds.has(e.to))
  879 + .map(e => ({
  880 + from: e.from,
  881 + to: e.to,
  882 + arrows: 'to',
  883 + color: { color: '#475569', opacity: 0.6 },
  884 + width: 1
  885 + }));
  886 +
  887 + const data = {
  888 + nodes: new vis.DataSet(nodes),
  889 + edges: new vis.DataSet(edges)
  890 + };
790 891
791 - // 图谱配置  
792 const options = { 892 const options = {
793 nodes: { 893 nodes: {
794 borderWidth: 2, 894 borderWidth: 2,
795 - shadow: true 895 + shadow: true,
  896 + font: { color: '#f8fafc', size: 12, face: 'Inter' }
796 }, 897 },
797 edges: { 898 edges: {
798 - width: 1,  
799 - shadow: true 899 + smooth: { type: 'continuous', roundness: 0.5 }
800 }, 900 },
801 physics: { 901 physics: {
802 - enabled: true,  
803 - solver: 'forceAtlas2Based',  
804 - forceAtlas2Based: {  
805 - gravitationalConstant: -100,  
806 - centralGravity: 0.01,  
807 - springLength: 150,  
808 - springConstant: 0.08,  
809 - damping: 0.5  
810 - },  
811 - stabilization: {  
812 - enabled: true,  
813 - iterations: 200  
814 - } 902 + stabilization: { enabled: true, iterations: 100 },
  903 + barnesHut: { gravitationalConstant: -2000, springConstant: 0.04, springLength: 95 }
815 }, 904 },
816 interaction: { 905 interaction: {
817 hover: true, 906 hover: true,
818 - tooltipDelay: 100,  
819 - zoomView: true,  
820 - dragView: true 907 + tooltipDelay: 200,
  908 + zoomView: true
821 } 909 }
822 }; 910 };
823 911
824 - // 创建网络  
825 - network = new vis.Network(container, { nodes, edges }, options);  
826 -  
827 - // 节点点击事件  
828 - network.on('click', (params) => {  
829 - if (params.nodes.length > 0) {  
830 - const nodeId = params.nodes[0];  
831 - const node = allNodes.find(n => n.id === nodeId);  
832 - if (node) {  
833 - showNodeDetail(node); 912 + if (!network) {
  913 + network = new vis.Network(els.network, data, options);
  914 +
  915 + // 事件绑定
  916 + network.on('click', params => {
  917 + if (params.nodes.length) {
  918 + const nodeId = params.nodes[0];
  919 + const node = graphData.nodes.find(n => n.id === nodeId);
  920 + showNodeDetails(node);
  921 + } else {
  922 + hideNodeDetail();
834 } 923 }
835 - } else {  
836 - hideNodeDetail();  
837 - }  
838 - });  
839 -  
840 - // 稳定后适应视图  
841 - network.once('stabilizationIterationsDone', () => {  
842 - network.fit({ animation: true });  
843 - }); 924 + });
844 925
845 - // 如果已有搜索关键词,重新聚焦当前匹配;否则更新状态显示  
846 - if (graphSearchKeyword) {  
847 - runGraphSearch(graphSearchKeyword); 926 + network.on('stabilizationIterationsDone', () => {
  927 + // 初次加载完成也可以fit一下,但有时会跳动,可视情况开启
  928 + // network.fit();
  929 + });
848 } else { 930 } else {
849 - updateGraphSearchStatus(); 931 + network.setData(data);
850 } 932 }
851 } 933 }
852 934
853 - // 显示节点详情  
854 - function showNodeDetail(node) {  
855 - const detailPanel = document.getElementById('nodeDetail');  
856 - const titleEl = document.getElementById('detailTitle');  
857 - const typeEl = document.getElementById('detailType');  
858 - const propsEl = document.getElementById('detailProps');  
859 -  
860 - titleEl.textContent = node.label;  
861 -  
862 - const typeLabels = {  
863 - topic: '主题',  
864 - engine: '分析引擎',  
865 - section: '报告段落',  
866 - search_query: '搜索关键词',  
867 - source: '数据来源' 935 + function getNodeStyle(group) {
  936 + const conf = NODE_CONFIG[group] || { color: '#94a3b8', shape: 'dot' };
  937 + return {
  938 + color: {
  939 + background: conf.color,
  940 + border: conf.color,
  941 + highlight: { background: lighten(conf.color, 20), border: conf.color },
  942 + hover: { background: lighten(conf.color, 20), border: conf.color }
  943 + },
  944 + shape: conf.shape,
  945 + size: conf.size
868 }; 946 };
869 - typeEl.textContent = typeLabels[node.group] || node.group;  
870 -  
871 - // 显示属性  
872 - let propsHtml = '';  
873 - const props = node.properties || {};  
874 - for (const [key, value] of Object.entries(props)) {  
875 - if (value) {  
876 - propsHtml += `  
877 - <div class="prop-item">  
878 - <div class="prop-key">${key}</div>  
879 - <div class="prop-value">${truncateText(String(value), 200)}</div>  
880 - </div>  
881 - `;  
882 - }  
883 - }  
884 - propsEl.innerHTML = propsHtml || '<div class="prop-item">无附加属性</div>';  
885 -  
886 - detailPanel.style.display = 'block';  
887 } 947 }
888 948
889 - // 隐藏节点详情  
890 - function hideNodeDetail() {  
891 - document.getElementById('nodeDetail').style.display = 'none';  
892 - } 949 + // 交互逻辑
  950 + function setupEventListeners() {
  951 + // 侧边栏切换
  952 + document.getElementById('toggleSidebar').addEventListener('click', () => {
  953 + els.sidebar.classList.add('collapsed');
  954 + });
  955 + document.getElementById('expandSidebar').addEventListener('click', () => {
  956 + els.sidebar.classList.remove('collapsed');
  957 + });
893 958
894 - function resetGraphSearchState() {  
895 - graphSearchResults = [];  
896 - graphSearchIndex = -1;  
897 - graphSearchKeyword = '';  
898 - updateGraphSearchStatus();  
899 - } 959 + // 搜索
  960 + els.search.input.addEventListener('input', (e) => runSearch(e.target.value));
  961 + els.search.prev.addEventListener('click', () => navSearch(-1));
  962 + els.search.next.addEventListener('click', () => navSearch(1));
900 963
901 - function updateGraphSearchStatus() {  
902 - const statusEl = document.getElementById('searchStatus');  
903 - const prevBtn = document.getElementById('searchPrevBtn');  
904 - const nextBtn = document.getElementById('searchNextBtn');  
905 - const hasResults = graphSearchResults.length > 0 && graphSearchIndex >= 0;  
906 - if (statusEl) {  
907 - statusEl.textContent = hasResults ? `${graphSearchIndex + 1}/${graphSearchResults.length}` : '0/0';  
908 - statusEl.style.visibility = hasResults ? 'visible' : 'hidden';  
909 - }  
910 - if (prevBtn) prevBtn.disabled = !hasResults;  
911 - if (nextBtn) nextBtn.disabled = !hasResults;  
912 - } 964 + // 过滤器
  965 + document.querySelectorAll('.filter-item input').forEach(cb => {
  966 + cb.addEventListener('change', () => {
  967 + const parent = cb.closest('.filter-item');
  968 + if (cb.checked) parent.classList.add('active');
  969 + else parent.classList.remove('active');
  970 + renderNetwork();
  971 + });
  972 + });
913 973
914 - function runGraphSearch(keyword) {  
915 - if (!network) return;  
916 - const term = (keyword || '').trim();  
917 - graphSearchKeyword = term; 974 + // 工具栏
  975 + document.getElementById('refreshBtn').addEventListener('click', () => loadGraphData({fromManual: true}));
  976 + document.getElementById('zoomInBtn').addEventListener('click', () => network?.moveTo({scale: network.getScale() * 1.2, animation: true}));
  977 + document.getElementById('zoomOutBtn').addEventListener('click', () => network?.moveTo({scale: network.getScale() / 1.2, animation: true}));
  978 + document.getElementById('fitBtn').addEventListener('click', () => network?.fit({animation: true}));
  979 + document.getElementById('fullscreenBtn').addEventListener('click', toggleFullScreen);
  980 + }
918 981
919 - if (!term) {  
920 - resetGraphSearchState();  
921 - network.selectNodes([]); 982 + // 搜索功能
  983 + function runSearch(keyword) {
  984 + keyword = keyword.trim().toLowerCase();
  985 + searchState.keyword = keyword;
  986 +
  987 + if (!keyword) {
  988 + searchState.results = [];
  989 + searchState.currentIndex = -1;
  990 + updateSearchUI();
  991 + if(network) network.unselectAll();
922 return; 992 return;
923 } 993 }
924 994
925 - const lower = term.toLowerCase();  
926 - const nodesDataset = network.body && network.body.data && network.body.data.nodes ? network.body.data.nodes.get() : [];  
927 - graphSearchResults = nodesDataset.filter(n => (n.label || '').toLowerCase().includes(lower));  
928 - graphSearchResults.sort((a, b) => {  
929 - const aLabel = (a.label || '').toLowerCase();  
930 - const bLabel = (b.label || '').toLowerCase();  
931 - if (aLabel === bLabel) {  
932 - return String(a.id).localeCompare(String(b.id), 'zh');  
933 - }  
934 - return aLabel.localeCompare(bLabel, 'zh');  
935 - });  
936 - graphSearchIndex = graphSearchResults.length ? 0 : -1;  
937 -  
938 - if (!graphSearchResults.length) {  
939 - network.selectNodes([]);  
940 - hideNodeDetail();  
941 - updateGraphSearchStatus();  
942 - return; 995 + // 在当前显示的数据集中搜索
  996 + const visibleTypes = getVisibleTypes();
  997 + searchState.results = graphData.nodes
  998 + .filter(n => visibleTypes.includes(n.group) && n.label.toLowerCase().includes(keyword))
  999 + .map(n => n.id);
  1000 +
  1001 + searchState.currentIndex = searchState.results.length ? 0 : -1;
  1002 + updateSearchUI();
  1003 +
  1004 + if (searchState.results.length > 0) {
  1005 + focusNode(searchState.results[0]);
943 } 1006 }
944 -  
945 - focusGraphSearchIndex(graphSearchIndex);  
946 } 1007 }
947 1008
948 - function focusGraphSearchIndex(index) {  
949 - if (!network || !graphSearchResults.length) return;  
950 - const total = graphSearchResults.length;  
951 - graphSearchIndex = ((index % total) + total) % total;  
952 - const target = graphSearchResults[graphSearchIndex];  
953 - network.selectNodes([target.id]);  
954 - network.focus(target.id, { animation: true, scale: 1.4 });  
955 - showNodeDetail(target._data || target);  
956 - updateGraphSearchStatus(); 1009 + function navSearch(direction) {
  1010 + if (!searchState.results.length) return;
  1011 + const len = searchState.results.length;
  1012 + searchState.currentIndex = (searchState.currentIndex + direction + len) % len;
  1013 + focusNode(searchState.results[searchState.currentIndex]);
  1014 + updateSearchUI();
957 } 1015 }
958 1016
959 - function stepGraphSearch(delta) {  
960 - if (!graphSearchResults.length) return;  
961 - focusGraphSearchIndex(graphSearchIndex + delta); 1017 + function focusNode(nodeId) {
  1018 + if (!network) return;
  1019 + network.selectNodes([nodeId]);
  1020 + network.focus(nodeId, {
  1021 + scale: 1.2,
  1022 + animation: true
  1023 + });
  1024 + const node = graphData.nodes.find(n => n.id === nodeId);
  1025 + if (node) showNodeDetails(node);
962 } 1026 }
963 1027
964 - // 更新统计  
965 - function updateStats(stats) {  
966 - document.getElementById('nodeCount').textContent = stats.total_nodes || 0;  
967 - document.getElementById('edgeCount').textContent = stats.total_edges || 0; 1028 + function updateSearchUI() {
  1029 + const { results, currentIndex } = searchState;
  1030 + const hasResults = results.length > 0;
968 1031
969 - // 更新各类型计数  
970 - document.getElementById('count-topic').textContent = stats.topic || 0;  
971 - document.getElementById('count-engine').textContent = stats.engine || 0;  
972 - document.getElementById('count-section').textContent = stats.section || 0;  
973 - document.getElementById('count-search_query').textContent = stats.search_query || 0;  
974 - document.getElementById('count-source').textContent = stats.source || 0;  
975 - }  
976 -  
977 - // 获取可见类型  
978 - function getVisibleTypes() {  
979 - const types = [];  
980 - document.querySelectorAll('.filter-item input[type="checkbox"]').forEach(cb => {  
981 - if (cb.checked) {  
982 - types.push(cb.dataset.type); 1032 + els.search.prev.disabled = !hasResults;
  1033 + els.search.next.disabled = !hasResults;
  1034 + els.search.status.textContent = hasResults
  1035 + ? `${currentIndex + 1} / ${results.length}`
  1036 + : (searchState.keyword ? '无结果' : '');
  1037 + }
  1038 +
  1039 + // 详情面板
  1040 + function showNodeDetails(node) {
  1041 + if (!node) return;
  1042 + els.detail.panel.style.display = 'block';
  1043 + els.detail.title.textContent = node.label;
  1044 + els.detail.type.textContent = (NODE_CONFIG[node.group] || {}).label || node.group;
  1045 +
  1046 + let html = '';
  1047 + const props = node.properties || {};
  1048 + if (Object.keys(props).length === 0) {
  1049 + html = '<div class="prop-label" style="text-align:center; padding:10px;">暂无额外属性</div>';
  1050 + } else {
  1051 + for (const [k, v] of Object.entries(props)) {
  1052 + html += `
  1053 + <div class="prop-row">
  1054 + <div class="prop-label">${k}</div>
  1055 + <div class="prop-value">${v}</div>
  1056 + </div>
  1057 + `;
983 } 1058 }
984 - });  
985 - return types; 1059 + }
  1060 + els.detail.props.innerHTML = html;
986 } 1061 }
987 1062
988 - // 设置事件监听  
989 - function setupEventListeners() {  
990 - // 侧边栏切换  
991 - document.getElementById('toggleSidebar').addEventListener('click', () => {  
992 - const sidebar = document.getElementById('sidebar');  
993 - const container = document.getElementById('graphContainer');  
994 - const legend = document.getElementById('legend');  
995 -  
996 - sidebar.classList.toggle('collapsed');  
997 - container.classList.toggle('fullwidth');  
998 - legend.classList.toggle('fullwidth');  
999 - });  
1000 -  
1001 - // 适应视图  
1002 - document.getElementById('fitBtn').addEventListener('click', () => {  
1003 - if (network) network.fit({ animation: true });  
1004 - });  
1005 -  
1006 - // 放大  
1007 - document.getElementById('zoomInBtn').addEventListener('click', () => {  
1008 - if (network) {  
1009 - const scale = network.getScale() * 1.2;  
1010 - network.moveTo({ scale, animation: true });  
1011 - }  
1012 - }); 1063 + function hideNodeDetail() {
  1064 + els.detail.panel.style.display = 'none';
  1065 + }
1013 1066
1014 - // 缩小  
1015 - document.getElementById('zoomOutBtn').addEventListener('click', () => {  
1016 - if (network) {  
1017 - const scale = network.getScale() / 1.2;  
1018 - network.moveTo({ scale, animation: true });  
1019 - } 1067 + // 辅助功能
  1068 + function updateStats(stats = {}) {
  1069 + els.stats.nodes.textContent = stats.total_nodes || 0;
  1070 + els.stats.edges.textContent = stats.total_edges || 0;
  1071 +
  1072 + ['topic', 'engine', 'section', 'search_query', 'source'].forEach(type => {
  1073 + const el = document.getElementById(`count-${type}`);
  1074 + if (el) el.textContent = stats[type] || 0;
1020 }); 1075 });
  1076 + }
1021 1077
1022 - // 手动刷新  
1023 - const manualRefreshBtn = document.getElementById('manualRefreshBtn');  
1024 - if (manualRefreshBtn) {  
1025 - manualRefreshBtn.addEventListener('click', () => {  
1026 - loadGraphData({ fromManual: true });  
1027 - });  
1028 - } 1078 + function getVisibleTypes() {
  1079 + return Array.from(document.querySelectorAll('.filter-item input:checked'))
  1080 + .map(cb => cb.dataset.type);
  1081 + }
1029 1082
1030 - // 全屏  
1031 - document.getElementById('fullscreenBtn').addEventListener('click', () => {  
1032 - if (!document.fullscreenElement) {  
1033 - document.documentElement.requestFullscreen();  
1034 - } else {  
1035 - document.exitFullscreen();  
1036 - }  
1037 - }); 1083 + function startPolling() {
  1084 + if (pollTimer) return;
  1085 + pollTimer = setInterval(() => loadGraphData({fromPoll: true}), 5000);
  1086 + }
1038 1087
1039 - // 搜索  
1040 - const searchInput = document.getElementById('searchInput');  
1041 - const searchBtn = document.getElementById('searchBtn');  
1042 - const searchPrevBtn = document.getElementById('searchPrevBtn');  
1043 - const searchNextBtn = document.getElementById('searchNextBtn');  
1044 - if (searchInput) {  
1045 - searchInput.addEventListener('keydown', (e) => {  
1046 - if (e.key === 'Enter') {  
1047 - runGraphSearch(searchInput.value);  
1048 - }  
1049 - });  
1050 - searchInput.addEventListener('input', () => {  
1051 - if (!searchInput.value) {  
1052 - resetGraphSearchState();  
1053 - if (network) network.selectNodes([]);  
1054 - }  
1055 - });  
1056 - }  
1057 - if (searchBtn) {  
1058 - searchBtn.addEventListener('click', () => runGraphSearch(searchInput ? searchInput.value : ''));  
1059 - }  
1060 - if (searchPrevBtn) {  
1061 - searchPrevBtn.addEventListener('click', () => stepGraphSearch(-1)); 1088 + function stopPolling() {
  1089 + if (pollTimer) {
  1090 + clearInterval(pollTimer);
  1091 + pollTimer = null;
1062 } 1092 }
1063 - if (searchNextBtn) {  
1064 - searchNextBtn.addEventListener('click', () => stepGraphSearch(1));  
1065 - }  
1066 -  
1067 - // 筛选  
1068 - document.querySelectorAll('.filter-item input[type="checkbox"]').forEach(cb => {  
1069 - cb.addEventListener('change', () => {  
1070 - renderGraph();  
1071 - });  
1072 - });  
1073 } 1093 }
1074 1094
1075 - // 辅助函数  
1076 function showLoading(show) { 1095 function showLoading(show) {
1077 - document.getElementById('loadingOverlay').style.display = show ? 'flex' : 'none'; 1096 + els.loading.style.display = show ? 'flex' : 'none';
1078 } 1097 }
1079 1098
1080 function showEmpty(show) { 1099 function showEmpty(show) {
1081 - document.getElementById('emptyState').style.display = show ? 'block' : 'none'; 1100 + els.empty.style.display = show ? 'flex' : 'none';
  1101 + if (show) els.loading.style.display = 'none';
1082 } 1102 }
1083 1103
1084 - function showToast(message) { 1104 + function showToast(msg, type = 'info') {
1085 const toast = document.getElementById('toast'); 1105 const toast = document.getElementById('toast');
1086 - toast.textContent = message;  
1087 - toast.style.display = 'block';  
1088 - setTimeout(() => {  
1089 - toast.style.display = 'none';  
1090 - }, 3000); 1106 + const iconContainer = document.getElementById('toastIcon');
  1107 +
  1108 + document.getElementById('toastMsg').textContent = msg;
  1109 + toast.style.borderColor = type === 'error' ? '#EF4444' : '#6366f1';
  1110 +
  1111 + // 图标定义
  1112 + const icons = {
  1113 + success: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>',
  1114 + error: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>'
  1115 + };
  1116 +
  1117 + // 如果是 error 则显示错号,否则显示对号
  1118 + const isError = type === 'error';
  1119 + iconContainer.innerHTML = isError ? icons.error : icons.success;
  1120 + iconContainer.style.color = isError ? '#EF4444' : '#10B981';
  1121 +
  1122 + toast.classList.add('show');
  1123 + setTimeout(() => toast.classList.remove('show'), 3000);
1091 } 1124 }
1092 1125
1093 - function truncateLabel(text, maxLen) {  
1094 - if (!text) return '';  
1095 - return text.length > maxLen ? text.slice(0, maxLen) + '...' : text; 1126 + function toggleFullScreen() {
  1127 + if (!document.fullscreenElement) {
  1128 + document.documentElement.requestFullscreen();
  1129 + } else {
  1130 + if (document.exitFullscreen) document.exitFullscreen();
  1131 + }
1096 } 1132 }
1097 1133
1098 - function truncateText(text, maxLen) {  
1099 - if (!text) return '';  
1100 - return text.length > maxLen ? text.slice(0, maxLen) + '...' : text; 1134 + // Utils
  1135 + function truncate(str, n) {
  1136 + return (str && str.length > n) ? str.substr(0, n - 1) + '...' : str;
1101 } 1137 }
1102 1138
1103 - function lightenColor(color) {  
1104 - // 简单的颜色变亮  
1105 - const hex = color.replace('#', '');  
1106 - const r = Math.min(255, parseInt(hex.slice(0, 2), 16) + 40);  
1107 - const g = Math.min(255, parseInt(hex.slice(2, 4), 16) + 40);  
1108 - const b = Math.min(255, parseInt(hex.slice(4, 6), 16) + 40);  
1109 - return `rgb(${r}, ${g}, ${b})`; 1139 + function lighten(col, amt) {
  1140 + var usePound = false;
  1141 + if (col[0] == "#") {
  1142 + col = col.slice(1);
  1143 + usePound = true;
  1144 + }
  1145 + var num = parseInt(col, 16);
  1146 + var r = (num >> 16) + amt;
  1147 + if (r > 255) r = 255;
  1148 + else if (r < 0) r = 0;
  1149 + var b = ((num >> 8) & 0x00FF) + amt;
  1150 + if (b > 255) b = 255;
  1151 + else if (b < 0) b = 0;
  1152 + var g = (num & 0x0000FF) + amt;
  1153 + if (g > 255) g = 255;
  1154 + else if (g < 0) g = 0;
  1155 + return (usePound ? "#" : "") + (g | (b << 8) | (r << 16)).toString(16);
1110 } 1156 }
1111 </script> 1157 </script>
1112 </body> 1158 </body>