Files
WhatIDo/public/js/audit.js
T
dev 54cf5bbfa4 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 обновлены
2026-09-27 11:29:17 +03:00

302 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const ACTION_LABELS = {
'auth.login': 'Вход в систему',
'settings.update': 'Изменены настройки',
'user.create': 'Создан пользователь',
'user.update': 'Изменён пользователь',
'user.delete': 'Удалён пользователь',
'ip.ban': 'Заблокирован IP-адрес',
'ip.unban': 'Разблокирован IP-адрес',
'group.create': 'Создана группа',
'group.update': 'Изменена группа',
'group.delete': 'Удалена группа',
'group.soft-delete': 'Группа в корзину',
'group.restore': 'Группа восстановлена',
'group.permanent-delete': 'Группа удалена безвозвратно',
'group.schedule-delete': 'Группа помечена на удаление',
'group.unschedule': 'Удаление группы отменено',
'group.photo.create': 'Загружена фото группы',
'group.photo.update': 'Изменено фото группы',
'group.photo.reorder': 'Изменён порядок фото группы',
'group.photo.set_cover': 'Обновлена обложка группы',
'group.photo.delete': 'Удалено фото группы',
'branch.create': 'Создан филиал',
'branch.update': 'Изменён филиал',
'branch.delete': 'Удалён филиал',
'student.create': 'Добавлен ученик',
'student.update': 'Изменён ученик',
'student.delete': 'Удалён ученик',
'student.batch-group': 'Ученики перенесены в группу',
'module.create': 'Создан модуль',
'module.update': 'Изменён модуль',
'module.delete': 'Модуль отключён',
'module.restore': 'Модуль включён',
'entry.update': 'Изменена запись',
'entry.photo.update': 'Изменено фото записи',
'entry.photo.set_main': 'Главное фото записи',
'entry.photo.delete': 'Удалено фото записи',
'entry.photo.enhance': 'Фото улучшено',
'entry.photo.enhance-ai.queue': 'ИИ-обработка фото поставлена в очередь',
'entry.photo.apply': 'Применён результат обработки фото',
'entry.photo.reject': 'Отклонён результат обработки фото',
'entry.photo.rollback': 'Фото отката к версии',
'entry.photo.restore_original': 'Возвращён оригинал фото',
'photo.job.preview': 'Создан предпросмотр обработки фото',
'photo.job.retry': 'Повтор обработки фото',
'photo.job.error': 'Ошибка обработки фото',
'photo-jobs.wake': 'Фото-воркер разбужен',
'photo-jobs.enabled': 'Переключена обработка фото',
'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь',
'entry.soft-delete': 'Запись в корзину',
'entry.restore': 'Запись восстановлена',
'entry.permanent-delete': 'Запись удалена безвозвратно',
'entry.schedule-delete': 'Запись помечена на удаление',
'entry.unschedule': 'Удаление записи отменено',
'trash.clear': 'Корзина помечена на удаление',
'link.create': 'Создана ссылка',
'link.update': 'Изменена ссылка',
'link.delete': 'Удалена ссылка',
'backup.download': 'Скачан бэкап',
'backup.restore': 'Выполнено восстановление',
'entry.ai.auto-check': 'ИИ-автопроверка записи',
'entry.ai.retry': 'Повтор ИИ-проверки записи',
'entry.ai.recheck': 'Запись отправлена на ИИ-проверку',
'entry.ai.revert': 'Возвращён оригинал текста записи',
'entry.ai.error': 'Ошибка ИИ-проверки записи',
'ai.wake': 'Воркер ИИ разбужен вручную',
'ai.enabled': 'Переключена автопроверка ИИ',
'ai.requeue-failed': 'Ошибочные записи возвращены в очередь ИИ',
'ai.profile.create': 'Создан профиль ИИ',
'ai.profile.update': 'Изменён профиль ИИ',
'ai.profile.delete': 'Удалён профиль ИИ',
'ai.profile.activate': 'Выбран активный профиль ИИ',
'export.student': 'Экспорт данных ученика',
};
const AUDIT_PAGE_SIZE = 50;
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') {
return esc(JSON.stringify(t).slice(0, 120));
}
try {
const o = JSON.parse(t);
return esc(JSON.stringify(o).slice(0, 120));
} catch { return esc(String(t).slice(0, 120)); }
}
function fullTarget(t) {
if (!t) return '';
if (typeof t === 'object') return JSON.stringify(t, null, 2);
try {
return JSON.stringify(JSON.parse(t), null, 2);
} 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 = [];
const maxVisible = 5;
let start = Math.max(1, page - Math.floor(maxVisible / 2));
let end = Math.min(totalPages, start + maxVisible - 1);
if (end - start + 1 < maxVisible) start = Math.max(1, end - maxVisible + 1);
for (let i = start; i <= end; i++) pages.push(i);
let html = '';
if (page > 1) html += `<button data-page="${page - 1}">&laquo;</button>`;
if (start > 1) { html += `<button data-page="1">1</button>`; if (start > 2) html += `<span class="pager-ellipsis">…</span>`; }
for (const p of pages) html += `<button data-page="${p}"${p === page ? ' class="active"' : ''}>${p}</button>`;
if (end < totalPages) { if (end < totalPages - 1) html += `<span class="pager-ellipsis">…</span>`; html += `<button data-page="${totalPages}">${totalPages}</button>`; }
if (page < totalPages) html += `<button data-page="${page + 1}">&raquo;</button>`;
container.innerHTML = html;
container.querySelectorAll('button').forEach(b => b.addEventListener('click', () => onPageChange(parseInt(b.dataset.page, 10))));
}
async function loadAudit() {
const el = document.getElementById('auditTable').querySelector('tbody');
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="5" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
const rows = await res.json();
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} ${targetSummary(r.target)}`.toLowerCase().includes(q)) : rows;
if (!filtered.length) {
document.getElementById('auditEmpty').style.display = 'block';
el.innerHTML = '';
} else {
document.getElementById('auditEmpty').style.display = 'none';
auditRows = filtered;
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}">${targetSummary(r.target)}</button></td>
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
</tr>`).join('');
}
renderPager(document.getElementById('auditPager'), auditPage, auditTotal, goAuditPage);
}
function goAuditPage(p) {
auditPage = p;
loadAudit();
}
function fillActionFilter() {
const sel = document.getElementById('auditActionFilter');
const current = sel.value;
const opts = Object.entries(ACTION_LABELS)
.sort((a, b) => a[1].localeCompare(b[1], 'ru'))
.map(([k, v]) => `<option value="${esc(k)}">${esc(v)}</option>`)
.join('');
sel.innerHTML = '<option value="">Все действия</option>' + opts;
sel.value = current;
}
document.getElementById('auditActionFilter').addEventListener('change', () => { auditPage = 1; loadAudit(); });
document.getElementById('auditFilter').addEventListener('input', () => { auditPage = 1; loadAudit(); });
document.getElementById('auditRefreshBtn').addEventListener('click', loadAudit);
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;
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');
});
document.getElementById('auditDetailsModal').addEventListener('click', e => {
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
});
(async () => {
if (await requireAdminPage()) {
buildSidebar(document.body.dataset.page);
fillActionFilter();
loadAudit();
}
})();