feat: Phase 5 — 审查备注编辑器与 PDF 导出

This commit is contained in:
2026-06-19 22:10:13 +08:00
parent 1cfab2d2d1
commit b35d6f874b
10 changed files with 914 additions and 15 deletions
+296
View File
@@ -0,0 +1,296 @@
// Note Editor: manages review notes with auto-save and three-level scoping
// Scopes: overall (page-level), file (per-file), suggestion (per-suggestion)
const NoteEditor = {
repoId: null,
analysisId: null,
_saveTimers: {}, // debounce timers by key
_notes: {}, // loaded notes keyed by "scope:scopeKey"
/**
* Initialize the note editor for a review page.
* @param {number} repoId - Repository ID
* @param {number} analysisId - Analysis ID (from the most recent review)
*/
init(repoId, analysisId) {
this.repoId = repoId;
this.analysisId = analysisId;
this._notes = {};
this._saveTimers = {};
// Bind existing note editor placeholders (from diff-viewer suggestion cards)
this._bindPlaceholders();
// Watch for new placeholders added dynamically (from SSE streaming)
this._observeNewPlaceholders();
},
/**
* Load all notes for the current analysis and populate editors.
*/
async loadNotes() {
if (!this.analysisId) return;
try {
const resp = await fetch(`/api/repos/${this.repoId}/review/notes?analysis_id=${this.analysisId}`);
if (!resp.ok) return;
const notes = await resp.json();
// Index notes
notes.forEach(n => {
const key = n.scope + ':' + (n.scope_key || '');
this._notes[key] = n;
});
// Populate all existing editors
this._populateAllEditors();
} catch (err) {
console.error('Failed to load notes:', err);
}
},
/**
* Save a note (upsert). Debounced to avoid rapid-fire requests.
*/
saveNote(scope, scopeKey, content) {
const key = scope + ':' + (scopeKey || '');
// Clear existing timer for this key
if (this._saveTimers[key]) {
clearTimeout(this._saveTimers[key]);
}
// Debounce: wait 800ms after last keystroke
this._saveTimers[key] = setTimeout(async () => {
delete this._saveTimers[key];
try {
const resp = await fetch(`/api/repos/${this.repoId}/review/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
analysis_id: this.analysisId,
scope: scope,
scope_key: scopeKey || '',
content: content,
}),
});
if (resp.ok) {
const note = await resp.json();
this._notes[key] = note;
// Update save indicator
const indicator = document.querySelector(`[data-note-key="${key}"] .note-save-indicator`);
if (indicator) {
indicator.textContent = '已保存';
indicator.classList.remove('text-gray-400');
indicator.classList.add('text-green-500');
setTimeout(() => {
indicator.textContent = '';
indicator.classList.remove('text-green-500');
}, 2000);
}
}
} catch (err) {
console.error('Failed to save note:', err);
}
}, 800);
},
/**
* Create a note editor HTML for a given scope.
* @param {string} scope - 'overall', 'file', or 'suggestion'
* @param {string} scopeKey - empty for overall, filename for file, suggestionId for suggestion
* @returns {string} HTML string
*/
createEditorHTML(scope, scopeKey) {
const key = scope + ':' + (scopeKey || '');
const existing = this._notes[key];
const content = existing ? existing.content : '';
const placeholder = this._getPlaceholder(scope);
return `
<div class="note-editor mt-2" data-note-key="${key}" data-scope="${scope}" data-scope-key="${scopeKey || ''}">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-gray-400">💬 备注 (Markdown)</span>
<span class="note-save-indicator text-xs"></span>
</div>
<textarea
class="note-textarea"
placeholder="${placeholder}"
rows="2"
oninput="NoteEditor.onInput(this, '${scope}', '${scopeKey || ''}')"
>${this._escapeHtml(content)}</textarea>
</div>`;
},
/**
* Render a standalone "overall" note editor section (for the review page).
*/
renderOverallEditor() {
if (!this.analysisId) return '';
return `
<div id="overall-note-section" class="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-3">📝 整体备注</h2>
${this.createEditorHTML('overall', '')}
</div>`;
},
/**
* Render a "PDF Export" button.
*/
renderPDFButton() {
return `
<button onclick="NoteEditor.exportPDF()" id="btn-export-pdf"
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 text-sm">
📄 导出 PDF 报告
</button>`;
},
/**
* Trigger PDF export and download.
*/
async exportPDF() {
if (!this.analysisId) {
alert('请先完成审查再导出 PDF');
return;
}
const btn = document.getElementById('btn-export-pdf');
if (btn) {
btn.disabled = true;
btn.textContent = '⏳ 生成中...';
}
try {
const resp = await fetch(`/api/repos/${this.repoId}/review/pdf`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
analysis_id: this.analysisId,
base: document.getElementById('base-ref')?.value || '',
head: document.getElementById('head-ref')?.value || '',
}),
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.error || 'PDF generation failed');
}
// Download the PDF
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pr-helper-review-${new Date().toISOString().slice(0, 10)}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
alert('PDF 导出失败: ' + err.message);
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = '📄 导出 PDF 报告';
}
}
},
// ── Internal helpers ──────────────────────────────────────────
_getPlaceholder(scope) {
switch (scope) {
case 'overall': return '添加整体备注...';
case 'file': return '添加文件级备注...';
case 'suggestion': return '添加备注...';
default: return '添加备注...';
}
},
_escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
},
onInput(textarea, scope, scopeKey) {
// Update save indicator to "saving..."
const key = scope + ':' + (scopeKey || '');
const indicator = textarea.closest('.note-editor')?.querySelector('.note-save-indicator');
if (indicator) {
indicator.textContent = '保存中...';
indicator.classList.remove('text-green-500');
indicator.classList.add('text-gray-400');
}
this.saveNote(scope, scopeKey, textarea.value);
},
/**
* Bind click/input handlers to existing note-editor-placeholder divs.
*/
_bindPlaceholders() {
document.querySelectorAll('.note-editor-placeholder').forEach(placeholder => {
if (placeholder.dataset.initialized) return;
placeholder.dataset.initialized = 'true';
const scope = placeholder.dataset.scope || 'suggestion';
const scopeKey = placeholder.dataset.scopeKey || '';
placeholder.innerHTML = this.createEditorHTML(scope, scopeKey);
});
},
/**
* Populate all note editors with loaded content.
*/
_populateAllEditors() {
document.querySelectorAll('.note-editor').forEach(editor => {
const key = editor.dataset.noteKey;
const note = this._notes[key];
if (note && note.content) {
const textarea = editor.querySelector('.note-textarea');
if (textarea && !textarea.value) {
textarea.value = note.content;
}
}
});
},
/**
* Observe DOM for new note-editor-placeholder elements (from SSE streaming).
*/
_observeNewPlaceholders() {
const observer = new MutationObserver((mutations) => {
let hasNew = false;
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.classList?.contains('note-editor-placeholder') ||
node.querySelector?.('.note-editor-placeholder')) {
hasNew = true;
break;
}
}
if (hasNew) break;
}
if (hasNew) {
this._bindPlaceholders();
this._populateAllEditors();
}
});
observer.observe(document.body, { childList: true, subtree: true });
},
/**
* Set the analysis ID (called after a review completes and analysis is saved).
*/
setAnalysisId(id) {
this.analysisId = id;
},
};
window.NoteEditor = NoteEditor;