forked from Gitlink/gitlink-cli
494 lines
14 KiB
Go
494 lines
14 KiB
Go
package contrib
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"os"
|
|
"sort"
|
|
)
|
|
|
|
// ReportData 报告数据
|
|
type ReportData struct {
|
|
Owner string
|
|
Repo string
|
|
Contributors []Contributor
|
|
ChartLabels template.JS
|
|
ChartValues template.JS
|
|
TableRows []TableRow
|
|
}
|
|
|
|
// TableRow 表格行
|
|
type TableRow struct {
|
|
Rank int
|
|
Login string
|
|
Name string
|
|
Commits int
|
|
CodeLines int
|
|
Issues int
|
|
PRs int
|
|
Score float64
|
|
ScorePct float64
|
|
}
|
|
|
|
// generateHTML 生成 HTML 报告
|
|
func generateHTML(owner, repo string, contributors []Contributor, outputPath string) error {
|
|
// 按分数排序
|
|
sort.Slice(contributors, func(i, j int) bool {
|
|
return contributors[i].Score > contributors[j].Score
|
|
})
|
|
|
|
// 准备图表数据
|
|
var labels []string
|
|
var values []string
|
|
var tableRows []TableRow
|
|
|
|
totalScore := 0.0
|
|
for _, c := range contributors {
|
|
totalScore += c.Score
|
|
}
|
|
|
|
for i, c := range contributors {
|
|
labels = append(labels, fmt.Sprintf("%q", c.Login))
|
|
values = append(values, fmt.Sprintf("\"%.4f\"", c.Score))
|
|
|
|
scorePct := 0.0
|
|
if totalScore > 0 {
|
|
scorePct = c.Score / totalScore * 100
|
|
}
|
|
|
|
tableRows = append(tableRows, TableRow{
|
|
Rank: i + 1,
|
|
Login: c.Login,
|
|
Name: c.Name,
|
|
Commits: c.Commits,
|
|
CodeLines: c.Additions + c.Deletions,
|
|
Issues: c.Issues,
|
|
PRs: c.PRs,
|
|
Score: c.Score,
|
|
ScorePct: scorePct,
|
|
})
|
|
}
|
|
|
|
data := ReportData{
|
|
Owner: owner,
|
|
Repo: repo,
|
|
Contributors: contributors,
|
|
ChartLabels: template.JS(fmt.Sprintf("[%s]", joinStrings(labels, ","))),
|
|
ChartValues: template.JS(fmt.Sprintf("[%s]", joinStrings(values, ","))),
|
|
TableRows: tableRows,
|
|
}
|
|
|
|
// 创建 HTML 文件
|
|
file, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
// 解析并执行模板
|
|
tmpl, err := template.New("report").Parse(htmlTemplate)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return tmpl.Execute(file, data)
|
|
}
|
|
|
|
// joinStrings 连接字符串切片
|
|
func joinStrings(strs []string, sep string) string {
|
|
result := ""
|
|
for i, s := range strs {
|
|
if i > 0 {
|
|
result += sep
|
|
}
|
|
result += s
|
|
}
|
|
return result
|
|
}
|
|
|
|
// htmlTemplate HTML 模板
|
|
const htmlTemplate = `<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>贡献者报告 - {{.Owner}}/{{.Repo}}</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
padding: 40px 20px;
|
|
}
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
.header {
|
|
text-align: center;
|
|
color: white;
|
|
margin-bottom: 40px;
|
|
}
|
|
.header h1 {
|
|
font-size: 2.5rem;
|
|
margin-bottom: 10px;
|
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
|
}
|
|
.header p {
|
|
font-size: 1.1rem;
|
|
opacity: 0.9;
|
|
}
|
|
.card {
|
|
background: white;
|
|
border-radius: 16px;
|
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
|
padding: 30px;
|
|
margin-bottom: 30px;
|
|
}
|
|
.card h2 {
|
|
color: #333;
|
|
margin-bottom: 20px;
|
|
font-size: 1.5rem;
|
|
border-bottom: 3px solid #667eea;
|
|
padding-bottom: 10px;
|
|
}
|
|
.chart-container {
|
|
width: 100%;
|
|
height: 500px;
|
|
}
|
|
.table-container {
|
|
overflow-x: auto;
|
|
}
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-top: 20px;
|
|
}
|
|
th, td {
|
|
padding: 15px 20px;
|
|
text-align: left;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
th {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
font-size: 0.85rem;
|
|
letter-spacing: 1px;
|
|
}
|
|
tr:hover {
|
|
background: #f8f9ff;
|
|
}
|
|
.rank {
|
|
font-weight: bold;
|
|
color: #667eea;
|
|
font-size: 1.2rem;
|
|
}
|
|
.rank-1 { color: #FFD700; }
|
|
.rank-2 { color: #C0C0C0; }
|
|
.rank-3 { color: #CD7F32; }
|
|
.score-bar {
|
|
background: #e9ecef;
|
|
border-radius: 10px;
|
|
height: 20px;
|
|
overflow: hidden;
|
|
}
|
|
.score-fill {
|
|
background: linear-gradient(90deg, #667eea, #764ba2);
|
|
height: 100%;
|
|
border-radius: 10px;
|
|
transition: width 0.5s ease;
|
|
}
|
|
.score-text {
|
|
font-weight: bold;
|
|
color: #667eea;
|
|
}
|
|
.avatar {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: white;
|
|
font-weight: bold;
|
|
margin-right: 10px;
|
|
}
|
|
.user-info {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
.user-name {
|
|
font-weight: 600;
|
|
color: #333;
|
|
}
|
|
.user-login {
|
|
color: #666;
|
|
font-size: 0.9rem;
|
|
}
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
margin-top: 30px;
|
|
}
|
|
.stat-card {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
color: white;
|
|
text-align: center;
|
|
}
|
|
.stat-value {
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
margin-bottom: 5px;
|
|
}
|
|
.stat-label {
|
|
font-size: 0.9rem;
|
|
opacity: 0.9;
|
|
}
|
|
.weight-info {
|
|
background: #f8f9ff;
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
margin-top: 20px;
|
|
}
|
|
.weight-info h3 {
|
|
color: #667eea;
|
|
margin-bottom: 15px;
|
|
}
|
|
.weight-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 10px;
|
|
}
|
|
.weight-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
background: white;
|
|
border-radius: 8px;
|
|
border-left: 4px solid #667eea;
|
|
}
|
|
.weight-label {
|
|
color: #666;
|
|
}
|
|
.weight-value {
|
|
font-weight: 600;
|
|
color: #667eea;
|
|
}
|
|
@media (max-width: 768px) {
|
|
.header h1 {
|
|
font-size: 1.8rem;
|
|
}
|
|
th, td {
|
|
padding: 10px 12px;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>贡献者报告</h1>
|
|
<p>{{.Owner}}/{{.Repo}} - 团队成员贡献分析</p>
|
|
</div>
|
|
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{len .Contributors}}</div>
|
|
<div class="stat-label">贡献者总数</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="total-commits">0</div>
|
|
<div class="stat-label">总提交数</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="total-issues">0</div>
|
|
<div class="stat-label">总 Issue 数</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" id="total-prs">0</div>
|
|
<div class="stat-label">总 PR 数</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>贡献占比分布</h2>
|
|
<div id="pieChart" class="chart-container"></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>详细排名</h2>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>排名</th>
|
|
<th>成员</th>
|
|
<th>Commits</th>
|
|
<th>代码行数</th>
|
|
<th>Issues</th>
|
|
<th>PRs</th>
|
|
<th>贡献分数</th>
|
|
<th>占比</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .TableRows}}
|
|
<tr>
|
|
<td class="rank rank-{{.Rank}}">{{.Rank}}</td>
|
|
<td>
|
|
<div class="user-info">
|
|
<div class="avatar">{{slice .Login 0 1}}</div>
|
|
<div>
|
|
<div class="user-name">{{.Name}}</div>
|
|
<div class="user-login">@{{.Login}}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>{{.Commits}}</td>
|
|
<td>{{.CodeLines}}</td>
|
|
<td>{{.Issues}}</td>
|
|
<td>{{.PRs}}</td>
|
|
<td class="score-text">{{printf "%.4f" .Score}}</td>
|
|
<td>
|
|
<div class="score-bar">
|
|
<div class="score-fill" style="width: {{printf "%.1f" .ScorePct}}%"></div>
|
|
</div>
|
|
<small>{{printf "%.1f" .ScorePct}}%</small>
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>AHP 权重说明</h2>
|
|
<div class="weight-info">
|
|
<h3>层次分析法 (AHP) 权重分配</h3>
|
|
<div class="weight-grid">
|
|
<div class="weight-item">
|
|
<span class="weight-label">Commits</span>
|
|
<span class="weight-value">14.3%</span>
|
|
</div>
|
|
<div class="weight-item">
|
|
<span class="weight-label">代码行数</span>
|
|
<span class="weight-value">28.6%</span>
|
|
</div>
|
|
<div class="weight-item">
|
|
<span class="weight-label">PR 合并数</span>
|
|
<span class="weight-value">7.1%</span>
|
|
</div>
|
|
<div class="weight-item">
|
|
<span class="weight-label">Issue 创建</span>
|
|
<span class="weight-value">5.0%</span>
|
|
</div>
|
|
<div class="weight-item">
|
|
<span class="weight-label">Issue 解决</span>
|
|
<span class="weight-value">10.0%</span>
|
|
</div>
|
|
<div class="weight-item">
|
|
<span class="weight-label">Release</span>
|
|
<span class="weight-value">10.0%</span>
|
|
</div>
|
|
<div class="weight-item">
|
|
<span class="weight-label">Issue 评论</span>
|
|
<span class="weight-value">8.3%</span>
|
|
</div>
|
|
<div class="weight-item">
|
|
<span class="weight-label">PR 评审</span>
|
|
<span class="weight-value">8.3%</span>
|
|
</div>
|
|
<div class="weight-item">
|
|
<span class="weight-label">Wiki</span>
|
|
<span class="weight-value">8.3%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// 计算总数
|
|
let totalCommits = 0;
|
|
let totalIssues = 0;
|
|
let totalPRs = 0;
|
|
{{range .Contributors}}
|
|
totalCommits += {{.Commits}};
|
|
totalIssues += {{.Issues}};
|
|
totalPRs += {{.PRs}};
|
|
{{end}}
|
|
document.getElementById('total-commits').textContent = totalCommits;
|
|
document.getElementById('total-issues').textContent = totalIssues;
|
|
document.getElementById('total-prs').textContent = totalPRs;
|
|
|
|
// 初始化饼图
|
|
var chart = echarts.init(document.getElementById('pieChart'));
|
|
var option = {
|
|
tooltip: {
|
|
trigger: 'item',
|
|
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
|
},
|
|
legend: {
|
|
orient: 'vertical',
|
|
left: 'left',
|
|
top: 'middle',
|
|
textStyle: {
|
|
fontSize: 14
|
|
}
|
|
},
|
|
series: [{
|
|
name: '贡献占比',
|
|
type: 'pie',
|
|
radius: ['40%', '70%'],
|
|
center: ['60%', '50%'],
|
|
avoidLabelOverlap: true,
|
|
itemStyle: {
|
|
borderRadius: 10,
|
|
borderColor: '#fff',
|
|
borderWidth: 2
|
|
},
|
|
label: {
|
|
show: true,
|
|
formatter: '{b}\n{d}%',
|
|
fontSize: 12
|
|
},
|
|
emphasis: {
|
|
label: {
|
|
show: true,
|
|
fontSize: 16,
|
|
fontWeight: 'bold'
|
|
}
|
|
},
|
|
data: [
|
|
{{range .Contributors}}
|
|
{
|
|
value: {{printf "%.4f" .Score}},
|
|
name: '{{.Login}}'
|
|
},
|
|
{{end}}
|
|
]
|
|
}]
|
|
};
|
|
chart.setOption(option);
|
|
|
|
// 响应式
|
|
window.addEventListener('resize', function() {
|
|
chart.resize();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>`
|