opencroc 1.7.0 → 1.8.0

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.
@@ -0,0 +1,804 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>OpenCroc Studio — 项目智能分析平台</title>
7
+ <style>
8
+ /* ===== CSS Variables — Dual Theme ===== */
9
+ :root {
10
+ --bg-primary: #0a0a1a;
11
+ --bg-secondary: #111128;
12
+ --bg-card: #1a1a35;
13
+ --bg-input: #222250;
14
+ --text-primary: #e0e0e0;
15
+ --text-secondary: #888;
16
+ --text-muted: #555;
17
+ --accent: #4ecca3;
18
+ --accent-hover: #45b890;
19
+ --danger: #e94560;
20
+ --warning: #f39c12;
21
+ --info: #3498db;
22
+ --border: #2a2a50;
23
+ --shadow: rgba(0,0,0,0.4);
24
+ --font-pixel: 'Courier New', monospace;
25
+ --font-pro: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
26
+ --font-current: var(--font-pixel);
27
+ --radius: 8px;
28
+ --node-model: #4ecca3;
29
+ --node-api: #e94560;
30
+ --node-service: #3498db;
31
+ --node-module: #f39c12;
32
+ --node-component: #9b59b6;
33
+ --node-file: #666;
34
+ --node-dependency: #888;
35
+ }
36
+
37
+ [data-theme="professional"] {
38
+ --bg-primary: #f5f7fa;
39
+ --bg-secondary: #ffffff;
40
+ --bg-card: #ffffff;
41
+ --bg-input: #f0f2f5;
42
+ --text-primary: #1a1a2e;
43
+ --text-secondary: #555;
44
+ --text-muted: #999;
45
+ --border: #e0e0e0;
46
+ --shadow: rgba(0,0,0,0.08);
47
+ --font-current: var(--font-pro);
48
+ }
49
+
50
+ /* ===== Reset & Base ===== */
51
+ * { margin: 0; padding: 0; box-sizing: border-box; }
52
+ body {
53
+ background: var(--bg-primary);
54
+ color: var(--text-primary);
55
+ font-family: var(--font-current);
56
+ overflow: hidden;
57
+ height: 100vh;
58
+ }
59
+
60
+ /* ===== Layout ===== */
61
+ .app { display: flex; height: 100vh; }
62
+ .sidebar { width: 280px; background: var(--bg-secondary); border-right: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; }
63
+ .main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
64
+ .header { padding: 12px 20px; background: var(--bg-secondary); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
65
+ .graph-area { flex: 1; position: relative; overflow: hidden; }
66
+ .panel { width: 340px; background: var(--bg-secondary); border-left: 1px solid var(--border); overflow-y: auto; flex-shrink: 0; display: none; }
67
+ .panel.open { display: block; }
68
+
69
+ /* ===== Header ===== */
70
+ .logo { display: flex; align-items: center; gap: 8px; }
71
+ .logo-icon { font-size: 28px; }
72
+ .logo-text { font-size: 16px; font-weight: bold; color: var(--accent); }
73
+ .logo-sub { font-size: 11px; color: var(--text-secondary); margin-left: 4px; }
74
+ .header-actions { display: flex; gap: 8px; align-items: center; }
75
+
76
+ /* ===== Buttons ===== */
77
+ .btn {
78
+ padding: 6px 14px; border: 1px solid var(--border); border-radius: var(--radius);
79
+ background: var(--bg-card); color: var(--text-primary); cursor: pointer;
80
+ font-family: var(--font-current); font-size: 12px; transition: all 0.2s;
81
+ }
82
+ .btn:hover { border-color: var(--accent); color: var(--accent); }
83
+ .btn-primary { background: var(--accent); color: #000; border-color: var(--accent); font-weight: bold; }
84
+ .btn-primary:hover { background: var(--accent-hover); }
85
+ .btn-sm { padding: 4px 10px; font-size: 11px; }
86
+ .btn-icon { width: 32px; height: 32px; padding: 0; display: flex; align-items: center; justify-content: center; font-size: 16px; }
87
+
88
+ /* ===== Sidebar ===== */
89
+ .sidebar-header { padding: 16px; border-bottom: 1px solid var(--border); }
90
+ .scan-input-group { display: flex; gap: 6px; margin-top: 10px; }
91
+ .scan-input {
92
+ flex: 1; padding: 8px 10px; background: var(--bg-input); border: 1px solid var(--border);
93
+ border-radius: var(--radius); color: var(--text-primary); font-family: var(--font-current); font-size: 12px;
94
+ }
95
+ .scan-input::placeholder { color: var(--text-muted); }
96
+ .scan-input:focus { outline: none; border-color: var(--accent); }
97
+
98
+ .sidebar-section { padding: 12px 16px; border-bottom: 1px solid var(--border); }
99
+ .sidebar-section h3 { font-size: 11px; text-transform: uppercase; color: var(--text-secondary); margin-bottom: 8px; letter-spacing: 1px; }
100
+
101
+ .stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
102
+ .stat-item { padding: 8px; background: var(--bg-card); border-radius: var(--radius); text-align: center; }
103
+ .stat-value { font-size: 18px; font-weight: bold; color: var(--accent); }
104
+ .stat-label { font-size: 10px; color: var(--text-secondary); margin-top: 2px; }
105
+
106
+ .health-bar { height: 8px; background: var(--bg-input); border-radius: 4px; overflow: hidden; margin-top: 6px; }
107
+ .health-fill { height: 100%; border-radius: 4px; transition: width 0.5s; }
108
+
109
+ .node-type-list { list-style: none; }
110
+ .node-type-item {
111
+ display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: var(--radius);
112
+ cursor: pointer; font-size: 12px; transition: background 0.2s;
113
+ }
114
+ .node-type-item:hover { background: var(--bg-card); }
115
+ .node-type-dot { width: 10px; height: 10px; border-radius: 50%; }
116
+ .node-type-count { margin-left: auto; color: var(--text-secondary); font-size: 11px; }
117
+
118
+ .risk-list { list-style: none; }
119
+ .risk-item { padding: 8px; background: var(--bg-card); border-radius: var(--radius); margin-bottom: 4px; font-size: 11px; cursor: pointer; }
120
+ .risk-item:hover { border-color: var(--accent); }
121
+ .risk-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: bold; text-transform: uppercase; margin-right: 6px; }
122
+ .risk-critical { background: var(--danger); color: #fff; }
123
+ .risk-high { background: #e67e22; color: #fff; }
124
+ .risk-medium { background: var(--warning); color: #000; }
125
+ .risk-low { background: var(--info); color: #fff; }
126
+
127
+ /* ===== Graph Canvas ===== */
128
+ #graph-canvas { width: 100%; height: 100%; background: var(--bg-primary); }
129
+
130
+ /* ===== Perspective Tabs ===== */
131
+ .perspective-tabs { display: flex; gap: 4px; padding: 8px 16px; background: var(--bg-secondary); border-bottom: 1px solid var(--border); overflow-x: auto; }
132
+ .perspective-tab {
133
+ padding: 4px 12px; border-radius: var(--radius); font-size: 11px; cursor: pointer;
134
+ background: var(--bg-card); color: var(--text-secondary); white-space: nowrap; border: 1px solid transparent;
135
+ }
136
+ .perspective-tab:hover { color: var(--text-primary); border-color: var(--border); }
137
+ .perspective-tab.active { background: var(--accent); color: #000; font-weight: bold; }
138
+
139
+ /* ===== Panel Content ===== */
140
+ .panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
141
+ .panel-header h2 { font-size: 14px; }
142
+ .panel-body { padding: 16px; }
143
+ .panel-body h3 { font-size: 13px; margin: 12px 0 6px; color: var(--accent); }
144
+ .panel-body p { font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
145
+ .panel-body pre { background: var(--bg-input); padding: 10px; border-radius: var(--radius); font-size: 11px; overflow-x: auto; margin: 8px 0; }
146
+ .panel-body ul { padding-left: 16px; font-size: 12px; line-height: 1.8; }
147
+
148
+ /* ===== Loading & Status ===== */
149
+ .loading-overlay {
150
+ position: absolute; inset: 0; background: rgba(0,0,0,0.7); display: flex;
151
+ flex-direction: column; align-items: center; justify-content: center; z-index: 100;
152
+ }
153
+ .loading-overlay.hidden { display: none; }
154
+ .loading-croc { font-size: 64px; animation: bounce 0.6s infinite alternate; }
155
+ @keyframes bounce { from { transform: translateY(0); } to { transform: translateY(-15px); } }
156
+ .loading-text { margin-top: 12px; color: var(--accent); font-size: 14px; }
157
+ .loading-detail { color: var(--text-secondary); font-size: 11px; margin-top: 4px; }
158
+
159
+ /* ===== Welcome Screen ===== */
160
+ .welcome {
161
+ position: absolute; inset: 0; display: flex; flex-direction: column;
162
+ align-items: center; justify-content: center; z-index: 50;
163
+ }
164
+ .welcome.hidden { display: none; }
165
+ .welcome-croc { font-size: 80px; animation: bounce 1s infinite alternate; }
166
+ .welcome h1 { font-size: 32px; color: var(--accent); margin-top: 20px; }
167
+ .welcome p { color: var(--text-secondary); margin-top: 8px; font-size: 14px; }
168
+ .welcome-actions { margin-top: 24px; display: flex; gap: 12px; }
169
+ .welcome-input { width: 400px; padding: 12px 16px; font-size: 14px; }
170
+
171
+ /* ===== Scrollbar ===== */
172
+ ::-webkit-scrollbar { width: 6px; }
173
+ ::-webkit-scrollbar-track { background: var(--bg-primary); }
174
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
175
+
176
+ /* ===== Agent Bar ===== */
177
+ .agent-bar { display: flex; gap: 8px; padding: 8px 16px; background: var(--bg-secondary); border-top: 1px solid var(--border); }
178
+ .agent-chip {
179
+ display: flex; align-items: center; gap: 4px; padding: 4px 10px;
180
+ background: var(--bg-card); border-radius: 12px; font-size: 11px;
181
+ }
182
+ .agent-status { width: 6px; height: 6px; border-radius: 50%; }
183
+ .agent-status.idle { background: var(--text-muted); }
184
+ .agent-status.working { background: var(--warning); animation: pulse 1s infinite; }
185
+ .agent-status.done { background: var(--accent); }
186
+ .agent-status.error { background: var(--danger); }
187
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
188
+
189
+ /* ===== Tooltip ===== */
190
+ .tooltip {
191
+ position: fixed; background: var(--bg-card); border: 1px solid var(--border);
192
+ padding: 8px 12px; border-radius: var(--radius); font-size: 11px;
193
+ pointer-events: none; z-index: 200; max-width: 280px; box-shadow: 0 4px 12px var(--shadow);
194
+ display: none;
195
+ }
196
+ .tooltip.visible { display: block; }
197
+ </style>
198
+ </head>
199
+ <body>
200
+ <div class="app" id="app">
201
+ <!-- Sidebar -->
202
+ <div class="sidebar">
203
+ <div class="sidebar-header">
204
+ <div class="logo">
205
+ <span class="logo-icon">🐊</span>
206
+ <span class="logo-text">OpenCroc Studio</span>
207
+ </div>
208
+ <div class="scan-input-group">
209
+ <input id="scan-input" class="scan-input" placeholder="路径 / GitHub URL / user/repo" />
210
+ <button id="scan-btn" class="btn btn-primary btn-sm" onclick="startScan()">扫描</button>
211
+ </div>
212
+ </div>
213
+
214
+ <!-- Stats Section -->
215
+ <div class="sidebar-section" id="stats-section" style="display:none">
216
+ <h3>项目概览</h3>
217
+ <div id="project-name" style="font-size:14px;font-weight:bold;margin-bottom:6px;color:var(--accent)"></div>
218
+ <div id="project-type" style="font-size:11px;color:var(--text-secondary);margin-bottom:8px"></div>
219
+ <div class="stat-grid">
220
+ <div class="stat-item"><div class="stat-value" id="stat-apis">0</div><div class="stat-label">APIs</div></div>
221
+ <div class="stat-item"><div class="stat-value" id="stat-models">0</div><div class="stat-label">Models</div></div>
222
+ <div class="stat-item"><div class="stat-value" id="stat-files">0</div><div class="stat-label">Files</div></div>
223
+ <div class="stat-item"><div class="stat-value" id="stat-risks">0</div><div class="stat-label">Risks</div></div>
224
+ </div>
225
+ <div style="margin-top:8px">
226
+ <div style="display:flex;justify-content:space-between;font-size:11px">
227
+ <span>健康度</span><span id="health-score">—</span>
228
+ </div>
229
+ <div class="health-bar"><div class="health-fill" id="health-fill" style="width:0%;background:var(--accent)"></div></div>
230
+ </div>
231
+ </div>
232
+
233
+ <!-- Node Types Filter -->
234
+ <div class="sidebar-section" id="filter-section" style="display:none">
235
+ <h3>实体类型</h3>
236
+ <ul class="node-type-list" id="node-type-list"></ul>
237
+ </div>
238
+
239
+ <!-- Risks -->
240
+ <div class="sidebar-section" id="risk-section" style="display:none;flex:1;overflow-y:auto">
241
+ <h3>风险点 <span id="risk-count" style="color:var(--danger)"></span></h3>
242
+ <ul class="risk-list" id="risk-list"></ul>
243
+ </div>
244
+ </div>
245
+
246
+ <!-- Main Content -->
247
+ <div class="main-content">
248
+ <!-- Header -->
249
+ <div class="header">
250
+ <div class="perspective-tabs" id="perspective-tabs">
251
+ <div class="perspective-tab active" data-view="graph">📊 知识图谱</div>
252
+ <div class="perspective-tab" data-perspective="developer">👨‍💻 开发者</div>
253
+ <div class="perspective-tab" data-perspective="architect">🏗️ 架构师</div>
254
+ <div class="perspective-tab" data-perspective="tester">🧪 测试</div>
255
+ <div class="perspective-tab" data-perspective="product">📋 产品</div>
256
+ <div class="perspective-tab" data-perspective="student">🎓 学生</div>
257
+ <div class="perspective-tab" data-perspective="executive">📈 管理层</div>
258
+ </div>
259
+ <div class="header-actions">
260
+ <button class="btn btn-icon" onclick="toggleTheme()" title="切换主题">🎨</button>
261
+ <button class="btn btn-icon" onclick="togglePanel()" title="详情面板">📋</button>
262
+ </div>
263
+ </div>
264
+
265
+ <!-- Graph Area -->
266
+ <div class="graph-area" id="graph-area">
267
+ <!-- Welcome Screen -->
268
+ <div class="welcome" id="welcome">
269
+ <div class="welcome-croc">🐊</div>
270
+ <h1>OpenCroc Studio</h1>
271
+ <p>任意项目 → 60秒 → 知识图谱 + 风险分析 + 多角色报告</p>
272
+ <div class="welcome-actions">
273
+ <input id="welcome-input" class="scan-input welcome-input" placeholder="输入本地路径、GitHub URL 或 user/repo" />
274
+ <button class="btn btn-primary" onclick="startScanFromWelcome()">开始分析</button>
275
+ </div>
276
+ <div style="margin-top:16px;color:var(--text-muted);font-size:12px">
277
+ 示例: <code>./backend</code> &nbsp;|&nbsp; <code>https://github.com/expressjs/express</code> &nbsp;|&nbsp; <code>facebook/react</code>
278
+ </div>
279
+ </div>
280
+
281
+ <!-- Loading Overlay -->
282
+ <div class="loading-overlay hidden" id="loading">
283
+ <div class="loading-croc">🐊</div>
284
+ <div class="loading-text" id="loading-text">扫描中...</div>
285
+ <div class="loading-detail" id="loading-detail"></div>
286
+ </div>
287
+
288
+ <!-- SVG Graph Canvas -->
289
+ <svg id="graph-canvas"></svg>
290
+
291
+ <!-- Report View (hidden by default) -->
292
+ <div id="report-view" style="display:none;padding:24px;overflow-y:auto;height:100%;background:var(--bg-primary)">
293
+ <div id="report-content" style="max-width:800px;margin:0 auto"></div>
294
+ </div>
295
+ </div>
296
+
297
+ <!-- Agent Bar -->
298
+ <div class="agent-bar" id="agent-bar">
299
+ <div class="agent-chip"><div class="agent-status idle" id="agent-parser"></div> 解析鳄</div>
300
+ <div class="agent-chip"><div class="agent-status idle" id="agent-analyzer"></div> 分析鳄</div>
301
+ <div class="agent-chip"><div class="agent-status idle" id="agent-planner"></div> 规划鳄</div>
302
+ <div class="agent-chip"><div class="agent-status idle" id="agent-tester"></div> 测试鳄</div>
303
+ <div class="agent-chip"><div class="agent-status idle" id="agent-healer"></div> 修复鳄</div>
304
+ <div class="agent-chip"><div class="agent-status idle" id="agent-reporter"></div> 汇报鳄</div>
305
+ </div>
306
+ </div>
307
+
308
+ <!-- Right Panel -->
309
+ <div class="panel" id="panel">
310
+ <div class="panel-header">
311
+ <h2 id="panel-title">详情</h2>
312
+ <button class="btn btn-sm" onclick="togglePanel()">✕</button>
313
+ </div>
314
+ <div class="panel-body" id="panel-body"></div>
315
+ </div>
316
+ </div>
317
+
318
+ <!-- Tooltip -->
319
+ <div class="tooltip" id="tooltip"></div>
320
+
321
+ <script>
322
+ // ===== State =====
323
+ let graphData = { nodes: [], edges: [] };
324
+ let riskData = [];
325
+ let currentTheme = 'pixel';
326
+ let selectedNode = null;
327
+ let simulation = null; // Force simulation
328
+ let transform = { x: 0, y: 0, scale: 1 };
329
+
330
+ // Node type colors
331
+ const TYPE_COLORS = {
332
+ model: '#4ecca3', api: '#e94560', service: '#3498db', module: '#f39c12',
333
+ component: '#9b59b6', file: '#666', dependency: '#888', class: '#2ecc71',
334
+ function: '#1abc9c', middleware: '#e67e22', route: '#e94560', database: '#8e44ad',
335
+ cache: '#d35400', queue: '#c0392b', 'external-api': '#7f8c8d',
336
+ permission: '#e74c3c', page: '#16a085', store: '#2980b9', test: '#27ae60',
337
+ unknown: '#555',
338
+ };
339
+
340
+ const TYPE_LABELS = {
341
+ model: '数据模型', api: 'API接口', service: '服务', module: '模块',
342
+ component: '组件', file: '文件', dependency: '依赖', class: '类',
343
+ function: '函数', middleware: '中间件', route: '路由', database: '数据库',
344
+ cache: '缓存', queue: '队列', 'external-api': '外部API',
345
+ permission: '权限', page: '页面', store: '状态管理', test: '测试',
346
+ unknown: '未知',
347
+ };
348
+
349
+ // ===== Scan =====
350
+ async function startScan() {
351
+ const target = document.getElementById('scan-input').value.trim();
352
+ if (!target) return;
353
+ await doScan(target);
354
+ }
355
+
356
+ function startScanFromWelcome() {
357
+ const target = document.getElementById('welcome-input').value.trim();
358
+ if (!target) return;
359
+ document.getElementById('scan-input').value = target;
360
+ doScan(target);
361
+ }
362
+
363
+ async function doScan(target) {
364
+ document.getElementById('welcome').classList.add('hidden');
365
+ document.getElementById('loading').classList.remove('hidden');
366
+ document.getElementById('loading-text').textContent = '正在扫描 ' + target + '...';
367
+
368
+ try {
369
+ const res = await fetch('/api/studio/scan', {
370
+ method: 'POST',
371
+ headers: { 'Content-Type': 'application/json' },
372
+ body: JSON.stringify({ target }),
373
+ });
374
+ const data = await res.json();
375
+
376
+ if (!res.ok) {
377
+ document.getElementById('loading-text').textContent = '❌ ' + (data.error || '扫描失败');
378
+ setTimeout(() => document.getElementById('loading').classList.add('hidden'), 3000);
379
+ return;
380
+ }
381
+
382
+ // Load graph and risks
383
+ await loadGraph();
384
+ await loadRisks();
385
+ await loadSummary();
386
+
387
+ document.getElementById('loading').classList.add('hidden');
388
+ document.getElementById('stats-section').style.display = '';
389
+ document.getElementById('filter-section').style.display = '';
390
+ document.getElementById('risk-section').style.display = '';
391
+ } catch (err) {
392
+ document.getElementById('loading-text').textContent = '❌ ' + err.message;
393
+ setTimeout(() => document.getElementById('loading').classList.add('hidden'), 3000);
394
+ }
395
+ }
396
+
397
+ async function loadGraph() {
398
+ const res = await fetch('/api/studio/graph');
399
+ const data = await res.json();
400
+ graphData = data;
401
+ renderGraph();
402
+ renderNodeTypeFilter();
403
+ }
404
+
405
+ async function loadRisks() {
406
+ const res = await fetch('/api/studio/risks');
407
+ const data = await res.json();
408
+ riskData = data.risks || [];
409
+ renderRiskList();
410
+ document.getElementById('stat-risks').textContent = riskData.length;
411
+ document.getElementById('risk-count').textContent = `(${riskData.length})`;
412
+ }
413
+
414
+ async function loadSummary() {
415
+ const res = await fetch('/api/studio/summary');
416
+ const data = await res.json();
417
+ document.getElementById('project-name').textContent = data.name || '—';
418
+ document.getElementById('project-type').textContent = data.oneLiner || '';
419
+ document.getElementById('stat-apis').textContent = data.stats?.apiCount || 0;
420
+ document.getElementById('stat-models').textContent = data.stats?.modelCount || 0;
421
+ document.getElementById('stat-files').textContent = data.stats?.fileCount || 0;
422
+ document.getElementById('health-score').textContent = (data.healthScore || 0) + '/100';
423
+ const fill = document.getElementById('health-fill');
424
+ fill.style.width = (data.healthScore || 0) + '%';
425
+ fill.style.background = data.healthScore >= 80 ? '#4ecca3' : data.healthScore >= 60 ? '#f39c12' : '#e94560';
426
+ }
427
+
428
+ // ===== Graph Rendering (Force-Directed) =====
429
+ function renderGraph() {
430
+ const svg = document.getElementById('graph-canvas');
431
+ const { width, height } = svg.getBoundingClientRect();
432
+ svg.innerHTML = '';
433
+
434
+ const nodes = (graphData.nodes || []).filter(n => n.type !== 'file' && n.type !== 'dependency');
435
+ 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));
437
+
438
+ if (nodes.length === 0) return;
439
+
440
+ // Initialize positions (circular layout)
441
+ nodes.forEach((n, i) => {
442
+ const angle = (i / nodes.length) * 2 * Math.PI;
443
+ 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;
448
+ });
449
+
450
+ // Simple force simulation (run synchronously for speed)
451
+ for (let iter = 0; iter < 80; iter++) {
452
+ // 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;
457
+ let dist = Math.sqrt(dx * dx + dy * dy) || 1;
458
+ let force = 800 / (dist * dist);
459
+ let fx = (dx / dist) * force;
460
+ 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;
465
+ }
466
+ }
467
+
468
+ // Attraction along edges
469
+ for (const e of edges) {
470
+ const s = nodeMap.get(e.source);
471
+ const t = nodeMap.get(e.target);
472
+ if (!s || !t) continue;
473
+ let dx = t._x - s._x;
474
+ let dy = t._y - s._y;
475
+ let dist = Math.sqrt(dx * dx + dy * dy) || 1;
476
+ let force = (dist - 120) * 0.01;
477
+ let fx = (dx / dist) * force;
478
+ let fy = (dy / dist) * force;
479
+ s._vx += fx;
480
+ s._vy += fy;
481
+ t._vx -= fx;
482
+ t._vy -= fy;
483
+ }
484
+
485
+ // Center gravity
486
+ for (const n of nodes) {
487
+ n._vx += (width / 2 - n._x) * 0.001;
488
+ n._vy += (height / 2 - n._y) * 0.001;
489
+ n._x += n._vx * 0.4;
490
+ n._y += n._vy * 0.4;
491
+ n._vx *= 0.7;
492
+ n._vy *= 0.7;
493
+ // Bounds
494
+ n._x = Math.max(30, Math.min(width - 30, n._x));
495
+ n._y = Math.max(30, Math.min(height - 30, n._y));
496
+ }
497
+ }
498
+
499
+ // Render SVG
500
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
501
+ g.setAttribute('id', 'graph-group');
502
+
503
+ // Edges
504
+ for (const e of edges) {
505
+ const s = nodeMap.get(e.source);
506
+ const t = nodeMap.get(e.target);
507
+ if (!s || !t) continue;
508
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
509
+ line.setAttribute('x1', s._x);
510
+ line.setAttribute('y1', s._y);
511
+ line.setAttribute('x2', t._x);
512
+ line.setAttribute('y2', t._y);
513
+ line.setAttribute('stroke', '#333');
514
+ line.setAttribute('stroke-width', '1');
515
+ line.setAttribute('opacity', '0.4');
516
+ g.appendChild(line);
517
+ }
518
+
519
+ // Nodes
520
+ for (const n of nodes) {
521
+ const color = TYPE_COLORS[n.type] || TYPE_COLORS.unknown;
522
+ const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
523
+ group.setAttribute('transform', `translate(${n._x}, ${n._y})`);
524
+ group.style.cursor = 'pointer';
525
+ group.addEventListener('click', () => showNodeDetail(n));
526
+ group.addEventListener('mouseenter', (evt) => showTooltip(evt, n));
527
+ group.addEventListener('mouseleave', hideTooltip);
528
+
529
+ // Circle node
530
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
531
+ circle.setAttribute('r', n.type === 'module' ? 14 : n.type === 'api' || n.type === 'model' ? 10 : 7);
532
+ circle.setAttribute('fill', color);
533
+ circle.setAttribute('opacity', '0.85');
534
+ circle.setAttribute('stroke', selectedNode === n.id ? '#fff' : 'none');
535
+ circle.setAttribute('stroke-width', '2');
536
+ group.appendChild(circle);
537
+
538
+ // Label
539
+ const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
540
+ text.setAttribute('y', n.type === 'module' ? 24 : 18);
541
+ text.setAttribute('text-anchor', 'middle');
542
+ text.setAttribute('fill', '#ccc');
543
+ text.setAttribute('font-size', n.type === 'module' ? '11' : '9');
544
+ text.setAttribute('font-family', 'Courier New, monospace');
545
+ const label = n.label.length > 20 ? n.label.slice(0, 18) + '..' : n.label;
546
+ text.textContent = label;
547
+ group.appendChild(text);
548
+
549
+ g.appendChild(group);
550
+ }
551
+
552
+ svg.appendChild(g);
553
+
554
+ // Pan & Zoom
555
+ svg.addEventListener('wheel', (e) => {
556
+ e.preventDefault();
557
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
558
+ transform.scale *= delta;
559
+ transform.scale = Math.max(0.2, Math.min(5, transform.scale));
560
+ applyTransform();
561
+ });
562
+
563
+ let dragging = false, lastX, lastY;
564
+ svg.addEventListener('mousedown', (e) => { dragging = true; lastX = e.clientX; lastY = e.clientY; });
565
+ svg.addEventListener('mousemove', (e) => {
566
+ if (!dragging) return;
567
+ transform.x += e.clientX - lastX;
568
+ transform.y += e.clientY - lastY;
569
+ lastX = e.clientX;
570
+ lastY = e.clientY;
571
+ applyTransform();
572
+ });
573
+ svg.addEventListener('mouseup', () => { dragging = false; });
574
+ svg.addEventListener('mouseleave', () => { dragging = false; });
575
+ }
576
+
577
+ function applyTransform() {
578
+ const g = document.getElementById('graph-group');
579
+ if (g) g.setAttribute('transform', `translate(${transform.x},${transform.y}) scale(${transform.scale})`);
580
+ }
581
+
582
+ // ===== Sidebar Renderers =====
583
+ function renderNodeTypeFilter() {
584
+ const list = document.getElementById('node-type-list');
585
+ const typeCounts = {};
586
+ for (const n of (graphData.nodes || [])) {
587
+ typeCounts[n.type] = (typeCounts[n.type] || 0) + 1;
588
+ }
589
+
590
+ list.innerHTML = Object.entries(typeCounts)
591
+ .sort((a, b) => b[1] - a[1])
592
+ .map(([type, count]) => `
593
+ <li class="node-type-item" onclick="filterByType('${type}')">
594
+ <div class="node-type-dot" style="background:${TYPE_COLORS[type] || '#555'}"></div>
595
+ <span>${TYPE_LABELS[type] || type}</span>
596
+ <span class="node-type-count">${count}</span>
597
+ </li>
598
+ `).join('');
599
+ }
600
+
601
+ function renderRiskList() {
602
+ const list = document.getElementById('risk-list');
603
+ list.innerHTML = riskData.slice(0, 20).map(r => `
604
+ <li class="risk-item" onclick="showRiskDetail('${r.id}')">
605
+ <span class="risk-badge risk-${r.severity}">${r.severity}</span>
606
+ ${escapeHtml(r.title)}
607
+ </li>
608
+ `).join('');
609
+ }
610
+
611
+ // ===== Node Detail Panel =====
612
+ async function showNodeDetail(node) {
613
+ selectedNode = node.id;
614
+ renderGraph(); // Highlight selected
615
+
616
+ const panel = document.getElementById('panel');
617
+ panel.classList.add('open');
618
+ document.getElementById('panel-title').textContent = node.label;
619
+
620
+ // Fetch node detail
621
+ const res = await fetch('/api/studio/node/' + encodeURIComponent(node.id));
622
+ const data = await res.json();
623
+
624
+ let html = `<p><b>类型:</b> ${TYPE_LABELS[node.type] || node.type}</p>`;
625
+ if (node.filePath) html += `<p><b>文件:</b> ${escapeHtml(node.filePath)}</p>`;
626
+ if (node.language) html += `<p><b>语言:</b> ${node.language}</p>`;
627
+ if (node.module) html += `<p><b>模块:</b> ${node.module}</p>`;
628
+
629
+ if (data.outgoing?.length > 0) {
630
+ html += `<h3>输出关系 (${data.outgoing.length})</h3><ul>`;
631
+ data.outgoing.slice(0, 15).forEach(e => {
632
+ const target = data.neighbors?.find(n => n.id === e.target);
633
+ html += `<li>${e.relation} → ${escapeHtml(target?.label || e.target)}</li>`;
634
+ });
635
+ html += '</ul>';
636
+ }
637
+ if (data.incoming?.length > 0) {
638
+ html += `<h3>输入关系 (${data.incoming.length})</h3><ul>`;
639
+ data.incoming.slice(0, 15).forEach(e => {
640
+ const source = data.neighbors?.find(n => n.id === e.source);
641
+ html += `<li>${escapeHtml(source?.label || e.source)} → ${e.relation}</li>`;
642
+ });
643
+ html += '</ul>';
644
+ }
645
+
646
+ // Impact analysis button
647
+ html += `<div style="margin-top:16px"><button class="btn btn-primary btn-sm" onclick="showImpact('${encodeURIComponent(node.id)}')">🔍 影响分析</button></div>`;
648
+
649
+ document.getElementById('panel-body').innerHTML = html;
650
+ }
651
+
652
+ async function showImpact(encodedNodeId) {
653
+ const res = await fetch('/api/studio/impact/' + encodedNodeId);
654
+ const data = await res.json();
655
+
656
+ let html = `<h3>影响分析</h3>`;
657
+ html += `<p>${escapeHtml(data.summary || '')}</p>`;
658
+ html += `<p><b>直接影响:</b> ${data.directImpact?.length || 0} 个实体</p>`;
659
+ html += `<p><b>传递影响:</b> ${data.transitiveImpact?.length || 0} 个实体</p>`;
660
+ html += `<p><b>风险等级:</b> <span class="risk-badge risk-${data.riskLevel || 'low'}">${data.riskLevel || '?'}</span></p>`;
661
+
662
+ document.getElementById('panel-body').innerHTML += html;
663
+ }
664
+
665
+ function showRiskDetail(riskId) {
666
+ const risk = riskData.find(r => r.id === riskId);
667
+ if (!risk) return;
668
+
669
+ const panel = document.getElementById('panel');
670
+ panel.classList.add('open');
671
+ document.getElementById('panel-title').textContent = '⚠️ 风险详情';
672
+ document.getElementById('panel-body').innerHTML = `
673
+ <p><span class="risk-badge risk-${risk.severity}">${risk.severity}</span> <b>${risk.category}</b></p>
674
+ <h3>${escapeHtml(risk.title)}</h3>
675
+ <p>${escapeHtml(risk.description)}</p>
676
+ ${risk.suggestion ? `<h3>建议</h3><p>${escapeHtml(risk.suggestion)}</p>` : ''}
677
+ <p style="color:var(--text-muted);margin-top:8px">置信度: ${(risk.confidence * 100).toFixed(0)}%</p>
678
+ `;
679
+ }
680
+
681
+ // ===== Perspective Reports =====
682
+ document.getElementById('perspective-tabs').addEventListener('click', async (e) => {
683
+ const tab = e.target.closest('.perspective-tab');
684
+ if (!tab) return;
685
+
686
+ // Update active state
687
+ document.querySelectorAll('.perspective-tab').forEach(t => t.classList.remove('active'));
688
+ tab.classList.add('active');
689
+
690
+ const view = tab.dataset.view;
691
+ const perspective = tab.dataset.perspective;
692
+
693
+ if (view === 'graph') {
694
+ document.getElementById('graph-canvas').style.display = '';
695
+ document.getElementById('report-view').style.display = 'none';
696
+ renderGraph();
697
+ return;
698
+ }
699
+
700
+ if (!graphData.nodes?.length) return;
701
+
702
+ // Show report view
703
+ document.getElementById('graph-canvas').style.display = 'none';
704
+ document.getElementById('report-view').style.display = 'block';
705
+ document.getElementById('report-content').innerHTML = '<div class="loading-text">生成报告中...</div>';
706
+
707
+ const res = await fetch('/api/studio/report/' + perspective);
708
+ const report = await res.json();
709
+
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>`;
712
+
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>`;
718
+ }
719
+ }
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
+ });
724
+
725
+ // ===== Theme Toggle =====
726
+ function toggleTheme() {
727
+ currentTheme = currentTheme === 'pixel' ? 'professional' : 'pixel';
728
+ document.documentElement.setAttribute('data-theme', currentTheme === 'pixel' ? '' : 'professional');
729
+ }
730
+
731
+ function togglePanel() {
732
+ document.getElementById('panel').classList.toggle('open');
733
+ }
734
+
735
+ function filterByType(type) {
736
+ // TODO: implement filter highlighting
737
+ }
738
+
739
+ // ===== Tooltip =====
740
+ function showTooltip(evt, node) {
741
+ const tooltip = document.getElementById('tooltip');
742
+ tooltip.innerHTML = `<b>${escapeHtml(node.label)}</b><br>` +
743
+ `类型: ${TYPE_LABELS[node.type] || node.type}<br>` +
744
+ (node.module ? `模块: ${node.module}<br>` : '') +
745
+ (node.filePath ? `文件: ${escapeHtml(node.filePath)}` : '');
746
+ tooltip.style.left = (evt.clientX + 15) + 'px';
747
+ tooltip.style.top = (evt.clientY - 10) + 'px';
748
+ tooltip.classList.add('visible');
749
+ }
750
+
751
+ function hideTooltip() {
752
+ document.getElementById('tooltip').classList.remove('visible');
753
+ }
754
+
755
+ // ===== WebSocket for live updates =====
756
+ function connectWS() {
757
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
758
+ const ws = new WebSocket(protocol + '//' + location.host + '/ws');
759
+
760
+ ws.onmessage = (e) => {
761
+ try {
762
+ 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(); }
765
+ else if (msg.type === 'scan:progress') {
766
+ document.getElementById('loading-detail').textContent = msg.payload.detail || '';
767
+ }
768
+ else if (msg.type === 'log') {
769
+ console.log(`[${msg.payload.level}] ${msg.payload.message}`);
770
+ }
771
+ } catch {}
772
+ };
773
+
774
+ ws.onclose = () => setTimeout(connectWS, 3000);
775
+ }
776
+
777
+ function updateAgents(agents) {
778
+ if (!Array.isArray(agents)) return;
779
+ const agentMap = { 'parser-croc': 'agent-parser', 'analyzer-croc': 'agent-analyzer', 'planner-croc': 'agent-planner', 'tester-croc': 'agent-tester', 'healer-croc': 'agent-healer', 'reporter-croc': 'agent-reporter' };
780
+ for (const a of agents) {
781
+ const el = document.getElementById(agentMap[a.id]);
782
+ if (el) { el.className = 'agent-status ' + a.status; }
783
+ }
784
+ }
785
+
786
+ // ===== Helpers =====
787
+ function escapeHtml(s) { if (!s) return ''; return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
788
+
789
+ function markdownToHtml(md) {
790
+ if (!md) return '';
791
+ let html = escapeHtml(md);
792
+ html = html.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
793
+ html = html.replace(/`([^`]+)`/g, '<code style="background:var(--bg-input);padding:2px 4px;border-radius:3px">$1</code>');
794
+ html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
795
+ html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
796
+ html = html.replace(/\n/g, '<br>');
797
+ return html;
798
+ }
799
+
800
+ // ===== Init =====
801
+ connectWS();
802
+ </script>
803
+ </body>
804
+ </html>