6dfd01445d
Deploy PR-Helper / deploy (push) Successful in 30s
- 引入 marked.js(~40KB)作为专业 Markdown 解析引擎 - markdown.js 精简为 20 行薄封装,保留 renderMarkdown 兼容别名 - CSS 从 .md-* 选择器改为标准 HTML 标签选择器 - 支持 GFM(表格、任务列表、删除线)、代码块语法高亮 - 天然处理流式不完整 Markdown(未闭合代码块等)
256 lines
11 KiB
HTML
256 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
{{template "head" .}}
|
|
<script src="/static/js/sse.js"></script>
|
|
<script src="/static/lib/marked.min.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 = '';
|
|
|
|
// LLM outputs markdown directly — just accumulate and render
|
|
SSE.post(`/api/repos/${repoId}/generate`, {
|
|
base: baseRef,
|
|
head: headRef,
|
|
}, {
|
|
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');
|
|
},
|
|
error(data) {
|
|
showToast('生成失败: ' + (data.message || '未知错误'), 'error');
|
|
indicator.classList.add('hidden');
|
|
btn.disabled = false;
|
|
btn.textContent = '生成 PR 描述';
|
|
btn.classList.remove('opacity-70', 'cursor-not-allowed');
|
|
},
|
|
});
|
|
}
|
|
|
|
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>
|