Files
PR-Helper/templates/pages/generate.html
T
wonder cd1141d099
Deploy PR-Helper / deploy (push) Successful in 28s
refactor: 流式输出直接写入两栏布局,移除独立流式区域
- 左栏实时渲染 Markdown,右栏实时显示源码
- 每个 SSE 事件触发 updateColumns() 同时更新两栏
- 加载状态精简为标题旁的小 spinner + 状态文字
- 移除独立的 #streaming 区域和原始 LLM 文本展示
2026-06-21 15:53:28 +08:00

298 lines
13 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
{{template "head" .}}
<script src="/static/js/sse.js"></script>
<script src="/static/js/markdown.js"></script>
</head>
<body class="bg-gray-50 min-h-screen">
{{template "nav" .}}
<main class="max-w-4xl mx-auto px-4 py-8">
<h1 class="text-2xl font-bold text-gray-900 mb-6">生成 PR 描述</h1>
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="grid grid-cols-2 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>
<button onclick="generate()" id="btn-generate" class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700">
生成 PR 描述
</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>
</div>
<!-- Output: two-column layout, updates in real-time during streaming -->
<div id="output" class="bg-white rounded-lg shadow-md p-6 hidden">
<div class="flex justify-between items-center mb-4">
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold">PR 描述</h2>
<div id="generating-indicator" class="hidden flex items-center gap-2">
<div class="spinner inline-block"></div>
<span id="streaming-status" class="text-xs text-gray-400"></span>
</div>
</div>
<button onclick="copyMarkdown()" class="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">
复制 Markdown
</button>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-xs text-gray-400 mb-1">渲染效果</div>
<div id="pr-rendered" class="text-gray-700 prose text-sm p-4 border rounded-md bg-white min-h-[200px]"></div>
</div>
<div>
<div class="text-xs text-gray-400 mb-1">Markdown 源码</div>
<pre id="pr-markdown" class="whitespace-pre-wrap text-sm text-gray-800 font-mono p-4 border rounded-md bg-gray-50 min-h-[200px] overflow-auto"></pre>
</div>
</div>
</div>
</main>
{{template "footer" .}}
<script>
const repoId = '{{.ID}}';
let markdownContent = '';
// 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);
// Check if value exists in select options
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 generate() {
const baseRef = document.getElementById('base-ref').value;
const headRef = document.getElementById('head-ref').value;
if (!baseRef || !headRef) {
showToast('请选择 Base 和 Head 分支', 'warning');
return;
}
const btn = document.getElementById('btn-generate');
const output = document.getElementById('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 = '';
// Helper: update both columns from current fields
const fields = { title: '', type: '', summary: '', details: '', impact: '' };
function updateColumns() {
markdownContent = buildMarkdown(fields);
document.getElementById('pr-rendered').innerHTML = renderMarkdown(markdownContent);
document.getElementById('pr-markdown').textContent = markdownContent;
}
SSE.post(`/api/repos/${repoId}/generate`, {
base: baseRef,
head: headRef,
}, {
title(data) {
fields.title += data.content || '';
updateColumns();
document.getElementById('streaming-status').textContent = '解析标题...';
},
type(data) {
fields.type += data.content || '';
updateColumns();
document.getElementById('streaming-status').textContent = '解析类型...';
},
summary(data) {
fields.summary += data.content || '';
updateColumns();
document.getElementById('streaming-status').textContent = '解析摘要...';
},
detail(data) {
fields.details += data.content || '';
updateColumns();
document.getElementById('streaming-status').textContent = '解析详细说明...';
},
impact(data) {
fields.impact += data.content || '';
updateColumns();
document.getElementById('streaming-status').textContent = '解析影响范围...';
},
done() {
updateColumns();
indicator.classList.add('hidden');
btn.disabled = false;
btn.textContent = '生成 PR 描述';
btn.classList.remove('opacity-70', 'cursor-not-allowed');
},
error(data) {
showToast('生成失败: ' + (data.message || '未知错误'), 'error');
indicator.classList.add('hidden');
btn.disabled = false;
btn.textContent = '生成 PR 描述';
btn.classList.remove('opacity-70', 'cursor-not-allowed');
},
});
}
// Markdown rendering (delegates to shared Markdown.render)
function renderMarkdown(text) {
return Markdown.render(text);
}
function buildMarkdown(f) {
const parts = [];
if (f.title) parts.push(`# ${f.title}`);
if (f.type) parts.push(`**类型**: ${f.type}`);
if (f.summary) parts.push(`## 摘要\n${f.summary}`);
if (f.details) parts.push(`## 详细说明\n${f.details}`);
if (f.impact) parts.push(`## 影响\n${f.impact}`);
return parts.join('\n\n');
}
function copyMarkdown() {
const markdown = markdownContent || document.getElementById('pr-markdown').textContent;
if (!markdown.trim()) {
showToast('没有可复制的内容,请先生成 PR 描述', 'warning');
return;
}
// Clipboard API requires HTTPS; fallback to textarea for HTTP
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);
}
loadRefs();
</script>
</body>
</html>