173 lines
8.2 KiB
JavaScript
173 lines
8.2 KiB
JavaScript
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.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': 'Ученики перенесены в группу',
|
|
'entry.update': 'Изменена запись',
|
|
'entry.photo.update': 'Изменено фото записи',
|
|
'entry.photo.set_main': 'Главное фото записи',
|
|
'entry.photo.delete': 'Удалено фото записи',
|
|
'entry.soft-delete': 'Запись в корзину',
|
|
'entry.restore': 'Запись восстановлена',
|
|
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
|
'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 = [];
|
|
|
|
function fmtTime(t) {
|
|
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
|
|
}
|
|
|
|
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 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}">«</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}">»</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="4" 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; }
|
|
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;
|
|
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><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 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', e => {
|
|
const btn = e.target.closest('.audit-details');
|
|
if (!btn) return;
|
|
const r = auditRows[parseInt(btn.dataset.auditIdx, 10)];
|
|
if (!r) return;
|
|
document.getElementById('auditDetailsTitle').textContent = ACTION_LABELS[r.action] || r.action;
|
|
document.getElementById('auditDetailsBody').textContent = fullTarget(r.target);
|
|
document.getElementById('auditDetailsModal').classList.add('open');
|
|
});
|
|
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();
|
|
}
|
|
})(); |