Files
code_scan/web/index.html
Dang Zerong 14680f053e add web
2026-03-11 21:16:47 +08:00

1010 lines
50 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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; }
/* Diff 语法高亮 */
#detail-code-diff .diff-add { color: #98c379; background: rgba(72, 120, 80, 0.2); }
#detail-code-diff .diff-del { color: #e06c75; background: rgba(180, 80, 80, 0.2); }
#detail-code-diff .diff-info { color: #61afef; }
#detail-code-diff .diff-header { color: #e5c07b; }
.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; }
/* PR 详情 - Git 风格:左侧文件树 + 右侧文件内容 */
.pr-detail-file-layout { display: flex; height: 500px; border: 1px solid #dee2e6; border-radius: 6px; overflow: hidden; }
.pr-detail-file-tree { width: 280px; min-width: 280px; overflow-y: auto; background: #f8f9fa; border-right: 1px solid #dee2e6; }
.pr-detail-file-tree .tree-root { padding: 8px 0; }
.pr-detail-file-tree .tree-node { padding: 2px 8px; font-size: 13px; cursor: pointer; border-radius: 4px; }
.pr-detail-file-tree .tree-node:hover { background: #e9ecef; }
.pr-detail-file-tree .tree-node.active { background: #0d6efd; color: #fff; }
.pr-detail-file-tree .tree-folder { font-weight: 600; color: #495057; }
.pr-detail-file-tree .tree-file { padding-left: 12px; }
/* 代码在中间,右侧缺陷标注,虚线连接代码行与标注(参考审阅风格) */
.pr-detail-file-right { flex: 1; display: flex; overflow: hidden; min-width: 0; }
.pr-detail-file-wrapper { width: 100%; height: 100%; min-width: 0; overflow: hidden; }
.pr-detail-code-view { width: 100%; height: 100%; overflow: auto; background: #1e1e1e; font-family: 'Consolas', 'Monaco', monospace; font-size: 13px; line-height: 1.5; }
.pr-detail-code-view .code-line { display: flex; min-height: 1.5em; align-items: stretch; }
.pr-detail-code-view .code-line .line-gutter { width: 48px; min-width: 48px; flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; padding-right: 8px; color: #6c757d; background: #252526; user-select: none; }
.pr-detail-code-view .code-line .line-gutter .line-num { margin-right: 4px; }
.pr-detail-code-view .code-line .line-gutter .gutter-icon { width: 14px; height: 14px; flex-shrink: 0; }
.pr-detail-code-view .code-line .line-gutter .gutter-icon.icon-error { color: #f14c4c; }
.pr-detail-code-view .code-line .line-gutter .gutter-icon.icon-warning { color: #cca700; }
.pr-detail-code-view .code-line .line-content { flex: 1; min-width: 0; color: #d4d4d4; padding: 0 12px; white-space: pre-wrap; word-break: break-all; overflow-wrap: break-word; }
.pr-detail-code-view .code-line.line-has-issue .line-content { background: rgba(220, 53, 69, 0.1); border-left: 2px solid #dc3545; padding-left: 10px; }
/* 虚线连接区:代码与标注之间 */
.pr-detail-code-view .code-line .line-connector { width: 20px; min-width: 20px; flex-shrink: 0; position: relative; background: #1e1e1e; }
.pr-detail-code-view .code-line.line-has-issue .line-connector::before { content: ''; position: absolute; left: 0; right: 0; top: 50%; margin-top: -1px; height: 0; border-bottom: 1px dashed #f14c4c; }
.pr-detail-code-view .code-line .line-annotation { width: 180px; min-width: 180px; flex-shrink: 0; padding: 6px 8px; font-size: 11px; background: #252526; color: #9d9d9d; border-left: 1px solid #3c3c3c; white-space: normal; word-break: break-word; display: flex; align-items: center; font-family: inherit; }
.pr-detail-code-view .code-line.line-has-issue .line-annotation { background: #fff8f8; border-left: 2px solid #dc3545; color: #c62828; }
.pr-detail-code-view .code-line .line-annotation .anno-text { line-height: 1.4; }
.pr-detail-code-view .code-line .line-annotation .anno-icon { color: #dc3545; margin-right: 6px; flex-shrink: 0; }
.pr-detail-file-placeholder { color: #6c757d; padding: 24px; text-align: center; width: 100%; }
/* PR 详情弹窗加宽:占屏幕大部分宽度,代码区随之扩大 */
.modal-dialog-pr-wide { max-width: 92%; width: 92%; margin-left: auto; margin-right: auto; }
@media (min-width: 1400px) { .modal-dialog-pr-wide { max-width: 1400px; width: 92%; } }
</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-dialog-pr-wide">
<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>
<!-- 仅保留文件 Tab左侧文件树 + 右侧完整文件内容,最右侧为问题标注 -->
<div class="mt-3">
<div class="pr-detail-file-layout">
<div class="pr-detail-file-tree">
<div id="pr-file-tree-loading" class="p-3 text-center text-muted">加载文件列表中...</div>
<div id="pr-file-tree" class="tree-root" style="display: none;"></div>
</div>
<div class="pr-detail-file-right">
<div id="pr-file-content-placeholder" class="pr-detail-file-placeholder">点击左侧文件查看完整内容</div>
<div id="pr-file-content-wrapper" class="pr-detail-file-wrapper">
<div class="pr-detail-code-view" id="pr-file-content"></div>
</div>
<div id="pr-file-content-error" class="pr-detail-file-placeholder" style="display: none; color: #dc3545;"></div>
</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;
let currentDiffData = null;
let currentAIReview = null;
// 全局Markdown 简易格式化
function formatMarkdown(text) {
if (!text) return '';
return String(text)
.replace(/^### (.+)$/gm, '<h5 class="mt-3 mb-2">$1</h5>')
.replace(/^## (.+)$/gm, '<h4 class="mt-3 mb-2">$1</h4>')
.replace(/^# (.+)$/gm, '<h3 class="mt-3 mb-2">$1</h3>')
.replace(/^\*\*(.+):\*\*$/gm, '<strong>$1:</strong>')
.replace(/^- /gm, '<br>• ')
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
}
// 将 AI 审查对象格式化为 HTML优点/问题/优化 或 raw_review
function formatReviewObject(review) {
if (!review || typeof review !== 'object') return '';
if (review.raw_review) return formatMarkdown(review.raw_review);
let html = '';
if (review['优点'] && review['优点'].length) {
html += '<div class="mb-2"><strong class="text-success">优点</strong><ul class="mb-0">';
review['优点'].forEach(s => { html += '<li>' + escapeHtml(s) + '</li>'; });
html += '</ul></div>';
}
if (review['问题'] && review['问题'].length) {
html += '<div class="mb-2"><strong class="text-danger">问题</strong><ul class="mb-0">';
review['问题'].forEach(s => { html += '<li>' + escapeHtml(s) + '</li>'; });
html += '</ul></div>';
}
if (review['优化'] && review['优化'].length) {
html += '<div class="mb-2"><strong class="text-primary">优化</strong><ul class="mb-0">';
review['优化'].forEach(s => { html += '<li>' + escapeHtml(s) + '</li>'; });
html += '</ul></div>';
}
return html || formatMarkdown(JSON.stringify(review, null, 2));
}
// 页面切换
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';
// 根据状态显示/隐藏按钮
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';
// 显示模态框
const modal = new bootstrap.Modal(document.getElementById('prDetailModal'));
modal.show();
// 加载文件树(左侧树,点击文件在右侧显示完整内容+标注)
loadPRFileTree(id);
} catch (e) {
alert('加载 PR 详情失败: ' + e.message);
}
}
// 加载 PR 文件列表并渲染左侧树,点击文件在右侧显示完整内容
async function loadPRFileTree(prId) {
const loadingEl = document.getElementById('pr-file-tree-loading');
const treeEl = document.getElementById('pr-file-tree');
const placeholderEl = document.getElementById('pr-file-content-placeholder');
const wrapperEl = document.getElementById('pr-file-content-wrapper');
const contentEl = document.getElementById('pr-file-content');
const errorEl = document.getElementById('pr-file-content-error');
if (loadingEl) loadingEl.style.display = 'block';
if (treeEl) treeEl.style.display = 'none';
if (placeholderEl) placeholderEl.style.display = 'block';
if (wrapperEl) wrapperEl.style.display = 'none';
if (contentEl) contentEl.innerHTML = '';
if (errorEl) errorEl.style.display = 'none';
try {
const res = await fetch('/api/prs/' + prId + '/files');
const data = await res.json();
if (data.error || !data.files || !data.files.length) {
if (treeEl) {
treeEl.style.display = 'block';
treeEl.innerHTML = '<div class="p-3 text-muted small">' + (data.error || '暂无变更文件') + '</div>';
}
if (loadingEl) loadingEl.style.display = 'none';
return;
}
const files = data.files;
const tree = buildFileTree(files);
if (treeEl) {
treeEl.innerHTML = renderFileTreeNodes(tree, prId);
treeEl.style.display = 'block';
}
if (loadingEl) loadingEl.style.display = 'none';
} catch (e) {
if (treeEl) {
treeEl.style.display = 'block';
treeEl.innerHTML = '<div class="p-3 text-danger small">加载失败: ' + escapeHtml(e.message) + '</div>';
}
if (loadingEl) loadingEl.style.display = 'none';
}
}
// 将扁平文件列表转为树结构 { name, children?, path? }
function buildFileTree(files) {
const root = { name: '', children: {} };
for (const f of files) {
const path = f.filename || f.path || f;
const parts = typeof path === 'string' ? path.split('/') : [String(path)];
let cur = root;
for (let i = 0; i < parts.length; i++) {
const isFile = i === parts.length - 1;
const name = parts[i];
if (isFile) {
if (!cur.children[name]) cur.children[name] = { name, path, file: true, status: f.status };
} else {
if (!cur.children[name]) cur.children[name] = { name, children: {} };
cur = cur.children[name];
}
}
}
return root;
}
// 递归渲染树节点 HTML
function renderFileTreeNodes(node, prId) {
const entries = Object.keys(node.children || {}).sort((a, b) => {
const aa = node.children[a];
const ab = node.children[b];
const aIsDir = !aa.file;
const bIsDir = !ab.file;
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
return a.localeCompare(b);
});
if (entries.length === 0) return '';
let html = '';
for (const key of entries) {
const child = node.children[key];
if (child.file) {
const status = child.status || '';
const statusCls = status === 'added' ? 'text-success' : status === 'removed' ? 'text-danger' : 'text-secondary';
html += '<div class="tree-node tree-file ' + statusCls + '" data-path="' + escapeHtml(child.path) + '" data-pr-id="' + prId + '"><i class="bi bi-file-code me-1"></i>' + escapeHtml(key) + '</div>';
} else {
html += '<div class="tree-folder tree-node">' + escapeHtml(key) + '</div>';
html += '<div style="padding-left: 12px;">' + renderFileTreeNodes(child, prId) + '</div>';
}
}
return html;
}
// 点击文件时加载并显示内容
document.addEventListener('click', function(ev) {
const node = ev.target.closest('.tree-file');
if (!node) return;
ev.preventDefault();
const path = node.getAttribute('data-path');
const prId = node.getAttribute('data-pr-id');
if (!path || !prId) return;
document.querySelectorAll('.pr-detail-file-tree .tree-node.active').forEach(el => el.classList.remove('active'));
node.classList.add('active');
loadPRFileContent(prId, path);
});
async function loadPRFileContent(prId, path) {
const placeholderEl = document.getElementById('pr-file-content-placeholder');
const wrapperEl = document.getElementById('pr-file-content-wrapper');
const contentEl = document.getElementById('pr-file-content');
const errorEl = document.getElementById('pr-file-content-error');
if (placeholderEl) placeholderEl.style.display = 'none';
if (errorEl) errorEl.style.display = 'none';
try {
const res = await fetch('/api/prs/' + prId + '/file?path=' + encodeURIComponent(path));
const data = await res.json();
if (data.error) {
errorEl.textContent = data.error;
errorEl.style.display = 'block';
return;
}
// 行号 -> 问题列表
const lineIssues = {};
const issues = data.scan_issues || [];
for (const issue of issues) {
const line = issue.line || 1;
if (!lineIssues[line]) lineIssues[line] = [];
lineIssues[line].push(issue);
}
// 代码在中间,右侧缺陷标注,虚线连接代码行与标注
const lines = (data.content || '').split('\n');
let html = '';
for (let i = 0; i < lines.length; i++) {
const lineNum = i + 1;
const rowIssues = lineIssues[lineNum] || [];
const hasIssue = rowIssues.length > 0;
const rowClass = hasIssue ? 'code-line line-has-issue' : 'code-line';
const lineText = lines[i];
const displayText = lineText === '' ? '\u00A0' : lineText;
let gutterHtml = '<span class="line-num">' + lineNum + '</span>';
if (hasIssue) {
const first = rowIssues[0];
const sev = first.severity || 'info';
const iconClass = (sev === 'error' || sev === 'high') ? 'gutter-icon icon-error' : 'gutter-icon icon-warning';
gutterHtml += '<span class="' + iconClass + '"><i class="bi bi-exclamation-circle-fill" style="font-size:12px;"></i></span>';
}
const reasonText = hasIssue ? rowIssues.map(function(iss) {
return (iss.scanner ? '[' + iss.scanner + '] ' : '') + (iss.message || '');
}).join('') : '';
html += '<div class="' + rowClass + '">';
html += '<div class="line-gutter">' + gutterHtml + '</div>';
html += '<span class="line-content">' + escapeHtml(displayText) + '</span>';
html += '<div class="line-connector"></div>';
html += '<div class="line-annotation">';
if (hasIssue && reasonText) {
html += '<span class="anno-icon"><i class="bi bi-exclamation-triangle-fill"></i></span>';
html += '<span class="anno-text">' + escapeHtml(reasonText) + '</span>';
}
html += '</div>';
html += '</div>';
}
contentEl.innerHTML = html;
if (wrapperEl) wrapperEl.style.display = 'block';
} catch (e) {
errorEl.textContent = '加载失败: ' + e.message;
errorEl.style.display = 'block';
}
}
// 渲染带代码片段的扫描结果
function renderScanDetailsWithCode(scanDetails) {
let html = '';
// 显示汇总信息
if (scanDetails.summary) {
html += '<div class="alert alert-info mb-3">';
html += `<strong>总问题数:</strong> ${scanDetails.total_issues} | `;
html += `<strong>扫描器:</strong> ${scanDetails.scanners ? scanDetails.scanners.length : 0}`;
html += '</div>';
}
// 遍历每个扫描器
if (scanDetails.scanners) {
for (const scanner of scanDetails.scanners) {
html += `<div class="card mb-3">
<div class="card-header bg-light">
<strong>${scanner.name}</strong>
<span class="badge bg-${scanner.total_issues > 0 ? 'danger' : 'success'}">
${scanner.total_issues} 个问题
</span>
</div>
<div class="card-body">`;
if (scanner.issues && scanner.issues.length > 0) {
for (const issue of scanner.issues) {
html += `<div class="issue-item mb-3 p-2 border rounded">`;
html += `<div class="fw-bold text-${issue.severity === 'error' ? 'danger' : 'warning'}">`;
html += `<i class="bi bi-exclamation-triangle"></i> ${issue.message || issue.description || '问题'}`;
html += ` <small class="text-muted">(${issue.file}:${issue.line || '?'})</small>`;
html += `</div>`;
// 显示代码片段
if (issue.code_context) {
const ctx = issue.code_context;
html += `<div class="code-context mt-2" style="background: #1e1e1e; color: #d4d4d4; padding: 10px; border-radius: 4px; font-size: 12px; overflow-x: auto;">`;
html += `<div class="text-muted small mb-1">文件: ${ctx.file || issue.file}</div>`;
if (ctx.context) {
// 显示上下文代码
for (const line of ctx.context) {
const lineClass = line.is_issue_line ? 'background: rgba(255, 200, 0, 0.2);' : '';
const prefix = line.is_issue_line ? '👉 ' : ' ';
html += `<div style="${lineClass}white-space: pre;">${prefix}${line.line_number}: ${escapeHtml(line.code)}</div>`;
}
} else if (ctx.preview) {
// 显示文件预览
html += `<pre style="margin: 0;">${escapeHtml(ctx.preview)}</pre>`;
if (ctx.has_more) {
html += `<div class="text-muted small">... (更多内容)</div>`;
}
}
html += `</div>`;
}
html += `</div>`;
}
} else {
html += '<div class="text-muted">没有问题</div>';
}
html += '</div></div>';
}
}
return html;
}
// HTML 转义
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 加载 PR 代码差异
async function loadPRDiff(prId) {
try {
const response = await fetch('/api/prs/' + prId + '/diff');
const data = await response.json();
const loadingEl = document.getElementById('detail-code-diff-loading');
const diffEl = document.getElementById('detail-code-diff');
if (loadingEl) loadingEl.style.display = 'none';
if (data.error) {
if (diffEl) {
diffEl.style.display = 'block';
diffEl.textContent = '加载失败: ' + data.error;
}
return;
}
// 格式化 diff 显示
const diff = data.diff || '';
if (!diff) {
if (diffEl) {
diffEl.style.display = 'block';
diffEl.textContent = '没有代码差异';
}
return;
}
// 添加语法高亮样式
const formattedDiff = diff
.replace(/^diff --git.*$/gm, match => '\n' + match)
.replace(/^@@.*@@$/gm, match => '\n' + match + '\n')
.replace(/^\+.*$/gm, match => match)
.replace(/^-.*$/gm, match => match);
if (diffEl) {
diffEl.style.display = 'block';
diffEl.textContent = diff;
}
} catch (e) {
const loadingEl = document.getElementById('detail-code-diff-loading');
if (loadingEl) loadingEl.style.display = 'none';
if (diffEl) {
diffEl.style.display = 'block';
diffEl.textContent = '加载失败: ' + e.message;
}
}
}
// 加载 AI 审查结果:一行一行展示(每行左侧代码片段 + 右侧审查结果)
async function loadAIReview(prId) {
try {
const prResponse = await fetch('/api/prs/' + prId);
const pr = await prResponse.json();
if (pr.error) {
showAIReviewError('加载失败: ' + pr.error);
return;
}
let aiReview = pr.ai_review;
if (typeof aiReview === 'string') {
try { aiReview = JSON.parse(aiReview); } catch(e) {}
}
currentAIReview = aiReview;
const diffResponse = await fetch('/api/prs/' + prId + '/diff');
const diffData = await diffResponse.json();
currentDiffData = diffData.diff || '';
const loadingEl = document.getElementById('ai-review-loading');
const emptyEl = document.getElementById('ai-review-empty');
const contentEl = document.getElementById('ai-review-content');
const summaryEl = document.getElementById('ai-review-summary');
const rowsEl = document.getElementById('ai-review-rows');
if (!loadingEl || !emptyEl || !contentEl || !rowsEl) return;
loadingEl.style.display = 'none';
// 兼容处理:优先使用 reviews 数组,若无则尝试从 summary 文本中解析文件审查
let reviews = [];
if (aiReview && Array.isArray(aiReview.reviews)) {
reviews = aiReview.reviews;
} else if (aiReview && aiReview.summary && typeof aiReview.summary === 'string') {
// 尝试从 summary 文本中解析每个文件的审查结果
// 格式如: "### 📄 app.py\n\n**优点:**\n- ..."
const summaryText = aiReview.summary;
// 按 "### 📄 " 分割,提取每个文件的审查
const fileBlocks = summaryText.split(/(?=### 📄 )/);
for (const block of fileBlocks) {
const match = block.match(/### 📄 ([^\n]+)\n([\s\S]*)/);
if (match) {
const fileName = match[1].trim();
const reviewContent = match[2].trim();
// 将文本格式转为对象结构
const reviewObj = parseReviewTextToObject(reviewContent);
reviews.push({ file: fileName, review: reviewObj });
}
}
// 如果解析不出文件,则将整个 summary 作为整体审查
if (reviews.length === 0) {
reviews = [{ file: '整体审查结果', review: { 'raw_review': summaryText } }];
}
}
// 辅助函数:将审查文本转为对象结构
function parseReviewTextToObject(text) {
const obj = {};
// 匹配 "**优点:**" / "**需要改进:**" / "**优化建议:**" 等
const sections = { '优点': [], '问题': [], '优化': [] };
let currentSection = null; // '优点' | '问题' | '优化'
const lines = text.split('\n');
for (const line of lines) {
let found = false;
if (/优点/.test(line) && /\*\*/.test(line)) {
currentSection = '优点';
found = true;
} else if (/(需要改进|问题)/.test(line) && /\*\*/.test(line)) {
currentSection = '问题';
found = true;
} else if (/(优化建议|优化)/.test(line) && /\*\*/.test(line)) {
currentSection = '优化';
found = true;
}
if (!found && currentSection) {
const m = line.match(/^- (.+)/);
if (m) {
sections[currentSection].push(m[1]);
}
}
}
// 清理空数组
if (sections['优点'].length) obj['优点'] = sections['优点'];
if (sections['问题'].length) obj['问题'] = sections['问题'];
if (sections['优化'].length) obj['优化'] = sections['优化'];
// 如果解析不出结构,直接返回原始文本
return Object.keys(obj).length ? obj : { 'raw_review': text };
}
if (reviews.length === 0) {
emptyEl.style.display = 'block';
contentEl.style.display = 'none';
return;
}
emptyEl.style.display = 'none';
contentEl.style.display = 'block';
if (summaryEl) {
summaryEl.textContent = aiReview.summary || ('已审查 ' + reviews.length + ' 个文件');
}
let rowsHtml = '';
const diff = currentDiffData || '';
for (const item of reviews) {
const fileDisplay = item.file || (item.path ? item.path.replace(/^.*[/\\]/, '') : '');
const fileForDiff = item.file || fileDisplay;
const codeSnippet = diff ? extractFileFromDiff(diff, fileForDiff) : '';
const codeDisplay = codeSnippet || '(该文件无 diff 或未在 PR 中修改)';
const reviewHtml = formatReviewObject(item.review);
rowsHtml += `
<div class="card mb-3 border">
<div class="card-header py-2 bg-light d-flex justify-content-between align-items-center">
<span><i class="bi bi-file-code me-1"></i> ${escapeHtml(fileDisplay)}</span>
</div>
<div class="card-body p-0">
<div class="row g-0">
<div class="col-md-6 border-end">
<div class="p-2 bg-dark text-light">
<pre class="mb-0 p-2" style="background:#1e1e1e;color:#d4d4d4;font-size:12px;max-height:280px;overflow:auto;white-space:pre;">${escapeHtml(codeDisplay)}</pre>
</div>
</div>
<div class="col-md-6">
<div class="p-3" style="min-height:120px;">${reviewHtml || '—'}</div>
</div>
</div>
</div>
</div>`;
}
rowsEl.innerHTML = rowsHtml;
} catch (e) {
showAIReviewError('加载失败: ' + e.message);
}
}
// 显示 AI 审查错误信息
function showAIReviewError(message) {
const loadingEl = document.getElementById('ai-review-loading');
const emptyEl = document.getElementById('ai-review-empty');
const contentEl = document.getElementById('ai-review-content');
if (loadingEl) loadingEl.style.display = 'none';
if (contentEl) contentEl.style.display = 'none';
if (emptyEl) {
emptyEl.style.display = 'block';
emptyEl.textContent = message;
}
}
// 从 diff 中提取指定文件的代码
function extractFileFromDiff(diff, targetFile) {
const lines = diff.split('\n');
let inTargetFile = false;
let result = [];
let currentFile = '';
for (const line of lines) {
// 检测新文件开始
const diffMatch = line.match(/diff --git a\/(.+) b\/(.+)/);
if (diffMatch) {
currentFile = diffMatch[1];
inTargetFile = currentFile.includes(targetFile) || targetFile.includes(currentFile);
continue;
}
// 如果在目标文件中,收集代码行
if (inTargetFile) {
// 遇到新文件,停止
if (line.startsWith('diff --git')) break;
// 跳过 diff 和 hunk 头部
if (line.startsWith('@@')) {
result.push(line);
continue;
}
// 添加实际代码(去掉 +, -, 前缀,但保留内容)
if (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) {
result.push(line);
}
}
}
return result.join('\n');
}
// 监听 AI 审查标签点击
// 合并 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');
}
// 初始化
window.onerror = function(msg, url, line, col, error) {
console.error('Global error:', msg, 'at line', line);
return false;
};
loadDashboard();
</script>
</body>
</html>