opencroc 1.8.1 → 1.8.2
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.
- package/dist/cli/index.js +755 -8
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +128 -1
- package/dist/index.js +548 -0
- package/dist/index.js.map +1 -1
- package/dist/web/dist/assets/main-Ccg3eDNK.js +1 -0
- package/dist/web/dist/assets/office-runtime-B3iNctxE.css +1 -0
- package/dist/web/dist/assets/office-runtime-BsCh82Pj.js +183 -0
- package/dist/web/dist/assets/pixel-page-3BYGm7dH.js +470 -0
- package/dist/web/dist/assets/react-vendor-C8RhVn0h.js +49 -0
- package/dist/web/dist/assets/studio-page-BInoyoV2.css +1 -0
- package/dist/web/dist/assets/studio-page-o3SCvE_v.js +351 -0
- package/dist/web/dist/assets/three-addons-BdrPp04O.js +470 -0
- package/dist/web/dist/assets/three-core-CsxM1PCY.js +4057 -0
- package/dist/web/dist/index.html +15 -0
- package/dist/web/index.html +11 -572
- package/dist/web/public/botreview/char_0.png +0 -0
- package/dist/web/public/botreview/char_1.png +0 -0
- package/dist/web/public/botreview/char_2.png +0 -0
- package/dist/web/public/botreview/coffee-machine.gif +0 -0
- package/dist/web/public/botreview/server.gif +0 -0
- package/dist/web/public/botreview/walls.png +0 -0
- package/dist/web/public/star/desk-v3.webp +0 -0
- package/dist/web/public/star/office_bg_small.webp +0 -0
- package/dist/web/public/star/star-idle-v5.png +0 -0
- package/dist/web/public/star/star-working-spritesheet-grid.webp +0 -0
- package/dist/web/src/app/AppLayout.tsx +34 -0
- package/dist/web/src/app/AppRouter.tsx +46 -0
- package/dist/web/src/app/bootstrap.tsx +22 -0
- package/dist/web/src/app/routes.tsx +52 -0
- package/dist/web/src/features/office/runtime/index.ts +1 -0
- package/dist/web/src/features/office/runtime/mount.ts +809 -0
- package/dist/web/src/features/pixel/runtime/index.ts +1 -0
- package/dist/web/src/features/pixel/runtime/mount.ts +728 -0
- package/dist/web/src/features/studio/runtime/index.ts +1 -0
- package/dist/web/src/features/studio/runtime/mount.ts +664 -0
- package/dist/web/src/features/three/engine/index.ts +1 -0
- package/dist/web/src/main.tsx +7 -0
- package/dist/web/src/pages/office/index.ts +1 -0
- package/dist/web/src/pages/office/page.tsx +283 -0
- package/dist/web/src/pages/pixel/index.ts +1 -0
- package/dist/web/src/pages/pixel/page.tsx +564 -0
- package/dist/web/src/pages/studio/index.ts +1 -0
- package/dist/web/src/pages/studio/page.tsx +446 -0
- package/dist/web/{js/agents.js → src/runtime/agents.ts} +304 -31
- package/dist/web/{js/camera.js → src/runtime/camera.ts} +12 -5
- package/dist/web/{js/dataviz.js → src/runtime/dataviz.ts} +38 -14
- package/dist/web/{js/effects.js → src/runtime/effects.ts} +139 -2
- package/dist/web/{js/engine.js → src/runtime/engine.ts} +45 -6
- package/dist/web/{js/office.js → src/runtime/office.ts} +136 -20
- package/dist/web/{js/ui.js → src/runtime/ui.ts} +11 -7
- package/dist/web/src/shared/assets.ts +4 -0
- package/dist/web/src/shared/navigation.ts +47 -0
- package/dist/web/src/styles/app-layout.css +19 -0
- package/dist/web/src/styles/office.css +268 -0
- package/dist/web/tsconfig.json +28 -0
- package/dist/web/vite.config.ts +93 -0
- package/package.json +11 -2
- package/dist/web/index-studio.html +0 -1644
- package/dist/web/index-v2-pixel.html +0 -1571
- /package/dist/web/{assets → dist}/botreview/char_0.png +0 -0
- /package/dist/web/{assets → dist}/botreview/char_1.png +0 -0
- /package/dist/web/{assets → dist}/botreview/char_2.png +0 -0
- /package/dist/web/{assets → dist}/botreview/coffee-machine.gif +0 -0
- /package/dist/web/{assets → dist}/botreview/server.gif +0 -0
- /package/dist/web/{assets → dist}/botreview/walls.png +0 -0
- /package/dist/web/{assets → dist}/star/desk-v3.webp +0 -0
- /package/dist/web/{assets → dist}/star/office_bg_small.webp +0 -0
- /package/dist/web/{assets → dist}/star/star-idle-v5.png +0 -0
- /package/dist/web/{assets → dist}/star/star-working-spritesheet-grid.webp +0 -0
- /package/dist/web/{js/state.js → src/runtime/state.ts} +0 -0
|
@@ -1,1644 +0,0 @@
|
|
|
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
|
-
.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
|
-
|
|
160
|
-
/* ===== Graph Canvas ===== */
|
|
161
|
-
#graph-canvas { width: 100%; height: 100%; background: var(--bg-primary); }
|
|
162
|
-
|
|
163
|
-
/* ===== Perspective Tabs ===== */
|
|
164
|
-
.perspective-tabs { display: flex; gap: 4px; padding: 8px 16px; background: var(--bg-secondary); border-bottom: 1px solid var(--border); overflow-x: auto; }
|
|
165
|
-
.perspective-tab {
|
|
166
|
-
padding: 4px 12px; border-radius: var(--radius); font-size: 11px; cursor: pointer;
|
|
167
|
-
background: var(--bg-card); color: var(--text-secondary); white-space: nowrap; border: 1px solid transparent;
|
|
168
|
-
}
|
|
169
|
-
.perspective-tab:hover { color: var(--text-primary); border-color: var(--border); }
|
|
170
|
-
.perspective-tab.active { background: var(--accent); color: #000; font-weight: bold; }
|
|
171
|
-
|
|
172
|
-
/* ===== Panel Content ===== */
|
|
173
|
-
.panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
|
|
174
|
-
.panel-header h2 { font-size: 14px; }
|
|
175
|
-
.panel-body { padding: 16px; }
|
|
176
|
-
.panel-body h3 { font-size: 13px; margin: 12px 0 6px; color: var(--accent); }
|
|
177
|
-
.panel-body p { font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
|
|
178
|
-
.panel-body pre { background: var(--bg-input); padding: 10px; border-radius: var(--radius); font-size: 11px; overflow-x: auto; margin: 8px 0; }
|
|
179
|
-
.panel-body ul { padding-left: 16px; font-size: 12px; line-height: 1.8; }
|
|
180
|
-
|
|
181
|
-
/* ===== Loading & Status ===== */
|
|
182
|
-
.loading-overlay {
|
|
183
|
-
position: absolute; inset: 0; background: rgba(0,0,0,0.7); display: flex;
|
|
184
|
-
flex-direction: column; align-items: center; justify-content: center; z-index: 100;
|
|
185
|
-
}
|
|
186
|
-
.loading-overlay.hidden { display: none; }
|
|
187
|
-
.loading-croc { font-size: 64px; animation: bounce 0.6s infinite alternate; }
|
|
188
|
-
@keyframes bounce { from { transform: translateY(0); } to { transform: translateY(-15px); } }
|
|
189
|
-
.loading-text { margin-top: 12px; color: var(--accent); font-size: 14px; }
|
|
190
|
-
.loading-detail { color: var(--text-secondary); font-size: 11px; margin-top: 4px; }
|
|
191
|
-
|
|
192
|
-
/* ===== Welcome Screen ===== */
|
|
193
|
-
.welcome {
|
|
194
|
-
position: absolute; inset: 0; display: flex; flex-direction: column;
|
|
195
|
-
align-items: center; justify-content: center; z-index: 50;
|
|
196
|
-
}
|
|
197
|
-
.welcome.hidden { display: none; }
|
|
198
|
-
.welcome-croc { font-size: 80px; animation: bounce 1s infinite alternate; }
|
|
199
|
-
.welcome h1 { font-size: 32px; color: var(--accent); margin-top: 20px; }
|
|
200
|
-
.welcome p { color: var(--text-secondary); margin-top: 8px; font-size: 14px; }
|
|
201
|
-
.welcome-actions { margin-top: 24px; display: flex; gap: 12px; }
|
|
202
|
-
.welcome-input { width: 400px; padding: 12px 16px; font-size: 14px; }
|
|
203
|
-
|
|
204
|
-
/* ===== Scrollbar ===== */
|
|
205
|
-
::-webkit-scrollbar { width: 6px; }
|
|
206
|
-
::-webkit-scrollbar-track { background: var(--bg-primary); }
|
|
207
|
-
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
208
|
-
|
|
209
|
-
/* ===== Agent Bar ===== */
|
|
210
|
-
.agent-bar { display: flex; gap: 8px; padding: 8px 16px; background: var(--bg-secondary); border-top: 1px solid var(--border); }
|
|
211
|
-
.agent-chip {
|
|
212
|
-
display: flex; align-items: center; gap: 4px; padding: 4px 10px;
|
|
213
|
-
background: var(--bg-card); border-radius: 12px; font-size: 11px;
|
|
214
|
-
}
|
|
215
|
-
.agent-status { width: 6px; height: 6px; border-radius: 50%; }
|
|
216
|
-
.agent-status.idle { background: var(--text-muted); }
|
|
217
|
-
.agent-status.working { background: var(--warning); animation: pulse 1s infinite; }
|
|
218
|
-
.agent-status.done { background: var(--accent); }
|
|
219
|
-
.agent-status.error { background: var(--danger); }
|
|
220
|
-
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
221
|
-
|
|
222
|
-
/* ===== Tooltip ===== */
|
|
223
|
-
.tooltip {
|
|
224
|
-
position: fixed; background: var(--bg-card); border: 1px solid var(--border);
|
|
225
|
-
padding: 8px 12px; border-radius: var(--radius); font-size: 11px;
|
|
226
|
-
pointer-events: none; z-index: 200; max-width: 280px; box-shadow: 0 4px 12px var(--shadow);
|
|
227
|
-
display: none;
|
|
228
|
-
}
|
|
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
|
-
}
|
|
319
|
-
</style>
|
|
320
|
-
</head>
|
|
321
|
-
<body>
|
|
322
|
-
<div class="app" id="app">
|
|
323
|
-
<!-- Sidebar -->
|
|
324
|
-
<div class="sidebar">
|
|
325
|
-
<div class="sidebar-header">
|
|
326
|
-
<div class="logo">
|
|
327
|
-
<span class="logo-icon">🐊</span>
|
|
328
|
-
<span class="logo-text">OpenCroc Studio</span>
|
|
329
|
-
</div>
|
|
330
|
-
<div class="scan-input-group">
|
|
331
|
-
<input id="scan-input" class="scan-input" placeholder="路径 / GitHub URL / user/repo" />
|
|
332
|
-
<button id="scan-btn" class="btn btn-primary btn-sm" onclick="startScan()">扫描</button>
|
|
333
|
-
</div>
|
|
334
|
-
</div>
|
|
335
|
-
|
|
336
|
-
<!-- Stats Section -->
|
|
337
|
-
<div class="sidebar-section" id="stats-section" style="display:none">
|
|
338
|
-
<h3>项目概览</h3>
|
|
339
|
-
<div id="project-name" style="font-size:14px;font-weight:bold;margin-bottom:6px;color:var(--accent)"></div>
|
|
340
|
-
<div id="project-type" style="font-size:11px;color:var(--text-secondary);margin-bottom:8px"></div>
|
|
341
|
-
<div class="stat-grid">
|
|
342
|
-
<div class="stat-item"><div class="stat-value" id="stat-apis">0</div><div class="stat-label">APIs</div></div>
|
|
343
|
-
<div class="stat-item"><div class="stat-value" id="stat-models">0</div><div class="stat-label">Models</div></div>
|
|
344
|
-
<div class="stat-item"><div class="stat-value" id="stat-files">0</div><div class="stat-label">Files</div></div>
|
|
345
|
-
<div class="stat-item"><div class="stat-value" id="stat-risks">0</div><div class="stat-label">Risks</div></div>
|
|
346
|
-
</div>
|
|
347
|
-
<div style="margin-top:8px">
|
|
348
|
-
<div style="display:flex;justify-content:space-between;font-size:11px">
|
|
349
|
-
<span>健康度</span><span id="health-score">—</span>
|
|
350
|
-
</div>
|
|
351
|
-
<div class="health-bar"><div class="health-fill" id="health-fill" style="width:0%;background:var(--accent)"></div></div>
|
|
352
|
-
</div>
|
|
353
|
-
</div>
|
|
354
|
-
|
|
355
|
-
<!-- Node Types Filter -->
|
|
356
|
-
<div class="sidebar-section" id="filter-section" style="display:none">
|
|
357
|
-
<h3>实体类型</h3>
|
|
358
|
-
<ul class="node-type-list" id="node-type-list"></ul>
|
|
359
|
-
</div>
|
|
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
|
-
|
|
368
|
-
<!-- Risks -->
|
|
369
|
-
<div class="sidebar-section" id="risk-section" style="display:none;flex:1;overflow-y:auto">
|
|
370
|
-
<h3>风险点 <span id="risk-count" style="color:var(--danger)"></span></h3>
|
|
371
|
-
<ul class="risk-list" id="risk-list"></ul>
|
|
372
|
-
</div>
|
|
373
|
-
</div>
|
|
374
|
-
|
|
375
|
-
<!-- Main Content -->
|
|
376
|
-
<div class="main-content">
|
|
377
|
-
<!-- Header -->
|
|
378
|
-
<div class="header">
|
|
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>
|
|
381
|
-
<div class="perspective-tab active" data-view="graph">📊 知识图谱</div>
|
|
382
|
-
<div class="perspective-tab" data-perspective="developer">👨💻 开发者</div>
|
|
383
|
-
<div class="perspective-tab" data-perspective="architect">🏗️ 架构师</div>
|
|
384
|
-
<div class="perspective-tab" data-perspective="tester">🧪 测试</div>
|
|
385
|
-
<div class="perspective-tab" data-perspective="product">📋 产品</div>
|
|
386
|
-
<div class="perspective-tab" data-perspective="student">🎓 学生</div>
|
|
387
|
-
<div class="perspective-tab" data-perspective="executive">📈 管理层</div>
|
|
388
|
-
</div>
|
|
389
|
-
<div class="header-actions">
|
|
390
|
-
<button class="btn btn-icon" onclick="focusOnSelectedNode()" title="聚焦选中节点">🎯</button>
|
|
391
|
-
<button class="btn btn-icon" onclick="toggleTheme()" title="切换主题">🎨</button>
|
|
392
|
-
<button class="btn btn-icon" onclick="togglePanel()" title="详情面板">📋</button>
|
|
393
|
-
</div>
|
|
394
|
-
</div>
|
|
395
|
-
|
|
396
|
-
<!-- Graph Area -->
|
|
397
|
-
<div class="graph-area" id="graph-area">
|
|
398
|
-
<!-- Welcome Screen -->
|
|
399
|
-
<div class="welcome" id="welcome">
|
|
400
|
-
<div class="welcome-croc">🐊</div>
|
|
401
|
-
<h1>OpenCroc Studio</h1>
|
|
402
|
-
<p>任意项目 → 60秒 → 知识图谱 + 风险分析 + 多角色报告</p>
|
|
403
|
-
<div class="welcome-actions">
|
|
404
|
-
<input id="welcome-input" class="scan-input welcome-input" placeholder="输入本地路径、GitHub URL 或 user/repo" />
|
|
405
|
-
<button class="btn btn-primary" onclick="startScanFromWelcome()">开始分析</button>
|
|
406
|
-
</div>
|
|
407
|
-
<div style="margin-top:16px;color:var(--text-muted);font-size:12px">
|
|
408
|
-
示例: <code>./backend</code> | <code>https://github.com/expressjs/express</code> | <code>facebook/react</code>
|
|
409
|
-
</div>
|
|
410
|
-
</div>
|
|
411
|
-
|
|
412
|
-
<!-- Loading Overlay -->
|
|
413
|
-
<div class="loading-overlay hidden" id="loading">
|
|
414
|
-
<div class="loading-croc">🐊</div>
|
|
415
|
-
<div class="loading-text" id="loading-text">扫描中...</div>
|
|
416
|
-
<div class="loading-detail" id="loading-detail"></div>
|
|
417
|
-
</div>
|
|
418
|
-
|
|
419
|
-
<!-- SVG Graph Canvas -->
|
|
420
|
-
<svg id="graph-canvas"></svg>
|
|
421
|
-
<div class="graph-empty visible" id="graph-empty">暂无图谱数据,先在左侧输入路径并扫描</div>
|
|
422
|
-
|
|
423
|
-
<!-- Report View (hidden by default) -->
|
|
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>
|
|
433
|
-
<div id="report-content" style="max-width:800px;margin:0 auto"></div>
|
|
434
|
-
</div>
|
|
435
|
-
</div>
|
|
436
|
-
|
|
437
|
-
<!-- Agent Bar -->
|
|
438
|
-
<div class="agent-bar" id="agent-bar">
|
|
439
|
-
<div class="agent-chip"><div class="agent-status idle" id="agent-parser"></div> 解析鳄</div>
|
|
440
|
-
<div class="agent-chip"><div class="agent-status idle" id="agent-analyzer"></div> 分析鳄</div>
|
|
441
|
-
<div class="agent-chip"><div class="agent-status idle" id="agent-planner"></div> 规划鳄</div>
|
|
442
|
-
<div class="agent-chip"><div class="agent-status idle" id="agent-tester"></div> 测试鳄</div>
|
|
443
|
-
<div class="agent-chip"><div class="agent-status idle" id="agent-healer"></div> 修复鳄</div>
|
|
444
|
-
<div class="agent-chip"><div class="agent-status idle" id="agent-reporter"></div> 汇报鳄</div>
|
|
445
|
-
</div>
|
|
446
|
-
</div>
|
|
447
|
-
|
|
448
|
-
<!-- Right Panel -->
|
|
449
|
-
<div class="panel" id="panel">
|
|
450
|
-
<div class="panel-header">
|
|
451
|
-
<h2 id="panel-title">详情</h2>
|
|
452
|
-
<button class="btn btn-sm" onclick="togglePanel()">✕</button>
|
|
453
|
-
</div>
|
|
454
|
-
<div class="panel-body" id="panel-body"></div>
|
|
455
|
-
</div>
|
|
456
|
-
</div>
|
|
457
|
-
|
|
458
|
-
<!-- Tooltip -->
|
|
459
|
-
<div class="tooltip" id="tooltip"></div>
|
|
460
|
-
|
|
461
|
-
<script>
|
|
462
|
-
// ===== State =====
|
|
463
|
-
let graphData = { nodes: [], edges: [] };
|
|
464
|
-
let riskData = [];
|
|
465
|
-
let currentTheme = 'pixel';
|
|
466
|
-
let selectedNode = null;
|
|
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;
|
|
485
|
-
|
|
486
|
-
// Node type colors
|
|
487
|
-
const TYPE_COLORS = {
|
|
488
|
-
model: '#4ecca3', api: '#e94560', service: '#3498db', module: '#f39c12',
|
|
489
|
-
component: '#9b59b6', file: '#666', dependency: '#888', class: '#2ecc71',
|
|
490
|
-
function: '#1abc9c', middleware: '#e67e22', route: '#e94560', database: '#8e44ad',
|
|
491
|
-
cache: '#d35400', queue: '#c0392b', 'external-api': '#7f8c8d',
|
|
492
|
-
permission: '#e74c3c', page: '#16a085', store: '#2980b9', test: '#27ae60',
|
|
493
|
-
unknown: '#555',
|
|
494
|
-
};
|
|
495
|
-
|
|
496
|
-
const TYPE_LABELS = {
|
|
497
|
-
model: '数据模型', api: 'API接口', service: '服务', module: '模块',
|
|
498
|
-
component: '组件', file: '文件', dependency: '依赖', class: '类',
|
|
499
|
-
function: '函数', middleware: '中间件', route: '路由', database: '数据库',
|
|
500
|
-
cache: '缓存', queue: '队列', 'external-api': '外部API',
|
|
501
|
-
permission: '权限', page: '页面', store: '状态管理', test: '测试',
|
|
502
|
-
unknown: '未知',
|
|
503
|
-
};
|
|
504
|
-
|
|
505
|
-
// ===== Scan =====
|
|
506
|
-
async function startScan() {
|
|
507
|
-
const target = document.getElementById('scan-input').value.trim();
|
|
508
|
-
if (!target) return;
|
|
509
|
-
await doScan(target);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
function startScanFromWelcome() {
|
|
513
|
-
const target = document.getElementById('welcome-input').value.trim();
|
|
514
|
-
if (!target) return;
|
|
515
|
-
document.getElementById('scan-input').value = target;
|
|
516
|
-
doScan(target);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
async function doScan(target) {
|
|
520
|
-
reportCache.clear();
|
|
521
|
-
currentReport = null;
|
|
522
|
-
currentReportPerspective = null;
|
|
523
|
-
document.getElementById('welcome').classList.add('hidden');
|
|
524
|
-
document.getElementById('loading').classList.remove('hidden');
|
|
525
|
-
document.getElementById('loading-text').textContent = '正在扫描 ' + target + '...';
|
|
526
|
-
document.getElementById('loading-detail').textContent = '初始化扫描任务...';
|
|
527
|
-
appendOperationLog('开始扫描: ' + target, 'info');
|
|
528
|
-
|
|
529
|
-
try {
|
|
530
|
-
const res = await fetch('/api/studio/scan', {
|
|
531
|
-
method: 'POST',
|
|
532
|
-
headers: { 'Content-Type': 'application/json' },
|
|
533
|
-
body: JSON.stringify({ target }),
|
|
534
|
-
});
|
|
535
|
-
const data = await res.json();
|
|
536
|
-
|
|
537
|
-
if (!res.ok) {
|
|
538
|
-
document.getElementById('loading-text').textContent = '❌ ' + (data.error || '扫描失败');
|
|
539
|
-
appendOperationLog('扫描失败: ' + (data.error || 'unknown error'), 'error');
|
|
540
|
-
setTimeout(() => document.getElementById('loading').classList.add('hidden'), 3000);
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
// Load graph and risks
|
|
545
|
-
await loadGraph();
|
|
546
|
-
await loadRisks();
|
|
547
|
-
await loadSummary();
|
|
548
|
-
await loadSnapshots();
|
|
549
|
-
|
|
550
|
-
document.getElementById('loading').classList.add('hidden');
|
|
551
|
-
document.getElementById('stats-section').style.display = '';
|
|
552
|
-
document.getElementById('filter-section').style.display = '';
|
|
553
|
-
document.getElementById('snapshot-section').style.display = '';
|
|
554
|
-
document.getElementById('risk-section').style.display = '';
|
|
555
|
-
appendOperationLog('扫描完成,图谱已更新', 'info');
|
|
556
|
-
} catch (err) {
|
|
557
|
-
document.getElementById('loading-text').textContent = '❌ ' + err.message;
|
|
558
|
-
appendOperationLog('扫描异常: ' + err.message, 'error');
|
|
559
|
-
setTimeout(() => document.getElementById('loading').classList.add('hidden'), 3000);
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
async function loadGraph() {
|
|
564
|
-
const res = await fetch('/api/studio/graph');
|
|
565
|
-
if (!res.ok) return;
|
|
566
|
-
const data = await res.json();
|
|
567
|
-
graphData = data;
|
|
568
|
-
renderGraph();
|
|
569
|
-
renderNodeTypeFilter();
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
async function loadRisks() {
|
|
573
|
-
const res = await fetch('/api/studio/risks');
|
|
574
|
-
if (!res.ok) return;
|
|
575
|
-
const data = await res.json();
|
|
576
|
-
riskData = data.risks || [];
|
|
577
|
-
renderRiskList();
|
|
578
|
-
document.getElementById('stat-risks').textContent = riskData.length;
|
|
579
|
-
document.getElementById('risk-count').textContent = `(${riskData.length})`;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
async function loadSummary() {
|
|
583
|
-
const res = await fetch('/api/studio/summary');
|
|
584
|
-
if (!res.ok) return;
|
|
585
|
-
const data = await res.json();
|
|
586
|
-
document.getElementById('project-name').textContent = data.name || '—';
|
|
587
|
-
document.getElementById('project-type').textContent = data.oneLiner || '';
|
|
588
|
-
document.getElementById('stat-apis').textContent = data.stats?.apiCount || 0;
|
|
589
|
-
document.getElementById('stat-models').textContent = data.stats?.modelCount || 0;
|
|
590
|
-
document.getElementById('stat-files').textContent = data.stats?.fileCount || 0;
|
|
591
|
-
document.getElementById('health-score').textContent = (data.healthScore || 0) + '/100';
|
|
592
|
-
const fill = document.getElementById('health-fill');
|
|
593
|
-
fill.style.width = (data.healthScore || 0) + '%';
|
|
594
|
-
fill.style.background = data.healthScore >= 80 ? '#4ecca3' : data.healthScore >= 60 ? '#f39c12' : '#e94560';
|
|
595
|
-
}
|
|
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
|
-
|
|
609
|
-
// ===== Graph Rendering (Force-Directed) =====
|
|
610
|
-
function renderGraph() {
|
|
611
|
-
const svg = document.getElementById('graph-canvas');
|
|
612
|
-
const empty = document.getElementById('graph-empty');
|
|
613
|
-
const { width, height } = svg.getBoundingClientRect();
|
|
614
|
-
svg.innerHTML = '';
|
|
615
|
-
|
|
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));
|
|
619
|
-
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
|
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
|
-
}
|
|
631
|
-
|
|
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');
|
|
642
|
-
|
|
643
|
-
// Initialize positions (circular layout)
|
|
644
|
-
visibleNodes.forEach((n, i) => {
|
|
645
|
-
const angle = (i / visibleNodes.length) * 2 * Math.PI;
|
|
646
|
-
const r = Math.min(width, height) * 0.35;
|
|
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
|
-
}
|
|
653
|
-
});
|
|
654
|
-
|
|
655
|
-
// Simple force simulation (run synchronously for speed)
|
|
656
|
-
for (let iter = 0; iter < 80; iter++) {
|
|
657
|
-
// Repulsion between all pairs
|
|
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;
|
|
662
|
-
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
663
|
-
let force = 800 / (dist * dist);
|
|
664
|
-
let fx = (dx / dist) * force;
|
|
665
|
-
let fy = (dy / dist) * force;
|
|
666
|
-
visibleNodes[i]._vx += fx;
|
|
667
|
-
visibleNodes[i]._vy += fy;
|
|
668
|
-
visibleNodes[j]._vx -= fx;
|
|
669
|
-
visibleNodes[j]._vy -= fy;
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
// Attraction along edges
|
|
674
|
-
for (const e of edges) {
|
|
675
|
-
const s = nodeMap.get(e.source);
|
|
676
|
-
const t = nodeMap.get(e.target);
|
|
677
|
-
if (!s || !t) continue;
|
|
678
|
-
let dx = t._x - s._x;
|
|
679
|
-
let dy = t._y - s._y;
|
|
680
|
-
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
681
|
-
let force = (dist - 120) * 0.01;
|
|
682
|
-
let fx = (dx / dist) * force;
|
|
683
|
-
let fy = (dy / dist) * force;
|
|
684
|
-
s._vx += fx;
|
|
685
|
-
s._vy += fy;
|
|
686
|
-
t._vx -= fx;
|
|
687
|
-
t._vy -= fy;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// Center gravity
|
|
691
|
-
for (const n of visibleNodes) {
|
|
692
|
-
n._vx += (width / 2 - n._x) * 0.001;
|
|
693
|
-
n._vy += (height / 2 - n._y) * 0.001;
|
|
694
|
-
n._x += n._vx * 0.4;
|
|
695
|
-
n._y += n._vy * 0.4;
|
|
696
|
-
n._vx *= 0.7;
|
|
697
|
-
n._vy *= 0.7;
|
|
698
|
-
// Bounds
|
|
699
|
-
n._x = Math.max(30, Math.min(width - 30, n._x));
|
|
700
|
-
n._y = Math.max(30, Math.min(height - 30, n._y));
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
// Render SVG
|
|
705
|
-
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
706
|
-
g.setAttribute('id', 'graph-group');
|
|
707
|
-
const edgeDom = [];
|
|
708
|
-
const nodeDom = new Map();
|
|
709
|
-
|
|
710
|
-
// Edges
|
|
711
|
-
for (const e of edges) {
|
|
712
|
-
const s = nodeMap.get(e.source);
|
|
713
|
-
const t = nodeMap.get(e.target);
|
|
714
|
-
if (!s || !t) continue;
|
|
715
|
-
const active = !selectedNode || e.source === selectedNode || e.target === selectedNode;
|
|
716
|
-
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
717
|
-
line.setAttribute('x1', s._x);
|
|
718
|
-
line.setAttribute('y1', s._y);
|
|
719
|
-
line.setAttribute('x2', t._x);
|
|
720
|
-
line.setAttribute('y2', t._y);
|
|
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');
|
|
724
|
-
g.appendChild(line);
|
|
725
|
-
edgeDom.push({ line, edge: e });
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
// Nodes
|
|
729
|
-
for (const n of visibleNodes) {
|
|
730
|
-
const color = TYPE_COLORS[n.type] || TYPE_COLORS.unknown;
|
|
731
|
-
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
732
|
-
group.setAttribute('transform', `translate(${n._x}, ${n._y})`);
|
|
733
|
-
group.style.cursor = 'pointer';
|
|
734
|
-
group.addEventListener('click', () => showNodeDetail(n));
|
|
735
|
-
group.addEventListener('mouseenter', (evt) => showTooltip(evt, n));
|
|
736
|
-
group.addEventListener('mouseleave', hideTooltip);
|
|
737
|
-
|
|
738
|
-
// Circle node
|
|
739
|
-
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
740
|
-
circle.setAttribute('r', n.type === 'module' ? 14 : n.type === 'api' || n.type === 'model' ? 10 : 7);
|
|
741
|
-
circle.setAttribute('fill', color);
|
|
742
|
-
const isDimmed = selectedNode && !selectedAdjacent.has(n.id);
|
|
743
|
-
circle.setAttribute('opacity', isDimmed ? '0.25' : '0.9');
|
|
744
|
-
circle.setAttribute('stroke', selectedNode === n.id ? '#fff' : 'none');
|
|
745
|
-
circle.setAttribute('stroke-width', '2');
|
|
746
|
-
group.appendChild(circle);
|
|
747
|
-
|
|
748
|
-
// Label
|
|
749
|
-
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
750
|
-
text.setAttribute('y', n.type === 'module' ? 24 : 18);
|
|
751
|
-
text.setAttribute('text-anchor', 'middle');
|
|
752
|
-
text.setAttribute('fill', '#ccc');
|
|
753
|
-
text.setAttribute('font-size', n.type === 'module' ? '11' : '9');
|
|
754
|
-
text.setAttribute('font-family', 'Courier New, monospace');
|
|
755
|
-
text.setAttribute('opacity', isDimmed ? '0.3' : '1');
|
|
756
|
-
const label = n.label.length > 20 ? n.label.slice(0, 18) + '..' : n.label;
|
|
757
|
-
text.textContent = label;
|
|
758
|
-
group.appendChild(text);
|
|
759
|
-
|
|
760
|
-
nodeDom.set(n.id, { group, circle, node: n });
|
|
761
|
-
g.appendChild(group);
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
svg.appendChild(g);
|
|
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
|
-
|
|
795
|
-
// Pan & Zoom
|
|
796
|
-
svg.addEventListener('wheel', (e) => {
|
|
797
|
-
e.preventDefault();
|
|
798
|
-
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
|
799
|
-
transform.scale *= delta;
|
|
800
|
-
transform.scale = Math.max(0.2, Math.min(5, transform.scale));
|
|
801
|
-
applyTransform();
|
|
802
|
-
});
|
|
803
|
-
|
|
804
|
-
let dragging = false, lastX, lastY;
|
|
805
|
-
svg.addEventListener('mousedown', (e) => { dragging = true; lastX = e.clientX; lastY = e.clientY; });
|
|
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
|
-
|
|
820
|
-
if (!dragging) return;
|
|
821
|
-
transform.x += e.clientX - lastX;
|
|
822
|
-
transform.y += e.clientY - lastY;
|
|
823
|
-
lastX = e.clientX;
|
|
824
|
-
lastY = e.clientY;
|
|
825
|
-
applyTransform();
|
|
826
|
-
});
|
|
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();
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
function applyTransform() {
|
|
840
|
-
const g = document.getElementById('graph-group');
|
|
841
|
-
if (g) g.setAttribute('transform', `translate(${transform.x},${transform.y}) scale(${transform.scale})`);
|
|
842
|
-
}
|
|
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
|
-
|
|
884
|
-
// ===== Sidebar Renderers =====
|
|
885
|
-
function renderNodeTypeFilter() {
|
|
886
|
-
const list = document.getElementById('node-type-list');
|
|
887
|
-
const typeCounts = {};
|
|
888
|
-
for (const n of (graphData.nodes || [])) {
|
|
889
|
-
typeCounts[n.type] = (typeCounts[n.type] || 0) + 1;
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
list.innerHTML = Object.entries(typeCounts)
|
|
893
|
-
.sort((a, b) => b[1] - a[1])
|
|
894
|
-
.map(([type, count]) => `
|
|
895
|
-
<li class="node-type-item" onclick="filterByType('${type}')">
|
|
896
|
-
<span style="display:none" class="node-type-flag">${activeTypeFilter === type ? 'active' : ''}</span>
|
|
897
|
-
<div class="node-type-dot" style="background:${TYPE_COLORS[type] || '#555'}"></div>
|
|
898
|
-
<span>${TYPE_LABELS[type] || type}</span>
|
|
899
|
-
<span class="node-type-count">${count}</span>
|
|
900
|
-
</li>
|
|
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
|
-
});
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
function renderRiskList() {
|
|
912
|
-
const list = document.getElementById('risk-list');
|
|
913
|
-
list.innerHTML = riskData.slice(0, 20).map(r => `
|
|
914
|
-
<li class="risk-item" onclick="showRiskDetail('${r.id}')">
|
|
915
|
-
<span class="risk-badge risk-${r.severity}">${r.severity}</span>
|
|
916
|
-
${escapeHtml(r.title)}
|
|
917
|
-
</li>
|
|
918
|
-
`).join('');
|
|
919
|
-
}
|
|
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
|
-
|
|
1133
|
-
// ===== Node Detail Panel =====
|
|
1134
|
-
async function showNodeDetail(node) {
|
|
1135
|
-
selectedNode = node.id;
|
|
1136
|
-
renderGraph(); // Highlight selected
|
|
1137
|
-
|
|
1138
|
-
const panel = document.getElementById('panel');
|
|
1139
|
-
panel.classList.add('open');
|
|
1140
|
-
document.getElementById('panel-title').textContent = node.label;
|
|
1141
|
-
|
|
1142
|
-
// Fetch node detail
|
|
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
|
-
}
|
|
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;
|
|
1153
|
-
|
|
1154
|
-
let html = `<p><b>类型:</b> ${TYPE_LABELS[node.type] || node.type}</p>`;
|
|
1155
|
-
if (node.filePath) html += `<p><b>文件:</b> ${escapeHtml(node.filePath)}</p>`;
|
|
1156
|
-
if (node.language) html += `<p><b>语言:</b> ${node.language}</p>`;
|
|
1157
|
-
if (node.module) html += `<p><b>模块:</b> ${node.module}</p>`;
|
|
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
|
-
|
|
1168
|
-
if (data.outgoing?.length > 0) {
|
|
1169
|
-
html += `<h3>输出关系 (${data.outgoing.length})</h3><ul>`;
|
|
1170
|
-
data.outgoing.slice(0, 15).forEach(e => {
|
|
1171
|
-
const target = data.neighbors?.find(n => n.id === e.target);
|
|
1172
|
-
html += `<li>${e.relation} → ${escapeHtml(target?.label || e.target)}</li>`;
|
|
1173
|
-
});
|
|
1174
|
-
html += '</ul>';
|
|
1175
|
-
}
|
|
1176
|
-
if (data.incoming?.length > 0) {
|
|
1177
|
-
html += `<h3>输入关系 (${data.incoming.length})</h3><ul>`;
|
|
1178
|
-
data.incoming.slice(0, 15).forEach(e => {
|
|
1179
|
-
const source = data.neighbors?.find(n => n.id === e.source);
|
|
1180
|
-
html += `<li>${escapeHtml(source?.label || e.source)} → ${e.relation}</li>`;
|
|
1181
|
-
});
|
|
1182
|
-
html += '</ul>';
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
// Impact analysis button
|
|
1186
|
-
html += `<div style="margin-top:16px"><button class="btn btn-primary btn-sm" onclick="showImpact('${encodeURIComponent(node.id)}')">🔍 影响分析</button></div>`;
|
|
1187
|
-
|
|
1188
|
-
document.getElementById('panel-body').innerHTML = html;
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
async function showImpact(encodedNodeId) {
|
|
1192
|
-
const res = await fetch('/api/studio/impact/' + encodedNodeId);
|
|
1193
|
-
const data = await res.json();
|
|
1194
|
-
|
|
1195
|
-
let html = `<h3>影响分析</h3>`;
|
|
1196
|
-
html += `<p>${escapeHtml(data.summary || '')}</p>`;
|
|
1197
|
-
html += `<p><b>直接影响:</b> ${data.directImpact?.length || 0} 个实体</p>`;
|
|
1198
|
-
html += `<p><b>传递影响:</b> ${data.transitiveImpact?.length || 0} 个实体</p>`;
|
|
1199
|
-
html += `<p><b>风险等级:</b> <span class="risk-badge risk-${data.riskLevel || 'low'}">${data.riskLevel || '?'}</span></p>`;
|
|
1200
|
-
|
|
1201
|
-
document.getElementById('panel-body').innerHTML += html;
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
function showRiskDetail(riskId) {
|
|
1205
|
-
const risk = riskData.find(r => r.id === riskId);
|
|
1206
|
-
if (!risk) return;
|
|
1207
|
-
|
|
1208
|
-
const panel = document.getElementById('panel');
|
|
1209
|
-
panel.classList.add('open');
|
|
1210
|
-
document.getElementById('panel-title').textContent = '⚠️ 风险详情';
|
|
1211
|
-
document.getElementById('panel-body').innerHTML = `
|
|
1212
|
-
<p><span class="risk-badge risk-${risk.severity}">${risk.severity}</span> <b>${risk.category}</b></p>
|
|
1213
|
-
<h3>${escapeHtml(risk.title)}</h3>
|
|
1214
|
-
<p>${escapeHtml(risk.description)}</p>
|
|
1215
|
-
${risk.suggestion ? `<h3>建议</h3><p>${escapeHtml(risk.suggestion)}</p>` : ''}
|
|
1216
|
-
<p style="color:var(--text-muted);margin-top:8px">置信度: ${(risk.confidence * 100).toFixed(0)}%</p>
|
|
1217
|
-
`;
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
// ===== Perspective Reports =====
|
|
1221
|
-
document.getElementById('perspective-tabs').addEventListener('click', async (e) => {
|
|
1222
|
-
const tab = e.target.closest('.perspective-tab');
|
|
1223
|
-
if (!tab) return;
|
|
1224
|
-
|
|
1225
|
-
// Update active state
|
|
1226
|
-
document.querySelectorAll('.perspective-tab').forEach(t => t.classList.remove('active'));
|
|
1227
|
-
tab.classList.add('active');
|
|
1228
|
-
|
|
1229
|
-
const view = tab.dataset.view;
|
|
1230
|
-
const perspective = tab.dataset.perspective;
|
|
1231
|
-
|
|
1232
|
-
if (view === 'graph') {
|
|
1233
|
-
document.getElementById('graph-canvas').style.display = '';
|
|
1234
|
-
document.getElementById('report-view').style.display = 'none';
|
|
1235
|
-
document.getElementById('report-toolbar').style.display = 'none';
|
|
1236
|
-
renderGraph();
|
|
1237
|
-
return;
|
|
1238
|
-
}
|
|
1239
|
-
|
|
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
|
-
}
|
|
1247
|
-
|
|
1248
|
-
// Show report view
|
|
1249
|
-
document.getElementById('graph-canvas').style.display = 'none';
|
|
1250
|
-
document.getElementById('report-view').style.display = 'block';
|
|
1251
|
-
document.getElementById('report-toolbar').style.display = 'none';
|
|
1252
|
-
document.getElementById('report-content').innerHTML = '<div class="loading-text">生成报告中...</div>';
|
|
1253
|
-
|
|
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
|
-
});
|
|
1268
|
-
|
|
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
|
-
}
|
|
1278
|
-
|
|
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');
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
});
|
|
1290
|
-
|
|
1291
|
-
// ===== Theme Toggle =====
|
|
1292
|
-
function toggleTheme() {
|
|
1293
|
-
currentTheme = currentTheme === 'pixel' ? 'professional' : 'pixel';
|
|
1294
|
-
document.documentElement.setAttribute('data-theme', currentTheme === 'pixel' ? '' : 'professional');
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
function togglePanel() {
|
|
1298
|
-
document.getElementById('panel').classList.toggle('open');
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
function filterByType(type) {
|
|
1302
|
-
activeTypeFilter = activeTypeFilter === type ? null : type;
|
|
1303
|
-
renderNodeTypeFilter();
|
|
1304
|
-
renderGraph();
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
// ===== Tooltip =====
|
|
1308
|
-
function showTooltip(evt, node) {
|
|
1309
|
-
const tooltip = document.getElementById('tooltip');
|
|
1310
|
-
tooltip.innerHTML = `<b>${escapeHtml(node.label)}</b><br>` +
|
|
1311
|
-
`类型: ${TYPE_LABELS[node.type] || node.type}<br>` +
|
|
1312
|
-
(node.module ? `模块: ${node.module}<br>` : '') +
|
|
1313
|
-
(node.filePath ? `文件: ${escapeHtml(node.filePath)}` : '');
|
|
1314
|
-
tooltip.style.left = (evt.clientX + 15) + 'px';
|
|
1315
|
-
tooltip.style.top = (evt.clientY - 10) + 'px';
|
|
1316
|
-
tooltip.classList.add('visible');
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
function hideTooltip() {
|
|
1320
|
-
document.getElementById('tooltip').classList.remove('visible');
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
// ===== WebSocket for live updates =====
|
|
1324
|
-
function connectWS() {
|
|
1325
|
-
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
1326
|
-
wsClient = new WebSocket(protocol + '//' + location.host + '/ws');
|
|
1327
|
-
|
|
1328
|
-
wsClient.onopen = async () => {
|
|
1329
|
-
appendOperationLog('WebSocket 已连接', 'info');
|
|
1330
|
-
await rehydrateStudioState();
|
|
1331
|
-
};
|
|
1332
|
-
|
|
1333
|
-
wsClient.onmessage = (e) => {
|
|
1334
|
-
try {
|
|
1335
|
-
const msg = JSON.parse(e.data);
|
|
1336
|
-
if (msg.type === 'agent:update') scheduleAgentRefresh(msg.payload);
|
|
1337
|
-
else if (msg.type === 'graph:update') {
|
|
1338
|
-
graphData = { ...graphData, ...msg.payload };
|
|
1339
|
-
scheduleGraphRender();
|
|
1340
|
-
}
|
|
1341
|
-
else if (msg.type === 'scan:progress') {
|
|
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');
|
|
1346
|
-
}
|
|
1347
|
-
else if (msg.type === 'log') {
|
|
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
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
} catch {}
|
|
1364
|
-
};
|
|
1365
|
-
|
|
1366
|
-
wsClient.onclose = () => {
|
|
1367
|
-
appendOperationLog('WebSocket 已断开,3 秒后重连', 'warn');
|
|
1368
|
-
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
1369
|
-
reconnectTimer = setTimeout(connectWS, 3000);
|
|
1370
|
-
};
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
function updateAgents(agents) {
|
|
1374
|
-
if (!Array.isArray(agents)) return;
|
|
1375
|
-
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' };
|
|
1376
|
-
for (const a of agents) {
|
|
1377
|
-
const el = document.getElementById(agentMap[a.id]);
|
|
1378
|
-
if (el) { el.className = 'agent-status ' + a.status; }
|
|
1379
|
-
}
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
// ===== Helpers =====
|
|
1383
|
-
function escapeHtml(s) { if (!s) return ''; return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
1384
|
-
|
|
1385
|
-
function markdownToHtml(md) {
|
|
1386
|
-
if (!md) return '';
|
|
1387
|
-
let html = escapeHtml(md);
|
|
1388
|
-
html = html.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
|
|
1389
|
-
html = html.replace(/`([^`]+)`/g, '<code style="background:var(--bg-input);padding:2px 4px;border-radius:3px">$1</code>');
|
|
1390
|
-
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
|
|
1391
|
-
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
|
|
1392
|
-
html = html.replace(/\n/g, '<br>');
|
|
1393
|
-
return html;
|
|
1394
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
1641
|
-
connectWS();
|
|
1642
|
-
</script>
|
|
1643
|
-
</body>
|
|
1644
|
-
</html>
|