@zhaofx/deplens 1.0.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,684 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>DepLens - 依赖镜像空间</title>
6
+ <script src="https://d3js.org/d3.v7.min.js"></script>
7
+ <script src="/data.js"></script>
8
+ <link rel="icon" href="/favicon.png" type="image/png" />
9
+ <style>
10
+ :root {
11
+ --bg-color: #020617;
12
+ --panel-bg: rgba(15, 23, 42, 0.95);
13
+ --accent-color: #38bdf8;
14
+ --danger-color: #ef4444;
15
+ --ghost-color: #ec4899;
16
+ --text-main: #f8fafc;
17
+ --text-dim: #94a3b8;
18
+ }
19
+
20
+ body {
21
+ margin: 0;
22
+ background: var(--bg-color);
23
+ color: var(--text-main);
24
+ font-family: system-ui, sans-serif;
25
+ overflow: hidden;
26
+ display: flex;
27
+ }
28
+
29
+ .toolbar {
30
+ position: fixed;
31
+ top: 0;
32
+ left: 0;
33
+ right: 0;
34
+ display: flex;
35
+ align-items: center;
36
+ justify-content: space-between;
37
+ padding: 0 24px;
38
+ height: 64px;
39
+ background: rgba(15, 23, 42, 0.8);
40
+ border-bottom: 1px solid #1e293b;
41
+ backdrop-filter: blur(10px);
42
+ z-index: 100;
43
+ }
44
+
45
+ .search-box {
46
+ background: #1e293b;
47
+ border: 1px solid #334155;
48
+ color: #fff;
49
+ padding: 8px 16px;
50
+ border-radius: 6px;
51
+ width: 300px;
52
+ outline: none;
53
+ }
54
+ .search-box:focus {
55
+ border-color: var(--accent-color);
56
+ }
57
+
58
+ #canvas {
59
+ flex: 1;
60
+ height: calc(100vh - 64px);
61
+ padding-top: 64px;
62
+ box-sizing: border-box;
63
+ }
64
+
65
+ /* 侧边栏 */
66
+ #sidebar {
67
+ position: fixed;
68
+ right: -350px; /* 初始隐藏 */
69
+ top: 0;
70
+ width: 300px;
71
+ height: 100%;
72
+ background: rgba(15, 23, 42, 0.95);
73
+ backdrop-filter: blur(10px);
74
+ box-shadow: -5px 0 15px rgba(0, 0, 0, 0.3);
75
+ transition: right 0.3s ease;
76
+ padding: 20px;
77
+ z-index: 1000;
78
+ color: #f1f5f9;
79
+ }
80
+
81
+ #sidebar.open {
82
+ right: 0;
83
+ }
84
+
85
+ #sidebar-header button:hover {
86
+ color: #f87171 !important; /* 悬停时变红 */
87
+ transform: scale(1.1);
88
+ }
89
+
90
+ .tag {
91
+ display: inline-block;
92
+ padding: 4px 8px;
93
+ border-radius: 4px;
94
+ font-size: 12px;
95
+ font-weight: bold;
96
+ margin-bottom: 16px;
97
+ }
98
+ .ghost-box {
99
+ border: 1px solid var(--ghost-color);
100
+ background: rgba(236, 72, 153, 0.1);
101
+ padding: 16px;
102
+ border-radius: 8px;
103
+ margin-top: 16px;
104
+ }
105
+ .source-path {
106
+ font-family: monospace;
107
+ font-size: 12px;
108
+ color: var(--text-dim);
109
+ background: #0f172a;
110
+ padding: 6px;
111
+ border-radius: 4px;
112
+ margin-top: 6px;
113
+ }
114
+
115
+ /* D3 样式 */
116
+ .node-rect {
117
+ cursor: pointer;
118
+ transition: all 0.2s;
119
+ stroke-width: 1px;
120
+ }
121
+ .node-rect:hover {
122
+ filter: brightness(1.3);
123
+ stroke-width: 2px;
124
+ }
125
+ .node-rect.selected {
126
+ stroke: #fff !important;
127
+ stroke-width: 3px !important;
128
+ }
129
+ .dimmed {
130
+ opacity: 0.15;
131
+ filter: grayscale(80%);
132
+ pointer-events: none; /* 变暗的节点不可点击 */
133
+ }
134
+ .highlight {
135
+ opacity: 1 !important;
136
+ filter: none !important;
137
+ }
138
+
139
+ .label-name {
140
+ fill: #fff;
141
+ font-weight: 600;
142
+ pointer-events: none;
143
+ }
144
+ .label-meta {
145
+ fill: rgba(255, 255, 255, 0.6);
146
+ pointer-events: none;
147
+ }
148
+
149
+ /* 按钮基础样式 */
150
+ .btn-action {
151
+ background: var(--accent-color);
152
+ color: var(--bg-color);
153
+ border: none;
154
+ padding: 8px 16px;
155
+ border-radius: 6px;
156
+ font-weight: 600;
157
+ cursor: pointer;
158
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
159
+ font-size: 13px;
160
+ }
161
+
162
+ .btn-action:hover {
163
+ filter: brightness(1.1);
164
+ transform: translateY(-1px);
165
+ box-shadow: 0 4px 12px rgba(56, 189, 248, 0.3);
166
+ }
167
+
168
+ .btn-action:active {
169
+ transform: translateY(0);
170
+ }
171
+
172
+ /* 状态图例面板 (新增) */
173
+ .legend-panel {
174
+ position: fixed;
175
+ bottom: 24px;
176
+ left: 24px;
177
+ background: var(--panel-bg);
178
+ border: 1px solid #1e293b;
179
+ padding: 12px;
180
+ border-radius: 8px;
181
+ display: flex;
182
+ gap: 16px;
183
+ font-size: 12px;
184
+ z-index: 80;
185
+ backdrop-filter: blur(10px);
186
+ display: none; /* 默认隐藏,对比后显示 */
187
+ }
188
+
189
+ .legend-item {
190
+ display: flex;
191
+ align-items: center;
192
+ gap: 6px;
193
+ }
194
+ .dot {
195
+ width: 10px;
196
+ height: 10px;
197
+ border-radius: 50%;
198
+ }
199
+ /* 树枝节点标题文字样式 */
200
+ .node-container text {
201
+ pointer-events: none;
202
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
203
+ }
204
+
205
+ /* 调暗版本号颜色 */
206
+ .label-meta {
207
+ fill: var(--text-dim);
208
+ opacity: 0.8;
209
+ }
210
+ </style>
211
+ </head>
212
+ <body>
213
+ <div class="toolbar">
214
+ <h2 style="color: var(--accent-color); margin: 0">DepLens.IO</h2>
215
+ <div
216
+ id="header-env"
217
+ style="
218
+ display: none;
219
+ align-items: center;
220
+ gap: 12px;
221
+ padding-left: 20px;
222
+ border-left: 1px solid #1e293b;
223
+ font-size: 12px;
224
+ "
225
+ >
226
+ <div style="display: flex; align-items: center; gap: 4px">
227
+ <span style="color: var(--text-dim)">Node</span>
228
+ <span
229
+ id="node-ver-head"
230
+ style="color: #4ade80; font-family: monospace; font-weight: 600"
231
+ >-</span
232
+ >
233
+ </div>
234
+ <div style="display: flex; align-items: center; gap: 4px">
235
+ <span style="color: var(--text-dim)">npm</span>
236
+ <span
237
+ id="npm-ver-head"
238
+ style="color: #fbbf24; font-family: monospace; font-weight: 600"
239
+ >-</span
240
+ >
241
+ </div>
242
+ </div>
243
+ <div style="display: flex; gap: 12px">
244
+ <input
245
+ type="text"
246
+ id="search"
247
+ class="search-box"
248
+ placeholder="搜索依赖包..."
249
+ />
250
+ <button onclick="downloadSelfSnapshot()" class="btn-action">
251
+ 导出我的快照
252
+ </button>
253
+ <button
254
+ onclick="document.getElementById('peer-file').click()"
255
+ class="btn-action"
256
+ style="background: var(--ghost-color)"
257
+ >
258
+ 对比同事快照
259
+ </button>
260
+ <input
261
+ type="file"
262
+ id="peer-file"
263
+ style="display: none"
264
+ onchange="comparePeer(this)"
265
+ />
266
+ </div>
267
+ </div>
268
+
269
+ <div id="canvas"></div>
270
+
271
+ <aside id="sidebar">
272
+ <div
273
+ class="sidebar-header"
274
+ style="
275
+ display: flex;
276
+ justify-content: space-between;
277
+ align-items: center;
278
+ margin-bottom: 16px;
279
+ "
280
+ >
281
+ <h3 style="margin: 0; color: #f1f5f9">节点详情</h3>
282
+ <button
283
+ onclick="closeSidebar()"
284
+ style="
285
+ background: none;
286
+ border: none;
287
+ color: #94a3b8;
288
+ cursor: pointer;
289
+ font-size: 20px;
290
+ padding: 4px 8px;
291
+ line-height: 1;
292
+ "
293
+ >
294
+ &times;
295
+ </button>
296
+ </div>
297
+ <h1
298
+ id="side-name"
299
+ style="
300
+ color: var(--accent-color);
301
+ margin: 0 0 10px 0;
302
+ word-break: break-all;
303
+ "
304
+ >
305
+ -
306
+ </h1>
307
+ <div id="side-tag" class="tag">NORMAL</div>
308
+ <div style="margin-bottom: 10px">
309
+ <b>版本:</b> <span id="side-ver">-</span>
310
+ </div>
311
+ <div style="margin-bottom: 10px">
312
+ <b>磁盘体积:</b> <span id="side-size">-</span>
313
+ </div>
314
+
315
+ <div id="ghost-panel" style="display: none" class="ghost-box">
316
+ <div
317
+ style="
318
+ color: var(--ghost-color);
319
+ font-weight: bold;
320
+ margin-bottom: 8px;
321
+ "
322
+ >
323
+ 👻 幽灵依赖溯源
324
+ </div>
325
+ <div style="font-size: 13px; margin-bottom: 12px">
326
+ 该包未在 package.json 声明,但被以下源文件强制引入:
327
+ </div>
328
+ <div id="side-sources"></div>
329
+ </div>
330
+ </aside>
331
+ <div id="compare-legend" class="legend-panel">
332
+ <div class="legend-item">
333
+ <span class="dot" style="background: #065f46"></span> 一致 (Sync)
334
+ </div>
335
+ <div class="legend-item">
336
+ <span class="dot" style="background: #f87171"></span> 版本冲突
337
+ (Mismatch)
338
+ </div>
339
+ <div class="legend-item">
340
+ <span class="dot" style="background: #fbbf24"></span> 同事缺失 (Missing)
341
+ </div>
342
+ <button
343
+ onclick="location.reload()"
344
+ style="
345
+ background: none;
346
+ border: none;
347
+ color: var(--accent-color);
348
+ cursor: pointer;
349
+ margin-left: 10px;
350
+ "
351
+ >
352
+ 退出对比
353
+ </button>
354
+ </div>
355
+ <script>
356
+ const data = window.CHART_DATA;
357
+ const canvas = document.getElementById("canvas");
358
+ const sidebar = document.getElementById("sidebar");
359
+
360
+ function closeSidebar() {
361
+ const sidebar = document.getElementById("sidebar");
362
+
363
+ // 1. 隐藏侧边栏
364
+ sidebar.classList.remove("open"); // 如果你使用了 class 控制
365
+
366
+ // 2. (可选) 清除地图上的选中高亮状态
367
+ d3.selectAll(".node-rect")
368
+ .attr("stroke", "#ffffff55")
369
+ .attr("stroke-width", 1);
370
+
371
+ // 3. 清理对比信息
372
+ const oldDiff = document.getElementById("peer-diff-info");
373
+ if (oldDiff) oldDiff.remove();
374
+ }
375
+
376
+ // 导出功能
377
+ function downloadSelfSnapshot() {
378
+ const allDepsWithPaths = [];
379
+
380
+ // 递归函数,记录 pathStack 以形成唯一路径标识
381
+ function traverse(node, pathStack = []) {
382
+ // 如果是 UI 伪造的“(自身)”节点,直接跳过,不存入快照
383
+ if (node.isSelf) return;
384
+
385
+ const currentPath = [...pathStack, node.name].join(" > ");
386
+
387
+ if (node.name && node.version) {
388
+ allDepsWithPaths.push({
389
+ pathId: currentPath,
390
+ name: node.name,
391
+ version: node.version,
392
+ });
393
+ }
394
+
395
+ if (node.children) {
396
+ node.children.forEach((child) =>
397
+ traverse(child, [...pathStack, node.name])
398
+ );
399
+ }
400
+ }
401
+
402
+ traverse(window.CHART_DATA);
403
+
404
+ // 此处不再进行全局去重,因为同一路径下的包理论上是唯一的
405
+ const blob = new Blob([JSON.stringify(allDepsWithPaths)], {
406
+ type: "application/json",
407
+ });
408
+ const url = URL.createObjectURL(blob);
409
+ const a = document.createElement("a");
410
+ a.href = url;
411
+ a.download = `dep-strict-snapshot-${new Date().toISOString().slice(0, 10)}.json`;
412
+ a.click();
413
+ }
414
+
415
+ // 对比核心算法
416
+ function comparePeer(input) {
417
+ const file = input.files[0];
418
+ if (!file) return;
419
+
420
+ const reader = new FileReader();
421
+ reader.onload = function (e) {
422
+ try {
423
+ const peerData = JSON.parse(e.target.result);
424
+ // 使用 pathId 作为 Key,建立严格的路径索引映射
425
+ const peerPathMap = new Map(
426
+ peerData.map((d) => [d.pathId, d.version])
427
+ );
428
+ document.getElementById("compare-legend").style.display = "flex";
429
+
430
+ // 批量更新方块颜色
431
+ d3.selectAll(".node-rect")
432
+ .transition()
433
+ .duration(500)
434
+ .attr("fill", (d) => {
435
+ if (d.children && !d.data.version)
436
+ return "rgba(30, 41, 59, 0.4)";
437
+ // 1. 获取当前节点的原始数据
438
+ const data = d.data;
439
+ // 构造当前 D3 节点的完整路径
440
+ // 2. 构造路径 ID
441
+ // 如果是 isSelf 节点,路径向上退一级,映射到父包的路径上
442
+ let myPathId;
443
+ if (data.isSelf) {
444
+ // 去掉路径最后那个 "xxx (自身)",改用父包的路径
445
+ myPathId = d
446
+ .ancestors()
447
+ .slice(1) // 跳过自己
448
+ .reverse()
449
+ .map((a) => a.data.name)
450
+ .join(" > ");
451
+ } else {
452
+ myPathId = d
453
+ .ancestors()
454
+ .reverse()
455
+ .map((a) => a.data.name)
456
+ .join(" > ");
457
+ }
458
+ const myVer = d.data.version;
459
+ console.log(myPathId, myVer, d);
460
+ const peerVer = peerPathMap.get(myPathId);
461
+
462
+ if (peerVer === undefined) return "#fbbf24"; // 路径不匹配:同事缺失该位置的包
463
+ if (peerVer !== myVer) return "#f87171"; // 路径匹配但版本不同:真正的版本冲突
464
+ return "#065f46"; // 路径和版本完全一致
465
+ });
466
+
467
+ // 侧边栏点击逻辑同步更新
468
+ d3.selectAll("g").on("click.compare", (e, d) => {
469
+ const myPathId = d
470
+ .ancestors()
471
+ .reverse()
472
+ .map((a) => a.data.name)
473
+ .join(" > ");
474
+ const peerVer = peerPathMap.get(myPathId);
475
+
476
+ const oldDiff = document.getElementById("peer-diff-info");
477
+ if (oldDiff) oldDiff.remove();
478
+
479
+ if (peerVer && peerVer !== d.data.version) {
480
+ const diffHtml = `<div id="peer-diff-info" class="ghost-box" style="border-color:#f87171">
481
+ <div style="color:#f87171;font-weight:bold">严格路径对比冲突:</div>
482
+ <div style="font-size:12px">该位置同事的版本: ${peerVer}</div>
483
+ </div>`;
484
+ document
485
+ .getElementById("sidebar")
486
+ .appendChild(parseHTML(diffHtml));
487
+ }
488
+ });
489
+ } catch (err) {
490
+ console.error(err);
491
+ alert("严格快照解析失败。");
492
+ }
493
+ };
494
+ reader.readAsText(file);
495
+ }
496
+
497
+ // 辅助函数
498
+ function parseHTML(str) {
499
+ const tmp = document.createElement("div");
500
+ tmp.innerHTML = str;
501
+ return tmp.firstElementChild;
502
+ }
503
+
504
+ function render() {
505
+ const width = canvas.clientWidth;
506
+ const height = canvas.clientHeight;
507
+
508
+ d3.select("#canvas svg").remove();
509
+ const svg = d3
510
+ .select("#canvas")
511
+ .append("svg")
512
+ .attr("width", width)
513
+ .attr("height", height);
514
+
515
+ const root = d3
516
+ .hierarchy(data)
517
+ .sum((d) => d.size || d.value || 0) // 必须确保 sum 逻辑正确
518
+ .sort((a, b) => (b.value || 0) - (a.value || 0));
519
+
520
+ // 关键:增加 padding 让父容器露出边缘,形成“嵌套感”
521
+ d3
522
+ .treemap()
523
+ .size([width, height])
524
+ .paddingOuter(12) // 父节点外边距
525
+ .paddingTop(28) // 为父节点标题留出空间
526
+ .paddingInner(4) // 兄弟节点间距
527
+ .round(true)(root);
528
+
529
+ // 渲染所有节点 (descendants 包括了父节点)
530
+ const nodes = svg
531
+ .selectAll("g")
532
+ .data(root.descendants())
533
+ .join("g")
534
+ .attr("transform", (d) => `translate(${d.x0},${d.y0})`);
535
+
536
+ // 绘制方块
537
+ nodes
538
+ .append("rect")
539
+ .attr("class", (d) => (d.children ? "node-container" : "node-rect"))
540
+ .attr("width", (d) => Math.max(0, d.x1 - d.x0))
541
+ .attr("height", (d) => Math.max(0, d.y1 - d.y0))
542
+ .attr("fill", (d) => {
543
+ if (d.children) return "rgba(30, 41, 59, 0.4)"; // 父容器颜色
544
+ return d.data.isGhost
545
+ ? "#500724"
546
+ : d.data.isConflict
547
+ ? "#450a0a"
548
+ : "#0f172a";
549
+ })
550
+ .attr("stroke", (d) => {
551
+ if (d.children) return "rgba(56, 189, 248, 0.2)"; // 父容器边框
552
+ return d.data.isGhost
553
+ ? "#ec4899"
554
+ : d.data.isConflict
555
+ ? "#ef4444"
556
+ : "#38bdf8";
557
+ })
558
+ .attr("rx", 4);
559
+
560
+ // 智能标题绘制
561
+ nodes.each(function (d) {
562
+ const node = d3.select(this);
563
+ const w = d.x1 - d.x0;
564
+ const h = d.y1 - d.y0;
565
+
566
+ if (d.children) {
567
+ // 【针对树枝节点/容器】
568
+ // 只有宽度足够时才显示,避免小方块文字重叠
569
+ if (w > 60) {
570
+ const header = node
571
+ .append("text")
572
+ .attr("x", 6)
573
+ .attr("y", 18)
574
+ .style("fill", "var(--accent-color)")
575
+ .style("font-size", "11px")
576
+ .style("font-weight", "600");
577
+
578
+ // 显示:名称 @ 版本
579
+ header.append("tspan").text(d.data.name);
580
+ if (d.data.version) {
581
+ header
582
+ .append("tspan")
583
+ .attr("fill", "var(--text-dim)")
584
+ .attr("dx", "5px")
585
+ .style("font-weight", "400")
586
+ .text(`@${d.data.version}`);
587
+ }
588
+ }
589
+ } else {
590
+ // 【针对叶子节点】
591
+ if (w > 30 && h > 20) {
592
+ const label = node
593
+ .append("text")
594
+ .attr("class", "label-name")
595
+ .attr("x", 4)
596
+ .attr("y", 16)
597
+ .style("font-size", Math.min(12, w / 8) + "px");
598
+
599
+ label.text(d.data.name);
600
+
601
+ // 叶子节点如果空间够,也在下方显示版本
602
+ if (h > 35 && d.data.version) {
603
+ node
604
+ .append("text")
605
+ .attr("class", "label-meta")
606
+ .attr("x", 4)
607
+ .attr("y", 28)
608
+ .style("font-size", "9px")
609
+ .text(`v${d.data.version}`);
610
+ }
611
+ }
612
+ }
613
+ });
614
+ // 点击下钻逻辑
615
+ nodes
616
+ .filter((d) => !d.children)
617
+ .on("click", (e, d) => {
618
+ svg.selectAll("rect").classed("selected", false);
619
+ d3.select(e.currentTarget).select("rect").classed("selected", true);
620
+
621
+ sidebar.classList.add("open");
622
+ document.getElementById("side-name").innerText = d.data.name;
623
+ document.getElementById("side-ver").innerText = d.data.version;
624
+ document.getElementById("side-size").innerText =
625
+ d.data.formattedSize;
626
+
627
+ const tag = document.getElementById("side-tag");
628
+ if (d.data.isGhost) {
629
+ tag.innerText = "👻 幽灵依赖";
630
+ tag.style.background = "#ec4899";
631
+ tag.style.color = "#fff";
632
+ document.getElementById("ghost-panel").style.display = "block";
633
+ document.getElementById("side-sources").innerHTML = (
634
+ d.data.ghostSources || []
635
+ )
636
+ .map((s) => `<div class="source-path">${s}</div>`)
637
+ .join("");
638
+ } else if (d.data.isConflict) {
639
+ tag.innerText = "⚠️ 版本冲突";
640
+ tag.style.background = "#ef4444";
641
+ tag.style.color = "#fff";
642
+ document.getElementById("ghost-panel").style.display = "none";
643
+ } else {
644
+ tag.innerText = "✅ 正常";
645
+ tag.style.background = "#22c55e";
646
+ tag.style.color = "#fff";
647
+ document.getElementById("ghost-panel").style.display = "none";
648
+ }
649
+ });
650
+
651
+ // 搜索逻辑
652
+ document.getElementById("search").addEventListener("input", (e) => {
653
+ const val = e.target.value.toLowerCase();
654
+ console.log(val);
655
+ nodes.classed("dimmed", (d) => {
656
+ console.log(
657
+ d.data.name,
658
+ val && !d.data.name.toLowerCase().includes(val)
659
+ );
660
+ return val && !d.data.name.toLowerCase().includes(val);
661
+ });
662
+ });
663
+ }
664
+ // 在 template.html 的 <script> 标签中添加
665
+ function updateHeaderEnv() {
666
+ const env = window.CHART_DATA?.environment;
667
+ if (env) {
668
+ document.getElementById("header-env").style.display = "flex";
669
+ document.getElementById("node-ver-head").innerText = env.node || "-";
670
+ document.getElementById("npm-ver-head").innerText = env.npm || "-";
671
+ }
672
+ }
673
+
674
+ // 确保在 render 之前或之后调用
675
+ const originalRender = render;
676
+ render = function () {
677
+ originalRender();
678
+ updateHeaderEnv();
679
+ };
680
+ window.addEventListener("resize", render);
681
+ render();
682
+ </script>
683
+ </body>
684
+ </html>