450 lines
21 KiB
HTML
450 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>PR 扫描管理平台</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
|
<style>
|
|
body { background-color: #f5f7fa; }
|
|
.sidebar {
|
|
min-height: 100vh;
|
|
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
|
|
color: white;
|
|
}
|
|
.sidebar .nav-link { color: rgba(255,255,255,0.7); }
|
|
.sidebar .nav-link:hover, .sidebar .nav-link.active {
|
|
color: white;
|
|
background: rgba(255,255,255,0.1);
|
|
}
|
|
.stat-card {
|
|
border: none;
|
|
border-radius: 12px;
|
|
transition: transform 0.2s;
|
|
}
|
|
.stat-card:hover { transform: translateY(-5px); }
|
|
.pr-card {
|
|
border: none;
|
|
border-radius: 10px;
|
|
transition: box-shadow 0.2s;
|
|
}
|
|
.pr-card:hover { box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
|
|
.status-badge {
|
|
padding: 5px 12px;
|
|
border-radius: 20px;
|
|
font-size: 12px;
|
|
}
|
|
.status-open { background: #e3f2fd; color: #1976d2; }
|
|
.status-merged { background: #e8f5e9; color: #388e3c; }
|
|
.status-closed { background: #ffebee; color: #d32f2f; }
|
|
.status-pending { background: #fff3e0; color: #f57c00; }
|
|
.status-completed { background: #e8f5e9; color: #388e3c; }
|
|
.issue-high { color: #d32f2f; }
|
|
.issue-medium { color: #f57c00; }
|
|
.issue-low { color: #388e3c; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container-fluid">
|
|
<div class="row">
|
|
<!-- 侧边栏 -->
|
|
<div class="col-md-2 sidebar p-3">
|
|
<h4 class="mb-4"><i class="bi bi-shield-check"></i> 扫描管理</h4>
|
|
<ul class="nav flex-column">
|
|
<li class="nav-item">
|
|
<a class="nav-link active" href="#" onclick="showPage('dashboard')">
|
|
<i class="bi bi-speedometer2 me-2"></i>概览
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="#" onclick="showPage('prs')">
|
|
<i class="bi bi-git me-2"></i>PR 列表
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="#" onclick="showPage('settings')">
|
|
<i class="bi bi-gear me-2"></i>设置
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
|
|
<div class="mt-5 p-3" style="background: rgba(255,255,255,0.1); border-radius: 8px;">
|
|
<small>系统状态</small>
|
|
<div class="mt-2">
|
|
<span class="text-success"><i class="bi bi-check-circle-fill"></i> 服务正常</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 主内容区 -->
|
|
<div class="col-md-10 p-4">
|
|
<!-- 概览页面 -->
|
|
<div id="page-dashboard">
|
|
<h2 class="mb-4">概览</h2>
|
|
<div class="row mb-4">
|
|
<div class="col-md-3">
|
|
<div class="card stat-card bg-primary text-white">
|
|
<div class="card-body">
|
|
<h6 class="card-title"><i class="bi bi-inbox"></i> 待处理</h6>
|
|
<h2 class="mb-0" id="stat-pending">-</h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card stat-card bg-success text-white">
|
|
<div class="card-body">
|
|
<h6 class="card-title"><i class="bi bi-check-circle"></i> 已通过</h6>
|
|
<h2 class="mb-0" id="stat-merged">-</h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card stat-card bg-danger text-white">
|
|
<div class="card-body">
|
|
<h6 class="card-title"><i class="bi bi-x-circle"></i> 已拒绝</h6>
|
|
<h2 class="mb-0" id="stat-closed">-</h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card stat-card bg-warning text-white">
|
|
<div class="card-body">
|
|
<h6 class="card-title"><i class="bi bi-exclamation-triangle"></i> 问题数</h6>
|
|
<h2 class="mb-0" id="stat-issues">-</h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h5 class="mb-3">最近 PR</h5>
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover" id="recent-prs-table">
|
|
<thead>
|
|
<tr>
|
|
<th>PR</th>
|
|
<th>标题</th>
|
|
<th>作者</th>
|
|
<th>分支</th>
|
|
<th>状态</th>
|
|
<th>问题</th>
|
|
<th>时间</th>
|
|
<th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PR 列表页面 -->
|
|
<div id="page-prs" style="display:none;">
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<h2>PR 列表</h2>
|
|
<div>
|
|
<select class="form-select form-select-sm d-inline-block w-auto" id="filter-state" onchange="loadPRs()">
|
|
<option value="">全部状态</option>
|
|
<option value="open">待处理</option>
|
|
<option value="merged">已合并</option>
|
|
<option value="closed">已关闭</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover" id="prs-table">
|
|
<thead>
|
|
<tr>
|
|
<th>PR</th>
|
|
<th>标题</th>
|
|
<th>仓库</th>
|
|
<th>作者</th>
|
|
<th>分支</th>
|
|
<th>扫描状态</th>
|
|
<th>状态</th>
|
|
<th>问题</th>
|
|
<th>时间</th>
|
|
<th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 设置页面 -->
|
|
<div id="page-settings" style="display:none;">
|
|
<h2 class="mb-4">设置</h2>
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h5>API 端点</h5>
|
|
<div class="mb-3">
|
|
<label class="form-label">Webhook 地址</label>
|
|
<input type="text" class="form-control" value="/webhook/gitea" readonly>
|
|
<small class="text-muted">在 Gitea 中配置为此地址: http://your-server:5000/webhook/gitea</small>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">飞书回调地址</label>
|
|
<input type="text" class="form-control" value="/feishu/card_action" readonly>
|
|
</div>
|
|
<hr>
|
|
<h5>操作说明</h5>
|
|
<ul>
|
|
<li>点击「查看」查看 PR 扫描详情</li>
|
|
<li>点击「同意合并」合并 PR</li>
|
|
<li>点击「拒绝」关闭 PR</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PR 详情模态框 -->
|
|
<div class="modal fade" id="prDetailModal" tabindex="-1">
|
|
<div class="modal-dialog modal-xl">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="detail-pr-title">PR 详情</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="row mb-3">
|
|
<div class="col-md-6">
|
|
<p><strong>仓库:</strong> <span id="detail-repo"></span></p>
|
|
<p><strong>作者:</strong> <span id="detail-author"></span></p>
|
|
<p><strong>分支:</strong> <span id="detail-branch"></span></p>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<p><strong>扫描状态:</strong> <span id="detail-scan-status"></span></p>
|
|
<p><strong>问题数:</strong> <span id="detail-issues"></span></p>
|
|
<p><strong>安全漏洞:</strong> <span id="detail-security"></span></p>
|
|
</div>
|
|
</div>
|
|
<ul class="nav nav-tabs" id="detail-tabs" role="tablist">
|
|
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-scan-result">扫描结果</button></li>
|
|
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-ai-review">AI 审查</button></li>
|
|
</ul>
|
|
<div class="tab-content mt-3">
|
|
<div class="tab-pane fade show active" id="tab-scan-result">
|
|
<pre id="detail-scan-result" style="max-height: 400px; overflow: auto; background: #f8f9fa; padding: 15px; border-radius: 5px;"></pre>
|
|
</div>
|
|
<div class="tab-pane fade" id="tab-ai-review">
|
|
<div id="detail-ai-review" style="white-space: pre-wrap; background: #f8f9fa; padding: 15px; border-radius: 5px;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
|
<button type="button" class="btn btn-danger" id="btn-reject" onclick="rejectPR()">
|
|
<i class="bi bi-x-circle"></i> 拒绝
|
|
</button>
|
|
<button type="button" class="btn btn-success" id="btn-merge" onclick="mergePR()">
|
|
<i class="bi bi-check-circle"></i> 同意合并
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script>
|
|
let currentPRId = null;
|
|
let currentPRInfo = null;
|
|
|
|
// 页面切换
|
|
function showPage(page) {
|
|
document.querySelectorAll('[id^="page-"]').forEach(el => el.style.display = 'none');
|
|
document.getElementById('page-' + page).style.display = 'block';
|
|
document.querySelectorAll('.sidebar .nav-link').forEach(el => el.classList.remove('active'));
|
|
event.target.closest('.nav-link')?.classList.add('active');
|
|
|
|
if (page === 'dashboard') loadDashboard();
|
|
if (page === 'prs') loadPRs();
|
|
}
|
|
|
|
// 加载概览数据
|
|
async function loadDashboard() {
|
|
try {
|
|
const response = await fetch('/api/prs');
|
|
const prs = await response.json();
|
|
|
|
// 统计数据
|
|
const pending = prs.filter(p => p.state === 'open').length;
|
|
const merged = prs.filter(p => p.state === 'merged').length;
|
|
const closed = prs.filter(p => p.state === 'closed').length;
|
|
const totalIssues = prs.reduce((sum, p) => sum + (p.issues_count || 0), 0);
|
|
|
|
document.getElementById('stat-pending').textContent = pending;
|
|
document.getElementById('stat-merged').textContent = merged;
|
|
document.getElementById('stat-closed').textContent = closed;
|
|
document.getElementById('stat-issues').textContent = totalIssues;
|
|
|
|
// 最近 PR
|
|
const recentPRs = prs.slice(0, 5);
|
|
const tbody = document.querySelector('#recent-prs-table tbody');
|
|
tbody.innerHTML = recentPRs.map(pr => createPRRow(pr)).join('');
|
|
} catch (e) {
|
|
console.error('加载数据失败:', e);
|
|
}
|
|
}
|
|
|
|
// 加载 PR 列表
|
|
async function loadPRs() {
|
|
try {
|
|
const state = document.getElementById('filter-state').value;
|
|
const url = state ? '/api/prs?state=' + state : '/api/prs';
|
|
const response = await fetch(url);
|
|
const prs = await response.json();
|
|
|
|
const tbody = document.querySelector('#prs-table tbody');
|
|
tbody.innerHTML = prs.map(pr => createPRRow(pr)).join('');
|
|
} catch (e) {
|
|
console.error('加载数据失败:', e);
|
|
}
|
|
}
|
|
|
|
// 创建 PR 行
|
|
function createPRRow(pr) {
|
|
const stateClass = 'status-' + pr.state;
|
|
const scanClass = 'status-' + pr.scan_status;
|
|
const issuesClass = (pr.issues_count || 0) > 0 ? 'issue-high' : 'issue-low';
|
|
const securityClass = (pr.security_issues || 0) > 0 ? 'issue-high' : 'issue-low';
|
|
|
|
return `
|
|
<tr>
|
|
<td><a href="${pr.pr_url || '#'}" target="_blank">#${pr.pr_number}</a></td>
|
|
<td>${pr.pr_title || '-'}</td>
|
|
<td><small>${pr.repo_name}</small></td>
|
|
<td>${pr.author || '-'}</td>
|
|
<td><small>${pr.source_branch} → ${pr.target_branch}</small></td>
|
|
<td><span class="status-badge ${scanClass}">${pr.scan_status === 'completed' ? '已完成' : '待扫描'}</span></td>
|
|
<td><span class="status-badge ${stateClass}">${pr.state === 'open' ? '待处理' : pr.state === 'merged' ? '已合并' : '已关闭'}</span></td>
|
|
<td><span class="${issuesClass}">${pr.issues_count || 0}</span> / <span class="${securityClass}">${pr.security_issues || 0}</span></td>
|
|
<td><small>${formatDate(pr.updated_at)}</small></td>
|
|
<td>
|
|
<button class="btn btn-sm btn-primary" onclick="viewPR(${pr.id})">查看</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
// 查看 PR 详情
|
|
async function viewPR(id) {
|
|
try {
|
|
const response = await fetch('/api/prs/' + id);
|
|
const pr = await response.json();
|
|
|
|
currentPRId = id;
|
|
currentPRInfo = pr;
|
|
|
|
document.getElementById('detail-pr-title').textContent = 'PR #' + pr.pr_number + ': ' + (pr.pr_title || '');
|
|
document.getElementById('detail-repo').textContent = pr.repo_name;
|
|
document.getElementById('detail-author').textContent = pr.author || '-';
|
|
document.getElementById('detail-branch').textContent = pr.source_branch + ' → ' + pr.target_branch;
|
|
document.getElementById('detail-scan-status').innerHTML = pr.scan_status === 'completed' ? '<span class="text-success">已完成</span>' : '<span class="text-warning">待扫描</span>';
|
|
document.getElementById('detail-issues').textContent = pr.issues_count || 0;
|
|
document.getElementById('detail-security').innerHTML = (pr.security_issues || 0) > 0 ?
|
|
'<span class="text-danger">' + pr.security_issues + '</span>' : '0';
|
|
|
|
// 扫描结果
|
|
let scanResult = pr.scan_result;
|
|
if (typeof scanResult === 'string') {
|
|
try { scanResult = JSON.parse(scanResult); } catch(e) {}
|
|
}
|
|
document.getElementById('detail-scan-result').textContent = JSON.stringify(scanResult, null, 2);
|
|
|
|
// AI 审查
|
|
let aiReview = pr.ai_review;
|
|
if (typeof aiReview === 'string') {
|
|
try { aiReview = JSON.parse(aiReview); } catch(e) {}
|
|
}
|
|
if (aiReview && aiReview.review) {
|
|
document.getElementById('detail-ai-review').textContent = aiReview.review;
|
|
} else {
|
|
document.getElementById('detail-ai-review').textContent = '无 AI 审查结果';
|
|
}
|
|
|
|
// 根据状态显示/隐藏按钮
|
|
const canOperate = pr.state === 'open';
|
|
document.getElementById('btn-merge').style.display = canOperate ? 'inline-block' : 'none';
|
|
document.getElementById('btn-reject').style.display = canOperate ? 'inline-block' : 'none';
|
|
|
|
// 显示模态框
|
|
new bootstrap.Modal(document.getElementById('prDetailModal')).show();
|
|
} catch (e) {
|
|
alert('加载 PR 详情失败: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// 合并 PR
|
|
async function mergePR() {
|
|
if (!currentPRInfo) return;
|
|
|
|
if (!confirm('确定要合并 PR #' + currentPRInfo.pr_number + ' 吗?')) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/prs/' + currentPRId + '/merge', {
|
|
method: 'POST'
|
|
});
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
alert('PR 已成功合并!');
|
|
bootstrap.Modal.getInstance(document.getElementById('prDetailModal')).hide();
|
|
loadPRs();
|
|
loadDashboard();
|
|
} else {
|
|
alert('合并失败: ' + result.message);
|
|
}
|
|
} catch (e) {
|
|
alert('操作失败: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// 拒绝 PR
|
|
async function rejectPR() {
|
|
if (!currentPRInfo) return;
|
|
|
|
if (!confirm('确定要拒绝并关闭 PR #' + currentPRInfo.pr_number + ' 吗?')) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/prs/' + currentPRId + '/close', {
|
|
method: 'POST'
|
|
});
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
alert('PR 已关闭!');
|
|
bootstrap.Modal.getInstance(document.getElementById('prDetailModal')).hide();
|
|
loadPRs();
|
|
loadDashboard();
|
|
} else {
|
|
alert('操作失败: ' + result.message);
|
|
}
|
|
} catch (e) {
|
|
alert('操作失败: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// 格式化日期
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleString('zh-CN');
|
|
}
|
|
|
|
// 初始化
|
|
loadDashboard();
|
|
</script>
|
|
</body>
|
|
</html>
|