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': 'Ученики перенесены в группу', 'student.profile.update': 'Изменён профиль ученика', 'student.photo.create': 'Загружено фото ученика', 'student.photo.delete': 'Удалено фото ученика', 'student.photo.main': 'Главное фото ученика', 'module.create': 'Создан модуль', 'module.update': 'Изменён модуль', 'module.delete': 'Модуль отключён', 'module.restore': 'Модуль включён', 'module.photo.create': 'Загружено фото модуля', 'module.photo.delete': 'Удалено фото модуля', '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.soft_retry': 'ИИ-сервис недоступен, ожидание', 'photo.job.error': 'Ошибка обработки фото', 'photo-jobs.wake': 'Фото-воркер разбужен', 'photo-jobs.enabled': 'Переключена обработка фото', 'photo-jobs.requeue-failed': 'Ошибочные фото-задания возвращены в очередь', 'notifications.delete': 'Удалено уведомление', 'notifications.clear': 'Очищены уведомления', 'settings.logo.upload': 'Загружен логотип системы', 'settings.logo.remove': 'Удалён логотип системы', '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 `${text}`; if (s.type === 'del') return `${text}`; return `${text}`; }).join(''); } function renderChange(c) { const label = changeLabel(c); if (c.field !== 'description') { return `
${esc(label)}
было ${esc(c.before === null || c.before === undefined ? '—' : c.before)}
стало ${esc(c.after === null || c.after === undefined ? '—' : c.after)}
`; } const s = c.stats || {}; const parts = []; if (s.added_words || s.added_chars) parts.push(`+${s.added_words || 0} слов (+${s.added_chars || 0} симв.)`); if (s.removed_words || s.removed_chars) parts.push(`−${s.removed_words || 0} слов (−${s.removed_chars || 0} симв.)`); if (s.chars_before !== undefined) parts.push(`было ${s.chars_before} симв. → стало ${s.chars_after} симв.`); return `
${esc(label)}
${parts.join('')}
${renderDiffSegments(c.diff) || 'нет текстового диффа'}
${c.truncated ? '
Показана только часть изменений
' : ''}
`; } function renderTargetDetails(target) { const o = parseTarget(target); if (!o) return `
${esc(fullTarget(target))}
`; if (!Array.isArray(o.changes)) return `
${esc(fullTarget(target))}
`; const head = []; if (o.id) head.push(`Запись #${esc(o.id)}`); if (o.source) head.push(`Источник: ${esc(EDIT_SOURCE_LABELS[o.source] || o.source)}`); head.push(`Полей затронуто: ${o.changes.length}`); if (o.photos_added) head.push(`Добавлено фото: ${o.photos_added}`); const body = o.changes.length ? o.changes.map(renderChange).join('') : '
Изменений не зафиксировано.
'; return `${head.join('')}
${body}
Технические данные (JSON)
${esc(fullTarget(target))}
`; } 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 += ``; if (start > 1) { html += ``; if (start > 2) html += `…`; } for (const p of pages) html += ``; if (end < totalPages) { if (end < totalPages - 1) html += `…`; html += ``; } if (page < totalPages) html += ``; 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 = 'Загрузка…'; 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 = 'Ошибка загрузки'; 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) => ` ${fmtTime(r.created_at)} ${esc(r.user_name || 'система')} ${esc(ACTION_LABELS[r.action] || r.action)} ${esc(r.ip || '')} `).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]) => ``) .join(''); sel.innerHTML = '' + 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 = `${esc(fmtTime(r.created_at))}${esc(r.user_name || 'система')}${r.ip ? `IP ${esc(r.ip)}` : ''}`; 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(); } })();