Files
PR-Helper/templates/pages/repo.html
T

563 lines
25 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="zh-CN">
<head>
{{template "head" .}}
<link rel="stylesheet" href="/static/lib/diff2html.min.css">
<script src="/static/lib/d3.min.js"></script>
<script src="/static/lib/diff2html.min.js"></script>
<script src="/static/lib/highlight.min.js"></script>
2026-06-18 23:37:19 +08:00
<script src="/static/js/sse.js"></script>
<script src="/static/js/graph.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>
<style>
.graph-container {
background: #f8fafc;
border-radius: 8px;
min-height: 300px;
overflow: auto;
}
.graph-container svg {
display: block;
}
2026-06-18 23:37:19 +08:00
.selection-hint {
font-size: 12px;
color: #6b7280;
padding: 6px 12px;
background: #f3f4f6;
border-radius: 6px;
display: inline-block;
}
.selection-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
}
.badge-base {
background: #d1fae5;
color: #065f46;
border: 1px solid #a7f3d0;
}
.badge-head {
background: #fef3c7;
color: #92400e;
border: 1px solid #fde68a;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
{{template "nav" .}}
<main class="max-w-7xl mx-auto px-4 py-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">仓库详情</h1>
<p id="repo-url" class="text-gray-500 text-sm mt-1"></p>
</div>
<div class="flex gap-2">
<a id="btn-generate" href="#" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
生成 PR 描述
</a>
<a id="btn-review" href="#" class="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700">
AI 代码审查
</a>
</div>
</div>
<!-- Loading State -->
<div id="loading" class="bg-white rounded-lg shadow-md p-8 text-center">
<div class="spinner inline-block"></div>
<p class="mt-2 text-gray-500">加载仓库数据...</p>
</div>
<!-- Error State -->
<div id="error" class="bg-red-50 border border-red-200 rounded-lg p-4 hidden">
<p class="text-red-600" id="error-message"></p>
</div>
<!-- Main Content -->
<div id="main-content" class="hidden">
2026-06-18 23:37:19 +08:00
<!-- Shared Selection Bar -->
<div class="bg-white rounded-lg shadow-md p-4 mb-4">
<div class="flex items-center gap-4 flex-wrap">
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-600">Base:</label>
<select id="select-base" class="border rounded px-3 py-1.5 text-sm min-w-[160px]">
<option value="">选择分支/标签</option>
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-600">Head:</label>
<select id="select-head" class="border rounded px-3 py-1.5 text-sm min-w-[160px]">
<option value="">选择分支/标签</option>
</select>
</div>
<button onclick="loadDiff()" class="px-4 py-1.5 bg-blue-600 text-white rounded text-sm hover:bg-blue-700">
查看 Diff
</button>
<div class="flex gap-1 ml-auto items-center">
2026-06-18 23:37:19 +08:00
<button onclick="setDiffView('side-by-side')" id="btn-side-by-side" class="px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded font-medium">并排</button>
<button onclick="setDiffView('unified')" id="btn-unified" class="px-3 py-1 text-xs bg-gray-100 text-gray-600 rounded hover:bg-gray-200">统一</button>
<button onclick="startInlineReview()" id="btn-inline-review" class="px-3 py-1 text-xs bg-purple-100 text-purple-700 rounded font-medium hover:bg-purple-200 ml-2">
🤖 AI 评审
</button>
2026-06-18 23:37:19 +08:00
</div>
</div>
<div class="mt-2 flex items-center gap-3">
<span class="selection-hint" id="selection-hint">
💡 在 Git Graph 中点击节点选择 Base(绿)和 Head(橙)
</span>
<span id="selection-display" class="hidden">
<span id="sel-base-badge" class="selection-badge badge-base"></span>
<span class="text-gray-400 mx-1">→</span>
<span id="sel-head-badge" class="selection-badge badge-head"></span>
</span>
</div>
</div>
<!-- Tabs -->
<div class="bg-white rounded-lg shadow-md">
<div class="border-b border-gray-200">
<nav class="flex -mb-px">
<button onclick="showTab('graph')" id="tab-graph" class="px-6 py-3 text-sm font-medium border-b-2 border-blue-500 text-blue-600">
Git Graph
</button>
<button onclick="showTab('diff')" id="tab-diff" class="px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
Diff 视图
</button>
</nav>
</div>
<!-- Graph Tab -->
<div id="panel-graph" class="p-4">
<div id="git-graph" class="graph-container p-4"></div>
</div>
<!-- Diff Tab -->
<div id="panel-diff" class="p-4 hidden">
<div id="diff-container"></div>
<!-- Inline review progress -->
<div id="review-progress" class="hidden mt-4 bg-white rounded-lg border p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">🤖 AI 代码审查</span>
<button onclick="cancelInlineReview()" class="text-xs text-gray-400 hover:text-gray-600">取消</button>
</div>
<div class="w-full bg-gray-200 rounded-full h-2 mb-2">
<div id="review-progress-bar" class="bg-purple-600 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<p id="review-progress-text" class="text-xs text-gray-500">准备中...</p>
</div>
<!-- Review summary (shown after completion) -->
<div id="review-summary" class="hidden mt-4 bg-white rounded-lg border p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">审查总结</span>
<div class="flex gap-2">
<a id="btn-view-full-review" href="#" class="text-xs text-purple-600 hover:underline">查看完整评审</a>
<button onclick="clearInlineReview()" class="text-xs text-gray-400 hover:text-gray-600">清除</button>
</div>
</div>
<div id="review-summary-content" class="text-sm text-gray-700"></div>
</div>
</div>
</div>
</div>
</main>
{{template "footer" .}}
<script>
const repoId = '{{.ID}}';
let currentRefs = [];
async function init() {
try {
2026-06-18 23:37:19 +08:00
// Load repo info
const reposResp = await fetch('/api/repos');
if (reposResp.ok) {
const repos = await reposResp.json();
const repo = repos.find(r => r.id == repoId || r.id === parseInt(repoId));
if (repo) {
document.getElementById('repo-url').textContent = repo.url;
document.getElementById('btn-generate').href = `/repo/${repoId}/generate`;
document.getElementById('btn-review').href = `/repo/${repoId}/review`;
} else {
showError('仓库未找到 (ID: ' + repoId + ')');
return;
}
}
// Load refs
const refsResp = await fetch(`/api/repos/${repoId}/refs`);
if (refsResp.ok) {
currentRefs = await refsResp.json();
// Fetch recent commits independently
let commits = [];
try {
const graphResp = await fetch(`/api/repos/${repoId}/graph?max_commits=20`);
if (graphResp.ok) { const gd = await graphResp.json(); commits = gd.commits || []; }
} catch (e) { /* ignore */ }
populateRefSelects(currentRefs, commits);
updateButtonLinks();
}
2026-06-18 23:37:19 +08:00
// Init graph with click-to-select
GitGraph.init('git-graph', repoId);
GitGraph.onSelectionChange = onGraphSelectionChange;
// Init diff viewer
DiffViewer.init('diff-container');
// Show main content
document.getElementById('loading').classList.add('hidden');
document.getElementById('main-content').classList.remove('hidden');
} catch (err) {
console.error('Init error:', err);
showError('加载失败: ' + err.message);
}
}
function showError(message) {
document.getElementById('loading').classList.add('hidden');
document.getElementById('error').classList.remove('hidden');
document.getElementById('error-message').textContent = message;
}
function populateRefSelects(refs, commits) {
2026-06-18 23:37:19 +08:00
const selects = ['select-base', 'select-head'];
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;
2026-06-18 23:37:19 +08:00
opt.dataset.hash = ref.hash;
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;
2026-06-18 23:37:19 +08:00
opt.dataset.hash = ref.hash;
opt.textContent = ref.name;
group.appendChild(opt);
});
select.appendChild(group);
}
2026-06-18 23:37:19 +08:00
// Add "Recent Commits" group
if (commits && commits.length > 0) {
2026-06-18 23:37:19 +08:00
const group = document.createElement('optgroup');
group.label = '最近提交';
commits.forEach(commit => {
2026-06-18 23:37:19 +08:00
const opt = document.createElement('option');
opt.value = commit.hash;
opt.dataset.hash = commit.hash;
opt.textContent = `${commit.short_hash} ${commit.message.substring(0, 40)}`;
group.appendChild(opt);
});
select.appendChild(group);
}
});
// Auto-select main/master as base, HEAD as head
const mainBranch = branches.find(b => b.name === 'main' || b.name === 'master');
const headBranch = refs.find(r => r.is_head);
2026-06-18 23:37:19 +08:00
if (mainBranch) {
document.getElementById('select-base').value = mainBranch.name;
}
if (headBranch) {
document.getElementById('select-head').value = headBranch.name;
}
// Sync dropdowns to graph
syncDropdownsToGraph();
}
// Called when user clicks a commit node in the graph
function onGraphSelectionChange(baseHash, headHash) {
const baseSelect = document.getElementById('select-base');
const headSelect = document.getElementById('select-head');
const selectionDisplay = document.getElementById('selection-display');
const selectionHint = document.getElementById('selection-hint');
// Try to match hash to a ref name
const baseRef = currentRefs.find(r => r.hash === baseHash);
const headRef = currentRefs.find(r => r.hash === headHash);
if (baseHash) {
const val = baseRef ? baseRef.name : baseHash;
// Check if value exists as an option, add if not
ensureOption(baseSelect, val, baseRef ? baseRef.name : GitGraph.getRefName(baseHash));
baseSelect.value = val;
document.getElementById('sel-base-badge').textContent = baseRef ? baseRef.name : GitGraph.getRefName(baseHash);
} else {
baseSelect.value = '';
}
if (headHash) {
const val = headRef ? headRef.name : headHash;
ensureOption(headSelect, val, headRef ? headRef.name : GitGraph.getRefName(headHash));
headSelect.value = val;
document.getElementById('sel-head-badge').textContent = headRef ? headRef.name : GitGraph.getRefName(headHash);
} else {
headSelect.value = '';
}
// Update display
if (baseHash || headHash) {
selectionDisplay.classList.remove('hidden');
selectionHint.classList.add('hidden');
} else {
selectionDisplay.classList.add('hidden');
selectionHint.classList.remove('hidden');
}
// If both selected, auto-load diff
if (baseHash && headHash) {
showTab('diff');
loadDiff();
}
updateButtonLinks();
2026-06-18 23:37:19 +08:00
}
// Ensure a select has an option with given value
function ensureOption(select, value, label) {
for (const opt of select.options) {
if (opt.value === value) return;
}
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label || value;
select.appendChild(opt);
}
// Sync dropdown selections to graph highlights
function syncDropdownsToGraph() {
const baseSelect = document.getElementById('select-base');
const headSelect = document.getElementById('select-head');
const sync = () => {
const baseOpt = baseSelect.options[baseSelect.selectedIndex];
const headOpt = headSelect.options[headSelect.selectedIndex];
const baseHash = baseOpt ? (baseOpt.dataset.hash || '') : '';
const headHash = headOpt ? (headOpt.dataset.hash || '') : '';
if (baseHash || headHash) {
GitGraph.setSelection(baseHash || null, headHash || null);
}
updateButtonLinks();
2026-06-18 23:37:19 +08:00
};
baseSelect.addEventListener('change', sync);
headSelect.addEventListener('change', sync);
}
function updateButtonLinks() {
const base = document.getElementById('select-base').value;
const head = document.getElementById('select-head').value;
const params = new URLSearchParams();
if (base) params.set('base', base);
if (head) params.set('head', head);
const qs = params.toString();
const suffix = qs ? '?' + qs : '';
document.getElementById('btn-generate').href = `/repo/${repoId}/generate${suffix}`;
document.getElementById('btn-review').href = `/repo/${repoId}/review${suffix}`;
}
function showTab(tab) {
document.getElementById('panel-graph').classList.toggle('hidden', tab !== 'graph');
document.getElementById('panel-diff').classList.toggle('hidden', tab !== 'diff');
document.getElementById('tab-graph').classList.toggle('border-blue-500', tab === 'graph');
document.getElementById('tab-graph').classList.toggle('text-blue-600', tab === 'graph');
2026-06-18 23:37:19 +08:00
document.getElementById('tab-graph').classList.toggle('border-transparent', tab !== 'graph');
document.getElementById('tab-graph').classList.toggle('text-gray-500', tab !== 'graph');
document.getElementById('tab-diff').classList.toggle('border-blue-500', tab === 'diff');
document.getElementById('tab-diff').classList.toggle('text-blue-600', tab === 'diff');
2026-06-18 23:37:19 +08:00
document.getElementById('tab-diff').classList.toggle('border-transparent', tab !== 'diff');
document.getElementById('tab-diff').classList.toggle('text-gray-500', tab !== 'diff');
}
function loadDiff() {
2026-06-18 23:37:19 +08:00
const base = document.getElementById('select-base').value;
const head = document.getElementById('select-head').value;
if (!base || !head) {
alert('请选择 Base 和 Head 分支');
return;
}
DiffViewer.loadDiff(repoId, base, head);
}
function setDiffView(mode) {
DiffViewer.setViewMode(mode);
2026-06-18 23:37:19 +08:00
// Update button styles
const btnSide = document.getElementById('btn-side-by-side');
const btnUnified = document.getElementById('btn-unified');
if (mode === 'side-by-side') {
btnSide.className = 'px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded font-medium';
btnUnified.className = 'px-3 py-1 text-xs bg-gray-100 text-gray-600 rounded hover:bg-gray-200';
} else {
btnUnified.className = 'px-3 py-1 text-xs bg-blue-100 text-blue-700 rounded font-medium';
btnSide.className = 'px-3 py-1 text-xs bg-gray-100 text-gray-600 rounded hover:bg-gray-200';
}
}
// ── Inline AI Review ────────────────────────────────────────
let inlineReviewSSE = null;
let inlineSuggestions = [];
function startInlineReview() {
const base = document.getElementById('select-base').value;
const head = document.getElementById('select-head').value;
if (!base || !head) {
alert('请先选择 Base 和 Head 并查看 Diff');
return;
}
// Cancel previous if running
if (inlineReviewSSE) {
inlineReviewSSE.abort();
}
const btn = document.getElementById('btn-inline-review');
const progress = document.getElementById('review-progress');
const summary = document.getElementById('review-summary');
btn.disabled = true;
btn.textContent = '⏳ 审查中...';
progress.classList.remove('hidden');
summary.classList.add('hidden');
inlineSuggestions = [];
let totalFiles = 0;
let completedFiles = 0;
inlineReviewSSE = SSE.post(`/api/repos/${repoId}/review`, {
base, head, top_n: 0,
}, {
start(data) {
totalFiles = data.total_files || 0;
document.getElementById('review-progress-text').textContent = `准备审查 ${totalFiles} 个文件...`;
document.getElementById('review-progress-bar').style.width = '5%';
},
file_start(data) {
completedFiles++;
const pct = totalFiles > 0 ? Math.round((completedFiles / totalFiles) * 80 + 10) : 50;
document.getElementById('review-progress-bar').style.width = pct + '%';
document.getElementById('review-progress-text').textContent = `正在审查: ${data.file || ''} (${completedFiles}/${totalFiles})`;
},
suggestion(data) {
inlineSuggestions.push(data);
},
summary(data) {
document.getElementById('review-summary-content').innerHTML = renderInlineMarkdown(data.content || '');
document.getElementById('review-progress-bar').style.width = '90%';
},
done() {
document.getElementById('review-progress-bar').style.width = '100%';
document.getElementById('review-progress-text').textContent = '审查完成!';
setTimeout(() => {
progress.classList.add('hidden');
summary.classList.remove('hidden');
btn.disabled = false;
btn.textContent = '🤖 AI 评审';
}, 500);
// Insert suggestions into the existing diff
insertInlineSuggestions();
inlineReviewSSE = null;
},
analysis_saved(data) {
const viewLink = document.getElementById('btn-view-full-review');
viewLink.href = `/repo/${repoId}/review?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`;
},
error(data) {
alert('审查失败: ' + (data.message || '未知错误'));
progress.classList.add('hidden');
btn.disabled = false;
btn.textContent = '🤖 AI 评审';
inlineReviewSSE = null;
},
});
}
function cancelInlineReview() {
if (inlineReviewSSE) {
inlineReviewSSE.abort();
inlineReviewSSE = null;
}
document.getElementById('review-progress').classList.add('hidden');
const btn = document.getElementById('btn-inline-review');
btn.disabled = false;
btn.textContent = '🤖 AI 评审';
}
function insertInlineSuggestions() {
const fileSeverities = {};
const severityOrder = { critical: 3, warning: 2, info: 1 };
inlineSuggestions.forEach((s, i) => {
if (s.file) {
DiffViewer.insertFileSuggestion(s.file, s.severity, s.content, 'inline-sug-' + i);
const cur = fileSeverities[s.file] || 'info';
if ((severityOrder[s.severity] || 0) > (severityOrder[cur] || 0)) {
fileSeverities[s.file] = s.severity;
}
}
});
Object.entries(fileSeverities).forEach(([file, sev]) => {
DiffViewer.markFileSeverity(file, sev);
});
// Re-initialize note editor for any new placeholders
if (typeof NoteEditor !== 'undefined') {
NoteEditor._observeNewPlaceholders && NoteEditor._observeNewPlaceholders();
}
}
function clearInlineReview() {
// Remove all suggestion elements from the diff
const container = document.getElementById('diff-container');
container.querySelectorAll('.file-suggestions-container').forEach(el => el.remove());
container.querySelectorAll('.file-tree-severity').forEach(el => el.remove());
document.getElementById('review-summary').classList.add('hidden');
inlineSuggestions = [];
}
function renderInlineMarkdown(text) {
return Markdown.render(text);
}
init();
</script>
</body>
</html>