feat: Phase 5 — 审查备注编辑器与 PDF 导出
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user