commit-report 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,200 @@
1
+ // ============================================================
2
+ // 工程质量分析
3
+ // ============================================================
4
+ function renderEngineeringMetrics() {
5
+ const engineering = stats.engineering;
6
+ const empty = document.getElementById('engineering-empty');
7
+ const content = document.getElementById('engineering-content');
8
+
9
+ if (!engineering) {
10
+ empty.innerHTML = renderSingleRepoOnlyEmptyState('工程质量分析');
11
+ empty.classList.remove('hidden');
12
+ content.classList.add('hidden');
13
+ return;
14
+ }
15
+
16
+ empty.classList.add('hidden');
17
+ content.classList.remove('hidden');
18
+ renderEngineeringScoreCards(engineering);
19
+ renderBugHotFiles(engineering.bugFixHotFiles);
20
+ renderCodeOwnership(engineering.codeOwnership);
21
+ renderReviewQuality(engineering.reviewQuality);
22
+ renderChangeMix(engineering.changeMix);
23
+ }
24
+
25
+ function renderEngineeringScoreCards(engineering) {
26
+ const container = document.getElementById('engineering-score-cards');
27
+ const quality = engineering.commitQuality;
28
+ const review = engineering.reviewQuality;
29
+ const ownership = engineering.codeOwnership;
30
+
31
+ const scoreColor = quality.score >= 80 ? 'text-emerald-500' :
32
+ quality.score >= 60 ? 'text-amber-500' : 'text-rose-500';
33
+
34
+ const cards = [
35
+ ['Commit 质量', quality.score, scoreColor, '团队综合评分'],
36
+ ['规范度', formatPercent(quality.conventionalRate), 'text-primary-500', 'Conventional Commits'],
37
+ ['Scope 覆盖', formatPercent(quality.scopeCoverageRate), 'text-indigo-500', '带模块范围的提交'],
38
+ ['平均长度', quality.averageMessageLength.toFixed(1), 'text-slate-700 dark:text-slate-200', 'Subject 字符数'],
39
+ ['Review 参与', formatPercent(review.reviewParticipationRate), 'text-emerald-500', 'Merge trailer 覆盖'],
40
+ ['HEAD 文件', formatNumber(ownership ? ownership.totalFiles : 0), 'text-slate-700 dark:text-slate-200', 'blame 可分析文件'],
41
+ ];
42
+
43
+ container.innerHTML = `
44
+ <div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
45
+ ${cards.map(([label, value, color, desc]) => `
46
+ <div class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-5 border border-slate-100 dark:border-slate-700/50">
47
+ <div class="text-sm text-slate-500 dark:text-slate-400 font-medium mb-2">${label}</div>
48
+ <div class="text-2xl font-bold ${color}">${value}</div>
49
+ <div class="text-xs text-slate-400 mt-1">${desc}</div>
50
+ </div>
51
+ `).join('')}
52
+ </div>
53
+ `;
54
+ }
55
+
56
+ function renderBugHotFiles(bugFixHotFiles) {
57
+ const container = document.getElementById('bug-hot-files-list');
58
+ const files = bugFixHotFiles.hotFiles || [];
59
+
60
+ if (files.length === 0) {
61
+ container.innerHTML = emptyEngineeringMessage('暂无 fix: 文件热点');
62
+ return;
63
+ }
64
+
65
+ container.innerHTML = `
66
+ <div class="overflow-x-auto max-h-[360px] overflow-y-auto scrollbar-thin">
67
+ <table class="w-full text-sm">
68
+ <thead class="sticky top-0 bg-slate-50 dark:bg-slate-700/50">
69
+ <tr class="text-left border-b border-slate-200 dark:border-slate-600">
70
+ <th class="py-2 text-slate-500 dark:text-slate-400 font-medium">文件路径</th>
71
+ <th class="py-2 text-slate-500 dark:text-slate-400 font-medium text-right">修复次数</th>
72
+ <th class="py-2 text-slate-500 dark:text-slate-400 font-medium text-right">作者数</th>
73
+ </tr>
74
+ </thead>
75
+ <tbody>
76
+ ${files.slice(0, 20).map(file => `
77
+ <tr class="border-b border-slate-100 dark:border-slate-700/50 hover:bg-slate-100 dark:hover:bg-slate-800/50">
78
+ <td class="py-3 pr-4 font-mono text-xs text-slate-700 dark:text-slate-300 truncate max-w-sm" title="${escapeHtml(file.path)}">${escapeHtml(file.path)}</td>
79
+ <td class="py-3 text-right font-semibold text-rose-500">${formatNumber(file.fixCount)}</td>
80
+ <td class="py-3 text-right text-slate-600 dark:text-slate-400">${formatNumber(file.fixAuthors.length)}</td>
81
+ </tr>
82
+ `).join('')}
83
+ </tbody>
84
+ </table>
85
+ </div>
86
+ `;
87
+ }
88
+
89
+ function renderCodeOwnership(ownership) {
90
+ const container = document.getElementById('code-ownership-list');
91
+ const files = ownership ? ownership.files || [] : [];
92
+
93
+ if (files.length === 0) {
94
+ container.innerHTML = emptyEngineeringMessage('暂无 blame 所有权数据');
95
+ return;
96
+ }
97
+
98
+ container.innerHTML = `
99
+ <div class="overflow-x-auto max-h-[360px] overflow-y-auto scrollbar-thin">
100
+ <table class="w-full text-sm">
101
+ <thead class="sticky top-0 bg-slate-50 dark:bg-slate-700/50">
102
+ <tr class="text-left border-b border-slate-200 dark:border-slate-600">
103
+ <th class="py-2 text-slate-500 dark:text-slate-400 font-medium">文件路径</th>
104
+ <th class="py-2 text-slate-500 dark:text-slate-400 font-medium">主导者</th>
105
+ <th class="py-2 text-slate-500 dark:text-slate-400 font-medium text-right">占比</th>
106
+ </tr>
107
+ </thead>
108
+ <tbody>
109
+ ${files.slice(0, 20).map(file => `
110
+ <tr class="border-b border-slate-100 dark:border-slate-700/50 hover:bg-slate-100 dark:hover:bg-slate-800/50">
111
+ <td class="py-3 pr-4 font-mono text-xs text-slate-700 dark:text-slate-300 truncate max-w-xs" title="${escapeHtml(file.path)}">${escapeHtml(file.path)}</td>
112
+ <td class="py-3 text-slate-700 dark:text-slate-300">${escapeHtml(file.ownerName)}</td>
113
+ <td class="py-3 text-right">
114
+ <span class="px-2 py-1 rounded text-xs font-semibold bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300">${formatPercent(file.ownershipRatio)}</span>
115
+ </td>
116
+ </tr>
117
+ `).join('')}
118
+ </tbody>
119
+ </table>
120
+ </div>
121
+ `;
122
+ }
123
+
124
+ function renderReviewQuality(reviewQuality) {
125
+ const container = document.getElementById('review-quality-list');
126
+ const reviewers = reviewQuality.reviewers || [];
127
+
128
+ if (reviewQuality.mergeCommitCount === 0) {
129
+ container.innerHTML = emptyEngineeringMessage('暂无 merge commit 数据');
130
+ return;
131
+ }
132
+
133
+ container.innerHTML = `
134
+ <div class="grid grid-cols-3 gap-3 mb-4">
135
+ <div class="bg-white dark:bg-slate-800 rounded-lg p-3">
136
+ <div class="text-xs text-slate-400">Merge</div>
137
+ <div class="text-xl font-bold">${formatNumber(reviewQuality.mergeCommitCount)}</div>
138
+ </div>
139
+ <div class="bg-white dark:bg-slate-800 rounded-lg p-3">
140
+ <div class="text-xs text-slate-400">带评审</div>
141
+ <div class="text-xl font-bold text-emerald-500">${formatNumber(reviewQuality.reviewedMergeCount)}</div>
142
+ </div>
143
+ <div class="bg-white dark:bg-slate-800 rounded-lg p-3">
144
+ <div class="text-xs text-slate-400">参与率</div>
145
+ <div class="text-xl font-bold">${formatPercent(reviewQuality.reviewParticipationRate)}</div>
146
+ </div>
147
+ </div>
148
+ <div class="space-y-2 max-h-[250px] overflow-y-auto scrollbar-thin">
149
+ ${reviewers.length === 0 ? emptyEngineeringMessage('暂无 reviewer trailer') : reviewers.map(reviewer => `
150
+ <div class="flex items-center justify-between bg-white dark:bg-slate-800 rounded-lg px-3 py-2">
151
+ <div>
152
+ <div class="font-medium text-slate-700 dark:text-slate-200">${escapeHtml(reviewer.name)}</div>
153
+ <div class="text-xs text-slate-400">${escapeHtml(reviewer.email)}</div>
154
+ </div>
155
+ <span class="text-sm font-semibold text-slate-600 dark:text-slate-300">${formatNumber(reviewer.commits)}</span>
156
+ </div>
157
+ `).join('')}
158
+ </div>
159
+ `;
160
+ }
161
+
162
+ function renderChangeMix(changeMix) {
163
+ const container = document.getElementById('change-mix-list');
164
+ const total = changeMix.createdFiles + changeMix.deletedFiles + changeMix.modifiedFiles;
165
+ const featureWidth = total > 0 ? changeMix.featureRatio * 100 : 0;
166
+ const refactorWidth = total > 0 ? changeMix.refactorRatio * 100 : 0;
167
+
168
+ container.innerHTML = `
169
+ <div class="mb-5">
170
+ <div class="flex h-4 overflow-hidden rounded bg-slate-200 dark:bg-slate-800">
171
+ <div class="bg-emerald-500" style="width: ${featureWidth}%"></div>
172
+ <div class="bg-amber-500" style="width: ${refactorWidth}%"></div>
173
+ </div>
174
+ <div class="flex justify-between text-xs text-slate-500 dark:text-slate-400 mt-2">
175
+ <span>新增 ${formatPercent(changeMix.featureRatio)}</span>
176
+ <span>重构 ${formatPercent(changeMix.refactorRatio)}</span>
177
+ </div>
178
+ </div>
179
+ <div class="grid grid-cols-3 gap-3">
180
+ ${[
181
+ ['创建', changeMix.createdFiles, 'text-emerald-500'],
182
+ ['删除', changeMix.deletedFiles, 'text-rose-500'],
183
+ ['修改', changeMix.modifiedFiles, 'text-amber-500'],
184
+ ].map(([label, value, color]) => `
185
+ <div class="bg-white dark:bg-slate-800 rounded-lg p-4 text-center">
186
+ <div class="text-xs text-slate-400 mb-1">${label}</div>
187
+ <div class="text-2xl font-bold ${color}">${formatNumber(value)}</div>
188
+ </div>
189
+ `).join('')}
190
+ </div>
191
+ `;
192
+ }
193
+
194
+ function formatPercent(value) {
195
+ return (value * 100).toFixed(1) + '%';
196
+ }
197
+
198
+ function emptyEngineeringMessage(message) {
199
+ return `<div class="text-sm text-slate-500 dark:text-slate-400 py-8 text-center">${message}</div>`;
200
+ }
@@ -0,0 +1,313 @@
1
+ // ============================================================
2
+ // 扩展统计 - 渲染入口
3
+ // ============================================================
4
+ function renderExtensions() {
5
+ renderChangeSize();
6
+ renderDirCoupling();
7
+ renderAIQuality();
8
+ }
9
+
10
+ // ============================================================
11
+ // #2 变更尺寸分布
12
+ // ============================================================
13
+ function renderChangeSize() {
14
+ const data = stats.changeSizeDistribution;
15
+ const summaryEl = document.getElementById('change-size-summary');
16
+ if (!data || data.buckets.every(b => b.count === 0)) {
17
+ summaryEl.innerHTML = '<div class="col-span-4">' + renderSingleRepoOnlyEmptyState('变更尺寸分析', '未发现可用于分桶的提交变更数据。') + '</div>';
18
+ return;
19
+ }
20
+
21
+ // 概要卡片
22
+ const card = (label, value, desc, color) => `
23
+ <div class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-5 border border-slate-100 dark:border-slate-700/50">
24
+ <div class="text-sm text-slate-500 dark:text-slate-400 font-medium mb-2">${label}</div>
25
+ <div class="text-2xl font-bold ${color}">${value}</div>
26
+ <div class="text-xs text-slate-400 mt-1">${desc}</div>
27
+ </div>
28
+ `;
29
+ summaryEl.innerHTML =
30
+ card('平均行数', Math.round(data.avgChangeSize), '每次提交', 'text-primary-500') +
31
+ card('中位数', data.medianChangeSize, '50% 提交 ≤ 此值', 'text-emerald-500') +
32
+ card('P95', data.p95ChangeSize, '95% 提交 ≤ 此值', 'text-amber-500') +
33
+ card('XL 大提交', data.buckets.find(b => b.label === 'XL').count,
34
+ '≥1000 行(评审难)', 'text-rose-500');
35
+
36
+ // 分桶柱状图
37
+ const container = document.getElementById('change-size-buckets');
38
+ container.innerHTML = '';
39
+ const w = container.clientWidth || 400;
40
+ const h = 280;
41
+ const margin = { top: 20, right: 20, bottom: 40, left: 50 };
42
+ const innerW = w - margin.left - margin.right;
43
+ const innerH = h - margin.top - margin.bottom;
44
+
45
+ const svg = d3.select(container).append('svg').attr('width', w).attr('height', h);
46
+ const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`);
47
+
48
+ const x = d3.scaleBand().domain(data.buckets.map(b => b.label)).range([0, innerW]).padding(0.2);
49
+ const y = d3.scaleLinear().domain([0, d3.max(data.buckets, b => b.count) || 1]).nice().range([innerH, 0]);
50
+
51
+ const colorMap = { XS: '#10b981', S: '#22c55e', M: '#0ea5e9', L: '#f59e0b', XL: '#ef4444' };
52
+
53
+ g.append('g').attr('transform', `translate(0,${innerH})`)
54
+ .call(d3.axisBottom(x))
55
+ .selectAll('text')
56
+ .style('font-size', '11px')
57
+ .style('fill', isDark() ? '#cbd5e1' : '#475569');
58
+ g.append('g').call(d3.axisLeft(y).ticks(5))
59
+ .selectAll('text').style('fill', isDark() ? '#cbd5e1' : '#475569');
60
+
61
+ g.selectAll('.bar').data(data.buckets).enter().append('rect')
62
+ .attr('x', d => x(d.label))
63
+ .attr('y', d => y(d.count))
64
+ .attr('width', x.bandwidth())
65
+ .attr('height', d => innerH - y(d.count))
66
+ .attr('rx', 4)
67
+ .attr('fill', d => colorMap[d.label])
68
+ .on('mouseover', (e, d) =>
69
+ showTooltip(e, `<b>${d.label}</b> (${d.range} 行)<br/>${d.count} 次提交 (${d.percentage.toFixed(1)}%)`)
70
+ )
71
+ .on('mouseout', hideTooltip);
72
+
73
+ // 数值标签
74
+ g.selectAll('.label').data(data.buckets).enter().append('text')
75
+ .attr('x', d => x(d.label) + x.bandwidth() / 2)
76
+ .attr('y', d => y(d.count) - 6)
77
+ .attr('text-anchor', 'middle')
78
+ .attr('font-size', '11px')
79
+ .attr('fill', isDark() ? '#e2e8f0' : '#475569')
80
+ .text(d => d.percentage.toFixed(0) + '%');
81
+
82
+ // 大提交列表
83
+ const listEl = document.getElementById('large-commits-list');
84
+ if (!data.largeCommits.length) {
85
+ listEl.innerHTML = '<p class="text-sm text-slate-500 dark:text-slate-400">无大提交</p>';
86
+ return;
87
+ }
88
+ listEl.innerHTML = data.largeCommits.map(c => `
89
+ <div class="py-2 border-b border-slate-200 dark:border-slate-700 last:border-0">
90
+ <div class="flex items-center justify-between gap-2">
91
+ <code class="text-xs text-primary-500">${c.hash.slice(0, 7)}</code>
92
+ <span class="text-rose-500 font-semibold text-sm">${formatNumber(c.totalLines)} 行</span>
93
+ </div>
94
+ <div class="text-sm text-slate-700 dark:text-slate-200 truncate" title="${escapeHtml(c.message)}">${escapeHtml(c.message)}</div>
95
+ <div class="text-xs text-slate-500 dark:text-slate-400 mt-1">
96
+ ${escapeHtml(c.author)} · ${c.filesCount} 文件 · ${formatDateKeyLocal(c.date)}
97
+ </div>
98
+ </div>
99
+ `).join('');
100
+ }
101
+
102
+ // ============================================================
103
+ // #6 目录耦合
104
+ // ============================================================
105
+ function renderDirCoupling() {
106
+ const data = stats.directoryCoupling;
107
+ const emptyEl = document.getElementById('dir-coupling-empty');
108
+ const contentEl = document.getElementById('dir-coupling-content');
109
+
110
+ if (!data || !data.directories.length) {
111
+ emptyEl.innerHTML = renderSingleRepoOnlyEmptyState('目录耦合分析');
112
+ emptyEl.classList.remove('hidden');
113
+ contentEl.classList.add('hidden');
114
+ return;
115
+ }
116
+
117
+ // 矩阵热力图
118
+ const matrixEl = document.getElementById('dir-coupling-matrix');
119
+ matrixEl.innerHTML = '';
120
+ const dirs = data.directories;
121
+ const cellSize = Math.min(36, Math.floor(360 / dirs.length));
122
+ const labelW = 100;
123
+ const labelH = 100;
124
+ const w = labelW + dirs.length * cellSize + 20;
125
+ const h = labelH + dirs.length * cellSize + 20;
126
+
127
+ const svg = d3.select(matrixEl).append('svg').attr('width', w).attr('height', h);
128
+
129
+ const maxOff = d3.max(data.matrix.filter(c => c.dir1 !== c.dir2), c => c.value) || 1;
130
+ const colorScale = d3.scaleSequential(d3.interpolateOranges).domain([0, maxOff]);
131
+ const truncate = (s) => s.length > 12 ? s.slice(0, 11) + '…' : s;
132
+
133
+ // 顶部标签
134
+ dirs.forEach((d, i) => {
135
+ svg.append('text')
136
+ .attr('x', labelW + i * cellSize + cellSize / 2)
137
+ .attr('y', labelH - 6)
138
+ .attr('text-anchor', 'start')
139
+ .attr('transform', `rotate(-45 ${labelW + i * cellSize + cellSize / 2} ${labelH - 6})`)
140
+ .attr('font-size', '11px')
141
+ .attr('fill', isDark() ? '#cbd5e1' : '#475569')
142
+ .text(truncate(d));
143
+ });
144
+ // 左侧标签
145
+ dirs.forEach((d, i) => {
146
+ svg.append('text')
147
+ .attr('x', labelW - 6)
148
+ .attr('y', labelH + i * cellSize + cellSize / 2 + 4)
149
+ .attr('text-anchor', 'end')
150
+ .attr('font-size', '11px')
151
+ .attr('fill', isDark() ? '#cbd5e1' : '#475569')
152
+ .text(truncate(d));
153
+ });
154
+
155
+ // 单元格
156
+ data.matrix.forEach(cell => {
157
+ const i = dirs.indexOf(cell.dir1);
158
+ const j = dirs.indexOf(cell.dir2);
159
+ if (i < 0 || j < 0) return;
160
+ const isDiag = cell.dir1 === cell.dir2;
161
+ svg.append('rect')
162
+ .attr('x', labelW + j * cellSize + 1)
163
+ .attr('y', labelH + i * cellSize + 1)
164
+ .attr('width', cellSize - 2)
165
+ .attr('height', cellSize - 2)
166
+ .attr('rx', 2)
167
+ .attr('fill', isDiag ? (isDark() ? '#334155' : '#e2e8f0') : (cell.value > 0 ? colorScale(cell.value) : (isDark() ? '#1e293b' : '#f1f5f9')))
168
+ .on('mouseover', (e) => showTooltip(e,
169
+ isDiag
170
+ ? `<b>${escapeHtml(cell.dir1)}</b><br/>共 ${cell.value} 次提交`
171
+ : `<b>${escapeHtml(cell.dir1)}</b> ↔ <b>${escapeHtml(cell.dir2)}</b><br/>共变 ${cell.value} 次`
172
+ ))
173
+ .on('mouseout', hideTooltip);
174
+ });
175
+
176
+ // 列表
177
+ const listEl = document.getElementById('dir-coupling-list');
178
+ if (!data.pairs.length) {
179
+ listEl.innerHTML = '<p class="text-sm text-slate-500 dark:text-slate-400">无跨目录耦合</p>';
180
+ return;
181
+ }
182
+ listEl.innerHTML = '<table class="w-full text-sm">' +
183
+ '<thead><tr class="text-left text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700">' +
184
+ '<th class="pb-2">目录对</th><th class="pb-2 text-right">共变</th><th class="pb-2 text-right">耦合度</th></tr></thead><tbody>' +
185
+ data.pairs.slice(0, 20).map(p => {
186
+ const couplingPct = (p.coupling * 100).toFixed(0);
187
+ const color = p.coupling > 0.5 ? 'text-rose-500' : p.coupling > 0.3 ? 'text-amber-500' : 'text-emerald-500';
188
+ return `<tr class="border-b border-slate-100 dark:border-slate-700/50">
189
+ <td class="py-2"><code class="text-xs">${escapeHtml(p.dir1)}</code> ↔ <code class="text-xs">${escapeHtml(p.dir2)}</code></td>
190
+ <td class="py-2 text-right">${p.coOccurrence}</td>
191
+ <td class="py-2 text-right font-semibold ${color}">${couplingPct}%</td>
192
+ </tr>`;
193
+ }).join('') + '</tbody></table>';
194
+ }
195
+
196
+ // ============================================================
197
+ // #18 AI × 质量交叉风险
198
+ // ============================================================
199
+ function renderAIQuality() {
200
+ const data = stats.aiQualityRisk;
201
+ const summaryEl = document.getElementById('ai-quality-summary');
202
+ if (!data || !data.scatter.length) {
203
+ summaryEl.innerHTML = '<div class="col-span-4">' + renderSingleRepoOnlyEmptyState('AI × 质量风险分析', '当前范围内没有可交叉分析的 AI 与质量风险数据。') + '</div>';
204
+ return;
205
+ }
206
+
207
+ const s = data.summary;
208
+ const card = (label, value, color, desc) => `
209
+ <div class="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-5 border border-slate-100 dark:border-slate-700/50">
210
+ <div class="text-sm text-slate-500 dark:text-slate-400 font-medium mb-2">${label}</div>
211
+ <div class="text-2xl font-bold ${color}">${value}</div>
212
+ <div class="text-xs text-slate-400 mt-1">${desc}</div>
213
+ </div>
214
+ `;
215
+ summaryEl.innerHTML =
216
+ card('高 AI + 高 Churn', s.highAIHighChurn, 'text-rose-500', '强烈建议人审') +
217
+ card('高 AI + 低 Churn', s.highAILowChurn, 'text-amber-500', '可能稳定') +
218
+ card('低 AI + 高 Churn', s.lowAIHighChurn, 'text-orange-500', '人写但反复重写') +
219
+ card('低风险文件', s.lowAILowChurn, 'text-emerald-500', '健康代码');
220
+
221
+ // 散点图
222
+ const container = document.getElementById('ai-quality-scatter');
223
+ container.innerHTML = '';
224
+ const w = container.clientWidth || 400;
225
+ const h = 400;
226
+ const margin = { top: 20, right: 30, bottom: 50, left: 50 };
227
+ const innerW = w - margin.left - margin.right;
228
+ const innerH = h - margin.top - margin.bottom;
229
+
230
+ const svg = d3.select(container).append('svg').attr('width', w).attr('height', h);
231
+ const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`);
232
+
233
+ const x = d3.scaleLinear().domain([0, 100]).range([0, innerW]);
234
+ const y = d3.scaleLinear().domain([0, Math.min(2, (d3.max(data.scatter, p => p.churnRate) || 1) * 1.1)]).range([innerH, 0]);
235
+ const r = d3.scaleSqrt().domain([1, d3.max(data.scatter, p => p.modifyCount) || 1]).range([3, 12]);
236
+
237
+ // 高风险象限背景
238
+ g.append('rect')
239
+ .attr('x', x(50)).attr('y', y(2))
240
+ .attr('width', innerW - x(50)).attr('height', innerH - y(0.5) - (y(2) - y(0.5)))
241
+ .attr('fill', 'rgba(239,68,68,0.08)');
242
+
243
+ // 坐标轴
244
+ g.append('g').attr('transform', `translate(0,${innerH})`)
245
+ .call(d3.axisBottom(x).ticks(5).tickFormat(d => d + '%'))
246
+ .selectAll('text').style('fill', isDark() ? '#cbd5e1' : '#475569');
247
+ g.append('text')
248
+ .attr('x', innerW / 2).attr('y', innerH + 38)
249
+ .attr('text-anchor', 'middle').attr('font-size', '12px')
250
+ .attr('fill', isDark() ? '#cbd5e1' : '#475569')
251
+ .text('AI 评分 (%)');
252
+
253
+ g.append('g').call(d3.axisLeft(y).ticks(5))
254
+ .selectAll('text').style('fill', isDark() ? '#cbd5e1' : '#475569');
255
+ g.append('text')
256
+ .attr('transform', 'rotate(-90)')
257
+ .attr('x', -innerH / 2).attr('y', -36)
258
+ .attr('text-anchor', 'middle').attr('font-size', '12px')
259
+ .attr('fill', isDark() ? '#cbd5e1' : '#475569')
260
+ .text('Churn Rate (deleted/added)');
261
+
262
+ // 参考线
263
+ g.append('line')
264
+ .attr('x1', x(50)).attr('x2', x(50))
265
+ .attr('y1', 0).attr('y2', innerH)
266
+ .attr('stroke', isDark() ? '#475569' : '#cbd5e1').attr('stroke-dasharray', '3 3');
267
+ g.append('line')
268
+ .attr('x1', 0).attr('x2', innerW)
269
+ .attr('y1', y(0.5)).attr('y2', y(0.5))
270
+ .attr('stroke', isDark() ? '#475569' : '#cbd5e1').attr('stroke-dasharray', '3 3');
271
+
272
+ // 散点
273
+ g.selectAll('.dot').data(data.scatter).enter().append('circle')
274
+ .attr('cx', d => x(d.aiScore))
275
+ .attr('cy', d => y(Math.min(d.churnRate, 2)))
276
+ .attr('r', d => r(d.modifyCount))
277
+ .attr('fill', d => {
278
+ const high = d.aiScore > 50 && d.churnRate > 0.5;
279
+ return high ? 'rgba(239,68,68,0.7)' : 'rgba(14,165,233,0.5)';
280
+ })
281
+ .attr('stroke', d => d.aiScore > 50 && d.churnRate > 0.5 ? '#dc2626' : '#0284c7')
282
+ .attr('stroke-width', 1)
283
+ .on('mouseover', (e, d) => showTooltip(e,
284
+ `<b>${escapeHtml(d.path)}</b><br/>AI: ${d.aiScore.toFixed(0)}% · Churn: ${d.churnRate.toFixed(2)} · 修改 ${d.modifyCount} 次`
285
+ ))
286
+ .on('mouseout', hideTooltip);
287
+
288
+ // 文件列表
289
+ const listEl = document.getElementById('ai-quality-files');
290
+ if (!data.files.length) {
291
+ listEl.innerHTML = '<p class="text-sm text-slate-500 dark:text-slate-400">无高风险文件</p>';
292
+ return;
293
+ }
294
+ listEl.innerHTML = '<table class="w-full text-sm">' +
295
+ '<thead><tr class="text-left text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700">' +
296
+ '<th class="pb-2">文件</th><th class="pb-2 text-right">AI</th><th class="pb-2 text-right">Churn</th><th class="pb-2 text-right">修改</th><th class="pb-2">建议</th></tr></thead><tbody>' +
297
+ data.files.map(f => `
298
+ <tr class="border-b border-slate-100 dark:border-slate-700/50">
299
+ <td class="py-2"><code class="text-xs" title="${escapeHtml(f.path)}">${escapeHtml(f.path.length > 36 ? '…' + f.path.slice(-35) : f.path)}</code></td>
300
+ <td class="py-2 text-right ${f.aiScore > 50 ? 'text-rose-500 font-semibold' : 'text-slate-600 dark:text-slate-300'}">${f.aiScore.toFixed(0)}%</td>
301
+ <td class="py-2 text-right ${f.churnRate > 0.5 ? 'text-rose-500 font-semibold' : 'text-slate-600 dark:text-slate-300'}">${f.churnRate.toFixed(2)}</td>
302
+ <td class="py-2 text-right text-slate-600 dark:text-slate-300">${f.modifyCount}</td>
303
+ <td class="py-2 text-xs text-slate-600 dark:text-slate-300">${getAIQualitySuggestion(f)}</td>
304
+ </tr>
305
+ `).join('') + '</tbody></table>';
306
+ }
307
+
308
+ function getAIQualitySuggestion(file) {
309
+ if (file.aiScore > 50 && file.churnRate > 0.5) return '重点 Review + 补测试';
310
+ if (file.aiScore > 50) return '抽样 Review';
311
+ if (file.churnRate > 0.5) return '补回归测试';
312
+ return '常规巡检';
313
+ }
@@ -0,0 +1,54 @@
1
+ setupCommitDetailFilters();
2
+ setupReportControls();
3
+
4
+ // 启动渲染
5
+ renderAll();
6
+
7
+ // 窗口大小变化时重新渲染
8
+ let resizeTimer;
9
+ window.addEventListener('resize', () => {
10
+ clearTimeout(resizeTimer);
11
+ resizeTimer = setTimeout(renderAll, 300);
12
+ });
13
+
14
+ function activateAdvancedGroup(group) {
15
+ const groups = document.querySelectorAll('.advanced-tab-group');
16
+ const panels = document.querySelectorAll('[data-tab-group-panel]');
17
+
18
+ groups.forEach(item => item.classList.toggle('active', item.dataset.tabGroup === group));
19
+ panels.forEach(panel => panel.classList.toggle('hidden', panel.dataset.tabGroupPanel !== group));
20
+ reportState.advancedGroup = group;
21
+ }
22
+
23
+ function activateAdvancedTab(tabId, shouldUpdateHash = true) {
24
+ const tab = document.querySelector(`.advanced-tab[data-tab="${tabId}"]`);
25
+ const target = document.getElementById(tabId);
26
+ if (!tab || !target) return;
27
+
28
+ const group = tab.dataset.tabGroup || 'health';
29
+ activateAdvancedGroup(group);
30
+
31
+ document.querySelectorAll('.advanced-tab').forEach(item => item.classList.remove('active'));
32
+ document.querySelectorAll('.advanced-tab-content').forEach(content => content.classList.add('hidden'));
33
+
34
+ tab.classList.add('active');
35
+ target.classList.remove('hidden');
36
+ reportState.advancedTab = tabId;
37
+ renderAdvancedTab(tabId);
38
+ if (shouldUpdateHash) updateReportHash();
39
+ }
40
+
41
+ document.querySelectorAll('.advanced-tab-group').forEach(group => {
42
+ group.addEventListener('click', () => {
43
+ const targetGroup = group.dataset.tabGroup;
44
+ const firstVisibleTab = document.querySelector(`.advanced-tab[data-tab-group="${targetGroup}"]:not([style*="display: none"])`);
45
+ if (firstVisibleTab) activateAdvancedTab(firstVisibleTab.dataset.tab);
46
+ });
47
+ });
48
+
49
+ document.querySelectorAll('.advanced-tab').forEach(tab => {
50
+ tab.addEventListener('click', () => activateAdvancedTab(tab.dataset.tab));
51
+ });
52
+
53
+ activateAdvancedTab(reportState.advancedTab, false);
54
+ </script>