gitinsight-cli 0.1.0__py3-none-any.whl

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,622 @@
1
+ """
2
+ dashboard.py — 将所有图表组装为完整的 HTML 仪表板。
3
+
4
+ 核心功能:
5
+ - CSS Grid 布局 (科技蓝主题)
6
+ - KPI 指标卡片 (纯 HTML)
7
+ - 嵌入所有 pyecharts 图表
8
+ - 开发者点击事件 -> 个人面板 (弹窗)
9
+ - 时间维度选择器 (JS 联动)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import datetime
17
+ from typing import Any, Dict
18
+
19
+ import pandas as pd
20
+ from pyecharts.render import engine as pyecharts_engine
21
+
22
+ from .charts import (
23
+ build_calendar_heatmap,
24
+ build_personnel_trend_chart,
25
+ build_activity_sunburst,
26
+ build_lifecycle_scatter,
27
+ build_commit_rank_bar,
28
+ build_night_commit_rank,
29
+ build_maintenance_rank,
30
+ build_code_activity_chart,
31
+ build_file_heatmap_sunburst,
32
+ build_code_stability_chart,
33
+ build_developer_detail_charts,
34
+ build_lifecycle_gantt,
35
+ )
36
+
37
+
38
+ def _chart_to_html_fragment(chart) -> str:
39
+ """将 pyecharts 图表对象转为可嵌入的 HTML 片段 (不含 <html>/<body>)。"""
40
+ # 使用 render_embed 获取 JS 代码,或回退到 render_notebook_html
41
+ try:
42
+ return chart.render_notebook().data
43
+ except Exception:
44
+ pass
45
+
46
+ # 回退: 渲染到临时文件再读取 body
47
+ import tempfile
48
+
49
+ tmp = tempfile.mktemp(suffix=".html")
50
+ try:
51
+ chart.render(tmp)
52
+ with open(tmp, "r", encoding="utf-8") as f:
53
+ html = f.read()
54
+ # 提取 body 内容
55
+ start = html.find("<body>")
56
+ end = html.find("</body>")
57
+ if start != -1 and end != -1:
58
+ return html[start + 6 : end]
59
+ return html
60
+ finally:
61
+ try:
62
+ os.remove(tmp)
63
+ except OSError:
64
+ pass
65
+
66
+
67
+ def _render_chart_div(chart, div_id: str) -> str:
68
+ """将 pyecharts 图表渲染为独立的 div + script 片段。"""
69
+ import tempfile
70
+
71
+ tmp = tempfile.mktemp(suffix=".html")
72
+ try:
73
+ chart.render(tmp)
74
+ with open(tmp, "r", encoding="utf-8") as f:
75
+ full_html = f.read()
76
+ finally:
77
+ try:
78
+ os.remove(tmp)
79
+ except OSError:
80
+ pass
81
+
82
+ # 提取 <script> 内容和图表 <div>
83
+ # pyecharts 生成的 HTML 结构:
84
+ # <div id="xxx" ...></div>
85
+ # <script> ... </script>
86
+ import re
87
+
88
+ # 找所有 <div id="..."> 和 <script> 块
89
+ divs = re.findall(r'(<div id="[^"]*"[^>]*></div>)', full_html)
90
+ scripts = re.findall(r"(<script>.*?</script>)", full_html, re.DOTALL)
91
+
92
+ # 排除 echarts.min.js 的加载脚本
93
+ chart_scripts = [
94
+ s for s in scripts if "echarts.min.js" not in s and "var chart_" in s
95
+ ]
96
+
97
+ fragment = "\n".join(divs) + "\n" + "\n".join(chart_scripts)
98
+ return fragment
99
+
100
+
101
+ def _build_kpi_cards_html(metrics: Dict[str, Any]) -> str:
102
+ """构建 KPI 指标卡片 HTML。"""
103
+ cards = [
104
+ ("📊", "总提交数", f"{metrics.get('total_commits', 0):,}"),
105
+ ("👥", "总开发者", f"{metrics.get('total_authors', 0)}"),
106
+ ("🟢", "活跃开发者", f"{metrics.get('active_authors_6m', 0)}"),
107
+ ("📈", "代码净增长", f"{metrics.get('net_lines', 0):+,} 行"),
108
+ ("📅", "项目生命周期", f"{metrics.get('project_lifecycle_days', 0):,} 天"),
109
+ ]
110
+
111
+ html = '<div class="kpi-grid">\n'
112
+ for icon, label, value in cards:
113
+ html += f"""
114
+ <div class="kpi-card">
115
+ <div class="kpi-icon">{icon}</div>
116
+ <div class="kpi-value">{value}</div>
117
+ <div class="kpi-label">{label}</div>
118
+ </div>"""
119
+ html += "\n</div>"
120
+ return html
121
+
122
+
123
+ def _build_developer_panels_js(
124
+ prepared_df: pd.DataFrame,
125
+ author_stats: pd.DataFrame,
126
+ ) -> str:
127
+ """
128
+ 预计算所有开发者的详情数据,生成 JS 对象供弹窗使用。
129
+ """
130
+ if author_stats.empty:
131
+ return "var devData = {};"
132
+
133
+ dev_data = {}
134
+ for author_name in author_stats.index:
135
+ detail = build_developer_detail_charts(
136
+ prepared_df, str(author_name), author_stats
137
+ )
138
+ if not detail:
139
+ continue
140
+ info = detail.get("info", {})
141
+ dev_data[str(author_name)] = info
142
+
143
+ # 序列化为 JS
144
+ return f"var devData = {json.dumps(dev_data, ensure_ascii=False, default=str)};"
145
+
146
+
147
+ def build_dashboard_html(
148
+ metrics: Dict[str, Any],
149
+ repo_name: str,
150
+ output_file: str,
151
+ ) -> str:
152
+ """将所有指标和图表组装为完整的 HTML 仪表板。"""
153
+
154
+ # ---- 构建所有图表 ----
155
+ daily_commits = metrics.get("daily_commits", pd.Series(dtype=int))
156
+ monthly_trends = metrics.get("monthly_trends", pd.DataFrame())
157
+ author_stats = metrics.get("author_stats", pd.DataFrame())
158
+ author_halfyear_trends = metrics.get("author_halfyear_trends", pd.DataFrame())
159
+ author_halfyear_ranges = metrics.get("author_halfyear_ranges", pd.DataFrame())
160
+ code_activity = metrics.get("code_activity", pd.DataFrame())
161
+ code_stability = metrics.get("code_stability", pd.DataFrame())
162
+ file_heatmap = metrics.get("file_heatmap", [])
163
+ prepared_df = metrics.get("prepared_df", pd.DataFrame())
164
+
165
+ charts_html_list = []
166
+
167
+ # 各图表渲染
168
+ chart_builders = [
169
+ ("calendar", build_calendar_heatmap, (daily_commits,)),
170
+ ("trend", build_personnel_trend_chart, (monthly_trends,)),
171
+ ("sunburst", build_activity_sunburst, (author_stats,)),
172
+ ("gantt", build_lifecycle_gantt, (author_stats,)),
173
+ (
174
+ "scatter",
175
+ build_lifecycle_scatter,
176
+ (author_halfyear_trends, author_halfyear_ranges),
177
+ ),
178
+ ("commit_rank", build_commit_rank_bar, (author_stats,)),
179
+ ("night_rank", build_night_commit_rank, (author_stats,)),
180
+ ("maint_rank", build_maintenance_rank, (author_stats,)),
181
+ ("code_activity", build_code_activity_chart, (code_activity,)),
182
+ ("file_heat", build_file_heatmap_sunburst, (file_heatmap,)),
183
+ ("code_stability", build_code_stability_chart, (code_stability,)),
184
+ ]
185
+
186
+ chart_fragments: Dict[str, str] = {}
187
+ for chart_id, builder, args in chart_builders:
188
+ try:
189
+ chart_obj = builder(*args)
190
+ fragment = _render_chart_div(chart_obj, chart_id)
191
+ chart_fragments[chart_id] = fragment
192
+ except Exception as e:
193
+ chart_fragments[chart_id] = (
194
+ f'<div class="chart-error">图表 {chart_id} 渲染失败: {e}</div>'
195
+ )
196
+
197
+ # Developer detail table (pre-render top 20)
198
+ dev_table_fragments: Dict[str, str] = {}
199
+ if not author_stats.empty and not prepared_df.empty:
200
+ top_authors = list(author_stats.index[:20])
201
+ for author_name in top_authors:
202
+ try:
203
+ detail = build_developer_detail_charts(
204
+ prepared_df, str(author_name), author_stats
205
+ )
206
+ if detail and detail.get("hour_table_html"):
207
+ dev_table_fragments[str(author_name)] = detail["hour_table_html"]
208
+ except Exception:
209
+ pass
210
+
211
+ # Developer data JS
212
+ dev_data_js = _build_developer_panels_js(prepared_df, author_stats)
213
+
214
+ # Developer fragments JS map
215
+ # CRITICAL: Escape </script> inside JSON strings to prevent breaking the outer <script> block
216
+ def _escape_script_tags(s: str) -> str:
217
+ return s.replace("</script>", "<\\/script>")
218
+
219
+ dev_table_js_map = _escape_script_tags(
220
+ json.dumps(
221
+ {k: v for k, v in dev_table_fragments.items()},
222
+ ensure_ascii=False,
223
+ )
224
+ )
225
+
226
+ # ---- KPI ----
227
+ kpi_html = _build_kpi_cards_html(metrics)
228
+
229
+ # ---- 分析时间 ----
230
+ analysis_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
231
+ date_range = metrics.get("date_range", "")
232
+
233
+ # ---- 组装完整 HTML ----
234
+ html = f"""<!DOCTYPE html>
235
+ <html lang="zh-CN">
236
+ <head>
237
+ <meta charset="UTF-8">
238
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
239
+ <title>Git 项目人员分析 — {repo_name}</title>
240
+ <script src="https://assets.pyecharts.org/assets/v5/echarts.min.js"></script>
241
+ <style>
242
+ :root {{
243
+ --bg-primary: #f8fafc;
244
+ --bg-secondary: #ffffff;
245
+ --bg-card: #ffffff;
246
+ --bg-card-hover: #f1f5f9;
247
+ --accent: #3b82f6;
248
+ --accent-light: #2563eb;
249
+ --text-primary: #1e293b;
250
+ --text-secondary: #64748b;
251
+ --text-muted: #94a3b8;
252
+ --border: #e2e8f0;
253
+ --success: #22c55e;
254
+ --warning: #f59e0b;
255
+ --danger: #ef4444;
256
+ --purple: #8b5cf6;
257
+ --gradient-blue: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
258
+ --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
259
+ }}
260
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
261
+ body {{
262
+ font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
263
+ background: var(--bg-primary);
264
+ color: var(--text-primary);
265
+ min-height: 100vh;
266
+ }}
267
+ .dashboard-header {{
268
+ background: var(--gradient-blue);
269
+ padding: 28px 40px 20px;
270
+ border-bottom: 1px solid var(--border);
271
+ }}
272
+ .dashboard-header h1 {{
273
+ font-size: 28px;
274
+ font-weight: 700;
275
+ color: var(--accent-light);
276
+ margin-bottom: 6px;
277
+ }}
278
+ .dashboard-header .meta {{
279
+ color: var(--text-secondary);
280
+ font-size: 14px;
281
+ }}
282
+
283
+ /* KPI Grid */
284
+ .kpi-grid {{
285
+ display: grid;
286
+ grid-template-columns: repeat(5, 1fr);
287
+ gap: 16px;
288
+ padding: 20px 40px;
289
+ }}
290
+ .kpi-card {{
291
+ background: var(--bg-card);
292
+ border: 1px solid var(--border);
293
+ border-radius: 12px;
294
+ padding: 20px;
295
+ text-align: center;
296
+ transition: transform 0.2s, box-shadow 0.2s;
297
+ }}
298
+ .kpi-card:hover {{
299
+ transform: translateY(-3px);
300
+ box-shadow: var(--shadow);
301
+ border-color: var(--accent);
302
+ }}
303
+ .kpi-icon {{ font-size: 28px; margin-bottom: 8px; }}
304
+ .kpi-value {{
305
+ font-size: 26px;
306
+ font-weight: 700;
307
+ color: var(--accent-light);
308
+ margin-bottom: 4px;
309
+ }}
310
+ .kpi-label {{ font-size: 13px; color: var(--text-secondary); }}
311
+
312
+ /* Layout Grid */
313
+ .dashboard-body {{ padding: 0 40px 40px; }}
314
+ .section-title {{
315
+ font-size: 18px;
316
+ font-weight: 600;
317
+ color: var(--text-primary);
318
+ margin: 28px 0 14px;
319
+ padding-left: 12px;
320
+ border-left: 3px solid var(--accent);
321
+ }}
322
+ .chart-grid {{
323
+ display: grid;
324
+ grid-template-columns: 1fr 1fr;
325
+ gap: 20px;
326
+ }}
327
+ .chart-grid-3 {{
328
+ display: grid;
329
+ grid-template-columns: 1fr 1fr 1fr;
330
+ gap: 20px;
331
+ }}
332
+ .chart-panel {{
333
+ background: var(--bg-card);
334
+ border: 1px solid var(--border);
335
+ border-radius: 12px;
336
+ padding: 16px;
337
+ overflow: hidden;
338
+ }}
339
+ .chart-panel.full-width {{
340
+ grid-column: 1 / -1;
341
+ }}
342
+ .chart-error {{
343
+ color: var(--danger);
344
+ padding: 20px;
345
+ text-align: center;
346
+ }}
347
+
348
+ /* Developer Modal */
349
+ .modal-overlay {{
350
+ display: none;
351
+ position: fixed;
352
+ top: 0; left: 0; right: 0; bottom: 0;
353
+ background: rgba(0,0,0,0.7);
354
+ z-index: 1000;
355
+ justify-content: center;
356
+ align-items: center;
357
+ }}
358
+ .modal-overlay.active {{ display: flex; }}
359
+ .modal-content {{
360
+ background: var(--bg-secondary);
361
+ border: 1px solid var(--border);
362
+ border-radius: 16px;
363
+ padding: 30px;
364
+ width: 90%;
365
+ max-width: 1100px;
366
+ max-height: 85vh;
367
+ overflow-y: auto;
368
+ box-shadow: 0 8px 40px rgba(0,0,0,0.5);
369
+ }}
370
+ .modal-close {{
371
+ float: right;
372
+ background: none;
373
+ border: none;
374
+ color: var(--text-secondary);
375
+ font-size: 24px;
376
+ cursor: pointer;
377
+ padding: 4px 12px;
378
+ border-radius: 8px;
379
+ }}
380
+ .modal-close:hover {{ background: var(--bg-card); color: var(--text-primary); }}
381
+ .dev-info-grid {{
382
+ display: grid;
383
+ grid-template-columns: repeat(4, 1fr);
384
+ gap: 12px;
385
+ margin: 20px 0;
386
+ }}
387
+ .dev-info-item {{
388
+ background: var(--bg-card);
389
+ border-radius: 8px;
390
+ padding: 12px;
391
+ text-align: center;
392
+ }}
393
+ .dev-info-item .val {{
394
+ font-size: 20px;
395
+ font-weight: 700;
396
+ color: var(--accent-light);
397
+ }}
398
+ .dev-info-item .lbl {{
399
+ font-size: 12px;
400
+ color: var(--text-secondary);
401
+ margin-top: 4px;
402
+ }}
403
+ .dev-charts-grid {{
404
+ display: grid;
405
+ grid-template-columns: 1fr 1fr;
406
+ gap: 16px;
407
+ margin-top: 16px;
408
+ }}
409
+ .dev-chart-box {{
410
+ background: var(--bg-card);
411
+ border-radius: 8px;
412
+ padding: 12px;
413
+ min-height: 320px;
414
+ }}
415
+ .status-badge {{
416
+ display: inline-block;
417
+ padding: 3px 10px;
418
+ border-radius: 12px;
419
+ font-size: 12px;
420
+ font-weight: 600;
421
+ }}
422
+ .status-active {{ background: #16452680; color: var(--success); }}
423
+ .status-inactive {{ background: #3f3f4640; color: var(--text-muted); }}
424
+
425
+ /* Scrollbar */
426
+ ::-webkit-scrollbar {{ width: 8px; }}
427
+ ::-webkit-scrollbar-track {{ background: var(--bg-primary); }}
428
+ ::-webkit-scrollbar-thumb {{ background: var(--border); border-radius: 4px; }}
429
+ ::-webkit-scrollbar-thumb:hover {{ background: var(--text-muted); }}
430
+
431
+ /* Footer */
432
+ .dashboard-footer {{
433
+ text-align: center;
434
+ padding: 20px;
435
+ color: var(--text-muted);
436
+ font-size: 13px;
437
+ border-top: 1px solid var(--border);
438
+ }}
439
+
440
+ /* Responsive */
441
+ @media (max-width: 1200px) {{
442
+ .kpi-grid {{ grid-template-columns: repeat(3, 1fr); }}
443
+ .chart-grid {{ grid-template-columns: 1fr; }}
444
+ .chart-grid-3 {{ grid-template-columns: 1fr; }}
445
+ }}
446
+ </style>
447
+ </head>
448
+ <body>
449
+
450
+ <!-- Header -->
451
+ <div class="dashboard-header">
452
+ <h1>📊 {repo_name} — 项目人员分析报告</h1>
453
+ <div class="meta">分析时间: {analysis_time} | 数据范围: {date_range}</div>
454
+ </div>
455
+
456
+ <!-- KPI Cards -->
457
+ {kpi_html}
458
+
459
+ <div class="dashboard-body">
460
+
461
+ <!-- Calendar Section -->
462
+ <div class="section-title">提交活动日历</div>
463
+ <div class="chart-panel full-width">
464
+ {chart_fragments.get("calendar", "")}
465
+ </div>
466
+
467
+ <!-- Personnel Analysis Section -->
468
+ <div class="section-title">人员分析</div>
469
+ <div class="chart-grid">
470
+ <div class="chart-panel">
471
+ {chart_fragments.get("trend", "")}
472
+ </div>
473
+ <div class="chart-panel">
474
+ {chart_fragments.get("sunburst", "")}
475
+ </div>
476
+ </div>
477
+
478
+ <div class="chart-panel full-width" style="margin-top:20px;">
479
+ {chart_fragments.get("gantt", "")}
480
+ </div>
481
+
482
+ <div class="chart-panel" style="margin-top:20px;">
483
+ {chart_fragments.get("scatter", "")}
484
+ </div>
485
+
486
+ <!-- Ranking Section -->
487
+ <div class="section-title">开发者排行榜</div>
488
+ <div class="chart-grid-3">
489
+ <div class="chart-panel">
490
+ {chart_fragments.get("commit_rank", "")}
491
+ </div>
492
+ <div class="chart-panel">
493
+ {chart_fragments.get("night_rank", "")}
494
+ </div>
495
+ <div class="chart-panel">
496
+ {chart_fragments.get("maint_rank", "")}
497
+ </div>
498
+ </div>
499
+
500
+ <!-- Code Analysis Section -->
501
+ <div class="section-title">代码库分析</div>
502
+ <div class="chart-grid">
503
+ <div class="chart-panel">
504
+ {chart_fragments.get("code_activity", "")}
505
+ </div>
506
+ <div class="chart-panel">
507
+ {chart_fragments.get("code_stability", "")}
508
+ </div>
509
+ </div>
510
+
511
+ <!-- File Analysis Section -->
512
+ <div class="section-title">文件修改热度</div>
513
+ <div class="chart-panel full-width">
514
+ {chart_fragments.get("file_heat", "")}
515
+ </div>
516
+
517
+ </div>
518
+
519
+ <!-- Developer Detail Modal -->
520
+ <div class="modal-overlay" id="devModal">
521
+ <div class="modal-content">
522
+ <button class="modal-close" onclick="closeDevModal()">&times;</button>
523
+ <h2 id="devModalTitle" style="font-size:22px;margin-bottom:4px;"></h2>
524
+ <div id="devModalBadge"></div>
525
+ <div class="dev-info-grid" id="devInfoGrid"></div>
526
+
527
+ <div style="margin-top:20px;">
528
+ <h3 style="font-size:16px; margin-bottom:12px; color:#334155;">24小时提交分布</h3>
529
+ <div class="dev-chart-box" id="devTableBox" style="min-height:auto; padding:16px;"></div>
530
+ </div>
531
+ </div>
532
+ </div>
533
+
534
+ <!-- Footer -->
535
+ <div class="dashboard-footer">
536
+ Git 项目人员分析可视化系统 · GitEinsicht · 由 pyecharts 驱动
537
+ </div>
538
+
539
+ <script>
540
+ // Developer data
541
+ {dev_data_js}
542
+ var devTableFragments = {dev_table_js_map};
543
+
544
+ function showDevModal(name) {{
545
+ var d = devData[name];
546
+ if (!d) {{ alert('未找到开发者: ' + name); return; }}
547
+
548
+ document.getElementById('devModalTitle').textContent = d.name + ' (' + (d.email||'') + ')';
549
+
550
+ var badgeClass = d.is_active ? 'status-active' : 'status-inactive';
551
+ var badgeText = d.is_active ? '活跃' : '不活跃';
552
+ document.getElementById('devModalBadge').innerHTML =
553
+ '<span class="status-badge ' + badgeClass + '">' + badgeText + '</span>';
554
+
555
+ var infoItems = [
556
+ ['首次提交', d.first_commit],
557
+ ['最后提交', d.last_commit],
558
+ ['总提交数', d.total_commits],
559
+ ['新增行数', (d.total_insertions||0).toLocaleString()],
560
+ ['删除行数', (d.total_deletions||0).toLocaleString()],
561
+ ['维护天数', d.maintenance_days + ' 天'],
562
+ ['夜间提交', d.night_commits],
563
+ ['夜间占比', d.night_ratio + '%'],
564
+ ];
565
+ var infoHtml = '';
566
+ for (var i = 0; i < infoItems.length; i++) {{
567
+ infoHtml += '<div class="dev-info-item"><div class="val">' +
568
+ infoItems[i][1] + '</div><div class="lbl">' + infoItems[i][0] + '</div></div>';
569
+ }}
570
+ document.getElementById('devInfoGrid').innerHTML = infoHtml;
571
+
572
+ // Table
573
+ var tableBox = document.getElementById('devTableBox');
574
+ tableBox.innerHTML = devTableFragments[name] || '<p style="text-align:center;padding:20px;color:#64748b;">暂无数据</p>';
575
+
576
+ document.getElementById('devModal').classList.add('active');
577
+ }}
578
+
579
+ function closeDevModal() {{
580
+ document.getElementById('devModal').classList.remove('active');
581
+ }}
582
+
583
+ // Close modal on outside click
584
+ document.getElementById('devModal').addEventListener('click', function(e) {{
585
+ if (e.target === this) closeDevModal();
586
+ }});
587
+
588
+ // Close on Escape
589
+ document.addEventListener('keydown', function(e) {{
590
+ if (e.key === 'Escape') closeDevModal();
591
+ }});
592
+
593
+ // Hook into echarts instances to capture clicks on developer names
594
+ // We use a MutationObserver approach: after all charts render,
595
+ // find all echarts instances and attach click handlers.
596
+ window.addEventListener('load', function() {{
597
+ setTimeout(function() {{
598
+ // Find all echarts instances
599
+ var containers = document.querySelectorAll('[_echarts_instance_]');
600
+ containers.forEach(function(el) {{
601
+ var chart = echarts.getInstanceByDom(el);
602
+ if (chart) {{
603
+ chart.on('click', function(params) {{
604
+ // Check if clicked on a developer name (bar chart name, scatter point, etc.)
605
+ var name = params.name || (params.value && params.value[3]);
606
+ if (name && devData[name]) {{
607
+ showDevModal(name);
608
+ }}
609
+ }});
610
+ }}
611
+ }});
612
+ }}, 1500); // Wait for charts to finish rendering
613
+ }});
614
+ </script>
615
+
616
+ </body>
617
+ </html>"""
618
+
619
+ with open(output_file, "w", encoding="utf-8") as f:
620
+ f.write(html)
621
+
622
+ return output_file