feat(audit): показывать изменения текста записи по шагам
При сохранении записи журнала (PUT /api/entries/:id) сравнивается состояние до и после, и в audit_log пишется не только факт правки, но и сами изменения: пословный дифф текста, статистика добавленных и удалённых слов, а также смена ФИО, группы и темы модуля. - diff.js: пословный LCS-дифф без зависимостей, обрезка больших текстов, сборка изменений по полям записи, облегчённый target для списка аудита - source правки: manual / ai / ai_manual / ai_revert; журнал шлёт edit_source, сервер доверяет явному значению и определяет источник по description_ai как запасной вариант - те же диффы пишутся для автопроверки ИИ (entry.ai.auto-check) и отката к оригиналу (entry.ai.revert) - GET /api/audit отдаёт список без diff, GET /api/audit/:id — полный target, чтобы не грузить килобайты текста на каждую строку - Аудит: колонка «Кто», сводка в таблице, модалка с подсветкой удалённого и добавленного текста, «было/стало» для полей - auth.login теперь пишет user_id, иначе колонка «Кто» показывала «система» - diff.selftest.js: 16 тестов диффа; README и AGENTS обновлены
This commit is contained in:
+113
-8
@@ -77,10 +77,30 @@ let auditPage = 1;
|
||||
let auditTotal = 0;
|
||||
let auditRows = [];
|
||||
|
||||
const EDIT_SOURCE_LABELS = {
|
||||
manual: 'Вручную',
|
||||
ai: 'ИИ',
|
||||
ai_manual: 'ИИ + правка вручную',
|
||||
ai_revert: 'Откат к оригиналу (ИИ)'
|
||||
};
|
||||
|
||||
const FIELD_LABELS = {
|
||||
student_name: 'ФИО ученика',
|
||||
group_id: 'Группа',
|
||||
module_id: 'Тема модуля',
|
||||
description: 'Текст работы'
|
||||
};
|
||||
|
||||
function fmtTime(t) {
|
||||
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
|
||||
}
|
||||
|
||||
function parseTarget(t) {
|
||||
if (t === null || t === undefined) return null;
|
||||
if (typeof t === 'object') return t;
|
||||
try { return JSON.parse(t); } catch { return null; }
|
||||
}
|
||||
|
||||
function dt(t) {
|
||||
if (!t) return '';
|
||||
if (typeof t === 'object') {
|
||||
@@ -100,6 +120,76 @@ function fullTarget(t) {
|
||||
} catch { return String(t); }
|
||||
}
|
||||
|
||||
function changeLabel(c) {
|
||||
return c.label || FIELD_LABELS[c.field] || c.field;
|
||||
}
|
||||
|
||||
function changeSummary(c) {
|
||||
if (c.field === 'description' && c.stats) return `текст: +${c.stats.added_words}/−${c.stats.removed_words} слов`;
|
||||
if (c.field === 'description') return 'текст работы';
|
||||
return changeLabel(c).toLowerCase();
|
||||
}
|
||||
|
||||
function targetSummary(t) {
|
||||
const o = parseTarget(t);
|
||||
if (!o) return dt(t);
|
||||
if (Array.isArray(o.changes)) {
|
||||
if (!o.changes.length) return esc(`без изменений${o.source ? ` · ${EDIT_SOURCE_LABELS[o.source] || o.source}` : ''}`);
|
||||
const parts = o.changes.map(changeSummary);
|
||||
if (o.photos_added) parts.push(`фото +${o.photos_added}`);
|
||||
const src = o.source ? ` · ${EDIT_SOURCE_LABELS[o.source] || o.source}` : '';
|
||||
return esc(parts.join(', ') + src);
|
||||
}
|
||||
return dt(t);
|
||||
}
|
||||
|
||||
function renderDiffSegments(segments) {
|
||||
return (segments || []).map(s => {
|
||||
const text = esc(s.text);
|
||||
if (s.type === 'add') return `<ins class="audit-diff-add">${text}</ins>`;
|
||||
if (s.type === 'del') return `<del class="audit-diff-del">${text}</del>`;
|
||||
return `<span>${text}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderChange(c) {
|
||||
const label = changeLabel(c);
|
||||
if (c.field !== 'description') {
|
||||
return `<div class="audit-change">
|
||||
<div class="audit-change-title">${esc(label)}</div>
|
||||
<div class="audit-change-line"><span class="audit-tag-del">было</span> ${esc(c.before === null || c.before === undefined ? '—' : c.before)}</div>
|
||||
<div class="audit-change-line"><span class="audit-tag-add">стало</span> ${esc(c.after === null || c.after === undefined ? '—' : c.after)}</div>
|
||||
</div>`;
|
||||
}
|
||||
const s = c.stats || {};
|
||||
const parts = [];
|
||||
if (s.added_words || s.added_chars) parts.push(`<span class="audit-stat-add">+${s.added_words || 0} слов (+${s.added_chars || 0} симв.)</span>`);
|
||||
if (s.removed_words || s.removed_chars) parts.push(`<span class="audit-stat-del">−${s.removed_words || 0} слов (−${s.removed_chars || 0} симв.)</span>`);
|
||||
if (s.chars_before !== undefined) parts.push(`<span class="audit-stat-muted">было ${s.chars_before} симв. → стало ${s.chars_after} симв.</span>`);
|
||||
return `<div class="audit-change">
|
||||
<div class="audit-change-title">${esc(label)}</div>
|
||||
<div class="audit-change-stats">${parts.join('')}</div>
|
||||
<div class="audit-diff">${renderDiffSegments(c.diff) || '<span class="audit-stat-muted">нет текстового диффа</span>'}</div>
|
||||
${c.truncated ? '<div class="audit-truncated">Показана только часть изменений</div>' : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderTargetDetails(target) {
|
||||
const o = parseTarget(target);
|
||||
if (!o) return `<pre>${esc(fullTarget(target))}</pre>`;
|
||||
if (!Array.isArray(o.changes)) return `<pre>${esc(fullTarget(target))}</pre>`;
|
||||
const head = [];
|
||||
if (o.id) head.push(`<span class="audit-pill">Запись #${esc(o.id)}</span>`);
|
||||
if (o.source) head.push(`<span class="audit-pill">Источник: ${esc(EDIT_SOURCE_LABELS[o.source] || o.source)}</span>`);
|
||||
head.push(`<span class="audit-pill">Полей затронуто: ${o.changes.length}</span>`);
|
||||
if (o.photos_added) head.push(`<span class="audit-pill">Добавлено фото: ${o.photos_added}</span>`);
|
||||
const body = o.changes.length
|
||||
? o.changes.map(renderChange).join('')
|
||||
: '<div class="audit-stat-muted">Изменений не зафиксировано.</div>';
|
||||
return `${head.join('')}<div class="audit-changes">${body}</div>
|
||||
<details class="audit-raw"><summary>Технические данные (JSON)</summary><pre>${esc(fullTarget(target))}</pre></details>`;
|
||||
}
|
||||
|
||||
function renderPager(container, page, totalPages, onPageChange) {
|
||||
if (totalPages <= 1) { container.innerHTML = ''; return; }
|
||||
const pages = [];
|
||||
@@ -120,21 +210,20 @@ function renderPager(container, page, totalPages, onPageChange) {
|
||||
|
||||
async function loadAudit() {
|
||||
const el = document.getElementById('auditTable').querySelector('tbody');
|
||||
el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Загрузка…</td></tr>';
|
||||
el.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">Загрузка…</td></tr>';
|
||||
const p = new URLSearchParams();
|
||||
p.set('limit', AUDIT_PAGE_SIZE);
|
||||
p.set('offset', (auditPage - 1) * AUDIT_PAGE_SIZE);
|
||||
const action = document.getElementById('auditActionFilter').value;
|
||||
if (action) p.set('action', action);
|
||||
const res = await fetch(`${API}/api/audit?${p}`, { headers: hdr() });
|
||||
if (!res.ok) { el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
|
||||
if (!res.ok) { el.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
|
||||
const rows = await res.json();
|
||||
// Total count for pagination (approximate: if we got less than page size, we're on last page)
|
||||
const totalPages = rows.length < AUDIT_PAGE_SIZE ? auditPage : auditPage + 1;
|
||||
auditTotal = totalPages;
|
||||
document.getElementById('auditCount').textContent = rows.length ? `Записей на странице: ${rows.length}` : '';
|
||||
const q = (document.getElementById('auditFilter').value || '').toLowerCase().trim();
|
||||
const filtered = q ? rows.filter(r => (ACTION_LABELS[r.action] || r.action).toLowerCase().includes(q)) : rows;
|
||||
const filtered = q ? rows.filter(r => `${ACTION_LABELS[r.action] || r.action} ${targetSummary(r.target)}`.toLowerCase().includes(q)) : rows;
|
||||
if (!filtered.length) {
|
||||
document.getElementById('auditEmpty').style.display = 'block';
|
||||
el.innerHTML = '';
|
||||
@@ -144,8 +233,9 @@ async function loadAudit() {
|
||||
el.innerHTML = filtered.map((r, i) => `
|
||||
<tr>
|
||||
<td style="white-space:nowrap">${fmtTime(r.created_at)}</td>
|
||||
<td style="white-space:nowrap">${esc(r.user_name || 'система')}</td>
|
||||
<td><b>${esc(ACTION_LABELS[r.action] || r.action)}</b></td>
|
||||
<td><button type="button" class="audit-details" data-audit-idx="${i}">${dt(r.target)}</button></td>
|
||||
<td><button type="button" class="audit-details" data-audit-idx="${i}">${targetSummary(r.target)}</button></td>
|
||||
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
@@ -172,14 +262,29 @@ document.getElementById('auditActionFilter').addEventListener('change', () => {
|
||||
document.getElementById('auditFilter').addEventListener('input', () => { auditPage = 1; loadAudit(); });
|
||||
document.getElementById('auditRefreshBtn').addEventListener('click', loadAudit);
|
||||
|
||||
document.getElementById('auditTable').addEventListener('click', e => {
|
||||
document.getElementById('auditTable').addEventListener('click', async e => {
|
||||
const btn = e.target.closest('.audit-details');
|
||||
if (!btn) return;
|
||||
const r = auditRows[parseInt(btn.dataset.auditIdx, 10)];
|
||||
if (!r) return;
|
||||
const modal = document.getElementById('auditDetailsModal');
|
||||
const headEl = document.getElementById('auditDetailsHead');
|
||||
const bodyEl = document.getElementById('auditDetailsBody');
|
||||
document.getElementById('auditDetailsTitle').textContent = ACTION_LABELS[r.action] || r.action;
|
||||
document.getElementById('auditDetailsBody').textContent = fullTarget(r.target);
|
||||
document.getElementById('auditDetailsModal').classList.add('open');
|
||||
headEl.innerHTML = `<span class="audit-pill">${esc(fmtTime(r.created_at))}</span><span class="audit-pill">${esc(r.user_name || 'система')}</span>${r.ip ? `<span class="audit-pill">IP ${esc(r.ip)}</span>` : ''}`;
|
||||
bodyEl.innerHTML = renderTargetDetails(r.target);
|
||||
modal.classList.add('open');
|
||||
if (!r.id) return;
|
||||
bodyEl.classList.add('audit-loading');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/audit/${r.id}`, { headers: hdr() });
|
||||
if (!res.ok) return;
|
||||
const full = await res.json();
|
||||
bodyEl.innerHTML = renderTargetDetails(full.target);
|
||||
} catch (err) {
|
||||
} finally {
|
||||
bodyEl.classList.remove('audit-loading');
|
||||
}
|
||||
});
|
||||
document.getElementById('auditDetailsCloseBtn').addEventListener('click', () => {
|
||||
document.getElementById('auditDetailsModal').classList.remove('open');
|
||||
|
||||
@@ -19,6 +19,7 @@ let searchTimer = null;
|
||||
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
|
||||
let aiCorrecting = false;
|
||||
let editDeletePhoto = false;
|
||||
let editAiSuggestion = null;
|
||||
let linkSettings = null;
|
||||
|
||||
async function loadLinkSettings() {
|
||||
@@ -395,6 +396,7 @@ async function openEdit(id) {
|
||||
if (!e) return;
|
||||
editId = id;
|
||||
editDeletePhoto = false;
|
||||
editAiSuggestion = null;
|
||||
fillAutocomplete();
|
||||
document.getElementById('editName').value = e.student_name;
|
||||
document.getElementById('editDesc').value = e.description;
|
||||
@@ -1140,6 +1142,7 @@ function acceptAiCorrect() {
|
||||
const suggestion = document.getElementById('aiSuggestion').textContent;
|
||||
if (suggestion && suggestion !== 'Обработка...' && !suggestion.startsWith('Ошибка:')) {
|
||||
document.getElementById('editDesc').value = suggestion;
|
||||
editAiSuggestion = suggestion.trim();
|
||||
}
|
||||
closeAiCorrect();
|
||||
}
|
||||
@@ -1190,6 +1193,11 @@ async function recheckAi(id) {
|
||||
refreshEntryCard(id);
|
||||
}
|
||||
|
||||
function editSourceFor(description) {
|
||||
if (!editAiSuggestion) return 'manual';
|
||||
return description === editAiSuggestion ? 'ai' : 'ai_manual';
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editId) return;
|
||||
const name = document.getElementById('editName').value.trim();
|
||||
@@ -1205,6 +1213,7 @@ async function saveEdit() {
|
||||
formData.append('group_id', group_id);
|
||||
formData.append('description', description);
|
||||
formData.append('module_id', moduleId);
|
||||
formData.append('edit_source', editSourceFor(description));
|
||||
|
||||
const photoFiles = document.getElementById('editPhoto').files;
|
||||
for (const f of photoFiles) formData.append('photo', f);
|
||||
|
||||
Reference in New Issue
Block a user