gitlink-cli/shortcuts/contrib/html.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>`