opencroc 1.8.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -124,6 +124,39 @@ body {
124
124
  .risk-medium { background: var(--warning); color: #000; }
125
125
  .risk-low { background: var(--info); color: #fff; }
126
126
 
127
+ .snapshot-list { list-style: none; }
128
+ .snapshot-search { width: 100%; margin-bottom: 8px; }
129
+ .snapshot-tag-filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
130
+ .snapshot-tag-chip {
131
+ border: 1px solid var(--border); background: var(--bg-card); color: var(--text-secondary);
132
+ border-radius: 999px; padding: 2px 8px; font-size: 10px; cursor: pointer;
133
+ }
134
+ .snapshot-tag-chip.active { border-color: var(--accent); color: var(--accent); }
135
+ .snapshot-item {
136
+ padding: 8px;
137
+ background: var(--bg-card);
138
+ border-radius: var(--radius);
139
+ margin-bottom: 6px;
140
+ border: 1px solid transparent;
141
+ }
142
+ .snapshot-item.current {
143
+ border-color: var(--accent);
144
+ }
145
+ .snapshot-item.pinned {
146
+ box-shadow: inset 0 0 0 1px rgba(78, 204, 163, 0.35);
147
+ }
148
+ .snapshot-name { font-size: 12px; font-weight: bold; }
149
+ .snapshot-meta { font-size: 10px; color: var(--text-secondary); margin-top: 4px; }
150
+ .snapshot-actions { margin-top: 6px; display: flex; justify-content: flex-end; }
151
+ .snapshot-actions .btn { margin-left: 6px; }
152
+ .snapshot-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
153
+ .snapshot-tag {
154
+ display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 999px;
155
+ background: rgba(78, 204, 163, 0.12); color: var(--accent); font-size: 10px;
156
+ border: 1px solid transparent; cursor: pointer;
157
+ }
158
+ .snapshot-tag:hover { border-color: rgba(78, 204, 163, 0.45); }
159
+
127
160
  /* ===== Graph Canvas ===== */
128
161
  #graph-canvas { width: 100%; height: 100%; background: var(--bg-primary); }
129
162
 
@@ -194,6 +227,95 @@ body {
194
227
  display: none;
195
228
  }
196
229
  .tooltip.visible { display: block; }
230
+
231
+ .graph-empty {
232
+ position: absolute;
233
+ left: 50%;
234
+ top: 50%;
235
+ transform: translate(-50%, -50%);
236
+ color: var(--text-secondary);
237
+ font-size: 13px;
238
+ text-align: center;
239
+ border: 1px dashed var(--border);
240
+ padding: 14px 18px;
241
+ border-radius: var(--radius);
242
+ background: color-mix(in srgb, var(--bg-card) 80%, transparent);
243
+ display: none;
244
+ }
245
+
246
+ .graph-empty.visible { display: block; }
247
+
248
+ .node-type-item.active {
249
+ background: var(--bg-card);
250
+ border: 1px solid var(--accent);
251
+ }
252
+
253
+ .report-mermaid,
254
+ .report-viz {
255
+ background: var(--bg-input);
256
+ border: 1px solid var(--border);
257
+ border-radius: var(--radius);
258
+ padding: 12px;
259
+ margin: 10px 0;
260
+ overflow: auto;
261
+ }
262
+
263
+ .event-log {
264
+ font-size: 11px;
265
+ line-height: 1.8;
266
+ }
267
+
268
+ .event-log-row {
269
+ border-bottom: 1px solid var(--border);
270
+ padding: 6px 0;
271
+ }
272
+
273
+ .event-log-toolbar {
274
+ display: flex;
275
+ gap: 6px;
276
+ margin-bottom: 8px;
277
+ }
278
+
279
+ .event-log-filter.active {
280
+ border-color: var(--accent);
281
+ color: var(--accent);
282
+ }
283
+
284
+ .event-log-level-info { color: var(--info); }
285
+ .event-log-level-warn { color: var(--warning); }
286
+ .event-log-level-error { color: var(--danger); }
287
+
288
+ .relation-summary {
289
+ display: grid;
290
+ grid-template-columns: repeat(4, minmax(0, 1fr));
291
+ gap: 8px;
292
+ margin: 10px 0 14px;
293
+ }
294
+
295
+ .relation-summary .stat-item {
296
+ border: 1px solid var(--border);
297
+ }
298
+
299
+ .report-toolbar {
300
+ display: flex;
301
+ align-items: center;
302
+ justify-content: space-between;
303
+ gap: 8px;
304
+ margin: 0 auto 12px;
305
+ max-width: 800px;
306
+ }
307
+
308
+ .report-toolbar-left {
309
+ display: flex;
310
+ gap: 6px;
311
+ }
312
+
313
+ .report-mode.active {
314
+ background: var(--accent);
315
+ color: #000;
316
+ border-color: var(--accent);
317
+ font-weight: bold;
318
+ }
197
319
  </style>
198
320
  </head>
199
321
  <body>
@@ -236,6 +358,13 @@ body {
236
358
  <ul class="node-type-list" id="node-type-list"></ul>
237
359
  </div>
238
360
 
361
+ <div class="sidebar-section" id="snapshot-section" style="display:none">
362
+ <h3>快照</h3>
363
+ <input id="snapshot-search" class="scan-input snapshot-search" placeholder="搜索快照名称或来源" />
364
+ <div class="snapshot-tag-filters" id="snapshot-tag-filters"></div>
365
+ <ul class="snapshot-list" id="snapshot-list"></ul>
366
+ </div>
367
+
239
368
  <!-- Risks -->
240
369
  <div class="sidebar-section" id="risk-section" style="display:none;flex:1;overflow-y:auto">
241
370
  <h3>风险点 <span id="risk-count" style="color:var(--danger)"></span></h3>
@@ -248,6 +377,7 @@ body {
248
377
  <!-- Header -->
249
378
  <div class="header">
250
379
  <div class="perspective-tabs" id="perspective-tabs">
380
+ <div class="perspective-tab" data-view="office" onclick="window.location.href='/index.html'">🏢 3D Office</div>
251
381
  <div class="perspective-tab active" data-view="graph">📊 知识图谱</div>
252
382
  <div class="perspective-tab" data-perspective="developer">👨‍💻 开发者</div>
253
383
  <div class="perspective-tab" data-perspective="architect">🏗️ 架构师</div>
@@ -257,6 +387,7 @@ body {
257
387
  <div class="perspective-tab" data-perspective="executive">📈 管理层</div>
258
388
  </div>
259
389
  <div class="header-actions">
390
+ <button class="btn btn-icon" onclick="focusOnSelectedNode()" title="聚焦选中节点">🎯</button>
260
391
  <button class="btn btn-icon" onclick="toggleTheme()" title="切换主题">🎨</button>
261
392
  <button class="btn btn-icon" onclick="togglePanel()" title="详情面板">📋</button>
262
393
  </div>
@@ -287,9 +418,18 @@ body {
287
418
 
288
419
  <!-- SVG Graph Canvas -->
289
420
  <svg id="graph-canvas"></svg>
421
+ <div class="graph-empty visible" id="graph-empty">暂无图谱数据,先在左侧输入路径并扫描</div>
290
422
 
291
423
  <!-- Report View (hidden by default) -->
292
424
  <div id="report-view" style="display:none;padding:24px;overflow-y:auto;height:100%;background:var(--bg-primary)">
425
+ <div class="report-toolbar" id="report-toolbar" style="display:none">
426
+ <div class="report-toolbar-left">
427
+ <button class="btn btn-sm report-mode active" data-mode="markdown">Markdown</button>
428
+ <button class="btn btn-sm report-mode" data-mode="mermaid">Mermaid</button>
429
+ <button class="btn btn-sm report-mode" data-mode="raw">Raw</button>
430
+ </div>
431
+ <button class="btn btn-sm" id="copy-report-btn">复制当前内容</button>
432
+ </div>
293
433
  <div id="report-content" style="max-width:800px;margin:0 auto"></div>
294
434
  </div>
295
435
  </div>
@@ -324,8 +464,24 @@ let graphData = { nodes: [], edges: [] };
324
464
  let riskData = [];
325
465
  let currentTheme = 'pixel';
326
466
  let selectedNode = null;
327
- let simulation = null; // Force simulation
328
467
  let transform = { x: 0, y: 0, scale: 1 };
468
+ let activeTypeFilter = null;
469
+ let wsClient = null;
470
+ let reconnectTimer = null;
471
+ let reportCache = new Map();
472
+ let eventLog = [];
473
+ let currentReport = null;
474
+ let currentReportMode = 'markdown';
475
+ let currentReportPerspective = null;
476
+ let currentLogFilter = 'all';
477
+ let eventLogRenderScheduled = false;
478
+ let graphRenderScheduled = false;
479
+ let latestAgentPayload = null;
480
+ let agentUpdateScheduled = false;
481
+ let snapshotList = [];
482
+ let snapshotQuery = '';
483
+ let activeSnapshotTags = [];
484
+ const MAX_EVENT_LOG_ROWS = 180;
329
485
 
330
486
  // Node type colors
331
487
  const TYPE_COLORS = {
@@ -361,9 +517,14 @@ function startScanFromWelcome() {
361
517
  }
362
518
 
363
519
  async function doScan(target) {
520
+ reportCache.clear();
521
+ currentReport = null;
522
+ currentReportPerspective = null;
364
523
  document.getElementById('welcome').classList.add('hidden');
365
524
  document.getElementById('loading').classList.remove('hidden');
366
525
  document.getElementById('loading-text').textContent = '正在扫描 ' + target + '...';
526
+ document.getElementById('loading-detail').textContent = '初始化扫描任务...';
527
+ appendOperationLog('开始扫描: ' + target, 'info');
367
528
 
368
529
  try {
369
530
  const res = await fetch('/api/studio/scan', {
@@ -375,6 +536,7 @@ async function doScan(target) {
375
536
 
376
537
  if (!res.ok) {
377
538
  document.getElementById('loading-text').textContent = '❌ ' + (data.error || '扫描失败');
539
+ appendOperationLog('扫描失败: ' + (data.error || 'unknown error'), 'error');
378
540
  setTimeout(() => document.getElementById('loading').classList.add('hidden'), 3000);
379
541
  return;
380
542
  }
@@ -383,19 +545,24 @@ async function doScan(target) {
383
545
  await loadGraph();
384
546
  await loadRisks();
385
547
  await loadSummary();
548
+ await loadSnapshots();
386
549
 
387
550
  document.getElementById('loading').classList.add('hidden');
388
551
  document.getElementById('stats-section').style.display = '';
389
552
  document.getElementById('filter-section').style.display = '';
553
+ document.getElementById('snapshot-section').style.display = '';
390
554
  document.getElementById('risk-section').style.display = '';
555
+ appendOperationLog('扫描完成,图谱已更新', 'info');
391
556
  } catch (err) {
392
557
  document.getElementById('loading-text').textContent = '❌ ' + err.message;
558
+ appendOperationLog('扫描异常: ' + err.message, 'error');
393
559
  setTimeout(() => document.getElementById('loading').classList.add('hidden'), 3000);
394
560
  }
395
561
  }
396
562
 
397
563
  async function loadGraph() {
398
564
  const res = await fetch('/api/studio/graph');
565
+ if (!res.ok) return;
399
566
  const data = await res.json();
400
567
  graphData = data;
401
568
  renderGraph();
@@ -404,6 +571,7 @@ async function loadGraph() {
404
571
 
405
572
  async function loadRisks() {
406
573
  const res = await fetch('/api/studio/risks');
574
+ if (!res.ok) return;
407
575
  const data = await res.json();
408
576
  riskData = data.risks || [];
409
577
  renderRiskList();
@@ -413,6 +581,7 @@ async function loadRisks() {
413
581
 
414
582
  async function loadSummary() {
415
583
  const res = await fetch('/api/studio/summary');
584
+ if (!res.ok) return;
416
585
  const data = await res.json();
417
586
  document.getElementById('project-name').textContent = data.name || '—';
418
587
  document.getElementById('project-type').textContent = data.oneLiner || '';
@@ -425,43 +594,79 @@ async function loadSummary() {
425
594
  fill.style.background = data.healthScore >= 80 ? '#4ecca3' : data.healthScore >= 60 ? '#f39c12' : '#e94560';
426
595
  }
427
596
 
597
+ async function loadSnapshots() {
598
+ const res = await fetch('/api/studio/snapshots');
599
+ if (!res.ok) return;
600
+ const data = await res.json();
601
+ snapshotList = data.snapshots || [];
602
+ if (activeSnapshotTags.length) {
603
+ const allTags = new Set(snapshotList.flatMap((snapshot) => Array.isArray(snapshot.tags) ? snapshot.tags : []));
604
+ activeSnapshotTags = activeSnapshotTags.filter((tag) => allTags.has(tag));
605
+ }
606
+ renderSnapshots();
607
+ }
608
+
428
609
  // ===== Graph Rendering (Force-Directed) =====
429
610
  function renderGraph() {
430
611
  const svg = document.getElementById('graph-canvas');
612
+ const empty = document.getElementById('graph-empty');
431
613
  const { width, height } = svg.getBoundingClientRect();
432
614
  svg.innerHTML = '';
433
615
 
434
616
  const nodes = (graphData.nodes || []).filter(n => n.type !== 'file' && n.type !== 'dependency');
617
+ const visibleNodes = activeTypeFilter ? nodes.filter(n => n.type === activeTypeFilter) : nodes;
618
+ const visibleIds = new Set(visibleNodes.map(n => n.id));
435
619
  const nodeMap = new Map(nodes.map(n => [n.id, n]));
436
- const edges = (graphData.edges || []).filter(e => nodeMap.has(e.source) && nodeMap.has(e.target));
620
+ const edges = (graphData.edges || []).filter(e => {
621
+ return nodeMap.has(e.source) && nodeMap.has(e.target) && visibleIds.has(e.source) && visibleIds.has(e.target);
622
+ });
623
+ const selectedAdjacent = new Set();
624
+ if (selectedNode) {
625
+ selectedAdjacent.add(selectedNode);
626
+ for (const e of edges) {
627
+ if (e.source === selectedNode) selectedAdjacent.add(e.target);
628
+ if (e.target === selectedNode) selectedAdjacent.add(e.source);
629
+ }
630
+ }
437
631
 
438
- if (nodes.length === 0) return;
632
+ if (visibleNodes.length === 0) {
633
+ if (empty) {
634
+ empty.classList.add('visible');
635
+ empty.textContent = activeTypeFilter
636
+ ? '当前类型筛选下无可展示节点'
637
+ : '暂无图谱数据,先在左侧输入路径并扫描';
638
+ }
639
+ return;
640
+ }
641
+ if (empty) empty.classList.remove('visible');
439
642
 
440
643
  // Initialize positions (circular layout)
441
- nodes.forEach((n, i) => {
442
- const angle = (i / nodes.length) * 2 * Math.PI;
644
+ visibleNodes.forEach((n, i) => {
645
+ const angle = (i / visibleNodes.length) * 2 * Math.PI;
443
646
  const r = Math.min(width, height) * 0.35;
444
- n._x = width / 2 + Math.cos(angle) * r;
445
- n._y = height / 2 + Math.sin(angle) * r;
446
- n._vx = 0;
447
- n._vy = 0;
647
+ if (typeof n._x !== 'number' || typeof n._y !== 'number') {
648
+ n._x = width / 2 + Math.cos(angle) * r;
649
+ n._y = height / 2 + Math.sin(angle) * r;
650
+ n._vx = 0;
651
+ n._vy = 0;
652
+ }
448
653
  });
449
654
 
450
655
  // Simple force simulation (run synchronously for speed)
451
656
  for (let iter = 0; iter < 80; iter++) {
452
657
  // Repulsion between all pairs
453
- for (let i = 0; i < nodes.length; i++) {
454
- for (let j = i + 1; j < nodes.length; j++) {
455
- let dx = nodes[i]._x - nodes[j]._x;
456
- let dy = nodes[i]._y - nodes[j]._y;
658
+ for (let i = 0; i < visibleNodes.length; i++) {
659
+ for (let j = i + 1; j < visibleNodes.length; j++) {
660
+ let dx = visibleNodes[i]._x - visibleNodes[j]._x;
661
+ let dy = visibleNodes[i]._y - visibleNodes[j]._y;
457
662
  let dist = Math.sqrt(dx * dx + dy * dy) || 1;
458
663
  let force = 800 / (dist * dist);
459
664
  let fx = (dx / dist) * force;
460
665
  let fy = (dy / dist) * force;
461
- nodes[i]._vx += fx;
462
- nodes[i]._vy += fy;
463
- nodes[j]._vx -= fx;
464
- nodes[j]._vy -= fy;
666
+ visibleNodes[i]._vx += fx;
667
+ visibleNodes[i]._vy += fy;
668
+ visibleNodes[j]._vx -= fx;
669
+ visibleNodes[j]._vy -= fy;
465
670
  }
466
671
  }
467
672
 
@@ -483,7 +688,7 @@ function renderGraph() {
483
688
  }
484
689
 
485
690
  // Center gravity
486
- for (const n of nodes) {
691
+ for (const n of visibleNodes) {
487
692
  n._vx += (width / 2 - n._x) * 0.001;
488
693
  n._vy += (height / 2 - n._y) * 0.001;
489
694
  n._x += n._vx * 0.4;
@@ -499,25 +704,29 @@ function renderGraph() {
499
704
  // Render SVG
500
705
  const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
501
706
  g.setAttribute('id', 'graph-group');
707
+ const edgeDom = [];
708
+ const nodeDom = new Map();
502
709
 
503
710
  // Edges
504
711
  for (const e of edges) {
505
712
  const s = nodeMap.get(e.source);
506
713
  const t = nodeMap.get(e.target);
507
714
  if (!s || !t) continue;
715
+ const active = !selectedNode || e.source === selectedNode || e.target === selectedNode;
508
716
  const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
509
717
  line.setAttribute('x1', s._x);
510
718
  line.setAttribute('y1', s._y);
511
719
  line.setAttribute('x2', t._x);
512
720
  line.setAttribute('y2', t._y);
513
- line.setAttribute('stroke', '#333');
514
- line.setAttribute('stroke-width', '1');
515
- line.setAttribute('opacity', '0.4');
721
+ line.setAttribute('stroke', active ? '#4ecca3' : '#333');
722
+ line.setAttribute('stroke-width', active ? '1.8' : '1');
723
+ line.setAttribute('opacity', active ? '0.75' : '0.15');
516
724
  g.appendChild(line);
725
+ edgeDom.push({ line, edge: e });
517
726
  }
518
727
 
519
728
  // Nodes
520
- for (const n of nodes) {
729
+ for (const n of visibleNodes) {
521
730
  const color = TYPE_COLORS[n.type] || TYPE_COLORS.unknown;
522
731
  const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
523
732
  group.setAttribute('transform', `translate(${n._x}, ${n._y})`);
@@ -530,7 +739,8 @@ function renderGraph() {
530
739
  const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
531
740
  circle.setAttribute('r', n.type === 'module' ? 14 : n.type === 'api' || n.type === 'model' ? 10 : 7);
532
741
  circle.setAttribute('fill', color);
533
- circle.setAttribute('opacity', '0.85');
742
+ const isDimmed = selectedNode && !selectedAdjacent.has(n.id);
743
+ circle.setAttribute('opacity', isDimmed ? '0.25' : '0.9');
534
744
  circle.setAttribute('stroke', selectedNode === n.id ? '#fff' : 'none');
535
745
  circle.setAttribute('stroke-width', '2');
536
746
  group.appendChild(circle);
@@ -542,15 +752,46 @@ function renderGraph() {
542
752
  text.setAttribute('fill', '#ccc');
543
753
  text.setAttribute('font-size', n.type === 'module' ? '11' : '9');
544
754
  text.setAttribute('font-family', 'Courier New, monospace');
755
+ text.setAttribute('opacity', isDimmed ? '0.3' : '1');
545
756
  const label = n.label.length > 20 ? n.label.slice(0, 18) + '..' : n.label;
546
757
  text.textContent = label;
547
758
  group.appendChild(text);
548
759
 
760
+ nodeDom.set(n.id, { group, circle, node: n });
549
761
  g.appendChild(group);
550
762
  }
551
763
 
552
764
  svg.appendChild(g);
553
765
 
766
+ const updateEdgeAndNodePositions = () => {
767
+ for (const e of edges) {
768
+ const line = edgeDom.find(item => item.edge === e)?.line;
769
+ if (!line) continue;
770
+ const s = nodeMap.get(e.source);
771
+ const t = nodeMap.get(e.target);
772
+ if (!s || !t) continue;
773
+ line.setAttribute('x1', String(s._x));
774
+ line.setAttribute('y1', String(s._y));
775
+ line.setAttribute('x2', String(t._x));
776
+ line.setAttribute('y2', String(t._y));
777
+ }
778
+
779
+ for (const item of nodeDom.values()) {
780
+ item.group.setAttribute('transform', `translate(${item.node._x}, ${item.node._y})`);
781
+ item.circle.setAttribute('stroke', selectedNode === item.node.id ? '#fff' : 'none');
782
+ }
783
+ };
784
+
785
+ // Drag node in current view
786
+ let draggingNode = null;
787
+ for (const item of nodeDom.values()) {
788
+ item.group.addEventListener('mousedown', (evt) => {
789
+ evt.stopPropagation();
790
+ draggingNode = item;
791
+ item.circle.setAttribute('stroke', '#fff');
792
+ });
793
+ }
794
+
554
795
  // Pan & Zoom
555
796
  svg.addEventListener('wheel', (e) => {
556
797
  e.preventDefault();
@@ -563,6 +804,19 @@ function renderGraph() {
563
804
  let dragging = false, lastX, lastY;
564
805
  svg.addEventListener('mousedown', (e) => { dragging = true; lastX = e.clientX; lastY = e.clientY; });
565
806
  svg.addEventListener('mousemove', (e) => {
807
+ if (draggingNode) {
808
+ const pt = svg.createSVGPoint();
809
+ pt.x = e.clientX;
810
+ pt.y = e.clientY;
811
+ const matrix = g.getScreenCTM();
812
+ if (!matrix) return;
813
+ const local = pt.matrixTransform(matrix.inverse());
814
+ draggingNode.node._x = local.x;
815
+ draggingNode.node._y = local.y;
816
+ updateEdgeAndNodePositions();
817
+ return;
818
+ }
819
+
566
820
  if (!dragging) return;
567
821
  transform.x += e.clientX - lastX;
568
822
  transform.y += e.clientY - lastY;
@@ -570,8 +824,16 @@ function renderGraph() {
570
824
  lastY = e.clientY;
571
825
  applyTransform();
572
826
  });
573
- svg.addEventListener('mouseup', () => { dragging = false; });
574
- svg.addEventListener('mouseleave', () => { dragging = false; });
827
+ svg.addEventListener('mouseup', () => { dragging = false; draggingNode = null; });
828
+ svg.addEventListener('mouseleave', () => { dragging = false; draggingNode = null; });
829
+ svg.addEventListener('click', (e) => {
830
+ if (e.target === svg || e.target.id === 'graph-group') {
831
+ selectedNode = null;
832
+ scheduleGraphRender();
833
+ }
834
+ });
835
+
836
+ applyTransform();
575
837
  }
576
838
 
577
839
  function applyTransform() {
@@ -579,6 +841,46 @@ function applyTransform() {
579
841
  if (g) g.setAttribute('transform', `translate(${transform.x},${transform.y}) scale(${transform.scale})`);
580
842
  }
581
843
 
844
+ function focusOnSelectedNode() {
845
+ if (!selectedNode) {
846
+ appendOperationLog('当前没有选中节点,无法聚焦', 'warn');
847
+ return;
848
+ }
849
+
850
+ const node = (graphData.nodes || []).find((item) => item.id === selectedNode);
851
+ const svg = document.getElementById('graph-canvas');
852
+ if (!node || !svg || typeof node._x !== 'number' || typeof node._y !== 'number') {
853
+ appendOperationLog('选中节点尚未渲染,无法聚焦', 'warn');
854
+ return;
855
+ }
856
+
857
+ const { width, height } = svg.getBoundingClientRect();
858
+ transform.scale = Math.max(1.25, transform.scale);
859
+ transform.x = width / 2 - node._x * transform.scale;
860
+ transform.y = height / 2 - node._y * transform.scale;
861
+ applyTransform();
862
+ appendOperationLog(`已聚焦节点: ${node.label || node.id}`, 'info');
863
+ }
864
+
865
+ function scheduleGraphRender() {
866
+ if (graphRenderScheduled) return;
867
+ graphRenderScheduled = true;
868
+ requestAnimationFrame(() => {
869
+ graphRenderScheduled = false;
870
+ renderGraph();
871
+ });
872
+ }
873
+
874
+ function scheduleAgentRefresh(payload) {
875
+ latestAgentPayload = payload;
876
+ if (agentUpdateScheduled) return;
877
+ agentUpdateScheduled = true;
878
+ requestAnimationFrame(() => {
879
+ agentUpdateScheduled = false;
880
+ updateAgents(latestAgentPayload);
881
+ });
882
+ }
883
+
582
884
  // ===== Sidebar Renderers =====
583
885
  function renderNodeTypeFilter() {
584
886
  const list = document.getElementById('node-type-list');
@@ -591,11 +893,19 @@ function renderNodeTypeFilter() {
591
893
  .sort((a, b) => b[1] - a[1])
592
894
  .map(([type, count]) => `
593
895
  <li class="node-type-item" onclick="filterByType('${type}')">
896
+ <span style="display:none" class="node-type-flag">${activeTypeFilter === type ? 'active' : ''}</span>
594
897
  <div class="node-type-dot" style="background:${TYPE_COLORS[type] || '#555'}"></div>
595
898
  <span>${TYPE_LABELS[type] || type}</span>
596
899
  <span class="node-type-count">${count}</span>
597
900
  </li>
598
901
  `).join('');
902
+
903
+ list.querySelectorAll('.node-type-item').forEach((el) => {
904
+ const flag = el.querySelector('.node-type-flag');
905
+ if (flag && flag.textContent === 'active') {
906
+ el.classList.add('active');
907
+ }
908
+ });
599
909
  }
600
910
 
601
911
  function renderRiskList() {
@@ -608,6 +918,218 @@ function renderRiskList() {
608
918
  `).join('');
609
919
  }
610
920
 
921
+ function renderSnapshots() {
922
+ const list = document.getElementById('snapshot-list');
923
+ const tagFilters = document.getElementById('snapshot-tag-filters');
924
+ if (!list) return;
925
+
926
+ const normalizedQuery = snapshotQuery.trim().toLowerCase();
927
+ const allTags = [...new Set(snapshotList.flatMap((snapshot) => Array.isArray(snapshot.tags) ? snapshot.tags : []))].sort();
928
+ if (tagFilters) {
929
+ const hasTags = allTags.length > 0;
930
+ tagFilters.innerHTML = hasTags
931
+ ? [`<button class="snapshot-tag-chip ${activeSnapshotTags.length ? '' : 'active'}" data-tag="" data-role="snapshot-filter">全部</button>`]
932
+ .concat(allTags.map((tag) => `<button class="snapshot-tag-chip ${activeSnapshotTags.includes(tag) ? 'active' : ''}" data-tag="${escapeHtml(tag)}" data-role="snapshot-filter">#${escapeHtml(tag)}</button>`))
933
+ .join('')
934
+ : '';
935
+ }
936
+
937
+ const visibleSnapshots = snapshotList.filter((snapshot) => {
938
+ const tags = Array.isArray(snapshot.tags) ? snapshot.tags : [];
939
+ const matchesQuery = !normalizedQuery
940
+ || (snapshot.name || '').toLowerCase().includes(normalizedQuery)
941
+ || (snapshot.source || '').toLowerCase().includes(normalizedQuery)
942
+ || tags.some((tag) => tag.toLowerCase().includes(normalizedQuery));
943
+ const matchesTag = !activeSnapshotTags.length || activeSnapshotTags.every((tag) => tags.includes(tag));
944
+ return matchesQuery && matchesTag;
945
+ });
946
+
947
+ if (!visibleSnapshots.length) {
948
+ list.innerHTML = '<li class="snapshot-item"><div class="snapshot-meta">暂无快照</div></li>';
949
+ return;
950
+ }
951
+
952
+ list.innerHTML = visibleSnapshots.slice(0, 8).map((snapshot) => {
953
+ const date = snapshot.scanTime ? new Date(snapshot.scanTime).toLocaleString('zh-CN') : 'unknown';
954
+ return `
955
+ <li class="snapshot-item ${snapshot.current ? 'current' : ''} ${snapshot.pinned ? 'pinned' : ''}">
956
+ <div class="snapshot-name">${escapeHtml(snapshot.name || 'unknown')}</div>
957
+ <div class="snapshot-meta">${escapeHtml(snapshot.source || '')}</div>
958
+ <div class="snapshot-meta">${snapshot.pinned ? '📌 ' : ''}${date} · 节点 ${snapshot.nodeCount} · 风险 ${snapshot.riskCount}</div>
959
+ ${(snapshot.tags || []).length ? `<div class="snapshot-tags">${snapshot.tags.map((tag) => `<button class="snapshot-tag" data-role="snapshot-filter" data-tag="${escapeHtml(tag)}">#${escapeHtml(tag)}</button>`).join('')}</div>` : ''}
960
+ <div class="snapshot-actions">
961
+ <button class="btn btn-sm" onclick="togglePinSnapshot('${snapshot.id}', ${snapshot.pinned ? 'false' : 'true'})">${snapshot.pinned ? '取消置顶' : '置顶'}</button>
962
+ <button class="btn btn-sm" onclick="editSnapshotTags('${snapshot.id}')">标签</button>
963
+ <button class="btn btn-sm" onclick="renameSnapshot('${snapshot.id}')">重命名</button>
964
+ <button class="btn btn-sm" onclick="restoreSnapshot('${snapshot.id}')">恢复</button>
965
+ <button class="btn btn-sm" onclick="deleteSnapshot('${snapshot.id}')">删除</button>
966
+ </div>
967
+ </li>
968
+ `;
969
+ }).join('');
970
+ }
971
+
972
+ function setSnapshotTagFilter(tag) {
973
+ const next = (tag || '').trim();
974
+ if (!next) {
975
+ activeSnapshotTags = [];
976
+ renderSnapshots();
977
+ return;
978
+ }
979
+
980
+ if (activeSnapshotTags.includes(next)) {
981
+ activeSnapshotTags = activeSnapshotTags.filter((item) => item !== next);
982
+ } else {
983
+ activeSnapshotTags = [...activeSnapshotTags, next];
984
+ }
985
+ renderSnapshots();
986
+ }
987
+
988
+ async function renameSnapshot(snapshotId) {
989
+ const snapshot = snapshotList.find((item) => item.id === snapshotId);
990
+ if (!snapshot) return;
991
+ const name = window.prompt('输入新的快照名称', snapshot.name || '');
992
+ if (!name || !name.trim() || name.trim() === snapshot.name) return;
993
+
994
+ try {
995
+ const res = await fetch('/api/studio/snapshots/' + encodeURIComponent(snapshotId) + '/rename', {
996
+ method: 'POST',
997
+ headers: { 'Content-Type': 'application/json' },
998
+ body: JSON.stringify({ name: name.trim() }),
999
+ });
1000
+ const data = await res.json();
1001
+ if (!res.ok) {
1002
+ throw new Error(data.error || 'rename failed');
1003
+ }
1004
+
1005
+ snapshotList = data.snapshots || [];
1006
+ renderSnapshots();
1007
+ appendOperationLog(`已重命名快照: ${name.trim()}`, 'info');
1008
+ } catch (err) {
1009
+ appendOperationLog(`重命名快照失败: ${err.message}`, 'error');
1010
+ }
1011
+ }
1012
+
1013
+ async function editSnapshotTags(snapshotId) {
1014
+ const snapshot = snapshotList.find((item) => item.id === snapshotId);
1015
+ if (!snapshot) return;
1016
+
1017
+ const value = window.prompt('输入快照标签,使用英文逗号分隔', (snapshot.tags || []).join(', '));
1018
+ if (value === null) return;
1019
+
1020
+ const tags = value.split(',').map((tag) => tag.trim()).filter(Boolean);
1021
+
1022
+ try {
1023
+ const res = await fetch('/api/studio/snapshots/' + encodeURIComponent(snapshotId) + '/tags', {
1024
+ method: 'POST',
1025
+ headers: { 'Content-Type': 'application/json' },
1026
+ body: JSON.stringify({ tags }),
1027
+ });
1028
+ const data = await res.json();
1029
+ if (!res.ok) {
1030
+ throw new Error(data.error || 'update tags failed');
1031
+ }
1032
+
1033
+ snapshotList = data.snapshots || [];
1034
+ if (activeSnapshotTags.length) {
1035
+ const allTags = new Set(snapshotList.flatMap((item) => Array.isArray(item.tags) ? item.tags : []));
1036
+ activeSnapshotTags = activeSnapshotTags.filter((tag) => allTags.has(tag));
1037
+ }
1038
+ renderSnapshots();
1039
+ appendOperationLog('快照标签已更新', 'info');
1040
+ } catch (err) {
1041
+ appendOperationLog(`更新快照标签失败: ${err.message}`, 'error');
1042
+ }
1043
+ }
1044
+
1045
+ async function togglePinSnapshot(snapshotId, pinned) {
1046
+ try {
1047
+ const res = await fetch('/api/studio/snapshots/' + encodeURIComponent(snapshotId) + '/pin', {
1048
+ method: 'POST',
1049
+ headers: { 'Content-Type': 'application/json' },
1050
+ body: JSON.stringify({ pinned }),
1051
+ });
1052
+ const data = await res.json();
1053
+ if (!res.ok) {
1054
+ throw new Error(data.error || 'pin failed');
1055
+ }
1056
+
1057
+ snapshotList = data.snapshots || [];
1058
+ renderSnapshots();
1059
+ appendOperationLog(pinned ? '快照已置顶' : '快照已取消置顶', 'info');
1060
+ } catch (err) {
1061
+ appendOperationLog(`更新快照置顶失败: ${err.message}`, 'error');
1062
+ }
1063
+ }
1064
+
1065
+ async function restoreSnapshot(snapshotId) {
1066
+ try {
1067
+ const res = await fetch('/api/studio/snapshots/' + encodeURIComponent(snapshotId) + '/load', { method: 'POST' });
1068
+ const data = await res.json();
1069
+ if (!res.ok) {
1070
+ throw new Error(data.error || 'restore failed');
1071
+ }
1072
+
1073
+ selectedNode = null;
1074
+ await Promise.all([loadGraph(), loadRisks(), loadSummary(), loadSnapshots()]);
1075
+ document.getElementById('stats-section').style.display = '';
1076
+ document.getElementById('filter-section').style.display = '';
1077
+ document.getElementById('snapshot-section').style.display = '';
1078
+ document.getElementById('risk-section').style.display = '';
1079
+ appendOperationLog(`已恢复快照: ${data.source || snapshotId}`, 'info');
1080
+ } catch (err) {
1081
+ appendOperationLog(`恢复快照失败: ${err.message}`, 'error');
1082
+ }
1083
+ }
1084
+
1085
+ async function deleteSnapshot(snapshotId) {
1086
+ const snapshot = snapshotList.find((item) => item.id === snapshotId);
1087
+ if (!snapshot) return;
1088
+ const confirmed = window.confirm(`确认删除快照“${snapshot.name || snapshot.id}”?`);
1089
+ if (!confirmed) return;
1090
+
1091
+ try {
1092
+ const res = await fetch('/api/studio/snapshots/' + encodeURIComponent(snapshotId) + '/delete', { method: 'POST' });
1093
+ const data = await res.json();
1094
+ if (!res.ok) {
1095
+ throw new Error(data.error || 'delete failed');
1096
+ }
1097
+
1098
+ snapshotList = data.snapshots || [];
1099
+ renderSnapshots();
1100
+ if (data.hasCurrent) {
1101
+ selectedNode = null;
1102
+ await Promise.all([loadGraph(), loadRisks(), loadSummary(), loadSnapshots()]);
1103
+ } else {
1104
+ clearStudioState();
1105
+ }
1106
+ appendOperationLog(`已删除快照: ${snapshot.name || snapshot.id}`, 'info');
1107
+ } catch (err) {
1108
+ appendOperationLog(`删除快照失败: ${err.message}`, 'error');
1109
+ }
1110
+ }
1111
+
1112
+ function clearStudioState() {
1113
+ graphData = { nodes: [], edges: [] };
1114
+ riskData = [];
1115
+ selectedNode = null;
1116
+ snapshotList = [];
1117
+ activeSnapshotTags = [];
1118
+ document.getElementById('project-name').textContent = '';
1119
+ document.getElementById('project-type').textContent = '';
1120
+ document.getElementById('stat-apis').textContent = '0';
1121
+ document.getElementById('stat-models').textContent = '0';
1122
+ document.getElementById('stat-files').textContent = '0';
1123
+ document.getElementById('stat-risks').textContent = '0';
1124
+ document.getElementById('risk-count').textContent = '(0)';
1125
+ document.getElementById('health-score').textContent = '—';
1126
+ document.getElementById('health-fill').style.width = '0%';
1127
+ renderGraph();
1128
+ renderNodeTypeFilter();
1129
+ renderRiskList();
1130
+ renderSnapshots();
1131
+ }
1132
+
611
1133
  // ===== Node Detail Panel =====
612
1134
  async function showNodeDetail(node) {
613
1135
  selectedNode = node.id;
@@ -619,13 +1141,30 @@ async function showNodeDetail(node) {
619
1141
 
620
1142
  // Fetch node detail
621
1143
  const res = await fetch('/api/studio/node/' + encodeURIComponent(node.id));
1144
+ if (!res.ok) {
1145
+ document.getElementById('panel-body').innerHTML = '<p>节点详情加载失败</p>';
1146
+ return;
1147
+ }
622
1148
  const data = await res.json();
1149
+ const outgoingCount = data.outgoing?.length || 0;
1150
+ const incomingCount = data.incoming?.length || 0;
1151
+ const neighborCount = data.neighbors?.length || 0;
1152
+ const degree = outgoingCount + incomingCount;
623
1153
 
624
1154
  let html = `<p><b>类型:</b> ${TYPE_LABELS[node.type] || node.type}</p>`;
625
1155
  if (node.filePath) html += `<p><b>文件:</b> ${escapeHtml(node.filePath)}</p>`;
626
1156
  if (node.language) html += `<p><b>语言:</b> ${node.language}</p>`;
627
1157
  if (node.module) html += `<p><b>模块:</b> ${node.module}</p>`;
628
1158
 
1159
+ html += `
1160
+ <div class="relation-summary">
1161
+ <div class="stat-item"><div class="stat-value">${outgoingCount}</div><div class="stat-label">输出</div></div>
1162
+ <div class="stat-item"><div class="stat-value">${incomingCount}</div><div class="stat-label">输入</div></div>
1163
+ <div class="stat-item"><div class="stat-value">${neighborCount}</div><div class="stat-label">邻居</div></div>
1164
+ <div class="stat-item"><div class="stat-value">${degree}</div><div class="stat-label">总关系</div></div>
1165
+ </div>
1166
+ `;
1167
+
629
1168
  if (data.outgoing?.length > 0) {
630
1169
  html += `<h3>输出关系 (${data.outgoing.length})</h3><ul>`;
631
1170
  data.outgoing.slice(0, 15).forEach(e => {
@@ -693,33 +1232,60 @@ document.getElementById('perspective-tabs').addEventListener('click', async (e)
693
1232
  if (view === 'graph') {
694
1233
  document.getElementById('graph-canvas').style.display = '';
695
1234
  document.getElementById('report-view').style.display = 'none';
1235
+ document.getElementById('report-toolbar').style.display = 'none';
696
1236
  renderGraph();
697
1237
  return;
698
1238
  }
699
1239
 
700
- if (!graphData.nodes?.length) return;
1240
+ if (!graphData.nodes?.length) {
1241
+ document.getElementById('report-content').innerHTML = '<div class="loading-text">暂无图谱数据,请先扫描项目</div>';
1242
+ document.getElementById('graph-canvas').style.display = 'none';
1243
+ document.getElementById('report-view').style.display = 'block';
1244
+ document.getElementById('report-toolbar').style.display = 'none';
1245
+ return;
1246
+ }
701
1247
 
702
1248
  // Show report view
703
1249
  document.getElementById('graph-canvas').style.display = 'none';
704
1250
  document.getElementById('report-view').style.display = 'block';
1251
+ document.getElementById('report-toolbar').style.display = 'none';
705
1252
  document.getElementById('report-content').innerHTML = '<div class="loading-text">生成报告中...</div>';
706
1253
 
707
- const res = await fetch('/api/studio/report/' + perspective);
708
- const report = await res.json();
1254
+ try {
1255
+ const report = await fetchPerspectiveReport(perspective);
1256
+ currentReport = report;
1257
+ currentReportMode = 'markdown';
1258
+ currentReportPerspective = perspective;
1259
+ renderCurrentReport();
1260
+ document.getElementById('report-toolbar').style.display = '';
1261
+ appendOperationLog(`报告已生成: ${perspective}`, 'info');
1262
+ } catch (err) {
1263
+ document.getElementById('report-content').innerHTML = renderReportError(err.message || 'unknown');
1264
+ document.getElementById('report-toolbar').style.display = 'none';
1265
+ appendOperationLog(`报告生成失败: ${err.message || 'unknown'}`, 'error');
1266
+ }
1267
+ });
709
1268
 
710
- let html = `<h1 style="color:var(--accent);margin-bottom:8px">${escapeHtml(report.title || '')}</h1>`;
711
- html += `<p style="color:var(--text-secondary);margin-bottom:24px">${escapeHtml(report.summary || '')}</p>`;
1269
+ document.getElementById('report-toolbar').addEventListener('click', async (e) => {
1270
+ const modeBtn = e.target.closest('.report-mode');
1271
+ if (modeBtn) {
1272
+ currentReportMode = modeBtn.dataset.mode || 'markdown';
1273
+ document.querySelectorAll('.report-mode').forEach((btn) => btn.classList.remove('active'));
1274
+ modeBtn.classList.add('active');
1275
+ renderCurrentReport();
1276
+ return;
1277
+ }
712
1278
 
713
- for (const section of (report.sections || [])) {
714
- html += `<h2 style="color:var(--accent);margin-top:24px;margin-bottom:8px">${escapeHtml(section.heading)}</h2>`;
715
- html += `<div style="line-height:1.8">${markdownToHtml(section.content)}</div>`;
716
- if (section.visualization) {
717
- html += `<pre style="background:var(--bg-input);padding:12px;border-radius:8px;margin:12px 0;font-size:11px;overflow-x:auto">${escapeHtml(section.visualization.data)}</pre>`;
1279
+ if (e.target.id === 'copy-report-btn') {
1280
+ const text = getCurrentReportText();
1281
+ if (!text) return;
1282
+ try {
1283
+ await navigator.clipboard.writeText(text);
1284
+ appendOperationLog('报告内容已复制到剪贴板', 'info');
1285
+ } catch {
1286
+ appendOperationLog('复制失败,请检查浏览器权限', 'warn');
718
1287
  }
719
1288
  }
720
-
721
- html += `<p style="color:var(--text-muted);margin-top:24px;font-size:11px">生成时间: ${report.generatedAt || new Date().toISOString()}</p>`;
722
- document.getElementById('report-content').innerHTML = html;
723
1289
  });
724
1290
 
725
1291
  // ===== Theme Toggle =====
@@ -733,7 +1299,9 @@ function togglePanel() {
733
1299
  }
734
1300
 
735
1301
  function filterByType(type) {
736
- // TODO: implement filter highlighting
1302
+ activeTypeFilter = activeTypeFilter === type ? null : type;
1303
+ renderNodeTypeFilter();
1304
+ renderGraph();
737
1305
  }
738
1306
 
739
1307
  // ===== Tooltip =====
@@ -755,23 +1323,51 @@ function hideTooltip() {
755
1323
  // ===== WebSocket for live updates =====
756
1324
  function connectWS() {
757
1325
  const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
758
- const ws = new WebSocket(protocol + '//' + location.host + '/ws');
1326
+ wsClient = new WebSocket(protocol + '//' + location.host + '/ws');
1327
+
1328
+ wsClient.onopen = async () => {
1329
+ appendOperationLog('WebSocket 已连接', 'info');
1330
+ await rehydrateStudioState();
1331
+ };
759
1332
 
760
- ws.onmessage = (e) => {
1333
+ wsClient.onmessage = (e) => {
761
1334
  try {
762
1335
  const msg = JSON.parse(e.data);
763
- if (msg.type === 'agent:update') updateAgents(msg.payload);
764
- else if (msg.type === 'graph:update') { graphData = { ...graphData, ...msg.payload }; renderGraph(); }
1336
+ if (msg.type === 'agent:update') scheduleAgentRefresh(msg.payload);
1337
+ else if (msg.type === 'graph:update') {
1338
+ graphData = { ...graphData, ...msg.payload };
1339
+ scheduleGraphRender();
1340
+ }
765
1341
  else if (msg.type === 'scan:progress') {
766
- document.getElementById('loading-detail').textContent = msg.payload.detail || '';
1342
+ const detail = msg.payload.detail || '';
1343
+ const percent = Number.isFinite(msg.payload.percent) ? Math.round(msg.payload.percent) : 0;
1344
+ document.getElementById('loading-detail').textContent = `${detail} (${percent}%)`;
1345
+ appendOperationLog(`扫描进度 ${percent}% - ${detail || msg.payload.phase || ''}`, 'info');
767
1346
  }
768
1347
  else if (msg.type === 'log') {
769
- console.log(`[${msg.payload.level}] ${msg.payload.message}`);
1348
+ appendOperationLog(msg.payload.message, msg.payload.level || 'info');
1349
+ }
1350
+ else if (msg.type === 'files:generated') {
1351
+ appendOperationLog(`已生成 ${Array.isArray(msg.payload) ? msg.payload.length : 0} 个测试文件`, 'info');
1352
+ }
1353
+ else if (msg.type === 'pipeline:complete') {
1354
+ const ok = msg.payload?.status === 'success';
1355
+ appendOperationLog(ok ? 'Pipeline 执行完成' : `Pipeline 执行失败: ${msg.payload?.error || 'unknown'}`, ok ? 'info' : 'error');
1356
+ }
1357
+ else if (msg.type === 'test:complete') {
1358
+ const metrics = msg.payload?.metrics;
1359
+ if (metrics) {
1360
+ appendOperationLog(`测试完成: pass ${metrics.passed}, fail ${metrics.failed}, skipped ${metrics.skipped}, timeout ${metrics.timedOut}`, 'info');
1361
+ }
770
1362
  }
771
1363
  } catch {}
772
1364
  };
773
1365
 
774
- ws.onclose = () => setTimeout(connectWS, 3000);
1366
+ wsClient.onclose = () => {
1367
+ appendOperationLog('WebSocket 已断开,3 秒后重连', 'warn');
1368
+ if (reconnectTimer) clearTimeout(reconnectTimer);
1369
+ reconnectTimer = setTimeout(connectWS, 3000);
1370
+ };
775
1371
  }
776
1372
 
777
1373
  function updateAgents(agents) {
@@ -797,7 +1393,251 @@ function markdownToHtml(md) {
797
1393
  return html;
798
1394
  }
799
1395
 
1396
+ function renderVisualization(viz) {
1397
+ if (!viz || !viz.data) return '';
1398
+ if (viz.type === 'mermaid') {
1399
+ const encoded = encodeURIComponent(viz.data);
1400
+ return `<div class="report-mermaid" data-graph="${encoded}"><pre class="mermaid">${escapeHtml(viz.data)}</pre></div>`;
1401
+ }
1402
+ return `<div class="report-viz"><pre>${escapeHtml(viz.data)}</pre></div>`;
1403
+ }
1404
+
1405
+ async function fetchPerspectiveReport(perspective) {
1406
+ const cached = reportCache.get(perspective);
1407
+ if (cached) return cached;
1408
+ const res = await fetch('/api/studio/report/' + perspective);
1409
+ if (!res.ok) {
1410
+ throw new Error('报告生成失败');
1411
+ }
1412
+ const report = await res.json();
1413
+ reportCache.set(perspective, report);
1414
+ return report;
1415
+ }
1416
+
1417
+ function renderReportError(message) {
1418
+ return `
1419
+ <div class="loading-text">报告生成失败: ${escapeHtml(message)}</div>
1420
+ <div style="margin-top:12px">
1421
+ <button class="btn btn-primary btn-sm" onclick="retryCurrentReport()">重试</button>
1422
+ </div>
1423
+ `;
1424
+ }
1425
+
1426
+ async function retryCurrentReport() {
1427
+ if (!currentReportPerspective) {
1428
+ appendOperationLog('当前没有可重试的报告视角', 'warn');
1429
+ return;
1430
+ }
1431
+
1432
+ reportCache.delete(currentReportPerspective);
1433
+ document.getElementById('report-content').innerHTML = '<div class="loading-text">重试生成报告中...</div>';
1434
+ try {
1435
+ const report = await fetchPerspectiveReport(currentReportPerspective);
1436
+ currentReport = report;
1437
+ currentReportMode = 'markdown';
1438
+ document.querySelectorAll('.report-mode').forEach((btn) => {
1439
+ btn.classList.toggle('active', btn.dataset.mode === 'markdown');
1440
+ });
1441
+ renderCurrentReport();
1442
+ document.getElementById('report-toolbar').style.display = '';
1443
+ appendOperationLog(`报告重试成功: ${currentReportPerspective}`, 'info');
1444
+ } catch (err) {
1445
+ document.getElementById('report-content').innerHTML = renderReportError(err.message || 'unknown');
1446
+ document.getElementById('report-toolbar').style.display = 'none';
1447
+ appendOperationLog(`报告重试失败: ${err.message || 'unknown'}`, 'error');
1448
+ }
1449
+ }
1450
+
1451
+ function renderCurrentReport() {
1452
+ const container = document.getElementById('report-content');
1453
+ if (!container || !currentReport) return;
1454
+
1455
+ if (currentReportMode === 'raw') {
1456
+ container.innerHTML = `<pre class="report-viz">${escapeHtml(JSON.stringify(currentReport, null, 2))}</pre>`;
1457
+ return;
1458
+ }
1459
+
1460
+ if (currentReportMode === 'mermaid') {
1461
+ const mermaidSections = (currentReport.sections || [])
1462
+ .filter((s) => s.visualization?.type === 'mermaid' && s.visualization?.data)
1463
+ .map((s) => {
1464
+ return `<h3 style="color:var(--accent);margin-top:18px">${escapeHtml(s.heading)}</h3>${renderVisualization(s.visualization)}`;
1465
+ });
1466
+ container.innerHTML = mermaidSections.length
1467
+ ? mermaidSections.join('')
1468
+ : '<div class="loading-text">当前报告不包含 Mermaid 可视化</div>';
1469
+ hydrateMermaid();
1470
+ return;
1471
+ }
1472
+
1473
+ let html = `<h1 style="color:var(--accent);margin-bottom:8px">${escapeHtml(currentReport.title || '')}</h1>`;
1474
+ html += `<p style="color:var(--text-secondary);margin-bottom:24px">${escapeHtml(currentReport.summary || '')}</p>`;
1475
+
1476
+ for (const section of (currentReport.sections || [])) {
1477
+ html += `<h2 style="color:var(--accent);margin-top:24px;margin-bottom:8px">${escapeHtml(section.heading)}</h2>`;
1478
+ html += `<div style="line-height:1.8">${markdownToHtml(section.content)}</div>`;
1479
+ if (section.visualization) {
1480
+ html += renderVisualization(section.visualization);
1481
+ }
1482
+ }
1483
+
1484
+ html += `<p style="color:var(--text-muted);margin-top:24px;font-size:11px">生成时间: ${currentReport.generatedAt || new Date().toISOString()}</p>`;
1485
+ container.innerHTML = html;
1486
+ hydrateMermaid();
1487
+ }
1488
+
1489
+ function getCurrentReportText() {
1490
+ if (!currentReport) return '';
1491
+ if (currentReportMode === 'raw') {
1492
+ return JSON.stringify(currentReport, null, 2);
1493
+ }
1494
+ if (currentReportMode === 'mermaid') {
1495
+ const charts = (currentReport.sections || [])
1496
+ .filter((s) => s.visualization?.type === 'mermaid' && s.visualization?.data)
1497
+ .map((s) => `# ${s.heading}\n${s.visualization.data}`);
1498
+ return charts.join('\n\n');
1499
+ }
1500
+ return reportToMarkdown(currentReport);
1501
+ }
1502
+
1503
+ function reportToMarkdown(report) {
1504
+ let text = `# ${report.title || 'Report'}\n\n`;
1505
+ if (report.summary) {
1506
+ text += `${report.summary}\n\n`;
1507
+ }
1508
+
1509
+ for (const section of (report.sections || [])) {
1510
+ text += `## ${section.heading}\n\n${section.content || ''}\n\n`;
1511
+ if (section.visualization?.data) {
1512
+ if (section.visualization.type === 'mermaid') {
1513
+ text += `\`\`\`mermaid\n${section.visualization.data}\n\`\`\`\n\n`;
1514
+ } else {
1515
+ text += `\`\`\`\n${section.visualization.data}\n\`\`\`\n\n`;
1516
+ }
1517
+ }
1518
+ }
1519
+
1520
+ if (report.generatedAt) {
1521
+ text += `> generatedAt: ${report.generatedAt}\n`;
1522
+ }
1523
+ return text;
1524
+ }
1525
+
1526
+ async function ensureMermaidReady() {
1527
+ if (window.mermaid) return window.mermaid;
1528
+ if (window.__mermaidLoading) return window.__mermaidLoading;
1529
+
1530
+ window.__mermaidLoading = new Promise((resolve, reject) => {
1531
+ const script = document.createElement('script');
1532
+ script.src = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js';
1533
+ script.onload = () => resolve(window.mermaid);
1534
+ script.onerror = () => reject(new Error('Failed to load Mermaid runtime'));
1535
+ document.head.appendChild(script);
1536
+ });
1537
+
1538
+ return window.__mermaidLoading;
1539
+ }
1540
+
1541
+ async function hydrateMermaid() {
1542
+ const blocks = document.querySelectorAll('.mermaid');
1543
+ if (!blocks.length) return;
1544
+ try {
1545
+ const mermaid = await ensureMermaidReady();
1546
+ mermaid.initialize({ startOnLoad: false, securityLevel: 'loose', theme: 'default' });
1547
+ await mermaid.run({ querySelector: '.mermaid' });
1548
+ } catch (err) {
1549
+ appendOperationLog('Mermaid 渲染失败,已回退为文本展示', 'warn');
1550
+ }
1551
+ }
1552
+
1553
+ function appendOperationLog(message, level = 'info') {
1554
+ const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false });
1555
+ eventLog.push({ ts, level, message: String(message || '') });
1556
+ if (eventLog.length > MAX_EVENT_LOG_ROWS) {
1557
+ eventLog = eventLog.slice(eventLog.length - MAX_EVENT_LOG_ROWS);
1558
+ }
1559
+
1560
+ scheduleEventLogRender();
1561
+ }
1562
+
1563
+ function scheduleEventLogRender() {
1564
+ if (eventLogRenderScheduled) return;
1565
+ eventLogRenderScheduled = true;
1566
+ requestAnimationFrame(() => {
1567
+ eventLogRenderScheduled = false;
1568
+ renderOperationLogPanel();
1569
+ });
1570
+ }
1571
+
1572
+ function renderOperationLogPanel() {
1573
+ const panel = document.getElementById('panel');
1574
+ const title = document.getElementById('panel-title');
1575
+ const body = document.getElementById('panel-body');
1576
+ if (!panel || !title || !body) return;
1577
+
1578
+ if (!panel.classList.contains('open') || title.textContent === '实时日志') {
1579
+ title.textContent = '实时日志';
1580
+ const filtered = eventLog.filter((row) => currentLogFilter === 'all' || row.level === currentLogFilter);
1581
+ const latestRows = filtered.slice(-40);
1582
+ body.innerHTML = `
1583
+ <div class="event-log-toolbar">
1584
+ <button class="btn btn-sm event-log-filter ${currentLogFilter === 'all' ? 'active' : ''}" data-level="all">全部</button>
1585
+ <button class="btn btn-sm event-log-filter ${currentLogFilter === 'info' ? 'active' : ''}" data-level="info">Info</button>
1586
+ <button class="btn btn-sm event-log-filter ${currentLogFilter === 'warn' ? 'active' : ''}" data-level="warn">Warn</button>
1587
+ <button class="btn btn-sm event-log-filter ${currentLogFilter === 'error' ? 'active' : ''}" data-level="error">Error</button>
1588
+ </div>
1589
+ <div class="event-log">
1590
+ ` + latestRows.map(row => {
1591
+ return `<div class="event-log-row"><span class="event-log-level-${row.level}">[${row.level}]</span> ${escapeHtml(row.ts)} ${escapeHtml(row.message)}</div>`;
1592
+ }).join('') + '</div>';
1593
+
1594
+ body.querySelectorAll('.event-log-filter').forEach((btn) => {
1595
+ btn.addEventListener('click', () => {
1596
+ currentLogFilter = btn.dataset.level || 'all';
1597
+ renderOperationLogPanel();
1598
+ });
1599
+ });
1600
+ }
1601
+ }
1602
+
1603
+ async function rehydrateStudioState() {
1604
+ try {
1605
+ await Promise.all([loadGraph(), loadRisks(), loadSummary(), loadSnapshots()]);
1606
+ document.getElementById('stats-section').style.display = '';
1607
+ document.getElementById('filter-section').style.display = '';
1608
+ document.getElementById('snapshot-section').style.display = '';
1609
+ document.getElementById('risk-section').style.display = '';
1610
+ } catch {
1611
+ // Ignore; no scanned project yet.
1612
+ }
1613
+ }
1614
+
800
1615
  // ===== Init =====
1616
+ document.getElementById('snapshot-search').addEventListener('input', (e) => {
1617
+ snapshotQuery = e.target.value || '';
1618
+ renderSnapshots();
1619
+ });
1620
+
1621
+ document.getElementById('snapshot-tag-filters').addEventListener('click', (e) => {
1622
+ const target = e.target.closest('[data-role="snapshot-filter"]');
1623
+ if (!target) return;
1624
+ setSnapshotTagFilter(target.dataset.tag || '');
1625
+ });
1626
+
1627
+ document.getElementById('snapshot-list').addEventListener('click', (e) => {
1628
+ const target = e.target.closest('[data-role="snapshot-filter"]');
1629
+ if (!target) return;
1630
+ setSnapshotTagFilter(target.dataset.tag || '');
1631
+ });
1632
+
1633
+ document.addEventListener('keydown', (e) => {
1634
+ if (e.key === 'Escape') {
1635
+ selectedNode = null;
1636
+ hideTooltip();
1637
+ scheduleGraphRender();
1638
+ }
1639
+ });
1640
+
801
1641
  connectWS();
802
1642
  </script>
803
1643
  </body>