Files
PR-Helper/templates/pages/generate.html
T
wonder 41cf19ff61
Deploy PR-Helper / deploy (push) Successful in 29s
feat: PR 生成页流式输出、左右两栏布局及按钮交互优化
- 新增 content 事件处理器,实时显示 LLM 流式原始输出
- 按钮增加 opacity-70 和 cursor-not-allowed 视觉反馈
- 详细说明区域改为左右两栏:左侧渲染效果、右侧 Markdown 源码
- 流式阶段显示带自动滚动的内容区域,完成后切换为结构化展示
- 精简布局:移除独立 Markdown 预览区域,合并到两栏中
2026-06-21 15:43:02 +08:00

347 lines
16 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 -->
<div id="output" class="bg-white rounded-lg shadow-md p-6 hidden">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">PR 描述</h2>
<button onclick="copyMarkdown()" class="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">
复制 Markdown
</button>
</div>
<!-- Streaming: show raw LLM output as it arrives -->
<div id="streaming" class="hidden">
<div class="flex items-center gap-2 mb-3">
<div class="spinner inline-block"></div>
<p class="text-gray-500 text-sm">正在生成 PR 描述...</p>
<p id="streaming-status" class="text-xs text-gray-400"></p>
</div>
<div id="streaming-content" class="p-4 bg-gray-50 rounded-md max-h-96 overflow-y-auto">
<pre id="streaming-text" class="whitespace-pre-wrap text-sm text-gray-700 font-mono"></pre>
</div>
</div>
<!-- Structured output (two-column after done) -->
<div id="output-content">
<div id="structured-fields">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-500">标题</label>
<p id="pr-title" class="text-lg font-semibold text-gray-900"></p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-500">类型</label>
<span id="pr-type" class="inline-block px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm"></span>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-500">摘要</label>
<p id="pr-summary" class="text-gray-700"></p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-500">影响</label>
<p id="pr-impact" class="text-gray-700"></p>
</div>
</div>
<!-- Two-column: rendered markdown (left) + raw markdown (right) -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-500 mb-2">详细说明</label>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-xs text-gray-400 mb-1">渲染效果</div>
<div id="pr-details" class="text-gray-700 prose text-sm p-4 border rounded-md bg-white min-h-[120px]"></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-[120px] overflow-auto"></pre>
</div>
</div>
</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 streaming = document.getElementById('streaming');
const outputContent = document.getElementById('output-content');
btn.disabled = true;
btn.textContent = '生成中...';
btn.classList.add('opacity-70', 'cursor-not-allowed');
output.classList.remove('hidden');
outputContent.classList.add('hidden');
streaming.classList.remove('hidden');
document.getElementById('streaming-text').textContent = '';
document.getElementById('streaming-status').textContent = '';
// Clear previous results
document.getElementById('pr-title').textContent = '';
document.getElementById('pr-type').textContent = '';
document.getElementById('pr-summary').textContent = '';
document.getElementById('pr-details').innerHTML = '';
document.getElementById('pr-impact').textContent = '';
document.getElementById('pr-markdown').textContent = '';
markdownContent = '';
// Accumulate streaming content
const fields = { title: '', type: '', summary: '', details: '', impact: '' };
let rawStreamText = '';
SSE.post(`/api/repos/${repoId}/generate`, {
base: baseRef,
head: headRef,
}, {
// Raw streaming content from LLM — show in real-time
content(data) {
const chunk = data.content || '';
rawStreamText += chunk;
document.getElementById('streaming-text').textContent = rawStreamText;
// Auto-scroll to bottom
const container = document.getElementById('streaming-content');
container.scrollTop = container.scrollHeight;
},
title(data) {
fields.title += data.content || '';
document.getElementById('pr-title').textContent = fields.title;
document.getElementById('streaming-status').textContent = '解析标题...';
},
type(data) {
fields.type += data.content || '';
document.getElementById('pr-type').textContent = fields.type;
document.getElementById('streaming-status').textContent = '解析类型...';
},
summary(data) {
fields.summary += data.content || '';
document.getElementById('pr-summary').textContent = fields.summary;
document.getElementById('streaming-status').textContent = '解析摘要...';
},
detail(data) {
fields.details += data.content || '';
document.getElementById('pr-details').innerHTML = renderMarkdown(fields.details);
document.getElementById('streaming-status').textContent = '解析详细说明...';
},
impact(data) {
fields.impact += data.content || '';
document.getElementById('pr-impact').textContent = fields.impact;
document.getElementById('streaming-status').textContent = '解析影响范围...';
},
markdown(data) {
markdownContent += data.content || '';
document.getElementById('pr-markdown').textContent = markdownContent;
},
done() {
// Assemble full markdown from structured fields
markdownContent = buildMarkdown(fields);
document.getElementById('pr-markdown').textContent = markdownContent;
// Switch from streaming to structured display
streaming.classList.add('hidden');
outputContent.classList.remove('hidden');
btn.disabled = false;
btn.textContent = '生成 PR 描述';
btn.classList.remove('opacity-70', 'cursor-not-allowed');
},
error(data) {
showToast('生成失败: ' + (data.message || '未知错误'), 'error');
streaming.classList.add('hidden');
output.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;
}
navigator.clipboard.writeText(markdown).then(() => {
showToast('已复制到剪贴板', 'success');
}).catch(() => {
// Fallback for non-HTTPS or permission-denied contexts
const textarea = document.createElement('textarea');
textarea.value = markdown;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
showToast('已复制到剪贴板', 'success');
} catch (e) {
showToast('复制失败,请手动选择文本复制', 'error');
}
document.body.removeChild(textarea);
});
}
loadRefs();
</script>
</body>
</html>