Часовой пояс и формат времени (24h/12h) перенесены из браузера в настройки приложения: валидация IANA-зоны, сид в db/init.sql + db/migration.sql, отдача в GET /api/public-settings, раздел #sec-datetime в настройках с живым превью. Границы суток в SQL переведены на tzDayStart/tzDayEnd + bindTz ($TZ$) — раньше $n::date по TIMESTAMPTZ считал дни в UTC (у контейнера TimeZone=UTC) и молча сдвигал выборку на день; хардкод Europe/Moscow вычищен, now() заменён на tzWall(). Новый public/js/datetime.js: три семейства хелперов (instant / чистая DATE-строка без Date() / чистая TIME-строка), подключён на всех страницах включая публичные share/report/index, initDateTime() встроен в checkAuth(). Прямые toLocale*/getFullYear/toISOString().slice(0,10) в public/ выпилены. Попутно: bindTz добавляет параметр только при наличии $TZ$ в SQL (иначе запрос виснет вечно без global error handler) — регрессия закрыта в api.smoketest.js; починены off-by-one месяца в report.js и группировка по дате в share.js.
322 lines
16 KiB
JavaScript
322 lines
16 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.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': 'Экспорт данных ученика',
|
||
'export.group_files': 'Экспорт файлов группы (ZIP)',
|
||
'lesson_report.create': 'Создан отчёт о занятии',
|
||
'lesson_report.update': 'Изменён отчёт о занятии',
|
||
'lesson_report.delete': 'Удалён отчёт о занятии',
|
||
'lesson_report.ai.format': 'ИИ оформил отчёт по шаблону',
|
||
'lesson_report.ai.error': 'Ошибка проверки отчёта по шаблону',
|
||
'lesson_report.ai.revert': 'Возвращён оригинал отчёта',
|
||
'lesson_report.version.restore': 'Восстановлена версия отчёта',
|
||
};
|
||
|
||
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) {
|
||
const f = fmtFull(t);
|
||
return f || 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 renderAuditPager(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="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('');
|
||
}
|
||
renderAuditPager(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();
|
||
}
|
||
})(); |