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.
- gitinsight/__init__.py +7 -0
- gitinsight/__main__.py +92 -0
- gitinsight/analysis.py +541 -0
- gitinsight/charts.py +849 -0
- gitinsight/dashboard.py +622 -0
- gitinsight/git_reader.py +225 -0
- gitinsight/report.py +26 -0
- gitinsight_cli-0.1.0.dist-info/METADATA +152 -0
- gitinsight_cli-0.1.0.dist-info/RECORD +11 -0
- gitinsight_cli-0.1.0.dist-info/WHEEL +4 -0
- gitinsight_cli-0.1.0.dist-info/entry_points.txt +2 -0
gitinsight/dashboard.py
ADDED
|
@@ -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()">×</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
|