418 lines
19 KiB
HTML
418 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
{{template "head" .}}
|
|
<link rel="stylesheet" href="/static/lib/diff2html.min.css">
|
|
<script src="/static/lib/diff2html.min.js"></script>
|
|
<script src="/static/lib/highlight.min.js"></script>
|
|
<script src="/static/js/sse.js"></script>
|
|
<script src="/static/js/diff-viewer.js"></script>
|
|
<script src="/static/js/review-inline.js"></script>
|
|
<script src="/static/js/note-editor.js"></script>
|
|
</head>
|
|
<body class="bg-gray-50 min-h-screen">
|
|
{{template "nav" .}}
|
|
|
|
<main class="max-w-6xl mx-auto px-4 py-8">
|
|
<h1 class="text-2xl font-bold text-gray-900 mb-6">AI 代码审查</h1>
|
|
|
|
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
|
<div class="grid grid-cols-3 gap-4 mb-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Base</label>
|
|
<select id="base-ref" class="w-full border rounded-md px-3 py-2" required>
|
|
<option value="">加载中...</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Head</label>
|
|
<select id="head-ref" class="w-full border rounded-md px-3 py-2" required>
|
|
<option value="">加载中...</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Top-N 文件数</label>
|
|
<input type="number" id="top-n" value="{{.TopN}}" min="0" max="100" class="w-full border rounded-md px-3 py-2"
|
|
placeholder="留空=全部">
|
|
<p class="text-xs text-gray-400 mt-1">设为 0 分析全部文件</p>
|
|
</div>
|
|
</div>
|
|
<button onclick="startReview()" id="btn-review" class="w-full bg-purple-600 text-white py-2 px-4 rounded-md hover:bg-purple-700">
|
|
开始审查
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Progress -->
|
|
<div id="progress" class="bg-white rounded-lg shadow-md p-6 mb-6 hidden">
|
|
<h3 class="font-semibold text-gray-700 mb-2">审查进度</h3>
|
|
<div class="w-full bg-gray-200 rounded-full h-2.5 mb-2">
|
|
<div id="progress-bar" class="bg-purple-600 h-2.5 rounded-full transition-all duration-300" style="width: 0%"></div>
|
|
</div>
|
|
<p id="progress-text" class="text-sm text-gray-500">准备中...</p>
|
|
</div>
|
|
|
|
<!-- Results -->
|
|
<div id="results" class="hidden">
|
|
<!-- Summary -->
|
|
<div id="summary" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">审查总结</h2>
|
|
<div id="summary-content" class="text-gray-700 prose text-sm"></div>
|
|
</div>
|
|
|
|
<!-- Overall Note Editor -->
|
|
<div id="overall-note-section" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
|
<h2 class="text-lg font-semibold text-gray-900 mb-3">📝 整体备注</h2>
|
|
<div class="note-editor" data-note-key="overall:" data-scope="overall" data-scope-key="">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-xs text-gray-400">💬 备注 (Markdown)</span>
|
|
<span class="note-save-indicator text-xs"></span>
|
|
</div>
|
|
<textarea class="note-textarea" placeholder="添加整体备注..." rows="2"
|
|
oninput="NoteEditor.onInput(this, 'overall', '')"></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions Bar -->
|
|
<div id="review-actions" class="flex items-center gap-3 mb-6 hidden">
|
|
<button onclick="NoteEditor.exportPDF()" id="btn-export-pdf"
|
|
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 text-sm">
|
|
📄 导出 PDF 报告
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Inline Diff + Suggestions View -->
|
|
<div id="diff-review-container" class="mb-6">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h2 class="text-lg font-semibold text-gray-900">代码变更 & 审查建议</h2>
|
|
<div class="flex gap-2">
|
|
<button onclick="toggleDiffView()" id="btn-toggle-diff" class="px-3 py-1 text-xs bg-gray-100 rounded hover:bg-gray-200">
|
|
隐藏 Diff
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="review-diff-container"></div>
|
|
</div>
|
|
|
|
<!-- File Reviews (card-based fallback) -->
|
|
<div id="file-reviews" class="space-y-4"></div>
|
|
</div>
|
|
</main>
|
|
|
|
{{template "footer" .}}
|
|
|
|
<script>
|
|
const repoId = '{{.ID}}';
|
|
let currentSSE = null;
|
|
let diffVisible = true;
|
|
let allSuggestions = [];
|
|
let fileReviews = {};
|
|
let currentAnalysisId = null;
|
|
|
|
// Load refs on page load
|
|
async function loadRefs() {
|
|
try {
|
|
const resp = await fetch(`/api/repos/${repoId}/refs`);
|
|
if (!resp.ok) throw new Error('Failed to load refs');
|
|
const refs = await resp.json();
|
|
|
|
// Fetch recent commits for the "Recent Commits" group
|
|
let commits = [];
|
|
try {
|
|
const graphResp = await fetch(`/api/repos/${repoId}/graph?max_commits=20`);
|
|
if (graphResp.ok) {
|
|
commits = await graphResp.json();
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to load recent commits:', e);
|
|
}
|
|
|
|
const selects = ['base-ref', 'head-ref'];
|
|
const branches = refs.filter(r => !r.is_tag);
|
|
const tags = refs.filter(r => r.is_tag);
|
|
|
|
selects.forEach(selectId => {
|
|
const select = document.getElementById(selectId);
|
|
select.innerHTML = '<option value="">选择分支/标签</option>';
|
|
|
|
if (branches.length > 0) {
|
|
const group = document.createElement('optgroup');
|
|
group.label = '分支';
|
|
branches.forEach(ref => {
|
|
const opt = document.createElement('option');
|
|
opt.value = ref.name;
|
|
opt.textContent = ref.name + (ref.is_head ? ' (HEAD)' : '');
|
|
group.appendChild(opt);
|
|
});
|
|
select.appendChild(group);
|
|
}
|
|
|
|
if (tags.length > 0) {
|
|
const group = document.createElement('optgroup');
|
|
group.label = '标签';
|
|
tags.forEach(ref => {
|
|
const opt = document.createElement('option');
|
|
opt.value = ref.name;
|
|
opt.textContent = ref.name;
|
|
group.appendChild(opt);
|
|
});
|
|
select.appendChild(group);
|
|
}
|
|
|
|
if (commits.length > 0) {
|
|
const group = document.createElement('optgroup');
|
|
group.label = '最近提交';
|
|
commits.forEach(commit => {
|
|
const opt = document.createElement('option');
|
|
opt.value = commit.hash;
|
|
opt.textContent = `${commit.short_hash} ${commit.message.substring(0, 40)}`;
|
|
group.appendChild(opt);
|
|
});
|
|
select.appendChild(group);
|
|
}
|
|
});
|
|
|
|
// Auto-select from URL params, or fall back to main/master and HEAD
|
|
const params = new URLSearchParams(window.location.search);
|
|
const urlBase = params.get('base');
|
|
const urlHead = params.get('head');
|
|
const baseSelect = document.getElementById('base-ref');
|
|
const headSelect = document.getElementById('head-ref');
|
|
|
|
const mainBranch = branches.find(b => b.name === 'main' || b.name === 'master');
|
|
const headBranch = refs.find(r => r.is_head);
|
|
|
|
const hasOption = (select, val) => Array.from(select.options).some(o => o.value === val);
|
|
|
|
if (urlBase && hasOption(baseSelect, urlBase)) {
|
|
baseSelect.value = urlBase;
|
|
} else if (mainBranch) {
|
|
baseSelect.value = mainBranch.name;
|
|
}
|
|
|
|
if (urlHead && hasOption(headSelect, urlHead)) {
|
|
headSelect.value = urlHead;
|
|
} else if (headBranch) {
|
|
headSelect.value = headBranch.name;
|
|
}
|
|
} catch (err) {
|
|
console.error('Load refs error:', err);
|
|
}
|
|
}
|
|
|
|
function startReview() {
|
|
const baseRef = document.getElementById('base-ref').value;
|
|
const headRef = document.getElementById('head-ref').value;
|
|
const topN = document.getElementById('top-n').value;
|
|
|
|
if (!baseRef || !headRef) {
|
|
alert('请选择 Base 和 Head 分支');
|
|
return;
|
|
}
|
|
|
|
// Abort any previous stream
|
|
if (currentSSE) {
|
|
currentSSE.abort();
|
|
}
|
|
|
|
const btn = document.getElementById('btn-review');
|
|
const progress = document.getElementById('progress');
|
|
const results = document.getElementById('results');
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = '审查中...';
|
|
progress.classList.remove('hidden');
|
|
results.classList.add('hidden');
|
|
|
|
// Reset state
|
|
allSuggestions = [];
|
|
fileReviews = {};
|
|
currentAnalysisId = null;
|
|
document.getElementById('summary-content').innerHTML = '';
|
|
document.getElementById('file-reviews').innerHTML = '';
|
|
document.getElementById('review-actions').classList.add('hidden');
|
|
|
|
// Progress tracking
|
|
let totalFiles = 0;
|
|
let completedFiles = 0;
|
|
let currentFile = '';
|
|
|
|
currentSSE = SSE.post(`/api/repos/${repoId}/review`, {
|
|
base: baseRef,
|
|
head: headRef,
|
|
top_n: parseInt(topN) || 0,
|
|
}, {
|
|
start(data) {
|
|
totalFiles = data.total_files || 0;
|
|
document.getElementById('progress-text').textContent = `准备审查 ${totalFiles} 个文件...`;
|
|
document.getElementById('progress-bar').style.width = '5%';
|
|
},
|
|
file_start(data) {
|
|
currentFile = data.file || '';
|
|
completedFiles++;
|
|
const pct = totalFiles > 0 ? Math.round((completedFiles / totalFiles) * 80 + 10) : 50;
|
|
document.getElementById('progress-bar').style.width = pct + '%';
|
|
document.getElementById('progress-text').textContent = `正在审查: ${currentFile} (${completedFiles}/${totalFiles})`;
|
|
|
|
// Initialize file review entry
|
|
if (!fileReviews[currentFile]) {
|
|
fileReviews[currentFile] = { filename: currentFile, suggestions: [], summary: '' };
|
|
}
|
|
},
|
|
suggestion(data) {
|
|
allSuggestions.push(data);
|
|
|
|
if (data.file && fileReviews[data.file]) {
|
|
fileReviews[data.file].suggestions.push(data);
|
|
}
|
|
|
|
// Add to card view incrementally
|
|
appendSuggestionCard(data);
|
|
},
|
|
file_end(data) {
|
|
// File done
|
|
},
|
|
file_summary(data) {
|
|
if (data.file && fileReviews[data.file]) {
|
|
fileReviews[data.file].summary = data.content || '';
|
|
}
|
|
},
|
|
diff(data) {
|
|
// Store diff for inline rendering
|
|
if (data.diff) {
|
|
window._reviewDiff = data.diff;
|
|
}
|
|
},
|
|
summary(data) {
|
|
document.getElementById('summary-content').innerHTML = renderMarkdown(data.content || '');
|
|
document.getElementById('progress-bar').style.width = '90%';
|
|
document.getElementById('progress-text').textContent = '生成总结...';
|
|
},
|
|
done() {
|
|
document.getElementById('progress-bar').style.width = '100%';
|
|
document.getElementById('progress-text').textContent = '审查完成!';
|
|
|
|
setTimeout(() => {
|
|
progress.classList.add('hidden');
|
|
results.classList.remove('hidden');
|
|
document.getElementById('review-actions').classList.remove('hidden');
|
|
btn.disabled = false;
|
|
btn.textContent = '开始审查';
|
|
}, 500);
|
|
|
|
// Render inline suggestions if diff is available
|
|
if (window._reviewDiff) {
|
|
renderInlineReview(window._reviewDiff);
|
|
}
|
|
|
|
currentSSE = null;
|
|
},
|
|
analysis_saved(data) {
|
|
currentAnalysisId = data.analysis_id;
|
|
// Initialize note editor with the analysis ID
|
|
NoteEditor.init(repoId, currentAnalysisId);
|
|
NoteEditor.loadNotes();
|
|
},
|
|
error(data) {
|
|
alert('审查失败: ' + (data.message || '未知错误'));
|
|
progress.classList.add('hidden');
|
|
btn.disabled = false;
|
|
btn.textContent = '开始审查';
|
|
currentSSE = null;
|
|
},
|
|
});
|
|
}
|
|
|
|
// Append a suggestion card incrementally
|
|
function appendSuggestionCard(suggestion) {
|
|
const container = document.getElementById('file-reviews');
|
|
|
|
// Find or create file section
|
|
let fileSection = document.getElementById('file-review-' + cssId(suggestion.file));
|
|
if (!fileSection) {
|
|
fileSection = document.createElement('div');
|
|
fileSection.id = 'file-review-' + cssId(suggestion.file);
|
|
fileSection.className = 'bg-white rounded-lg shadow-md border-l-4 border-gray-200 mb-4';
|
|
fileSection.innerHTML = `
|
|
<div class="p-4 border-b border-gray-100">
|
|
<h3 class="font-medium text-gray-900 font-mono text-sm">${suggestion.file || ''}</h3>
|
|
<p class="file-summary text-sm text-gray-600 mt-1"></p>
|
|
</div>
|
|
<div class="p-4 space-y-2 suggestions-container"></div>`;
|
|
container.appendChild(fileSection);
|
|
}
|
|
|
|
// Update border color based on max severity
|
|
const severities = { critical: 0, warning: 1, info: 2 };
|
|
const currentMax = fileSection.getAttribute('data-max-severity') || 'info';
|
|
if ((severities[suggestion.severity] || 2) < (severities[currentMax] || 2)) {
|
|
fileSection.setAttribute('data-max-severity', suggestion.severity);
|
|
const colors = { critical: 'border-red-500', warning: 'border-yellow-500', info: 'border-green-500' };
|
|
fileSection.className = fileSection.className.replace(/border-\w+-500/, '') + ' ' + (colors[suggestion.severity] || colors.info);
|
|
}
|
|
|
|
// Add suggestion card
|
|
const sugContainer = fileSection.querySelector('.suggestions-container');
|
|
const card = document.createElement('div');
|
|
card.innerHTML = ReviewInline.renderSuggestionCard(suggestion);
|
|
sugContainer.appendChild(card.firstElementChild);
|
|
}
|
|
|
|
function cssId(str) {
|
|
return (str || '').replace(/[^a-zA-Z0-9]/g, '-');
|
|
}
|
|
|
|
// Render diff with inline suggestions
|
|
function renderInlineReview(diffString) {
|
|
const container = document.getElementById('review-diff-container');
|
|
if (!container) return;
|
|
|
|
DiffViewer.init('review-diff-container');
|
|
DiffViewer.renderDiff(diffString, {
|
|
onComplete() {
|
|
// Insert suggestions after all files are rendered
|
|
allSuggestions.forEach((s, i) => {
|
|
if (s.file && s.line) {
|
|
DiffViewer.insertSuggestion(s.file, s.line, s.side || 'right', s.severity, s.content, 'sug-' + i);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function toggleDiffView() {
|
|
const container = document.getElementById('review-diff-container');
|
|
const btn = document.getElementById('btn-toggle-diff');
|
|
diffVisible = !diffVisible;
|
|
container.style.display = diffVisible ? '' : 'none';
|
|
btn.textContent = diffVisible ? '隐藏 Diff' : '显示 Diff';
|
|
}
|
|
|
|
// Simple markdown rendering
|
|
function renderMarkdown(text) {
|
|
if (!text) return '';
|
|
let html = text
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
|
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
|
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/`(.+?)`/g, '<code class="bg-gray-100 px-1 rounded">$1</code>')
|
|
.replace(/^- (.+)$/gm, '<li class="bullet-item">$1</li>')
|
|
.replace(/^(\d+)\. (.+)$/gm, '<li class="numbered-item">$2</li>')
|
|
.replace(/\n/g, '\n');
|
|
// Wrap consecutive bullet <li> in <ul>
|
|
html = html.replace(/((?:<li class="bullet-item">.*<\/li>\n?)+)/g, '<ul>$1</ul>');
|
|
// Wrap consecutive numbered <li> in <ol>
|
|
html = html.replace(/((?:<li class="numbered-item">.*<\/li>\n?)+)/g, '<ol>$1</ol>');
|
|
// Convert remaining newlines to <br> (outside lists)
|
|
html = html.replace(/\n/g, '<br>');
|
|
return html;
|
|
}
|
|
|
|
loadRefs();
|
|
</script>
|
|
</body>
|
|
</html>
|