commit-report 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,2135 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN" class="light">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>commit-report</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://d3js.org/d3.v7.min.js"></script>
9
+ <script>
10
+ tailwind.config = {
11
+ darkMode: 'class',
12
+ theme: {
13
+ extend: {
14
+ colors: {
15
+ primary: { 50: '#f0f9ff', 100: '#e0f2fe', 200: '#bae6fd', 300: '#7dd3fc', 400: '#38bdf8', 500: '#0ea5e9', 600: '#0284c7', 700: '#0369a1', 800: '#075985', 900: '#0c4a6e' },
16
+ }
17
+ }
18
+ }
19
+ }
20
+ </script>
21
+ <style>
22
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
23
+ body { font-family: 'Inter', system-ui, sans-serif; }
24
+ .heatmap-cell { rx: 2; ry: 2; }
25
+ .chart-tooltip {
26
+ position: absolute;
27
+ padding: 6px 10px;
28
+ background: rgba(0,0,0,0.8);
29
+ color: #fff;
30
+ border-radius: 6px;
31
+ font-size: 12px;
32
+ pointer-events: none;
33
+ opacity: 0;
34
+ transition: opacity 0.15s;
35
+ z-index: 100;
36
+ }
37
+ .dark .chart-tooltip { background: rgba(255,255,255,0.9); color: #1e293b; }
38
+ /* 高级分析 Tab 样式 - 优化版 (Pill Style) */
39
+ .advanced-tabs-nav {
40
+ display: flex;
41
+ gap: 0.5rem;
42
+ padding: 0.375rem;
43
+ background: #f1f5f9;
44
+ border-radius: 0.75rem;
45
+ overflow-x: auto;
46
+ scrollbar-width: none; /* Firefox */
47
+ }
48
+ .advanced-tabs-nav::-webkit-scrollbar {
49
+ display: none; /* Chrome/Safari */
50
+ }
51
+ .dark .advanced-tabs-nav {
52
+ background: #1e293b;
53
+ }
54
+ .advanced-tab {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 0.5rem;
58
+ padding: 0.625rem 1rem;
59
+ border-radius: 0.5rem;
60
+ font-weight: 500;
61
+ font-size: 0.875rem;
62
+ color: #64748b;
63
+ transition: all 0.2s;
64
+ white-space: nowrap;
65
+ cursor: pointer;
66
+ border: none;
67
+ background: transparent;
68
+ flex-shrink: 0;
69
+ }
70
+ .dark .advanced-tab {
71
+ color: #94a3b8;
72
+ }
73
+ .advanced-tab:hover {
74
+ color: #0f172a;
75
+ background: rgba(255, 255, 255, 0.5);
76
+ }
77
+ .dark .advanced-tab:hover {
78
+ color: #f8fafc;
79
+ background: rgba(255, 255, 255, 0.05);
80
+ }
81
+ .advanced-tab.active {
82
+ background: #fff;
83
+ color: #0ea5e9;
84
+ box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
85
+ }
86
+ .dark .advanced-tab.active {
87
+ background: #334155;
88
+ color: #38bdf8;
89
+ box-shadow: none;
90
+ }
91
+ .advanced-tab svg {
92
+ width: 1.125rem;
93
+ height: 1.125rem;
94
+ }
95
+ .advanced-tab-content.hidden {
96
+ display: none;
97
+ }
98
+ /* 模块卡片动画 */
99
+ .fade-in-up {
100
+ animation: fadeInUp 0.5s ease-out forwards;
101
+ }
102
+ @keyframes fadeInUp {
103
+ from { opacity: 0; transform: translateY(10px); }
104
+ to { opacity: 1; transform: translateY(0); }
105
+ }
106
+ </style>
107
+ </head>
108
+ <body class="bg-slate-50 text-slate-800 dark:bg-slate-900 dark:text-slate-200 min-h-screen transition-colors duration-300">
109
+ <!-- 数据注入占位 -->
110
+ <script>const DATA = __REPORT_DATA__;</script>
111
+
112
+ <div class="max-w-7xl mx-auto px-4 py-8">
113
+ <!-- Header -->
114
+ <header class="flex items-center justify-between mb-8">
115
+ <div>
116
+ <h1 class="text-3xl font-bold text-slate-900 dark:text-white">
117
+ <span class="text-primary-500">commitx</span> Report
118
+ </h1>
119
+ <p class="text-sm text-slate-500 dark:text-slate-400 mt-1" id="meta-info"></p>
120
+ </div>
121
+ <div class="flex items-center gap-4">
122
+ <span class="text-sm text-slate-500 dark:text-slate-400" id="time-range"></span>
123
+ <button id="theme-toggle"
124
+ class="p-2 rounded-lg bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 transition-colors"
125
+ title="切换主题">
126
+ <svg id="icon-sun" class="w-5 h-5 hidden dark:block" fill="none" viewBox="0 0 24 24" stroke="currentColor">
127
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
128
+ </svg>
129
+ <svg id="icon-moon" class="w-5 h-5 block dark:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor">
130
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
131
+ </svg>
132
+ </button>
133
+ </div>
134
+ </header>
135
+
136
+ <!-- Summary Cards -->
137
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4" id="summary-cards">
138
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-5 shadow-sm border border-slate-200 dark:border-slate-700">
139
+ <div class="text-3xl font-bold text-primary-500" id="card-commits">0</div>
140
+ <div class="text-sm text-slate-500 dark:text-slate-400 mt-1">提交次数</div>
141
+ </div>
142
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-5 shadow-sm border border-slate-200 dark:border-slate-700">
143
+ <div class="text-3xl font-bold text-emerald-500" id="card-added">0</div>
144
+ <div class="text-sm text-slate-500 dark:text-slate-400 mt-1">新增行数</div>
145
+ </div>
146
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-5 shadow-sm border border-slate-200 dark:border-slate-700">
147
+ <div class="text-3xl font-bold text-rose-500" id="card-deleted">0</div>
148
+ <div class="text-sm text-slate-500 dark:text-slate-400 mt-1">删除行数</div>
149
+ </div>
150
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-5 shadow-sm border border-slate-200 dark:border-slate-700">
151
+ <div class="text-3xl font-bold text-amber-500" id="card-files">0</div>
152
+ <div class="text-sm text-slate-500 dark:text-slate-400 mt-1">变更文件数</div>
153
+ </div>
154
+ </div>
155
+
156
+ <!-- Quality Cards -->
157
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8" id="quality-cards">
158
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-5 shadow-sm border border-slate-200 dark:border-slate-700">
159
+ <div class="text-3xl font-bold text-violet-500" id="card-avg-files">0</div>
160
+ <div class="text-sm text-slate-500 dark:text-slate-400 mt-1">平均文件/提交</div>
161
+ </div>
162
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-5 shadow-sm border border-slate-200 dark:border-slate-700">
163
+ <div class="text-3xl font-bold text-cyan-500" id="card-avg-lines">0</div>
164
+ <div class="text-sm text-slate-500 dark:text-slate-400 mt-1">平均行数/提交</div>
165
+ </div>
166
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-5 shadow-sm border border-slate-200 dark:border-slate-700">
167
+ <div class="text-3xl font-bold text-orange-500" id="card-churn">0%</div>
168
+ <div class="text-sm text-slate-500 dark:text-slate-400 mt-1">代码流失率</div>
169
+ </div>
170
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-5 shadow-sm border border-slate-200 dark:border-slate-700">
171
+ <div class="text-3xl font-bold text-pink-500" id="card-streak">0</div>
172
+ <div class="text-sm text-slate-500 dark:text-slate-400 mt-1">最长连续天数</div>
173
+ </div>
174
+ </div>
175
+
176
+ <!-- Heatmap -->
177
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-sm border border-slate-200 dark:border-slate-700 mb-6">
178
+ <h2 class="text-lg font-semibold mb-4">提交热力图</h2>
179
+ <div id="heatmap" class="overflow-x-auto"></div>
180
+ </div>
181
+
182
+ <!-- Two column charts -->
183
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
184
+ <!-- Weekly Trend -->
185
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
186
+ <h2 class="text-lg font-semibold mb-4">周趋势图</h2>
187
+ <div id="weekly-trend-chart"></div>
188
+ </div>
189
+
190
+ <!-- Cumulative Lines -->
191
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
192
+ <h2 class="text-lg font-semibold mb-4">累计代码量曲线</h2>
193
+ <div id="cumulative-chart"></div>
194
+ </div>
195
+ </div>
196
+
197
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
198
+ <!-- Hourly Distribution -->
199
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
200
+ <h2 class="text-lg font-semibold mb-4">提交时间分布 (24h)</h2>
201
+ <div id="hourly-chart"></div>
202
+ </div>
203
+
204
+ <!-- Weekday Distribution -->
205
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
206
+ <h2 class="text-lg font-semibold mb-4">周几分布</h2>
207
+ <div id="weekday-chart"></div>
208
+ </div>
209
+ </div>
210
+
211
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
212
+ <!-- File Types -->
213
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
214
+ <h2 class="text-lg font-semibold mb-4">文件类型占比</h2>
215
+ <div id="filetype-chart" class="flex justify-center"></div>
216
+ </div>
217
+
218
+ <!-- Commit Type Distribution -->
219
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
220
+ <h2 class="text-lg font-semibold mb-4">提交类型分布</h2>
221
+ <div id="commit-type-chart" class="flex justify-center"></div>
222
+ </div>
223
+ </div>
224
+
225
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
226
+ <!-- Author Ranking -->
227
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
228
+ <h2 class="text-lg font-semibold mb-4">作者贡献排行</h2>
229
+ <div id="author-chart"></div>
230
+ </div>
231
+
232
+ <!-- Directory Ranking -->
233
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
234
+ <h2 class="text-lg font-semibold mb-4">活跃目录 TOP 10</h2>
235
+ <div id="directory-chart"></div>
236
+ </div>
237
+ </div>
238
+
239
+ <!-- Busiest Day & Extra Stats -->
240
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-sm border border-slate-200 dark:border-slate-700 mb-6">
241
+ <h2 class="text-lg font-semibold mb-4">更多统计</h2>
242
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
243
+ <div class="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg">
244
+ <svg class="w-6 h-6 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
245
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
246
+ </svg>
247
+ <div>
248
+ <div class="text-slate-500 dark:text-slate-400">最繁忙的一天</div>
249
+ <div class="font-semibold" id="stat-busiest-day">-</div>
250
+ </div>
251
+ </div>
252
+ <div class="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg">
253
+ <svg class="w-6 h-6 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
254
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
255
+ </svg>
256
+ <div>
257
+ <div class="text-slate-500 dark:text-slate-400">最早提交</div>
258
+ <div class="font-semibold" id="stat-first-commit">-</div>
259
+ </div>
260
+ </div>
261
+ <div class="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg">
262
+ <svg class="w-6 h-6 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
263
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
264
+ </svg>
265
+ <div>
266
+ <div class="text-slate-500 dark:text-slate-400">最近提交</div>
267
+ <div class="font-semibold" id="stat-last-commit">-</div>
268
+ </div>
269
+ </div>
270
+ </div>
271
+ </div>
272
+
273
+ <!-- Hot Files Table -->
274
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-sm border border-slate-200 dark:border-slate-700 mb-6">
275
+ <h2 class="text-lg font-semibold mb-4">热点文件 TOP 10</h2>
276
+ <div class="overflow-x-auto">
277
+ <table class="w-full text-sm" id="hot-files-table">
278
+ <thead>
279
+ <tr class="text-left border-b border-slate-200 dark:border-slate-700">
280
+ <th class="pb-3 text-slate-500 dark:text-slate-400 font-medium">文件路径</th>
281
+ <th class="pb-3 text-slate-500 dark:text-slate-400 font-medium text-right">修改次数</th>
282
+ <th class="pb-3 text-slate-500 dark:text-slate-400 font-medium text-right">贡献者数</th>
283
+ </tr>
284
+ </thead>
285
+ <tbody id="hot-files-body"></tbody>
286
+ </table>
287
+ </div>
288
+ </div>
289
+
290
+ <!-- Solo Files Table (Knowledge Concentration Risk) -->
291
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-sm border border-slate-200 dark:border-slate-700 mb-6">
292
+ <h2 class="text-lg font-semibold mb-4">知识集中度风险 (单人维护文件)</h2>
293
+ <div class="overflow-x-auto">
294
+ <table class="w-full text-sm" id="solo-files-table">
295
+ <thead>
296
+ <tr class="text-left border-b border-slate-200 dark:border-slate-700">
297
+ <th class="pb-3 text-slate-500 dark:text-slate-400 font-medium">文件路径</th>
298
+ <th class="pb-3 text-slate-500 dark:text-slate-400 font-medium">唯一维护者</th>
299
+ <th class="pb-3 text-slate-500 dark:text-slate-400 font-medium text-right">提交次数</th>
300
+ </tr>
301
+ </thead>
302
+ <tbody id="solo-files-body"></tbody>
303
+ </table>
304
+ </div>
305
+ </div>
306
+
307
+ <!-- Author File Type Stats Table -->
308
+ <div class="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-sm border border-slate-200 dark:border-slate-700 mb-6">
309
+ <h2 class="text-lg font-semibold mb-4">作者文件类型贡献 TOP 20</h2>
310
+ <div class="overflow-x-auto">
311
+ <table class="w-full text-sm" id="author-filetype-table">
312
+ <thead>
313
+ <tr class="text-left border-b border-slate-200 dark:border-slate-700">
314
+ <th class="pb-3 text-slate-500 dark:text-slate-400 font-medium cursor-pointer hover:text-primary-500 select-none" data-sort="author">作者 <span class="sort-icon"></span></th>
315
+ <th class="pb-3 text-slate-500 dark:text-slate-400 font-medium cursor-pointer hover:text-primary-500 select-none" data-sort="extension">文件类型 <span class="sort-icon"></span></th>
316
+ <th class="pb-3 text-slate-500 dark:text-slate-400 font-medium text-right cursor-pointer hover:text-primary-500 select-none" data-sort="fileCount">文件数 <span class="sort-icon"></span></th>
317
+ <th class="pb-3 text-slate-500 dark:text-slate-400 font-medium text-right cursor-pointer hover:text-primary-500 select-none" data-sort="linesAdded">新增行 <span class="sort-icon"></span></th>
318
+ <th class="pb-3 text-slate-500 dark:text-slate-400 font-medium text-right cursor-pointer hover:text-primary-500 select-none" data-sort="linesDeleted">删除行 <span class="sort-icon"></span></th>
319
+ <th class="pb-3 text-slate-500 dark:text-slate-400 font-medium text-right cursor-pointer hover:text-primary-500 select-none" data-sort="commits">提交数 <span class="sort-icon"></span></th>
320
+ </tr>
321
+ </thead>
322
+ <tbody id="author-filetype-body"></tbody>
323
+ </table>
324
+ </div>
325
+ </div>
326
+
327
+ <!-- 高级分析区域 -->
328
+ <section id="advanced-analytics" class="bg-white dark:bg-slate-800 rounded-xl p-6 shadow-sm border border-slate-200 dark:border-slate-700 mb-6">
329
+ <div class="flex items-center gap-3 mb-6">
330
+ <div class="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg text-primary-600 dark:text-primary-400">
331
+ <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
332
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 002 2h2a2 2 0 002-2z" />
333
+ </svg>
334
+ </div>
335
+ <h2 class="text-xl font-bold">高级分析</h2>
336
+ </div>
337
+
338
+ <!-- Tab 导航 -->
339
+ <div class="advanced-tabs-nav mb-6">
340
+ <button class="advanced-tab active" data-tab="team-health">
341
+ <svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
342
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
343
+ </svg>
344
+ 团队健康度
345
+ </button>
346
+ <button class="advanced-tab" data-tab="stability">
347
+ <svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
348
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
349
+ </svg>
350
+ 代码稳定性
351
+ </button>
352
+ <button class="advanced-tab" data-tab="work-pressure">
353
+ <svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
354
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
355
+ </svg>
356
+ 工作压力
357
+ </button>
358
+ <button class="advanced-tab" data-tab="contributor-churn">
359
+ <svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
360
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
361
+ </svg>
362
+ 贡献者流失
363
+ </button>
364
+ <button class="advanced-tab" data-tab="collaboration">
365
+ <svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
366
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
367
+ </svg>
368
+ 协作热度
369
+ </button>
370
+ </div>
371
+
372
+ <!-- Tab 内容容器 -->
373
+ <div id="team-health" class="advanced-tab-content fade-in-up">
374
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
375
+ <div class="bg-slate-50 dark:bg-slate-700/30 rounded-xl p-6 flex flex-col items-center justify-center min-h-[300px]">
376
+ <h3 class="text-lg font-semibold mb-6 text-slate-700 dark:text-slate-200">Bus Factor (巴士系数)</h3>
377
+ <div id="bus-factor-gauge"></div>
378
+ <p class="text-sm text-slate-500 text-center mt-4 max-w-xs">衡量项目对特定开发者的依赖程度,数值越低风险越高。</p>
379
+ </div>
380
+ <div class="bg-slate-50 dark:bg-slate-700/30 rounded-xl p-6 min-h-[300px]">
381
+ <h3 class="text-lg font-semibold mb-4 text-slate-700 dark:text-slate-200 flex items-center gap-2">
382
+ <svg class="w-5 h-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
383
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
384
+ </svg>
385
+ 关键人员 (Key Authors)
386
+ </h3>
387
+ <div id="critical-authors-list"></div>
388
+ </div>
389
+ </div>
390
+ </div>
391
+
392
+ <div id="stability" class="advanced-tab-content hidden fade-in-up">
393
+ <div id="stability-metrics" class="mb-6"></div>
394
+ <div class="bg-slate-50 dark:bg-slate-700/30 rounded-xl p-6">
395
+ <h3 class="text-lg font-semibold mb-4 text-slate-700 dark:text-slate-200 flex items-center gap-2">
396
+ <svg class="w-5 h-5 text-rose-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
397
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
398
+ </svg>
399
+ 文件流失率 TOP 20
400
+ </h3>
401
+ <div id="unstable-files-list"></div>
402
+ </div>
403
+ </div>
404
+
405
+ <div id="work-pressure" class="advanced-tab-content hidden fade-in-up">
406
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-6">
407
+ <div class="bg-slate-50 dark:bg-slate-700/30 rounded-xl p-6 flex flex-col items-center justify-center">
408
+ <h3 class="text-lg font-semibold mb-6 text-slate-700 dark:text-slate-200">压力评分</h3>
409
+ <div id="pressure-gauge"></div>
410
+ </div>
411
+ <div class="bg-slate-50 dark:bg-slate-700/30 rounded-xl p-6">
412
+ <h3 class="text-lg font-semibold mb-4 text-slate-700 dark:text-slate-200">提交时间分布</h3>
413
+ <div id="off-hours-breakdown"></div>
414
+ </div>
415
+ </div>
416
+ </div>
417
+
418
+ <div id="contributor-churn" class="advanced-tab-content hidden fade-in-up">
419
+ <div id="churn-metrics" class="mb-6"></div>
420
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
421
+ <div class="bg-slate-50 dark:bg-slate-700/30 rounded-xl p-6">
422
+ <h3 class="text-lg font-semibold mb-4 text-slate-700 dark:text-slate-200">活跃度漏斗</h3>
423
+ <div id="churn-funnel"></div>
424
+ </div>
425
+ <div class="bg-slate-50 dark:bg-slate-700/30 rounded-xl p-6">
426
+ <h3 class="text-lg font-semibold mb-4 text-slate-700 dark:text-slate-200">作者状态列表</h3>
427
+ <div id="author-status-list"></div>
428
+ </div>
429
+ </div>
430
+ </div>
431
+
432
+ <div id="collaboration" class="advanced-tab-content hidden fade-in-up">
433
+ <div id="collaboration-metrics" class="mb-6"></div>
434
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
435
+ <div class="bg-slate-50 dark:bg-slate-700/30 rounded-xl p-6">
436
+ <h3 class="text-lg font-semibold mb-4 text-slate-700 dark:text-slate-200">强耦合文件 (Tight Coupling)</h3>
437
+ <div id="file-pairs-list"></div>
438
+ </div>
439
+ <div class="bg-slate-50 dark:bg-slate-700/30 rounded-xl p-6">
440
+ <h3 class="text-lg font-semibold mb-4 text-slate-700 dark:text-slate-200">结对编程检测</h3>
441
+ <div id="pair-programming-list"></div>
442
+ </div>
443
+ </div>
444
+ </div>
445
+ </section>
446
+
447
+ <!-- Footer -->
448
+ <footer class="text-center text-sm text-slate-400 dark:text-slate-500 py-4">
449
+ <div class="mb-2">
450
+ 由 <span class="text-primary-500 font-medium">commit-report</span> 生成于 <span id="generated-at"></span>
451
+ </div>
452
+ <div>
453
+ <a href="https://github.com/qqzhangyanhua/commitx"
454
+ target="_blank"
455
+ rel="noopener noreferrer"
456
+ class="inline-flex items-center gap-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
457
+ title="View on GitHub">
458
+ <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
459
+ <path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd" />
460
+ </svg>
461
+ </a>
462
+ </div>
463
+ </footer>
464
+ </div>
465
+
466
+ <!-- Tooltip -->
467
+ <div class="chart-tooltip" id="tooltip"></div>
468
+
469
+ <script>
470
+ // ============================================================
471
+ // 初始化
472
+ // ============================================================
473
+ const stats = DATA.stats;
474
+ const tooltip = document.getElementById('tooltip');
475
+ const isDark = () => document.documentElement.classList.contains('dark');
476
+
477
+ // 主题切换
478
+ document.getElementById('theme-toggle').addEventListener('click', () => {
479
+ document.documentElement.classList.toggle('dark');
480
+ // 重新绘制所有图表以更新颜色
481
+ renderAll();
482
+ });
483
+
484
+ // 检测系统主题
485
+ if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
486
+ document.documentElement.classList.add('dark');
487
+ }
488
+
489
+ function formatNumber(n) {
490
+ return n.toLocaleString('zh-CN');
491
+ }
492
+
493
+ function escapeHtml(str) {
494
+ const div = document.createElement('div');
495
+ div.textContent = str;
496
+ return div.innerHTML;
497
+ }
498
+
499
+ function showTooltip(event, text) {
500
+ tooltip.style.opacity = 1;
501
+ tooltip.innerHTML = text;
502
+ tooltip.style.left = event.pageX + 12 + 'px';
503
+ tooltip.style.top = event.pageY - 28 + 'px';
504
+ }
505
+ function hideTooltip() { tooltip.style.opacity = 0; }
506
+
507
+ // ============================================================
508
+ // 渲染入口
509
+ // ============================================================
510
+ function renderAll() {
511
+ renderSummary();
512
+ renderQualityCards();
513
+ renderHeatmap();
514
+ renderWeeklyTrendChart();
515
+ renderCumulativeChart();
516
+ renderHourlyChart();
517
+ renderWeekdayChart();
518
+ renderFileTypeChart();
519
+ renderCommitTypeChart();
520
+ renderAuthorChart();
521
+ renderDirectoryChart();
522
+ renderHotFilesTable();
523
+ renderSoloFilesTable();
524
+ renderAuthorFileTypeTable();
525
+ renderExtraStats();
526
+
527
+ // 渲染高级分析模块
528
+ renderTeamHealth();
529
+ renderStability();
530
+ renderWorkPressure();
531
+ renderContributorChurn();
532
+ renderCollaboration();
533
+ }
534
+
535
+ // ============================================================
536
+ // 概要卡片
537
+ // ============================================================
538
+ function renderSummary() {
539
+ document.getElementById('card-commits').textContent = formatNumber(stats.totalCommits);
540
+ document.getElementById('card-added').textContent = '+' + formatNumber(stats.linesAdded);
541
+ document.getElementById('card-deleted').textContent = '-' + formatNumber(stats.linesDeleted);
542
+ document.getElementById('card-files').textContent = formatNumber(stats.filesChanged);
543
+ document.getElementById('meta-info').textContent = DATA.repos.join(', ');
544
+ document.getElementById('time-range').textContent = DATA.timeRange
545
+ ? DATA.timeRange.from + ' ~ ' + DATA.timeRange.to
546
+ : '所有提交';
547
+ document.getElementById('generated-at').textContent = DATA.generatedAt;
548
+ }
549
+
550
+ // ============================================================
551
+ // 质量卡片
552
+ // ============================================================
553
+ function renderQualityCards() {
554
+ if (!stats.quality) return;
555
+ document.getElementById('card-avg-files').textContent = stats.quality.avgFilesPerCommit.toFixed(1);
556
+ document.getElementById('card-avg-lines').textContent = stats.quality.avgLinesPerCommit.toFixed(0);
557
+ document.getElementById('card-churn').textContent = (stats.quality.churnRate * 100).toFixed(1) + '%';
558
+ document.getElementById('card-streak').textContent = stats.timePatterns ? stats.timePatterns.longestStreak : 0;
559
+ }
560
+
561
+ // ============================================================
562
+ // 提交热力图 (GitHub 风格)
563
+ // ============================================================
564
+ function renderHeatmap() {
565
+ const container = document.getElementById('heatmap');
566
+ container.innerHTML = '';
567
+
568
+ const heatmapData = stats.dailyHeatmap;
569
+ const cellSize = 13;
570
+ const cellGap = 3;
571
+ const totalSize = cellSize + cellGap;
572
+ const weekDayLabels = ['', '一', '', '三', '', '五', ''];
573
+
574
+ // 计算日期范围
575
+ const dates = Object.keys(heatmapData).sort();
576
+ if (dates.length === 0) {
577
+ container.innerHTML = '<p class="text-slate-400 text-sm">暂无数据</p>';
578
+ return;
579
+ }
580
+
581
+ // 从数据或 timeRange 推断日期范围
582
+ const startDate = DATA.timeRange ? new Date(DATA.timeRange.from) : new Date(dates[0]);
583
+ const endDate = DATA.timeRange ? new Date(DATA.timeRange.to) : new Date(dates[dates.length - 1]);
584
+
585
+ // 调整到该周的周一
586
+ const adjustedStart = new Date(startDate);
587
+ adjustedStart.setDate(adjustedStart.getDate() - ((adjustedStart.getDay() + 6) % 7));
588
+
589
+ // 生成所有日期
590
+ const allDays = [];
591
+ const current = new Date(adjustedStart);
592
+ while (current <= endDate) {
593
+ const key = current.toISOString().split('T')[0];
594
+ allDays.push({ date: key, count: heatmapData[key] || 0 });
595
+ current.setDate(current.getDate() + 1);
596
+ }
597
+
598
+ const weeks = Math.ceil(allDays.length / 7);
599
+ const width = weeks * totalSize + 40;
600
+ const height = 7 * totalSize + 30;
601
+
602
+ const maxCount = Math.max(...allDays.map(d => d.count), 1);
603
+
604
+ const lightColors = ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39'];
605
+ const darkColors = ['#161b22', '#0e4429', '#006d32', '#26a641', '#39d353'];
606
+ const colors = isDark() ? darkColors : lightColors;
607
+
608
+ const colorScale = (count) => {
609
+ if (count === 0) return colors[0];
610
+ const ratio = count / maxCount;
611
+ if (ratio <= 0.25) return colors[1];
612
+ if (ratio <= 0.5) return colors[2];
613
+ if (ratio <= 0.75) return colors[3];
614
+ return colors[4];
615
+ };
616
+
617
+ const svg = d3.select(container)
618
+ .append('svg')
619
+ .attr('width', width)
620
+ .attr('height', height);
621
+
622
+ // 星期标签
623
+ weekDayLabels.forEach((label, i) => {
624
+ svg.append('text')
625
+ .attr('x', 20)
626
+ .attr('y', 28 + i * totalSize)
627
+ .attr('text-anchor', 'middle')
628
+ .attr('font-size', '10px')
629
+ .attr('fill', isDark() ? '#8b949e' : '#959da5')
630
+ .text(label);
631
+ });
632
+
633
+ // 月份标签
634
+ const monthPositions = {};
635
+ allDays.forEach((d, i) => {
636
+ const date = new Date(d.date);
637
+ if (date.getDate() <= 7) {
638
+ const weekIdx = Math.floor(i / 7);
639
+ const monthName = date.toLocaleDateString('zh-CN', { month: 'short' });
640
+ if (!monthPositions[monthName]) {
641
+ monthPositions[monthName] = 36 + weekIdx * totalSize;
642
+ }
643
+ }
644
+ });
645
+
646
+ Object.entries(monthPositions).forEach(([month, x]) => {
647
+ svg.append('text')
648
+ .attr('x', x)
649
+ .attr('y', 10)
650
+ .attr('font-size', '10px')
651
+ .attr('fill', isDark() ? '#8b949e' : '#959da5')
652
+ .text(month);
653
+ });
654
+
655
+ // 热力图格子
656
+ allDays.forEach((d, i) => {
657
+ const weekIdx = Math.floor(i / 7);
658
+ const dayIdx = i % 7;
659
+
660
+ svg.append('rect')
661
+ .attr('class', 'heatmap-cell')
662
+ .attr('x', 36 + weekIdx * totalSize)
663
+ .attr('y', 18 + dayIdx * totalSize)
664
+ .attr('width', cellSize)
665
+ .attr('height', cellSize)
666
+ .attr('fill', colorScale(d.count))
667
+ .on('mouseover', (event) => showTooltip(event, `${d.date}: ${d.count} 次提交`))
668
+ .on('mouseout', hideTooltip);
669
+ });
670
+ }
671
+
672
+ // ============================================================
673
+ // 提交时间分布 (24h 柱状图)
674
+ // ============================================================
675
+ function renderHourlyChart() {
676
+ const container = document.getElementById('hourly-chart');
677
+ container.innerHTML = '';
678
+
679
+ const data = stats.hourlyDistribution.map((count, hour) => ({ hour, count }));
680
+ const margin = { top: 10, right: 10, bottom: 30, left: 40 };
681
+ const width = container.clientWidth - margin.left - margin.right;
682
+ const height = 200 - margin.top - margin.bottom;
683
+
684
+ const svg = d3.select(container)
685
+ .append('svg')
686
+ .attr('width', width + margin.left + margin.right)
687
+ .attr('height', height + margin.top + margin.bottom)
688
+ .append('g')
689
+ .attr('transform', `translate(${margin.left},${margin.top})`);
690
+
691
+ const x = d3.scaleBand().domain(data.map(d => d.hour)).range([0, width]).padding(0.3);
692
+ const y = d3.scaleLinear().domain([0, d3.max(data, d => d.count) || 1]).nice().range([height, 0]);
693
+
694
+ const textColor = isDark() ? '#8b949e' : '#64748b';
695
+
696
+ svg.append('g')
697
+ .attr('transform', `translate(0,${height})`)
698
+ .call(d3.axisBottom(x).tickFormat(d => d + ':00').tickValues(data.filter((_, i) => i % 3 === 0).map(d => d.hour)))
699
+ .selectAll('text').attr('fill', textColor);
700
+
701
+ svg.append('g')
702
+ .call(d3.axisLeft(y).ticks(5))
703
+ .selectAll('text').attr('fill', textColor);
704
+
705
+ svg.selectAll('.domain, .tick line').attr('stroke', isDark() ? '#334155' : '#e2e8f0');
706
+
707
+ svg.selectAll('.bar')
708
+ .data(data)
709
+ .join('rect')
710
+ .attr('x', d => x(d.hour))
711
+ .attr('y', d => y(d.count))
712
+ .attr('width', x.bandwidth())
713
+ .attr('height', d => height - y(d.count))
714
+ .attr('fill', '#0ea5e9')
715
+ .attr('rx', 2)
716
+ .on('mouseover', (event, d) => {
717
+ const hourData = stats.hourlyByAuthor?.[d.hour];
718
+ let tooltip = `${d.hour}:00 - ${d.hour + 1}:00: ${d.count} 次提交`;
719
+ if (hourData && Object.keys(hourData.authors).length > 0) {
720
+ const authorList = Object.entries(hourData.authors)
721
+ .sort((a, b) => b[1] - a[1])
722
+ .slice(0, 5)
723
+ .map(([name, count]) => `${name}: ${count}`)
724
+ .join('\n');
725
+ tooltip += '\n' + authorList;
726
+ }
727
+ showTooltip(event, tooltip);
728
+ })
729
+ .on('mouseout', hideTooltip);
730
+ }
731
+
732
+ // ============================================================
733
+ // 文件类型占比 (甜甜圈图)
734
+ // ============================================================
735
+ function renderFileTypeChart() {
736
+ const container = document.getElementById('filetype-chart');
737
+ container.innerHTML = '';
738
+
739
+ const rawData = stats.fileTypes.slice(0, 8);
740
+ if (rawData.length === 0) {
741
+ container.innerHTML = '<p class="text-slate-400 text-sm">暂无数据</p>';
742
+ return;
743
+ }
744
+
745
+ const data = rawData.map(d => ({
746
+ name: d.extension,
747
+ value: d.added + d.deleted,
748
+ }));
749
+
750
+ const size = 220;
751
+ const radius = size / 2 - 10;
752
+
753
+ const colors = ['#0ea5e9', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316'];
754
+
755
+ // 外层容器:饼图 + 图例水平排列
756
+ const wrapper = d3.select(container)
757
+ .append('div')
758
+ .attr('class', 'flex items-center gap-6');
759
+
760
+ const svg = wrapper
761
+ .append('svg')
762
+ .attr('width', size)
763
+ .attr('height', size)
764
+ .append('g')
765
+ .attr('transform', `translate(${size/2},${size/2})`);
766
+
767
+ const pie = d3.pie().value(d => d.value).sort(null);
768
+ const arc = d3.arc().innerRadius(radius * 0.55).outerRadius(radius);
769
+
770
+ svg.selectAll('path')
771
+ .data(pie(data))
772
+ .join('path')
773
+ .attr('d', arc)
774
+ .attr('fill', (_, i) => colors[i % colors.length])
775
+ .attr('stroke', isDark() ? '#1e293b' : '#fff')
776
+ .attr('stroke-width', 2)
777
+ .on('mouseover', (event, d) => {
778
+ const pct = ((d.data.value / d3.sum(data, x => x.value)) * 100).toFixed(1);
779
+ showTooltip(event, `${d.data.name}: ${formatNumber(d.data.value)} 行 (${pct}%)`);
780
+ })
781
+ .on('mouseout', hideTooltip);
782
+
783
+ // 图例:垂直排列在右侧
784
+ const legend = wrapper
785
+ .append('div')
786
+ .attr('class', 'flex flex-col gap-2');
787
+
788
+ data.forEach((d, i) => {
789
+ const item = legend.append('div').attr('class', 'flex items-center gap-2 text-xs');
790
+ item.append('div')
791
+ .style('width', '10px')
792
+ .style('height', '10px')
793
+ .style('border-radius', '2px')
794
+ .style('flex-shrink', '0')
795
+ .style('background', colors[i % colors.length]);
796
+ item.append('span')
797
+ .attr('class', 'text-slate-600 dark:text-slate-400')
798
+ .text(d.name);
799
+ });
800
+ }
801
+
802
+ // ============================================================
803
+ // 作者贡献排行 (横向柱状图)
804
+ // ============================================================
805
+ function renderAuthorChart() {
806
+ const container = document.getElementById('author-chart');
807
+ container.innerHTML = '';
808
+
809
+ const data = stats.authors.slice(0, 10);
810
+ if (data.length === 0) {
811
+ container.innerHTML = '<p class="text-slate-400 text-sm">暂无数据</p>';
812
+ return;
813
+ }
814
+
815
+ const margin = { top: 5, right: 60, bottom: 5, left: 100 };
816
+ const barHeight = 28;
817
+ const height = data.length * barHeight + margin.top + margin.bottom;
818
+ const width = container.clientWidth - margin.left - margin.right;
819
+
820
+ const svg = d3.select(container)
821
+ .append('svg')
822
+ .attr('width', width + margin.left + margin.right)
823
+ .attr('height', height)
824
+ .append('g')
825
+ .attr('transform', `translate(${margin.left},${margin.top})`);
826
+
827
+ const x = d3.scaleLinear().domain([0, d3.max(data, d => d.commits) || 1]).range([0, width]);
828
+ const y = d3.scaleBand().domain(data.map(d => d.name)).range([0, data.length * barHeight]).padding(0.3);
829
+
830
+ const textColor = isDark() ? '#94a3b8' : '#64748b';
831
+
832
+ svg.selectAll('.bar')
833
+ .data(data)
834
+ .join('rect')
835
+ .attr('x', 0)
836
+ .attr('y', d => y(d.name))
837
+ .attr('width', d => x(d.commits))
838
+ .attr('height', y.bandwidth())
839
+ .attr('fill', '#0ea5e9')
840
+ .attr('rx', 3)
841
+ .on('mouseover', (event, d) =>
842
+ showTooltip(event, `${d.name}: ${d.commits} commits, +${formatNumber(d.linesAdded)} / -${formatNumber(d.linesDeleted)}`))
843
+ .on('mouseout', hideTooltip);
844
+
845
+ svg.selectAll('.label')
846
+ .data(data)
847
+ .join('text')
848
+ .attr('x', -8)
849
+ .attr('y', d => y(d.name) + y.bandwidth() / 2)
850
+ .attr('text-anchor', 'end')
851
+ .attr('dominant-baseline', 'middle')
852
+ .attr('font-size', '12px')
853
+ .attr('fill', textColor)
854
+ .text(d => d.name.length > 12 ? d.name.slice(0, 12) + '...' : d.name);
855
+
856
+ svg.selectAll('.value')
857
+ .data(data)
858
+ .join('text')
859
+ .attr('x', d => x(d.commits) + 6)
860
+ .attr('y', d => y(d.name) + y.bandwidth() / 2)
861
+ .attr('dominant-baseline', 'middle')
862
+ .attr('font-size', '11px')
863
+ .attr('fill', textColor)
864
+ .text(d => d.commits);
865
+ }
866
+
867
+ // ============================================================
868
+ // 活跃目录 TOP 10 (横向柱状图)
869
+ // ============================================================
870
+ function renderDirectoryChart() {
871
+ const container = document.getElementById('directory-chart');
872
+ container.innerHTML = '';
873
+
874
+ const data = stats.directories.slice(0, 10);
875
+ if (data.length === 0) {
876
+ container.innerHTML = '<p class="text-slate-400 text-sm">暂无数据</p>';
877
+ return;
878
+ }
879
+
880
+ const margin = { top: 5, right: 70, bottom: 5, left: 100 };
881
+ const barHeight = 28;
882
+ const height = data.length * barHeight + margin.top + margin.bottom;
883
+ const width = container.clientWidth - margin.left - margin.right;
884
+
885
+ const svg = d3.select(container)
886
+ .append('svg')
887
+ .attr('width', width + margin.left + margin.right)
888
+ .attr('height', height)
889
+ .append('g')
890
+ .attr('transform', `translate(${margin.left},${margin.top})`);
891
+
892
+ const x = d3.scaleLinear().domain([0, d3.max(data, d => d.linesChanged) || 1]).range([0, width]);
893
+ const y = d3.scaleBand().domain(data.map(d => d.path)).range([0, data.length * barHeight]).padding(0.3);
894
+
895
+ const textColor = isDark() ? '#94a3b8' : '#64748b';
896
+
897
+ svg.selectAll('.bar')
898
+ .data(data)
899
+ .join('rect')
900
+ .attr('x', 0)
901
+ .attr('y', d => y(d.path))
902
+ .attr('width', d => x(d.linesChanged))
903
+ .attr('height', y.bandwidth())
904
+ .attr('fill', '#8b5cf6')
905
+ .attr('rx', 3)
906
+ .on('mouseover', (event, d) =>
907
+ showTooltip(event, `${d.path}: ${d.commits} commits, ${formatNumber(d.linesChanged)} 行变更`))
908
+ .on('mouseout', hideTooltip);
909
+
910
+ svg.selectAll('.label')
911
+ .data(data)
912
+ .join('text')
913
+ .attr('x', -8)
914
+ .attr('y', d => y(d.path) + y.bandwidth() / 2)
915
+ .attr('text-anchor', 'end')
916
+ .attr('dominant-baseline', 'middle')
917
+ .attr('font-size', '12px')
918
+ .attr('fill', textColor)
919
+ .text(d => d.path.length > 14 ? d.path.slice(0, 14) + '...' : d.path);
920
+
921
+ svg.selectAll('.value')
922
+ .data(data)
923
+ .join('text')
924
+ .attr('x', d => x(d.linesChanged) + 6)
925
+ .attr('y', d => y(d.path) + y.bandwidth() / 2)
926
+ .attr('dominant-baseline', 'middle')
927
+ .attr('font-size', '11px')
928
+ .attr('fill', textColor)
929
+ .text(d => formatNumber(d.linesChanged));
930
+ }
931
+
932
+ // ============================================================
933
+ // 更多统计
934
+ // ============================================================
935
+ function renderExtraStats() {
936
+ if (stats.busiestDay && stats.busiestDay.date) {
937
+ document.getElementById('stat-busiest-day').textContent =
938
+ stats.busiestDay.date + ' (' + stats.busiestDay.count + ' 次提交)';
939
+ }
940
+ if (stats.firstCommitDate) {
941
+ document.getElementById('stat-first-commit').textContent =
942
+ new Date(stats.firstCommitDate).toLocaleDateString('zh-CN');
943
+ }
944
+ if (stats.lastCommitDate) {
945
+ document.getElementById('stat-last-commit').textContent =
946
+ new Date(stats.lastCommitDate).toLocaleDateString('zh-CN');
947
+ }
948
+ }
949
+
950
+ // ============================================================
951
+ // 周趋势图 (折线图)
952
+ // ============================================================
953
+ function renderWeeklyTrendChart() {
954
+ const container = document.getElementById('weekly-trend-chart');
955
+ container.innerHTML = '';
956
+
957
+ if (!stats.trends || !stats.trends.weeklyTrend || stats.trends.weeklyTrend.length === 0) {
958
+ container.innerHTML = '<p class="text-slate-400 text-sm">暂无数据</p>';
959
+ return;
960
+ }
961
+
962
+ const data = stats.trends.weeklyTrend;
963
+ const margin = { top: 20, right: 30, bottom: 40, left: 50 };
964
+ const width = container.clientWidth - margin.left - margin.right;
965
+ const height = 200 - margin.top - margin.bottom;
966
+
967
+ const svg = d3.select(container)
968
+ .append('svg')
969
+ .attr('width', width + margin.left + margin.right)
970
+ .attr('height', height + margin.top + margin.bottom)
971
+ .append('g')
972
+ .attr('transform', `translate(${margin.left},${margin.top})`);
973
+
974
+ const x = d3.scalePoint().domain(data.map(d => d.week)).range([0, width]).padding(0.5);
975
+ const y = d3.scaleLinear().domain([0, d3.max(data, d => d.commits) || 1]).nice().range([height, 0]);
976
+
977
+ const textColor = isDark() ? '#8b949e' : '#64748b';
978
+
979
+ svg.append('g')
980
+ .attr('transform', `translate(0,${height})`)
981
+ .call(d3.axisBottom(x).tickValues(data.filter((_, i) => i % Math.ceil(data.length / 6) === 0).map(d => d.week)))
982
+ .selectAll('text').attr('fill', textColor).attr('font-size', '10px');
983
+
984
+ svg.append('g')
985
+ .call(d3.axisLeft(y).ticks(5))
986
+ .selectAll('text').attr('fill', textColor);
987
+
988
+ svg.selectAll('.domain, .tick line').attr('stroke', isDark() ? '#334155' : '#e2e8f0');
989
+
990
+ const line = d3.line().x(d => x(d.week)).y(d => y(d.commits)).curve(d3.curveMonotoneX);
991
+
992
+ svg.append('path')
993
+ .datum(data)
994
+ .attr('fill', 'none')
995
+ .attr('stroke', '#0ea5e9')
996
+ .attr('stroke-width', 2)
997
+ .attr('d', line);
998
+
999
+ svg.selectAll('.dot')
1000
+ .data(data)
1001
+ .join('circle')
1002
+ .attr('cx', d => x(d.week))
1003
+ .attr('cy', d => y(d.commits))
1004
+ .attr('r', 4)
1005
+ .attr('fill', '#0ea5e9')
1006
+ .on('mouseover', (event, d) => showTooltip(event, `${d.week}: ${d.commits} 次提交, +${formatNumber(d.linesAdded)} / -${formatNumber(d.linesDeleted)}`))
1007
+ .on('mouseout', hideTooltip);
1008
+ }
1009
+
1010
+ // ============================================================
1011
+ // 累计代码量曲线 (面积图)
1012
+ // ============================================================
1013
+ function renderCumulativeChart() {
1014
+ const container = document.getElementById('cumulative-chart');
1015
+ container.innerHTML = '';
1016
+
1017
+ if (!stats.trends || !stats.trends.cumulativeLines || stats.trends.cumulativeLines.length === 0) {
1018
+ container.innerHTML = '<p class="text-slate-400 text-sm">暂无数据</p>';
1019
+ return;
1020
+ }
1021
+
1022
+ const data = stats.trends.cumulativeLines;
1023
+ const margin = { top: 20, right: 30, bottom: 40, left: 60 };
1024
+ const width = container.clientWidth - margin.left - margin.right;
1025
+ const height = 200 - margin.top - margin.bottom;
1026
+
1027
+ const svg = d3.select(container)
1028
+ .append('svg')
1029
+ .attr('width', width + margin.left + margin.right)
1030
+ .attr('height', height + margin.top + margin.bottom)
1031
+ .append('g')
1032
+ .attr('transform', `translate(${margin.left},${margin.top})`);
1033
+
1034
+ const x = d3.scalePoint().domain(data.map(d => d.date)).range([0, width]).padding(0.5);
1035
+ const yMin = d3.min(data, d => d.netLines) || 0;
1036
+ const yMax = d3.max(data, d => d.netLines) || 1;
1037
+ const y = d3.scaleLinear().domain([Math.min(0, yMin), yMax]).nice().range([height, 0]);
1038
+
1039
+ const textColor = isDark() ? '#8b949e' : '#64748b';
1040
+
1041
+ svg.append('g')
1042
+ .attr('transform', `translate(0,${height})`)
1043
+ .call(d3.axisBottom(x).tickValues(data.filter((_, i) => i % Math.ceil(data.length / 6) === 0).map(d => d.date)))
1044
+ .selectAll('text').attr('fill', textColor).attr('font-size', '10px');
1045
+
1046
+ svg.append('g')
1047
+ .call(d3.axisLeft(y).ticks(5).tickFormat(d => d >= 1000 ? (d/1000).toFixed(0) + 'k' : d))
1048
+ .selectAll('text').attr('fill', textColor);
1049
+
1050
+ svg.selectAll('.domain, .tick line').attr('stroke', isDark() ? '#334155' : '#e2e8f0');
1051
+
1052
+ const area = d3.area()
1053
+ .x(d => x(d.date))
1054
+ .y0(y(0))
1055
+ .y1(d => y(d.netLines))
1056
+ .curve(d3.curveMonotoneX);
1057
+
1058
+ svg.append('path')
1059
+ .datum(data)
1060
+ .attr('fill', isDark() ? 'rgba(34, 197, 94, 0.3)' : 'rgba(34, 197, 94, 0.2)')
1061
+ .attr('stroke', '#22c55e')
1062
+ .attr('stroke-width', 2)
1063
+ .attr('d', area);
1064
+ }
1065
+
1066
+ // ============================================================
1067
+ // 周几分布 (柱状图)
1068
+ // ============================================================
1069
+ function renderWeekdayChart() {
1070
+ const container = document.getElementById('weekday-chart');
1071
+ container.innerHTML = '';
1072
+
1073
+ if (!stats.timePatterns || !stats.timePatterns.weekdayDistribution) {
1074
+ container.innerHTML = '<p class="text-slate-400 text-sm">暂无数据</p>';
1075
+ return;
1076
+ }
1077
+
1078
+ const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
1079
+ const data = stats.timePatterns.weekdayDistribution.map((count, i) => ({ day: weekdays[i], count }));
1080
+
1081
+ const margin = { top: 10, right: 10, bottom: 30, left: 40 };
1082
+ const width = container.clientWidth - margin.left - margin.right;
1083
+ const height = 200 - margin.top - margin.bottom;
1084
+
1085
+ const svg = d3.select(container)
1086
+ .append('svg')
1087
+ .attr('width', width + margin.left + margin.right)
1088
+ .attr('height', height + margin.top + margin.bottom)
1089
+ .append('g')
1090
+ .attr('transform', `translate(${margin.left},${margin.top})`);
1091
+
1092
+ const x = d3.scaleBand().domain(data.map(d => d.day)).range([0, width]).padding(0.3);
1093
+ const y = d3.scaleLinear().domain([0, d3.max(data, d => d.count) || 1]).nice().range([height, 0]);
1094
+
1095
+ const textColor = isDark() ? '#8b949e' : '#64748b';
1096
+
1097
+ svg.append('g')
1098
+ .attr('transform', `translate(0,${height})`)
1099
+ .call(d3.axisBottom(x))
1100
+ .selectAll('text').attr('fill', textColor);
1101
+
1102
+ svg.append('g')
1103
+ .call(d3.axisLeft(y).ticks(5))
1104
+ .selectAll('text').attr('fill', textColor);
1105
+
1106
+ svg.selectAll('.domain, .tick line').attr('stroke', isDark() ? '#334155' : '#e2e8f0');
1107
+
1108
+ svg.selectAll('.bar')
1109
+ .data(data)
1110
+ .join('rect')
1111
+ .attr('x', d => x(d.day))
1112
+ .attr('y', d => y(d.count))
1113
+ .attr('width', x.bandwidth())
1114
+ .attr('height', d => height - y(d.count))
1115
+ .attr('fill', (_, i) => i >= 5 ? '#f59e0b' : '#8b5cf6')
1116
+ .attr('rx', 2)
1117
+ .on('mouseover', (event, d, i) => {
1118
+ const dayIndex = data.findIndex(item => item.day === d.day);
1119
+ const dayData = stats.timePatterns?.weekdayByAuthor?.[dayIndex];
1120
+ let tooltip = `${d.day}: ${d.count} 次提交`;
1121
+ if (dayData && Object.keys(dayData.authors).length > 0) {
1122
+ const authorList = Object.entries(dayData.authors)
1123
+ .sort((a, b) => b[1] - a[1])
1124
+ .slice(0, 5)
1125
+ .map(([name, count]) => `${name}: ${count}`)
1126
+ .join('\n');
1127
+ tooltip += '\n' + authorList;
1128
+ }
1129
+ showTooltip(event, tooltip);
1130
+ })
1131
+ .on('mouseout', hideTooltip);
1132
+ }
1133
+
1134
+ // ============================================================
1135
+ // 提交类型分布 (甜甜圈图)
1136
+ // ============================================================
1137
+ function renderCommitTypeChart() {
1138
+ const container = document.getElementById('commit-type-chart');
1139
+ container.innerHTML = '';
1140
+
1141
+ if (!stats.messageStats || !stats.messageStats.typeDistribution) {
1142
+ container.innerHTML = '<p class="text-slate-400 text-sm">暂无数据</p>';
1143
+ return;
1144
+ }
1145
+
1146
+ const rawData = Object.entries(stats.messageStats.typeDistribution);
1147
+ if (rawData.length === 0) {
1148
+ container.innerHTML = '<p class="text-slate-400 text-sm">暂无数据</p>';
1149
+ return;
1150
+ }
1151
+
1152
+ const data = rawData.map(([name, value]) => ({ name, value })).sort((a, b) => b.value - a.value).slice(0, 8);
1153
+
1154
+ const size = 220;
1155
+ const radius = size / 2 - 10;
1156
+
1157
+ const typeColors = {
1158
+ feat: '#22c55e', fix: '#ef4444', docs: '#3b82f6', style: '#ec4899',
1159
+ refactor: '#8b5cf6', test: '#f59e0b', chore: '#6b7280', perf: '#14b8a6',
1160
+ ci: '#06b6d4', build: '#84cc16', revert: '#f43f5e', other: '#94a3b8'
1161
+ };
1162
+
1163
+ const wrapper = d3.select(container)
1164
+ .append('div')
1165
+ .attr('class', 'flex items-center gap-6');
1166
+
1167
+ const svg = wrapper
1168
+ .append('svg')
1169
+ .attr('width', size)
1170
+ .attr('height', size)
1171
+ .append('g')
1172
+ .attr('transform', `translate(${size/2},${size/2})`);
1173
+
1174
+ const pie = d3.pie().value(d => d.value).sort(null);
1175
+ const arc = d3.arc().innerRadius(radius * 0.55).outerRadius(radius);
1176
+
1177
+ svg.selectAll('path')
1178
+ .data(pie(data))
1179
+ .join('path')
1180
+ .attr('d', arc)
1181
+ .attr('fill', d => typeColors[d.data.name] || '#94a3b8')
1182
+ .attr('stroke', isDark() ? '#1e293b' : '#fff')
1183
+ .attr('stroke-width', 2)
1184
+ .on('mouseover', (event, d) => {
1185
+ const pct = ((d.data.value / d3.sum(data, x => x.value)) * 100).toFixed(1);
1186
+ showTooltip(event, `${d.data.name}: ${d.data.value} 次 (${pct}%)`);
1187
+ })
1188
+ .on('mouseout', hideTooltip);
1189
+
1190
+ const legend = wrapper
1191
+ .append('div')
1192
+ .attr('class', 'flex flex-col gap-2');
1193
+
1194
+ data.forEach(d => {
1195
+ const item = legend.append('div').attr('class', 'flex items-center gap-2 text-xs');
1196
+ item.append('div')
1197
+ .style('width', '10px')
1198
+ .style('height', '10px')
1199
+ .style('border-radius', '2px')
1200
+ .style('flex-shrink', '0')
1201
+ .style('background', typeColors[d.name] || '#94a3b8');
1202
+ item.append('span')
1203
+ .attr('class', 'text-slate-600 dark:text-slate-400')
1204
+ .text(d.name);
1205
+ });
1206
+ }
1207
+
1208
+ // ============================================================
1209
+ // 热点文件表格
1210
+ // ============================================================
1211
+ function renderHotFilesTable() {
1212
+ const tbody = document.getElementById('hot-files-body');
1213
+ tbody.innerHTML = '';
1214
+
1215
+ if (!stats.quality || !stats.quality.hotFiles || stats.quality.hotFiles.length === 0) {
1216
+ tbody.innerHTML = '<tr><td colspan="3" class="py-4 text-center text-slate-400">暂无数据</td></tr>';
1217
+ return;
1218
+ }
1219
+
1220
+ for (const file of stats.quality.hotFiles) {
1221
+ const tr = document.createElement('tr');
1222
+ tr.className = 'border-b border-slate-100 dark:border-slate-700/50';
1223
+ tr.innerHTML = `
1224
+ <td class="py-2 text-slate-700 dark:text-slate-300 truncate max-w-xs" title="${file.path}">${file.path}</td>
1225
+ <td class="py-2 text-right text-slate-600 dark:text-slate-400">${file.modifyCount}</td>
1226
+ <td class="py-2 text-right text-slate-600 dark:text-slate-400">${file.authors.length}</td>
1227
+ `;
1228
+ tbody.appendChild(tr);
1229
+ }
1230
+ }
1231
+
1232
+ // ============================================================
1233
+ // 知识集中度风险表格
1234
+ // ============================================================
1235
+ function renderSoloFilesTable() {
1236
+ const tbody = document.getElementById('solo-files-body');
1237
+ tbody.innerHTML = '';
1238
+
1239
+ if (!stats.collaboration || !stats.collaboration.soloFiles || stats.collaboration.soloFiles.length === 0) {
1240
+ tbody.innerHTML = '<tr><td colspan="3" class="py-4 text-center text-slate-400">暂无单人维护文件</td></tr>';
1241
+ return;
1242
+ }
1243
+
1244
+ for (const file of stats.collaboration.soloFiles) {
1245
+ const tr = document.createElement('tr');
1246
+ tr.className = 'border-b border-slate-100 dark:border-slate-700/50';
1247
+ tr.innerHTML = `
1248
+ <td class="py-2 text-slate-700 dark:text-slate-300 truncate max-w-xs" title="${file.path}">${file.path}</td>
1249
+ <td class="py-2 text-slate-600 dark:text-slate-400">${file.author}</td>
1250
+ <td class="py-2 text-right text-slate-600 dark:text-slate-400">${file.commits}</td>
1251
+ `;
1252
+ tbody.appendChild(tr);
1253
+ }
1254
+ }
1255
+
1256
+ // ============================================================
1257
+ // 作者×文件类型贡献表格(支持排序)
1258
+ // ============================================================
1259
+ let aftSortKey = 'linesAdded';
1260
+ let aftSortAsc = false;
1261
+
1262
+ function renderAuthorFileTypeTable() {
1263
+ const tbody = document.getElementById('author-filetype-body');
1264
+ tbody.innerHTML = '';
1265
+
1266
+ if (!stats.authorFileTypeContributions || stats.authorFileTypeContributions.length === 0) {
1267
+ tbody.innerHTML = '<tr><td colspan="6" class="py-4 text-center text-slate-400">暂无数据</td></tr>';
1268
+ return;
1269
+ }
1270
+
1271
+ // 排序数据
1272
+ const sortedData = [...stats.authorFileTypeContributions].sort((a, b) => {
1273
+ let valA = a[aftSortKey];
1274
+ let valB = b[aftSortKey];
1275
+ // 字符串比较
1276
+ if (typeof valA === 'string') {
1277
+ valA = valA.toLowerCase();
1278
+ valB = valB.toLowerCase();
1279
+ return aftSortAsc ? valA.localeCompare(valB) : valB.localeCompare(valA);
1280
+ }
1281
+ // 数字比较
1282
+ return aftSortAsc ? valA - valB : valB - valA;
1283
+ });
1284
+
1285
+ for (const item of sortedData) {
1286
+ const tr = document.createElement('tr');
1287
+ tr.className = 'border-b border-slate-100 dark:border-slate-700/50';
1288
+ tr.innerHTML = `
1289
+ <td class="py-2 text-slate-700 dark:text-slate-300 truncate max-w-[120px]" title="${escapeHtml(item.author)}">${escapeHtml(item.author)}</td>
1290
+ <td class="py-2 text-slate-600 dark:text-slate-400">${escapeHtml(item.extension)}</td>
1291
+ <td class="py-2 text-right text-slate-600 dark:text-slate-400">${formatNumber(item.fileCount)}</td>
1292
+ <td class="py-2 text-right text-emerald-600 dark:text-emerald-400">+${formatNumber(item.linesAdded)}</td>
1293
+ <td class="py-2 text-right text-rose-600 dark:text-rose-400">-${formatNumber(item.linesDeleted)}</td>
1294
+ <td class="py-2 text-right text-slate-600 dark:text-slate-400">${item.commits}</td>
1295
+ `;
1296
+ tbody.appendChild(tr);
1297
+ }
1298
+
1299
+ // 更新排序图标
1300
+ document.querySelectorAll('#author-filetype-table th[data-sort]').forEach(th => {
1301
+ const icon = th.querySelector('.sort-icon');
1302
+ if (th.dataset.sort === aftSortKey) {
1303
+ icon.textContent = aftSortAsc ? '↑' : '↓';
1304
+ } else {
1305
+ icon.textContent = '';
1306
+ }
1307
+ });
1308
+ }
1309
+
1310
+ // 绑定表头点击排序
1311
+ document.querySelectorAll('#author-filetype-table th[data-sort]').forEach(th => {
1312
+ th.addEventListener('click', () => {
1313
+ const key = th.dataset.sort;
1314
+ if (aftSortKey === key) {
1315
+ aftSortAsc = !aftSortAsc;
1316
+ } else {
1317
+ aftSortKey = key;
1318
+ aftSortAsc = key === 'author' || key === 'extension'; // 字符串默认升序
1319
+ }
1320
+ renderAuthorFileTypeTable();
1321
+ });
1322
+ });
1323
+
1324
+ // ============================================================
1325
+ // 高级分析 - 团队健康度
1326
+ // ============================================================
1327
+ function renderTeamHealth() {
1328
+ const teamHealth = DATA.stats.teamHealth;
1329
+
1330
+ // 检查数据是否存在
1331
+ if (!teamHealth) {
1332
+ document.getElementById('team-health').innerHTML =
1333
+ '<div class="text-center py-12 text-slate-500 dark:text-slate-400">' +
1334
+ '<p class="text-lg mb-2">团队健康度分析</p>' +
1335
+ '<p class="text-sm">此功能仅在单仓库分析时可用</p>' +
1336
+ '</div>';
1337
+ return;
1338
+ }
1339
+
1340
+ const { busFactor, criticalAuthors, knowledgeDistribution, riskLevel } = teamHealth;
1341
+
1342
+ // 1. 渲染 Bus Factor 仪表盘
1343
+ renderBusFactorGauge(busFactor, riskLevel, knowledgeDistribution);
1344
+
1345
+ // 2. 渲染关键人员列表
1346
+ renderCriticalAuthorsList(criticalAuthors);
1347
+ }
1348
+
1349
+ // Bus Factor 仪表盘(半圆仪表)
1350
+ function renderBusFactorGauge(busFactor, riskLevel, knowledgeDistribution) {
1351
+ const container = d3.select('#bus-factor-gauge');
1352
+ container.html(''); // 清空
1353
+
1354
+ // 风险等级颜色映射
1355
+ const riskColors = {
1356
+ low: '#10b981', // 绿色
1357
+ medium: '#f59e0b', // 橙色
1358
+ high: '#ef4444' // 红色
1359
+ };
1360
+ const color = riskColors[riskLevel] || '#94a3b8';
1361
+
1362
+ // SVG 尺寸
1363
+ const width = 240; // 略微缩小以适应新布局
1364
+ const height = 140;
1365
+ const radius = 90;
1366
+
1367
+ const svg = container.append('svg')
1368
+ .attr('width', width)
1369
+ .attr('height', height)
1370
+ .append('g')
1371
+ .attr('transform', `translate(${width/2}, ${height - 10})`);
1372
+
1373
+ // 背景半圆(灰色)
1374
+ const bgArc = d3.arc()
1375
+ .innerRadius(radius - 15)
1376
+ .outerRadius(radius)
1377
+ .startAngle(-Math.PI / 2)
1378
+ .endAngle(Math.PI / 2);
1379
+
1380
+ svg.append('path')
1381
+ .attr('d', bgArc)
1382
+ .attr('fill', '#e2e8f0')
1383
+ .attr('class', 'dark:fill-slate-600');
1384
+
1385
+ // 前景半圆(根据风险等级)
1386
+ // 角度:low=0-60度, medium=60-120度, high=120-180度
1387
+ const angleMap = { low: Math.PI/6, medium: Math.PI/3, high: Math.PI/2 };
1388
+ const targetAngle = angleMap[riskLevel] || 0;
1389
+
1390
+ const foregroundArc = d3.arc()
1391
+ .innerRadius(radius - 15)
1392
+ .outerRadius(radius)
1393
+ .startAngle(-Math.PI / 2)
1394
+ .endAngle(-Math.PI / 2 + targetAngle);
1395
+
1396
+ svg.append('path')
1397
+ .attr('d', foregroundArc)
1398
+ .attr('fill', color);
1399
+
1400
+ // 中心文本 - Bus Factor 数值
1401
+ svg.append('text')
1402
+ .attr('y', -25)
1403
+ .attr('text-anchor', 'middle')
1404
+ .attr('class', 'text-5xl font-bold')
1405
+ .attr('fill', color)
1406
+ .text(busFactor);
1407
+
1408
+ // 风险等级标签
1409
+ const riskLabels = { low: '低风险', medium: '中等风险', high: '高风险' };
1410
+ svg.append('text')
1411
+ .attr('y', 0)
1412
+ .attr('text-anchor', 'middle')
1413
+ .attr('class', 'text-sm font-medium fill-slate-500 dark:fill-slate-400')
1414
+ .text(riskLabels[riskLevel] || '');
1415
+
1416
+ // 知识分布均匀度
1417
+ d3.select('#bus-factor-gauge')
1418
+ .append('div')
1419
+ .attr('class', 'text-center mt-2 text-xs text-slate-400')
1420
+ .text(`知识分布均匀度: ${(knowledgeDistribution * 100).toFixed(0)}%`);
1421
+ }
1422
+
1423
+ // 关键人员列表
1424
+ function renderCriticalAuthorsList(criticalAuthors) {
1425
+ const container = document.getElementById('critical-authors-list');
1426
+
1427
+ if (!criticalAuthors || criticalAuthors.length === 0) {
1428
+ container.innerHTML = '<div class="flex flex-col items-center justify-center h-40 text-slate-400"><p>未检测到关键人员</p><p class="text-xs mt-1">这意味着知识分布比较均匀</p></div>';
1429
+ return;
1430
+ }
1431
+
1432
+ let html = '<div class="overflow-x-auto max-h-[300px] overflow-y-auto scrollbar-thin">';
1433
+ html += '<table class="w-full text-sm">';
1434
+ html += '<thead class="sticky top-0 bg-slate-50 dark:bg-slate-800 z-10">';
1435
+ html += '<tr class="text-left border-b border-slate-200 dark:border-slate-700">';
1436
+ html += '<th class="pb-2 text-slate-500 dark:text-slate-400 font-medium pl-2">姓名</th>';
1437
+ html += '<th class="pb-2 text-slate-500 dark:text-slate-400 font-medium text-right">知识评分</th>';
1438
+ html += '<th class="pb-2 text-slate-500 dark:text-slate-400 font-medium text-right">独有文件</th>';
1439
+ html += '<th class="pb-2 text-slate-500 dark:text-slate-400 font-medium text-right pr-2">主导文件</th>';
1440
+ html += '</tr>';
1441
+ html += '</thead>';
1442
+ html += '<tbody class="divide-y divide-slate-100 dark:divide-slate-700/50">';
1443
+
1444
+ criticalAuthors.forEach((author) => {
1445
+ html += '<tr class="hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors">';
1446
+ html += `<td class="py-3 font-medium pl-2 text-slate-700 dark:text-slate-300">${escapeHtml(author.name)}</td>`;
1447
+ html += `<td class="py-3 text-right"><span class="px-2 py-0.5 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded text-xs font-medium">${author.knowledgeScore.toFixed(1)}</span></td>`;
1448
+ html += `<td class="py-3 text-right text-slate-600 dark:text-slate-400">${author.uniqueFiles.length}</td>`;
1449
+ html += `<td class="py-3 text-right text-slate-600 dark:text-slate-400 pr-2">${author.dominantFiles.length}</td>`;
1450
+ html += '</tr>';
1451
+ });
1452
+
1453
+ html += '</tbody>';
1454
+ html += '</table>';
1455
+ html += '</div>';
1456
+
1457
+ container.innerHTML = html;
1458
+ }
1459
+
1460
+ // ============================================================
1461
+ // 高级分析 - 代码稳定性
1462
+ // ============================================================
1463
+ function renderStability() {
1464
+ const stability = DATA.stats.stability;
1465
+
1466
+ // 检查数据是否存在
1467
+ if (!stability) {
1468
+ document.getElementById('stability').innerHTML =
1469
+ '<div class="text-center py-12 text-slate-500 dark:text-slate-400">' +
1470
+ '<p class="text-lg mb-2">代码稳定性分析</p>' +
1471
+ '<p class="text-sm">此功能仅在单仓库分析时可用</p>' +
1472
+ '</div>';
1473
+ return;
1474
+ }
1475
+
1476
+ const { fileChurnRate, stabilityScore, revertRate, fixCommitRate } = stability;
1477
+
1478
+ // 1. 渲染稳定性指标卡片
1479
+ renderStabilityCards(stabilityScore, revertRate, fixCommitRate, fileChurnRate);
1480
+
1481
+ // 2. 渲染文件流失率柱状图
1482
+ renderChurnChart(fileChurnRate);
1483
+ }
1484
+
1485
+ // 稳定性指标卡片
1486
+ function renderStabilityCards(stabilityScore, revertRate, fixCommitRate, fileChurnRate) {
1487
+ const unstableCount = fileChurnRate.filter(f => f.isUnstable).length;
1488
+ const container = document.getElementById('stability-metrics');
1489
+
1490
+ let html = '<div class="grid grid-cols-2 md:grid-cols-4 gap-4">';
1491
+
1492
+ // 辅助函数:生成卡片 HTML
1493
+ const createCard = (label, value, color, iconPath) => `
1494
+ <div class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-5 flex flex-col justify-between h-full border border-slate-100 dark:border-slate-700/50">
1495
+ <div class="flex items-center justify-between mb-2">
1496
+ <div class="text-sm text-slate-500 dark:text-slate-400 font-medium">${label}</div>
1497
+ <div class="${color} p-1.5 bg-white dark:bg-slate-800 rounded-lg shadow-sm">
1498
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1499
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="${iconPath}" />
1500
+ </svg>
1501
+ </div>
1502
+ </div>
1503
+ <div class="text-2xl font-bold ${color}">${value}</div>
1504
+ </div>
1505
+ `;
1506
+
1507
+ // 稳定性评分
1508
+ const scoreColor = stabilityScore >= 80 ? 'text-emerald-500' :
1509
+ stabilityScore >= 60 ? 'text-amber-500' : 'text-rose-500';
1510
+ html += createCard(
1511
+ '稳定性评分',
1512
+ stabilityScore.toFixed(0),
1513
+ scoreColor,
1514
+ 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'
1515
+ );
1516
+
1517
+ // Revert 率
1518
+ html += createCard(
1519
+ 'Revert 率',
1520
+ (revertRate * 100).toFixed(1) + '%',
1521
+ 'text-violet-500',
1522
+ 'M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6'
1523
+ );
1524
+
1525
+ // Fix 提交率
1526
+ html += createCard(
1527
+ 'Fix 提交率',
1528
+ (fixCommitRate * 100).toFixed(1) + '%',
1529
+ 'text-orange-500',
1530
+ 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z'
1531
+ );
1532
+
1533
+ // 不稳定文件数
1534
+ html += createCard(
1535
+ '不稳定文件',
1536
+ unstableCount,
1537
+ 'text-rose-500',
1538
+ 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'
1539
+ );
1540
+
1541
+ html += '</div>';
1542
+ container.innerHTML = html;
1543
+ }
1544
+
1545
+ // 文件流失率柱状图
1546
+ function renderChurnChart(fileChurnRate) {
1547
+ const container = document.getElementById('unstable-files-list');
1548
+
1549
+ if (!fileChurnRate || fileChurnRate.length === 0) {
1550
+ container.innerHTML = '<p class="text-sm text-slate-500 dark:text-slate-400">无流失率数据</p>';
1551
+ return;
1552
+ }
1553
+
1554
+ // 取 TOP 20
1555
+ const top20 = fileChurnRate.slice(0, 20);
1556
+
1557
+ let html = '<div class="space-y-3 max-h-[400px] overflow-y-auto scrollbar-thin pr-2">';
1558
+
1559
+ top20.forEach(file => {
1560
+ const percentage = (file.churnRate * 100).toFixed(1);
1561
+ const isUnstable = file.isUnstable;
1562
+ const barColor = isUnstable ? 'bg-rose-500' : 'bg-primary-500';
1563
+ const textColor = isUnstable ? 'text-rose-600 dark:text-rose-400 font-semibold' : 'text-slate-700 dark:text-slate-300';
1564
+
1565
+ // 文件路径截断
1566
+ const displayPath = file.path.length > 60 ? '...' + file.path.slice(-57) : file.path;
1567
+
1568
+ html += '<div class="group hover:bg-slate-100 dark:hover:bg-slate-800/50 p-2 rounded-lg transition-colors">';
1569
+ html += `<div class="flex items-center justify-between text-sm mb-1.5">`;
1570
+ html += `<span class="font-mono text-xs ${textColor}" title="${escapeHtml(file.path)}">${escapeHtml(displayPath)}</span>`;
1571
+ html += `<span class="text-xs font-medium ${isUnstable ? 'text-rose-500' : 'text-slate-500'}">${percentage}%</span>`;
1572
+ html += '</div>';
1573
+ html += '<div class="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-1.5">';
1574
+ html += `<div class="${barColor} h-1.5 rounded-full transition-all" style="width: ${Math.min(percentage, 100)}%"></div>`;
1575
+ html += '</div>';
1576
+ html += `<div class="text-[10px] text-slate-400 mt-1 flex gap-2">`;
1577
+ html += `<span>修改 ${file.modifyCount} 次</span>`;
1578
+ html += `<span><span class="text-emerald-500">+${formatNumber(file.added)}</span> / <span class="text-rose-500">-${formatNumber(file.deleted)}</span></span>`;
1579
+ html += `</div>`;
1580
+ html += '</div>';
1581
+ });
1582
+
1583
+ html += '</div>';
1584
+ container.innerHTML = html;
1585
+ }
1586
+
1587
+ // ============================================================
1588
+ // 高级分析 - 工作压力
1589
+ // ============================================================
1590
+ function renderWorkPressure() {
1591
+ const workPressure = DATA.stats.workPressure;
1592
+
1593
+ // 检查数据是否存在
1594
+ if (!workPressure) {
1595
+ document.getElementById('work-pressure').innerHTML =
1596
+ '<div class="text-center py-12 text-slate-500 dark:text-slate-400">' +
1597
+ '<p class="text-lg mb-2">工作压力分析</p>' +
1598
+ '<p class="text-sm">此功能仅在单仓库分析时可用</p>' +
1599
+ '</div>';
1600
+ return;
1601
+ }
1602
+
1603
+ const { pressureScore, lateNightCommits, earlyMorningCommits,
1604
+ weekendCommits, holidayCommits, offHoursRate } = workPressure;
1605
+
1606
+ // 1. 渲染压力评分仪表盘
1607
+ renderPressureGauge(pressureScore, offHoursRate);
1608
+
1609
+ // 2. 渲染非工作时间分布
1610
+ renderOffHoursBreakdown(lateNightCommits, earlyMorningCommits, weekendCommits, holidayCommits);
1611
+ }
1612
+
1613
+ // 压力评分仪表盘(半圆仪表)
1614
+ function renderPressureGauge(pressureScore, offHoursRate) {
1615
+ const container = d3.select('#pressure-gauge');
1616
+ container.html(''); // 清空
1617
+
1618
+ // 压力等级颜色映射
1619
+ const getColor = (score) => {
1620
+ if (score < 30) return '#10b981'; // 绿色 - 低压力
1621
+ if (score < 60) return '#f59e0b'; // 橙色 - 中等压力
1622
+ return '#ef4444'; // 红色 - 高压力
1623
+ };
1624
+ const color = getColor(pressureScore);
1625
+
1626
+ // SVG 尺寸
1627
+ const width = 240;
1628
+ const height = 140;
1629
+ const radius = 90;
1630
+
1631
+ const svg = container.append('svg')
1632
+ .attr('width', width)
1633
+ .attr('height', height)
1634
+ .append('g')
1635
+ .attr('transform', `translate(${width/2}, ${height - 10})`);
1636
+
1637
+ // 背景半圆(灰色)
1638
+ const bgArc = d3.arc()
1639
+ .innerRadius(radius - 15)
1640
+ .outerRadius(radius)
1641
+ .startAngle(-Math.PI / 2)
1642
+ .endAngle(Math.PI / 2);
1643
+
1644
+ svg.append('path')
1645
+ .attr('d', bgArc)
1646
+ .attr('fill', '#e2e8f0')
1647
+ .attr('class', 'dark:fill-slate-600');
1648
+
1649
+ // 前景半圆(根据压力评分)
1650
+ const targetAngle = -Math.PI / 2 + (Math.PI * pressureScore / 100);
1651
+
1652
+ const foregroundArc = d3.arc()
1653
+ .innerRadius(radius - 15)
1654
+ .outerRadius(radius)
1655
+ .startAngle(-Math.PI / 2)
1656
+ .endAngle(targetAngle);
1657
+
1658
+ svg.append('path')
1659
+ .attr('d', foregroundArc)
1660
+ .attr('fill', color);
1661
+
1662
+ // 中心文本 - 压力评分
1663
+ svg.append('text')
1664
+ .attr('y', -25)
1665
+ .attr('text-anchor', 'middle')
1666
+ .attr('class', 'text-5xl font-bold')
1667
+ .attr('fill', color)
1668
+ .text(pressureScore.toFixed(0));
1669
+
1670
+ // 压力等级标签
1671
+ const levelLabels = { low: '低压力', medium: '中等压力', high: '高压力' };
1672
+ const level = pressureScore < 30 ? 'low' : pressureScore < 60 ? 'medium' : 'high';
1673
+ svg.append('text')
1674
+ .attr('y', 0)
1675
+ .attr('text-anchor', 'middle')
1676
+ .attr('class', 'text-sm font-medium fill-slate-500 dark:fill-slate-400')
1677
+ .text(levelLabels[level]);
1678
+
1679
+ // 非工作时间占比
1680
+ d3.select('#pressure-gauge')
1681
+ .append('div')
1682
+ .attr('class', 'text-center mt-2 text-xs text-slate-400')
1683
+ .text(`非工作时间提交: ${(offHoursRate * 100).toFixed(1)}%`);
1684
+ }
1685
+
1686
+ // 非工作时间分布
1687
+ function renderOffHoursBreakdown(lateNightCommits, earlyMorningCommits, weekendCommits, holidayCommits) {
1688
+ const container = document.getElementById('off-hours-breakdown');
1689
+
1690
+ const total = DATA.stats.totalCommits || 1;
1691
+ const normalCommits = total - lateNightCommits - earlyMorningCommits - weekendCommits - holidayCommits.reduce((sum, h) => sum + h.commits, 0);
1692
+
1693
+ const data = [
1694
+ { label: '正常时间', count: normalCommits, color: '#10b981', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
1695
+ { label: '深夜 (23-02)', count: lateNightCommits, color: '#8b5cf6', icon: 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z' },
1696
+ { label: '凌晨 (02-06)', count: earlyMorningCommits, color: '#ef4444', icon: 'M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z' },
1697
+ { label: '周末', count: weekendCommits, color: '#f59e0b', icon: 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z' },
1698
+ { label: '假期', count: holidayCommits.reduce((sum, h) => sum + h.commits, 0), color: '#ec4899', icon: 'M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z' }
1699
+ ].filter(d => d.count > 0);
1700
+
1701
+ let html = '<div class="space-y-3">';
1702
+
1703
+ data.forEach(item => {
1704
+ const percentage = ((item.count / total) * 100).toFixed(1);
1705
+ html += `
1706
+ <div class="flex items-center justify-between p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-100 dark:border-slate-700/50">
1707
+ <div class="flex items-center gap-3">
1708
+ <div class="p-2 rounded-lg bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400">
1709
+ <svg class="w-5 h-5" style="color: ${item.color}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1710
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="${item.icon}" />
1711
+ </svg>
1712
+ </div>
1713
+ <div>
1714
+ <div class="text-sm font-medium text-slate-700 dark:text-slate-200">${item.label}</div>
1715
+ <div class="text-xs text-slate-500 dark:text-slate-400">${item.count} 次提交</div>
1716
+ </div>
1717
+ </div>
1718
+ <div class="text-right">
1719
+ <div class="text-lg font-bold" style="color: ${item.color}">${percentage}%</div>
1720
+ </div>
1721
+ </div>
1722
+ `;
1723
+ });
1724
+
1725
+ html += '</div>';
1726
+
1727
+ // 假期提交列表
1728
+ if (holidayCommits && holidayCommits.length > 0) {
1729
+ html += '<div class="mt-6"><h4 class="text-sm font-semibold mb-2 text-slate-600 dark:text-slate-300">假期提交记录</h4></div>';
1730
+ html += '<div class="space-y-2 max-h-[150px] overflow-y-auto scrollbar-thin">';
1731
+
1732
+ holidayCommits.forEach((holiday) => {
1733
+ html += `
1734
+ <div class="flex items-center justify-between text-sm py-2 px-3 bg-rose-50 dark:bg-rose-900/10 rounded-lg border border-rose-100 dark:border-rose-900/20">
1735
+ <span class="text-rose-700 dark:text-rose-300 font-medium">${escapeHtml(holiday.holidayName)}</span>
1736
+ <div class="flex items-center gap-3">
1737
+ <span class="text-xs text-slate-500">${holiday.date}</span>
1738
+ <span class="bg-rose-200 dark:bg-rose-800 text-rose-800 dark:text-rose-100 px-2 py-0.5 rounded text-xs">${holiday.commits}</span>
1739
+ </div>
1740
+ </div>
1741
+ `;
1742
+ });
1743
+ html += '</div>';
1744
+ }
1745
+
1746
+ container.innerHTML = html;
1747
+ }
1748
+
1749
+ // ============================================================
1750
+ // 高级分析 - 贡献者流失
1751
+ // ============================================================
1752
+ function renderContributorChurn() {
1753
+ const contributorChurn = DATA.stats.contributorChurn;
1754
+
1755
+ // 检查数据是否存在
1756
+ if (!contributorChurn) {
1757
+ document.getElementById('contributor-churn').innerHTML =
1758
+ '<div class="text-center py-12 text-slate-500 dark:text-slate-400">' +
1759
+ '<p class="text-lg mb-2">贡献者流失分析</p>' +
1760
+ '<p class="text-sm">此功能仅在单仓库分析时可用</p>' +
1761
+ '</div>';
1762
+ return;
1763
+ }
1764
+
1765
+ const { active, occasional, dormant, lost, newJoiners,
1766
+ churnRate, retentionRate, growthRate } = contributorChurn;
1767
+
1768
+ // 1. 渲染关键指标卡片
1769
+ renderChurnMetrics(churnRate, retentionRate, growthRate, newJoiners.length);
1770
+
1771
+ // 2. 渲染漏斗图
1772
+ renderChurnFunnel(active.length, occasional.length, dormant.length, lost.length);
1773
+
1774
+ // 3. 渲染作者状态列表
1775
+ renderAuthorStatusList(active, occasional, dormant, lost);
1776
+ }
1777
+
1778
+ // 关键指标卡片
1779
+ function renderChurnMetrics(churnRate, retentionRate, growthRate, newJoinersCount) {
1780
+ const container = document.getElementById('churn-metrics');
1781
+
1782
+ let html = '<div class="grid grid-cols-2 md:grid-cols-4 gap-4">';
1783
+
1784
+ // 辅助函数:生成卡片 HTML
1785
+ const createCard = (label, value, color, iconPath) => `
1786
+ <div class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-5 flex flex-col justify-between h-full border border-slate-100 dark:border-slate-700/50">
1787
+ <div class="flex items-center justify-between mb-2">
1788
+ <div class="text-sm text-slate-500 dark:text-slate-400 font-medium">${label}</div>
1789
+ <div class="${color} p-1.5 bg-white dark:bg-slate-800 rounded-lg shadow-sm">
1790
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1791
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="${iconPath}" />
1792
+ </svg>
1793
+ </div>
1794
+ </div>
1795
+ <div class="text-2xl font-bold ${color}">${value}</div>
1796
+ </div>
1797
+ `;
1798
+
1799
+ // 流失率
1800
+ const churnColor = churnRate > 0.3 ? 'text-rose-500' : churnRate > 0.15 ? 'text-amber-500' : 'text-emerald-500';
1801
+ html += createCard(
1802
+ '流失率',
1803
+ (churnRate * 100).toFixed(1) + '%',
1804
+ churnColor,
1805
+ 'M13 7a4 4 0 11-8 0 4 4 0 018 0zM9 14a6 6 0 00-6 6v1h12v-1a6 6 0 00-6-6zM21 12h-6'
1806
+ );
1807
+
1808
+ // 留存率
1809
+ const retentionColor = retentionRate > 0.7 ? 'text-emerald-500' : retentionRate > 0.4 ? 'text-amber-500' : 'text-rose-500';
1810
+ html += createCard(
1811
+ '留存率',
1812
+ (retentionRate * 100).toFixed(1) + '%',
1813
+ retentionColor,
1814
+ 'M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z'
1815
+ );
1816
+
1817
+ // 增长率
1818
+ const growthColor = growthRate > 0.2 ? 'text-emerald-500' : growthRate > 0 ? 'text-amber-500' : 'text-slate-500';
1819
+ html += createCard(
1820
+ '增长率',
1821
+ (growthRate * 100).toFixed(1) + '%',
1822
+ growthColor,
1823
+ 'M13 7h8m0 0v8m0-8l-8 8-4-4-6 6'
1824
+ );
1825
+
1826
+ // 新加入者数
1827
+ html += createCard(
1828
+ '新加入者',
1829
+ newJoinersCount,
1830
+ 'text-primary-500',
1831
+ 'M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z'
1832
+ );
1833
+
1834
+ html += '</div>';
1835
+ container.innerHTML = html;
1836
+ }
1837
+
1838
+ // 漏斗图(4级分类)
1839
+ function renderChurnFunnel(activeCount, occasionalCount, dormantCount, lostCount) {
1840
+ const container = document.getElementById('churn-funnel');
1841
+ const total = activeCount + occasionalCount + dormantCount + lostCount;
1842
+
1843
+ if (total === 0) {
1844
+ container.innerHTML = '<p class="text-sm text-slate-500 dark:text-slate-400 mt-4">无贡献者数据</p>';
1845
+ return;
1846
+ }
1847
+
1848
+ const levels = [
1849
+ { label: '活跃 (Active)', count: activeCount, color: '#10b981', desc: '<30天' },
1850
+ { label: '偶尔 (Occasional)', count: occasionalCount, color: '#f59e0b', desc: '30-90天' },
1851
+ { label: '休眠 (Dormant)', count: dormantCount, color: '#fb923c', desc: '90-180天' },
1852
+ { label: '流失 (Lost)', count: lostCount, color: '#ef4444', desc: '>180天' }
1853
+ ];
1854
+
1855
+ let html = '<div class="space-y-4 py-4">';
1856
+
1857
+ levels.forEach((level, index) => {
1858
+ const percentage = ((level.count / total) * 100).toFixed(1);
1859
+ const width = Math.max(30, 100 - index * 15); // 漏斗宽度递减
1860
+
1861
+ html += `<div class="relative flex justify-center">`;
1862
+ html += ` <div class="absolute inset-0 flex items-center justify-center opacity-10 pointer-events-none">`;
1863
+ html += ` <div style="width: ${width}%; background-color: ${level.color};" class="h-full rounded-lg"></div>`;
1864
+ html += ` </div>`;
1865
+ html += ` <div class="w-full max-w-md flex items-center justify-between px-4 py-2 relative z-10">`;
1866
+ html += ` <div class="flex items-center gap-3">`;
1867
+ html += ` <div class="w-2.5 h-2.5 rounded-full" style="background-color: ${level.color}"></div>`;
1868
+ html += ` <div>`;
1869
+ html += ` <div class="text-sm font-medium text-slate-700 dark:text-slate-200">${level.label}</div>`;
1870
+ html += ` <div class="text-[10px] text-slate-400">${level.desc}</div>`;
1871
+ html += ` </div>`;
1872
+ html += ` </div>`;
1873
+ html += ` <div class="text-right">`;
1874
+ html += ` <div class="text-lg font-bold" style="color: ${level.color}">${level.count}</div>`;
1875
+ html += ` <div class="text-xs text-slate-400">${percentage}%</div>`;
1876
+ html += ` </div>`;
1877
+ html += ` </div>`;
1878
+ html += `</div>`;
1879
+ });
1880
+
1881
+ html += '</div>';
1882
+ container.innerHTML = html;
1883
+ }
1884
+
1885
+ // 作者状态列表
1886
+ function renderAuthorStatusList(active, occasional, dormant, lost) {
1887
+ const container = document.getElementById('author-status-list');
1888
+
1889
+ const allAuthors = [
1890
+ ...active.map(a => ({ ...a, status: '活跃', statusColor: 'emerald', statusBg: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' })),
1891
+ ...occasional.map(a => ({ ...a, status: '偶尔', statusColor: 'amber', statusBg: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' })),
1892
+ ...dormant.map(a => ({ ...a, status: '休眠', statusColor: 'orange', statusBg: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300' })),
1893
+ ...lost.map(a => ({ ...a, status: '流失', statusColor: 'rose', statusBg: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300' }))
1894
+ ];
1895
+
1896
+ if (allAuthors.length === 0) {
1897
+ container.innerHTML = '<p class="text-sm text-slate-500 dark:text-slate-400">无作者数据</p>';
1898
+ return;
1899
+ }
1900
+
1901
+ // 按最后提交日期降序排序
1902
+ allAuthors.sort((a, b) => new Date(b.lastCommitDate).getTime() - new Date(a.lastCommitDate).getTime());
1903
+
1904
+ let html = '<div class="overflow-x-auto max-h-[350px] overflow-y-auto scrollbar-thin">';
1905
+ html += '<table class="w-full text-sm">';
1906
+ html += '<thead class="sticky top-0 bg-slate-50 dark:bg-slate-700/50 backdrop-blur-sm z-10">';
1907
+ html += '<tr class="text-left border-b border-slate-200 dark:border-slate-600">';
1908
+ html += '<th class="py-2 pl-2 text-slate-500 dark:text-slate-400 font-medium">姓名</th>';
1909
+ html += '<th class="py-2 text-slate-500 dark:text-slate-400 font-medium">状态</th>';
1910
+ html += '<th class="py-2 text-slate-500 dark:text-slate-400 font-medium text-right">上次活跃</th>';
1911
+ html += '</tr>';
1912
+ html += '</thead>';
1913
+ html += '<tbody class="divide-y divide-slate-100 dark:divide-slate-700/50">';
1914
+
1915
+ allAuthors.forEach((author) => {
1916
+ const days = author.daysSinceLastCommit;
1917
+ const daysText = days === 0 ? '今天' : `${days}天前`;
1918
+
1919
+ html += `<tr class="hover:bg-slate-100 dark:hover:bg-slate-800/50 transition-colors">`;
1920
+ html += `<td class="py-2.5 pl-2 font-medium text-slate-700 dark:text-slate-300 truncate max-w-[100px]" title="${escapeHtml(author.name)}">${escapeHtml(author.name)}</td>`;
1921
+ html += `<td class="py-2.5"><span class="px-2 py-0.5 rounded text-xs font-medium ${author.statusBg}">${author.status}</span></td>`;
1922
+ html += `<td class="py-2.5 text-right text-slate-500 dark:text-slate-400 text-xs">${daysText}</td>`;
1923
+ html += '</tr>';
1924
+ });
1925
+
1926
+ html += '</tbody>';
1927
+ html += '</table>';
1928
+ html += '</div>';
1929
+
1930
+ container.innerHTML = html;
1931
+ }
1932
+
1933
+ // ============================================================
1934
+ // 高级分析 - 协作热度
1935
+ // ============================================================
1936
+ function renderCollaboration() {
1937
+ const collaboration = DATA.stats.advancedCollaboration;
1938
+
1939
+ // 检查数据是否存在
1940
+ if (!collaboration) {
1941
+ document.getElementById('collaboration').innerHTML =
1942
+ '<div class="text-center py-12 text-slate-500 dark:text-slate-400">' +
1943
+ '<p class="text-lg mb-2">协作热度分析</p>' +
1944
+ '<p class="text-sm">此功能仅在单仓库分析时可用</p>' +
1945
+ '</div>';
1946
+ return;
1947
+ }
1948
+
1949
+ const { tightCoupling, pairProgramming, couplingScore } = collaboration;
1950
+
1951
+ // 1. 渲染耦合评分卡片
1952
+ renderCollaborationMetrics(couplingScore, tightCoupling);
1953
+
1954
+ // 2. 渲染文件耦合列表
1955
+ renderFileCouplingList(tightCoupling);
1956
+
1957
+ // 3. 渲染结对编程检测
1958
+ renderPairProgrammingList(pairProgramming);
1959
+ }
1960
+
1961
+ // 耦合评分卡片
1962
+ function renderCollaborationMetrics(couplingScore, tightCoupling) {
1963
+ const container = document.getElementById('collaboration-metrics');
1964
+
1965
+ const highCouplingCount = tightCoupling.filter(f => f.coupling > 0.7).length;
1966
+
1967
+ const scoreColor = couplingScore < 30 ? 'text-emerald-500' :
1968
+ couplingScore < 60 ? 'text-amber-500' : 'text-rose-500';
1969
+
1970
+ let html = '<div class="grid grid-cols-2 md:grid-cols-4 gap-4">';
1971
+
1972
+ // 辅助函数:生成卡片 HTML
1973
+ const createCard = (label, value, color, desc, iconPath) => `
1974
+ <div class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-5 flex flex-col justify-between h-full border border-slate-100 dark:border-slate-700/50">
1975
+ <div class="flex items-center justify-between mb-2">
1976
+ <div class="text-sm text-slate-500 dark:text-slate-400 font-medium">${label}</div>
1977
+ <div class="${color} p-1.5 bg-white dark:bg-slate-800 rounded-lg shadow-sm">
1978
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1979
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="${iconPath}" />
1980
+ </svg>
1981
+ </div>
1982
+ </div>
1983
+ <div class="text-2xl font-bold ${color}">${value}</div>
1984
+ <div class="text-xs text-slate-400 mt-1">${desc}</div>
1985
+ </div>
1986
+ `;
1987
+
1988
+ // 耦合评分
1989
+ html += createCard(
1990
+ '耦合评分',
1991
+ couplingScore.toFixed(0),
1992
+ scoreColor,
1993
+ '分数越低越好',
1994
+ 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1'
1995
+ );
1996
+
1997
+ // 高耦合文件对数量
1998
+ html += createCard(
1999
+ '高耦合文件对',
2000
+ highCouplingCount,
2001
+ 'text-rose-500',
2002
+ '耦合度 >70%',
2003
+ 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z'
2004
+ );
2005
+
2006
+ html += '</div>';
2007
+
2008
+ container.innerHTML = html;
2009
+ }
2010
+
2011
+ // 文件耦合列表(TOP 20)
2012
+ function renderFileCouplingList(tightCoupling) {
2013
+ const container = document.getElementById('file-pairs-list');
2014
+
2015
+ if (!tightCoupling || tightCoupling.length === 0) {
2016
+ container.innerHTML = '<p class="text-sm text-slate-500 dark:text-slate-400">无文件耦合数据</p>';
2017
+ return;
2018
+ }
2019
+
2020
+ // 取 TOP 20
2021
+ const top20 = tightCoupling.slice(0, 20);
2022
+
2023
+ let html = '<div class="overflow-x-auto max-h-[350px] overflow-y-auto scrollbar-thin">';
2024
+ html += '<table class="w-full text-sm">';
2025
+ html += '<thead class="sticky top-0 bg-slate-50 dark:bg-slate-700/50 backdrop-blur-sm z-10">';
2026
+ html += '<tr class="text-left border-b border-slate-200 dark:border-slate-600">';
2027
+ html += '<th class="py-2 pl-2 text-slate-500 dark:text-slate-400 font-medium">文件对</th>';
2028
+ html += '<th class="py-2 text-slate-500 dark:text-slate-400 font-medium text-right">共同提交</th>';
2029
+ html += '<th class="py-2 text-slate-500 dark:text-slate-400 font-medium text-right pr-2">耦合度</th>';
2030
+ html += '</tr>';
2031
+ html += '</thead>';
2032
+ html += '<tbody class="divide-y divide-slate-100 dark:divide-slate-700/50">';
2033
+
2034
+ top20.forEach((pair) => {
2035
+ const couplingPercentage = (pair.coupling * 100).toFixed(1);
2036
+ const isHighCoupling = pair.coupling > 0.7;
2037
+ const couplingColor = isHighCoupling ? 'text-rose-600 dark:text-rose-400 font-semibold' : 'text-slate-600 dark:text-slate-400';
2038
+ const badgeBg = isHighCoupling ? 'bg-rose-100 dark:bg-rose-900/30' : 'bg-slate-100 dark:bg-slate-700';
2039
+
2040
+ // 文件路径截断
2041
+ const displayPath1 = pair.file1.length > 30 ? '...' + pair.file1.slice(-27) : pair.file1;
2042
+ const displayPath2 = pair.file2.length > 30 ? '...' + pair.file2.slice(-27) : pair.file2;
2043
+
2044
+ html += `<tr class="hover:bg-slate-100 dark:hover:bg-slate-800/50 transition-colors">`;
2045
+ html += `<td class="py-3 pl-2">
2046
+ <div class="flex flex-col gap-1">
2047
+ <span class="font-mono text-xs text-slate-600 dark:text-slate-300" title="${escapeHtml(pair.file1)}">${escapeHtml(displayPath1)}</span>
2048
+ <span class="font-mono text-xs text-slate-400" title="${escapeHtml(pair.file2)}">↳ ${escapeHtml(displayPath2)}</span>
2049
+ </div>
2050
+ </td>`;
2051
+ html += `<td class="py-3 text-right text-slate-600 dark:text-slate-400 text-xs">${pair.coOccurrence}</td>`;
2052
+ html += `<td class="py-3 text-right pr-2"><span class="px-2 py-0.5 ${badgeBg} ${couplingColor} rounded text-xs">${couplingPercentage}%</span></td>`;
2053
+ html += '</tr>';
2054
+ });
2055
+
2056
+ html += '</tbody>';
2057
+ html += '</table>';
2058
+ html += '</div>';
2059
+
2060
+ container.innerHTML = html;
2061
+ }
2062
+
2063
+ // 结对编程检测列表
2064
+ function renderPairProgrammingList(pairProgramming) {
2065
+ const container = document.getElementById('pair-programming-list');
2066
+
2067
+ if (!pairProgramming || pairProgramming.length === 0) {
2068
+ container.innerHTML = '<div class="flex flex-col items-center justify-center h-40 text-slate-400"><p>未检测到明显的结对编程模式</p><p class="text-xs mt-1">需共同修改 ≥3 个文件</p></div>';
2069
+ return;
2070
+ }
2071
+
2072
+ let html = '<div class="overflow-x-auto max-h-[350px] overflow-y-auto scrollbar-thin">';
2073
+ html += '<table class="w-full text-sm">';
2074
+ html += '<thead class="sticky top-0 bg-slate-50 dark:bg-slate-700/50 backdrop-blur-sm z-10">';
2075
+ html += '<tr class="text-left border-b border-slate-200 dark:border-slate-600">';
2076
+ html += '<th class="py-2 pl-2 text-slate-500 dark:text-slate-400 font-medium">协作成员</th>';
2077
+ html += '<th class="py-2 text-slate-500 dark:text-slate-400 font-medium text-right">共同文件</th>';
2078
+ html += '<th class="py-2 text-slate-500 dark:text-slate-400 font-medium text-right pr-2">协作次数</th>';
2079
+ html += '</tr>';
2080
+ html += '</thead>';
2081
+ html += '<tbody class="divide-y divide-slate-100 dark:divide-slate-700/50">';
2082
+
2083
+ pairProgramming.forEach((pair) => {
2084
+ html += `<tr class="hover:bg-slate-100 dark:hover:bg-slate-800/50 transition-colors">`;
2085
+ html += `<td class="py-3 pl-2">
2086
+ <div class="flex items-center gap-2">
2087
+ <span class="font-medium text-slate-700 dark:text-slate-200">${escapeHtml(pair.author1)}</span>
2088
+ <span class="text-slate-400 text-xs">+</span>
2089
+ <span class="font-medium text-slate-700 dark:text-slate-200">${escapeHtml(pair.author2)}</span>
2090
+ </div>
2091
+ </td>`;
2092
+ html += `<td class="py-3 text-right"><span class="px-2 py-0.5 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded text-xs font-medium">${pair.sharedFiles.length}</span></td>`;
2093
+ html += `<td class="py-3 text-right text-slate-600 dark:text-slate-400 pr-2">${pair.collaborationCount}</td>`;
2094
+ html += '</tr>';
2095
+ });
2096
+
2097
+ html += '</tbody>';
2098
+ html += '</table>';
2099
+ html += '</div>';
2100
+
2101
+ container.innerHTML = html;
2102
+ }
2103
+
2104
+ // 启动渲染
2105
+ renderAll();
2106
+
2107
+ // 窗口大小变化时重新渲染
2108
+ let resizeTimer;
2109
+ window.addEventListener('resize', () => {
2110
+ clearTimeout(resizeTimer);
2111
+ resizeTimer = setTimeout(renderAll, 300);
2112
+ });
2113
+
2114
+ // 高级分析 Tab 切换
2115
+ document.addEventListener('DOMContentLoaded', function() {
2116
+ const tabs = document.querySelectorAll('.advanced-tab');
2117
+ const contents = document.querySelectorAll('.advanced-tab-content');
2118
+
2119
+ tabs.forEach(tab => {
2120
+ tab.addEventListener('click', function() {
2121
+ const targetTab = this.dataset.tab;
2122
+
2123
+ // 移除所有 active 类
2124
+ tabs.forEach(t => t.classList.remove('active'));
2125
+ contents.forEach(c => c.classList.add('hidden'));
2126
+
2127
+ // 激活当前 Tab
2128
+ this.classList.add('active');
2129
+ document.getElementById(targetTab).classList.remove('hidden');
2130
+ });
2131
+ });
2132
+ });
2133
+ </script>
2134
+ </body>
2135
+ </html>