2026-06-19 22:10:13 +08:00
|
|
|
// 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 {
|
2026-06-20 21:57:46 +08:00
|
|
|
const resp = await fetch(`/api/repos/${this.repoId}/review/notes?analysis_id=${this.analysisId}`, { credentials: 'same-origin' });
|
2026-06-19 22:10:13 +08:00
|
|
|
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' },
|
2026-06-20 21:57:46 +08:00
|
|
|
credentials: 'same-origin',
|
2026-06-19 22:10:13 +08:00
|
|
|
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>`;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-20 22:26:46 +08:00
|
|
|
* Trigger PDF export via browser's native print dialog.
|
|
|
|
|
* Users can select "Save as PDF" in the print dialog.
|
2026-06-19 22:10:13 +08:00
|
|
|
*/
|
2026-06-20 22:26:46 +08:00
|
|
|
exportPDF() {
|
2026-06-19 22:10:13 +08:00
|
|
|
if (!this.analysisId) {
|
2026-06-20 00:05:56 +08:00
|
|
|
showToast('请先完成审查再导出 PDF', 'warning');
|
2026-06-19 22:10:13 +08:00
|
|
|
return;
|
|
|
|
|
}
|
2026-06-20 22:26:46 +08:00
|
|
|
window.print();
|
2026-06-19 22:10:13 +08:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// ── 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;
|