297 lines
10 KiB
JavaScript
297 lines
10 KiB
JavaScript
// 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;
|