Files
PR-Helper/templates/pages/review.html
T
wonder 55bfdf08ce
Deploy PR-Helper / deploy (push) Successful in 31s
i18n: 中文化所有错误消息和用户交互文本
- Go 后端 handler 错误消息统一中文化(middleware/repos/generate/review/settings)
- 前端 JS 加载和错误消息中文化(diff-viewer.js, graph.js)
- 模板页面错误 fallback 和静态文本中文化(index/generate/review/base)
- 消除中英文混杂,提升中文用户体验
2026-06-21 15:11:20 +08:00

659 lines
30 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/markdown.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>
<style>
@media print {
/* Hide non-content elements */
nav, footer, #review-actions, #btn-review, #btn-toggle-diff,
.note-save-indicator, select, input, #progress,
#diff-review-container, #history-select,
label[for="base-ref"], label[for="head-ref"],
label[for="top-n"], label[for="concurrency"] { display: none !important; }
/* Show results and print header even if hidden */
#results { display: block !important; }
.print-header { display: block !important; }
/* Page setup */
body { background: white !important; padding: 0 !important; font-size: 12px; }
main { max-width: 100% !important; padding: 16px !important; }
/* Cards without shadow */
.bg-white { box-shadow: none !important; border: 1px solid #e5e7eb; }
/* Avoid page breaks inside cards */
.bg-white.rounded-lg { break-inside: avoid; }
/* File review cards */
#file-reviews > div { break-inside: avoid; margin-bottom: 12px; }
/* Note editors: show content as plain text */
.note-textarea {
border: none !important;
padding: 0 !important;
resize: none !important;
height: auto !important;
overflow: visible !important;
white-space: pre-wrap;
background: #f0f9ff !important;
padding: 8px !important;
border-left: 3px solid #3b82f6 !important;
}
/* Suggestion cards: ensure colors print */
.suggestion-card { break-inside: avoid; }
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
/* Score badge */
.rounded-full { border: 1px solid currentColor; }
/* Branch info as text */
#base-ref, #head-ref, #top-n, #concurrency {
display: inline !important;
border: none !important;
padding: 0 !important;
font-weight: 600;
}
}
</style>
</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-4 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>
<label class="block text-sm font-medium text-gray-700 mb-1">并发分析数</label>
<input type="number" id="concurrency" value="{{.Concurrency}}" min="1" max="20" class="w-full border rounded-md px-3 py-2"
placeholder="默认 5">
<p class="text-xs text-gray-400 mt-1">同时分析的文件数 (1-20)</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 id="load-error" class="hidden mt-3 p-3 bg-red-50 border border-red-200 rounded-md text-red-700 text-sm"></div>
<!-- History -->
<div class="mt-4 pt-4 border-t border-gray-200">
<div class="flex items-center gap-3">
<label class="text-sm font-medium text-gray-600">📋 历史评审:</label>
<select id="history-select" class="flex-1 border rounded-md px-3 py-1.5 text-sm" onchange="loadHistoryReview(this.value)">
<option value="">加载中...</option>
</select>
</div>
</div>
</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">
<!-- Print-only header -->
<div class="print-header hidden mb-6">
<h1 style="font-size:20px;font-weight:700;color:#111827;margin-bottom:8px;">PR-Helper 审查报告</h1>
<p style="color:#6b7280;font-size:12px;">
📦 仓库: {{.RepoURL}}
&nbsp;&nbsp;🌿 分支: <span class="print-base-ref"></span> → <span class="print-head-ref"></span>
&nbsp;&nbsp;📅 <span class="print-date"></span>
</p>
<hr style="margin-top:12px;border:none;border-top:1px solid #e5e7eb;">
</div>
<!-- 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;
// ── Utilities ────────────────────────────────────────────────
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderSummaryHtml(data) {
let html = '<div class="space-y-3">';
const score = data.score || 0;
const scoreColor = score >= 7 ? 'bg-green-100 text-green-800' : score >= 4 ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800';
html += `<div class="flex items-center gap-2"><span class="text-sm font-medium text-gray-600">整体评分</span><span class="px-2 py-0.5 rounded-full text-xs font-semibold ${scoreColor}">${score}/10</span></div>`;
if (data.overall) {
html += `<div><span class="text-sm font-medium text-gray-600">总体评价</span><p class="mt-1 text-sm text-gray-800">${escapeHtml(data.overall)}</p></div>`;
}
if (data.findings) {
html += `<div><span class="text-sm font-medium text-gray-600">主要发现</span><p class="mt-1 text-sm text-gray-800">${escapeHtml(data.findings)}</p></div>`;
}
if (data.recommendations) {
html += `<div><span class="text-sm font-medium text-gray-600">改进建议</span><p class="mt-1 text-sm text-gray-800">${escapeHtml(data.recommendations)}</p></div>`;
}
html += '</div>';
return html;
}
// Load refs on page load
async function loadRefs() {
try {
const resp = await fetch(`/api/repos/${repoId}/refs`, { credentials: 'same-origin' });
if (!resp.ok) throw new Error('加载分支列表失败');
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`, { credentials: 'same-origin' });
if (graphResp.ok) {
const gd = await graphResp.json();
commits = gd.commits || [];
}
} 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);
const errDiv = document.getElementById('load-error');
errDiv.textContent = '加载分支列表失败: ' + err.message;
errDiv.classList.remove('hidden');
document.getElementById('base-ref').innerHTML = '<option value="">加载失败</option>';
document.getElementById('head-ref').innerHTML = '<option value="">加载失败</option>';
}
}
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) {
showToast('请选择 Base 和 Head 分支', 'warning');
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 = '';
const concurrency = document.getElementById('concurrency').value;
currentSSE = SSE.post(`/api/repos/${repoId}/review`, {
base: baseRef,
head: headRef,
top_n: parseInt(topN) || 0,
concurrency: parseInt(concurrency) || 5,
}, {
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) {
const el = document.getElementById('summary-content');
if (data.score !== undefined) {
el.innerHTML = renderSummaryHtml(data);
} else {
el.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 = '开始审查';
updatePrintHeader();
}, 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) {
showToast('审查失败: ' + (data.message || '未知错误'), 'error');
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() {
// Track max severity per file for sidebar markers
const fileSeverities = {};
const severityOrder = { critical: 3, warning: 2, info: 1 };
// Insert file-level suggestions after all files are rendered
allSuggestions.forEach((s, i) => {
if (s.file) {
DiffViewer.insertFileSuggestion(s.file, s.severity, s.content, 'sug-' + i);
// Track max severity per file
const cur = fileSeverities[s.file] || 'info';
if ((severityOrder[s.severity] || 0) > (severityOrder[cur] || 0)) {
fileSeverities[s.file] = s.severity;
}
}
});
// Mark severity in file tree sidebar
Object.entries(fileSeverities).forEach(([file, sev]) => {
DiffViewer.markFileSeverity(file, sev);
});
}
});
}
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';
}
// Markdown rendering (delegates to shared Markdown.render)
function renderMarkdown(text) {
return Markdown.render(text);
}
// Update print-only header with current branch info
function updatePrintHeader() {
const baseEl = document.getElementById('base-ref');
const headEl = document.getElementById('head-ref');
const baseText = baseEl?.options[baseEl.selectedIndex]?.text || baseEl?.value || '';
const headText = headEl?.options[headEl.selectedIndex]?.text || headEl?.value || '';
document.querySelectorAll('.print-base-ref').forEach(el => el.textContent = baseText);
document.querySelectorAll('.print-head-ref').forEach(el => el.textContent = headText);
document.querySelectorAll('.print-date').forEach(el => el.textContent = new Date().toLocaleString('zh-CN'));
}
// ── History loading ────────────────────────────────────────
async function loadHistoryList() {
try {
const resp = await fetch(`/api/repos/${repoId}/review/analyses`, { credentials: 'same-origin' });
if (!resp.ok) return;
const analyses = await resp.json();
const select = document.getElementById('history-select');
select.innerHTML = '<option value="">选择历史评审...</option>';
analyses.forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
const date = (a.created_at || '').substring(0, 16).replace('T', ' ');
opt.textContent = `${a.base_ref} → ${a.head_ref} (${date})`;
select.appendChild(opt);
});
if (analyses.length === 0) {
select.innerHTML = '<option value="">暂无历史评审</option>';
}
} catch (e) {
console.warn('Failed to load history:', e);
}
}
async function loadHistoryReview(analysisId) {
if (!analysisId) return;
try {
// Fetch the review result
const resp = await fetch(`/api/repos/${repoId}/review/analyses/${analysisId}`, { credentials: 'same-origin' });
if (!resp.ok) throw new Error('加载评审数据失败');
const data = await resp.json();
const result = data.result;
if (!result || !result.file_reviews) {
showToast('该历史记录没有完整的评审数据', 'warning');
return;
}
// Set current analysis ID for notes
currentAnalysisId = parseInt(analysisId);
// Populate suggestions from stored data
allSuggestions = [];
fileReviews = {};
result.file_reviews.forEach(fr => {
fileReviews[fr.file_name] = { filename: fr.file_name, suggestions: [], summary: '' };
(fr.suggestions || []).forEach(s => {
const content = s.description || '';
const fullContent = content +
(s.suggestion ? '\n\n**建议修改:** ' + s.suggestion : '') +
(s.code_example ? '\n\n```\n' + s.code_example + '\n```' : '');
const sug = { file: fr.file_name, severity: s.severity, content: fullContent };
allSuggestions.push(sug);
fileReviews[fr.file_name].suggestions.push(sug);
});
});
// Render summary
const summary = result.summary || {};
document.getElementById('summary-content').innerHTML = renderSummaryHtml(summary);
// Clear and rebuild card view
document.getElementById('file-reviews').innerHTML = '';
allSuggestions.forEach(s => appendSuggestionCard(s));
// Show results
document.getElementById('results').classList.remove('hidden');
document.getElementById('review-actions').classList.remove('hidden');
updatePrintHeader();
// Load diff and render inline
const base = data.base_ref;
const head = data.head_ref;
// Load diff from API
const diffResp = await fetch(`/api/repos/${repoId}/diff?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`, { credentials: 'same-origin' });
if (diffResp.ok) {
const diffData = await diffResp.json();
const diffString = diffData.diff || '';
if (diffString) {
renderInlineReview(diffString);
}
}
// Init note editor
NoteEditor.init(repoId, currentAnalysisId);
NoteEditor.loadNotes();
} catch (e) {
console.error('Load history error:', e);
showToast('加载历史评审失败: ' + e.message, 'error');
}
}
// Auto-load history from URL param
const urlParams = new URLSearchParams(window.location.search);
const urlAnalysisId = urlParams.get('analysis_id');
loadRefs();
loadHistoryList();
// If analysis_id in URL, auto-load after refs are ready
if (urlAnalysisId) {
setTimeout(() => loadHistoryReview(urlAnalysisId), 500);
}
</script>
</body>
</html>