diff --git a/handlers/pages.go b/handlers/pages.go index 28bdf43..7954165 100644 --- a/handlers/pages.go +++ b/handlers/pages.go @@ -39,18 +39,6 @@ func (h *PageHandler) Index(c *gin.Context) { } func (h *PageHandler) Repo(c *gin.Context) { - data := userData(c) - data["ID"] = c.Param("id") - c.HTML(http.StatusOK, "pages/repo.html", data) -} - -func (h *PageHandler) Generate(c *gin.Context) { - data := userData(c) - data["ID"] = c.Param("id") - c.HTML(http.StatusOK, "pages/generate.html", data) -} - -func (h *PageHandler) Review(c *gin.Context) { user := GetCurrentUser(c) topN := "20" concurrency := "5" @@ -68,7 +56,7 @@ func (h *PageHandler) Review(c *gin.Context) { data["ID"] = c.Param("id") data["TopN"] = topN data["Concurrency"] = concurrency - c.HTML(http.StatusOK, "pages/review.html", data) + c.HTML(http.StatusOK, "pages/repo.html", data) } func (h *PageHandler) Settings(c *gin.Context) { diff --git a/main.go b/main.go index bfd6457..33135db 100644 --- a/main.go +++ b/main.go @@ -103,8 +103,6 @@ func main() { // Page routes r.GET("/", authMw, pages.Index) r.GET("/repo/:id", authMw, pages.Repo) - r.GET("/repo/:id/generate", authMw, pages.Generate) - r.GET("/repo/:id/review", authMw, pages.Review) r.GET("/settings", authMw, pages.Settings) // API routes diff --git a/templates/pages/generate.html b/templates/pages/generate.html deleted file mode 100644 index b485e04..0000000 --- a/templates/pages/generate.html +++ /dev/null @@ -1,255 +0,0 @@ - - - - {{template "head" .}} - - - - - - {{template "nav" .}} - -
-

生成 PR 描述

- -
-
-
- - -
-
- - -
-
- - -
- - - -
- - {{template "footer" .}} - - - - diff --git a/templates/pages/repo.html b/templates/pages/repo.html index c1dd2e8..bcab9b5 100644 --- a/templates/pages/repo.html +++ b/templates/pages/repo.html @@ -12,6 +12,7 @@ + @@ -60,14 +103,6 @@

仓库详情

-
- - 生成 PR 描述 - - - AI 代码审查 - -
@@ -131,6 +166,12 @@ + + @@ -158,13 +199,147 @@
审查总结
- 查看完整评审 +
+ + + + + + @@ -175,6 +350,7 @@ const repoId = '{{.ID}}'; let currentRefs = []; + // ── Init ──────────────────────────────────────────────────────── async function init() { try { // Load repo info @@ -184,8 +360,7 @@ 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`; + document.getElementById('print-repo-url').textContent = repo.url; } else { showError('仓库未找到 (ID: ' + repoId + ')'); return; @@ -205,7 +380,6 @@ } catch (e) { /* ignore */ } populateRefSelects(currentRefs, commits); - updateButtonLinks(); } // Init graph with click-to-select @@ -215,6 +389,9 @@ // Init diff viewer DiffViewer.init('diff-container'); + // Load review history + loadHistoryList(); + // Show main content document.getElementById('loading').classList.add('hidden'); document.getElementById('main-content').classList.remove('hidden'); @@ -231,6 +408,8 @@ document.getElementById('error-message').textContent = message; } + // ── Ref Selects ───────────────────────────────────────────────── + function populateRefSelects(refs, commits) { const selects = ['select-base', 'select-head']; const branches = refs.filter(r => !r.is_tag); @@ -309,10 +488,8 @@ 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 = ''; @@ -322,7 +499,6 @@ 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 = ''; @@ -342,8 +518,6 @@ showTab('diff'); loadDiff(); } - - updateButtonLinks(); } // Ensure a select has an option with given value @@ -370,38 +544,30 @@ if (baseHash || headHash) { GitGraph.setSelection(baseHash || null, headHash || null); } - updateButtonLinks(); }; 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}`; - } + // ── Tab Navigation ────────────────────────────────────────────── + + const TABS = ['graph', 'diff', 'generate', 'review']; + const TAB_STYLES = { + active: 'border-blue-500 text-blue-600', + inactive: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300', + }; 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'); - 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'); - document.getElementById('tab-diff').classList.toggle('border-transparent', tab !== 'diff'); - document.getElementById('tab-diff').classList.toggle('text-gray-500', tab !== 'diff'); + TABS.forEach(t => { + document.getElementById('panel-' + t).classList.toggle('hidden', t !== tab); + const tabBtn = document.getElementById('tab-' + t); + tabBtn.className = `px-6 py-3 text-sm font-medium border-b-2 ${t === tab ? TAB_STYLES.active : TAB_STYLES.inactive}`; + }); } + // ── Diff ──────────────────────────────────────────────────────── + function loadDiff() { const base = document.getElementById('select-base').value; const head = document.getElementById('select-head').value; @@ -415,7 +581,6 @@ function setDiffView(mode) { DiffViewer.setViewMode(mode); - // Update button styles const btnSide = document.getElementById('btn-side-by-side'); const btnUnified = document.getElementById('btn-unified'); @@ -428,7 +593,8 @@ } } - // ── Utilities ──────────────────────────────────────────────── + // ── Utilities ─────────────────────────────────────────────────── + function escapeHtml(text) { if (!text) return ''; return String(text) @@ -456,7 +622,11 @@ return html; } - // ── Inline AI Review ──────────────────────────────────────── + function renderMarkdown(text) { + return Markdown.render(text); + } + + // ── Inline AI Review (quick review in Diff tab) ───────────────── let inlineReviewSSE = null; let inlineSuggestions = []; @@ -509,7 +679,7 @@ if (data.score !== undefined) { el.innerHTML = renderSummaryHtml(data); } else { - el.innerHTML = renderInlineMarkdown(data.content || ''); + el.innerHTML = renderMarkdown(data.content || ''); } document.getElementById('review-progress-bar').style.width = '90%'; }, @@ -529,8 +699,7 @@ inlineReviewSSE = null; }, analysis_saved(data) { - const viewLink = document.getElementById('btn-view-full-review'); - viewLink.href = `/repo/${repoId}/review?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`; + // Update "查看完整审查" link behavior }, error(data) { showToast('审查失败: ' + (data.message || '未知错误'), 'error'); @@ -578,7 +747,6 @@ } 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()); @@ -586,8 +754,416 @@ inlineSuggestions = []; } - function renderInlineMarkdown(text) { - return Markdown.render(text); + // ── Generate PR Description ───────────────────────────────────── + + let markdownContent = ''; + let generateSSE = null; + + function generatePR() { + const base = document.getElementById('select-base').value; + const head = document.getElementById('select-head').value; + + if (!base || !head) { + showToast('请选择 Base 和 Head 分支', 'warning'); + return; + } + + // Cancel previous if running + if (generateSSE) { + generateSSE.abort(); + } + + const btn = document.getElementById('btn-generate'); + const output = document.getElementById('generate-output'); + const indicator = document.getElementById('generating-indicator'); + + btn.disabled = true; + btn.textContent = '生成中...'; + btn.classList.add('opacity-70', 'cursor-not-allowed'); + output.classList.remove('hidden'); + indicator.classList.remove('hidden'); + + // Clear previous results + document.getElementById('pr-rendered').innerHTML = ''; + document.getElementById('pr-markdown').textContent = ''; + markdownContent = ''; + + generateSSE = SSE.post(`/api/repos/${repoId}/generate`, { + base, head, + }, { + content(data) { + markdownContent += data.content || ''; + document.getElementById('pr-rendered').innerHTML = Markdown.render(markdownContent); + document.getElementById('pr-markdown').textContent = markdownContent; + }, + done() { + indicator.classList.add('hidden'); + btn.disabled = false; + btn.textContent = '生成 PR 描述'; + btn.classList.remove('opacity-70', 'cursor-not-allowed'); + generateSSE = null; + }, + error(data) { + showToast('生成失败: ' + (data.message || '未知错误'), 'error'); + indicator.classList.add('hidden'); + btn.disabled = false; + btn.textContent = '生成 PR 描述'; + btn.classList.remove('opacity-70', 'cursor-not-allowed'); + generateSSE = null; + }, + }); + } + + function copyMarkdown() { + const markdown = markdownContent || document.getElementById('pr-markdown').textContent; + if (!markdown.trim()) { + showToast('没有可复制的内容,请先生成 PR 描述', 'warning'); + return; + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(markdown).then(() => { + showToast('已复制到剪贴板', 'success'); + }).catch(() => { + fallbackCopy(markdown); + }); + } else { + fallbackCopy(markdown); + } + } + + function fallbackCopy(text) { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.cssText = 'position:fixed;left:0;top:0;opacity:0;'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + try { + const ok = document.execCommand('copy'); + showToast(ok ? '已复制到剪贴板' : '复制失败,请手动选择文本复制', ok ? 'success' : 'error'); + } catch (e) { + showToast('复制失败,请手动选择文本复制', 'error'); + } + document.body.removeChild(textarea); + } + + // ── Full AI Code Review ───────────────────────────────────────── + + let currentSSE = null; + let diffVisible = true; + let allSuggestions = []; + let fileReviews = {}; + let currentAnalysisId = null; + + function startReview() { + const base = document.getElementById('select-base').value; + const head = document.getElementById('select-head').value; + const topN = document.getElementById('top-n').value; + + if (!base || !head) { + showToast('请选择 Base 和 Head 分支', 'warning'); + return; + } + + // Abort any previous stream + if (currentSSE) { + currentSSE.abort(); + } + + const btn = document.getElementById('btn-review'); + const progress = document.getElementById('review-progress-full'); + const results = document.getElementById('review-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 startedFiles = 0; + + function updateProgress() { + const pct = totalFiles > 0 ? Math.round((completedFiles / totalFiles) * 80 + 10) : 50; + document.getElementById('review-progress-bar-full').style.width = pct + '%'; + } + + const concurrency = document.getElementById('concurrency').value; + + currentSSE = SSE.post(`/api/repos/${repoId}/review`, { + base, head, + top_n: parseInt(topN) || 0, + concurrency: parseInt(concurrency) || 5, + }, { + start(data) { + totalFiles = data.total_files || 0; + document.getElementById('review-progress-text-full').textContent = `准备审查 ${totalFiles} 个文件...`; + document.getElementById('review-progress-bar-full').style.width = '5%'; + }, + file_start(data) { + startedFiles++; + const currentFile = data.file || ''; + document.getElementById('review-progress-text-full').textContent = `正在审查: ${currentFile} (${startedFiles}/${totalFiles})`; + + 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); + } + + appendSuggestionCard(data); + }, + file_end(data) { + completedFiles++; + updateProgress(); + document.getElementById('review-progress-text-full').textContent = `已完成 ${completedFiles}/${totalFiles} 个文件`; + }, + file_summary(data) { + if (data.file && fileReviews[data.file]) { + fileReviews[data.file].summary = data.content || ''; + } + }, + diff(data) { + 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('review-progress-bar-full').style.width = '90%'; + document.getElementById('review-progress-text-full').textContent = '生成总结...'; + }, + done() { + document.getElementById('review-progress-bar-full').style.width = '100%'; + document.getElementById('review-progress-text-full').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; + NoteEditor.init(repoId, currentAnalysisId); + NoteEditor.loadNotes(); + // Refresh history list + loadHistoryList(); + }, + error(data) { + showToast('审查失败: ' + (data.message || '未知错误'), 'error'); + progress.classList.add('hidden'); + btn.disabled = false; + btn.textContent = '开始审查'; + currentSSE = null; + }, + }); + } + + function appendSuggestionCard(suggestion) { + const container = document.getElementById('file-reviews'); + + 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 = ` +
+

${suggestion.file || ''}

+

+
+
`; + 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); + } + + 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, '-'); + } + + function renderInlineReview(diffString) { + const container = document.getElementById('review-diff-container'); + if (!container) return; + + DiffViewer.init('review-diff-container'); + DiffViewer.renderDiff(diffString, { + onComplete() { + const fileSeverities = {}; + const severityOrder = { critical: 3, warning: 2, info: 1 }; + + allSuggestions.forEach((s, i) => { + if (s.file) { + DiffViewer.insertFileSuggestion(s.file, s.severity, s.content, '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); + }); + } + }); + } + + 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'; + } + + function updatePrintHeader() { + const baseEl = document.getElementById('select-base'); + const headEl = document.getElementById('select-head'); + 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 ───────────────────────────────────────────────────── + + 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 = ''; + + 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 = ''; + } + } catch (e) { + console.warn('Failed to load history:', e); + } + } + + async function loadHistoryReview(analysisId) { + if (!analysisId) return; + + try { + 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; + } + + 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('review-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; + + 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'); + } } init(); diff --git a/templates/pages/review.html b/templates/pages/review.html deleted file mode 100644 index 8a8eafb..0000000 --- a/templates/pages/review.html +++ /dev/null @@ -1,665 +0,0 @@ - - - - {{template "head" .}} - - - - - - - - - - - - - {{template "nav" .}} - -
-

AI 代码审查

- -
-
-
- - -
-
- - -
-
- - -

设为 0 分析全部文件

-
-
- - -

同时分析的文件数 (1-20)

-
-
- - - - -
-
- - -
-
-
- - - - - - -
- - {{template "footer" .}} - - - -