Files
WhatIDo/public/js/audit.js
T
dev 45033bffa8 feat(photo-ai): Stage 5 — фронтенд фото-ИИ (режимы, статус, метаданные)
Только фронтенд: server.js, worker.js, photo-ai/, compose и схема БД не тронуты.
Контракт /enhance-ai, /api/photo-ai/health и service в /api/photo-jobs/status
взят из Stage 4 как есть (I6 — новых колонок нет, метаданные из photo_jobs.params
и audit_log.target).

journal.html + journal.js:
- селект режима рядом с «🤖 ИИ»: Универсально (x2) / Быстро (x4) / Лица /
  Лица + фон; PHOTO_AI_MODES — единственная таблица режим→{model, face}
- значение уходит в POST .../enhance-ai телом {model, face, face_model};
  face_model подставляется только когда face !== 'off'
- дефолт селекта из photo_ai_face_mode (face=all → facesbg, face=face → faces)
- подсказка про медленный CPU: loadPhotoAiDevice() читает
  GET /api/photo-ai/health (только для админа)
- текст подтверждения и надпись «ИИ обрабатывает…» различают апскейл и face-режим
- кнопка и селект скрываются вместе при photo_ai_enabled === 'false' (I3)

settings.html + settings.js:
- новый раздел «Фото-ИИ»: режим по умолчанию, модель лиц, желаемое устройство
- read-only статус из /api/photo-jobs/status: фактическое устройство,
  device_name, VRAM, модели/модели лиц/загруженные; жёлтым — расхождение с
  желаемым устройством, красным — недоступность и незаданный PHOTO_AI_URL
- renderStackInfo() рисует карточку «Фото-ИИ» из stack.photo_ai
- новые id в DIRTY_FIELDS и в payload PUT /api/settings
- photo_ai_device_pref документирующий: фактическое устройство задаёт
  PHOTO_AI_DEVICE в контейнере

worker.html + worker.js:
- PHOTO_ACTION_LABELS: ai_face «ИИ + лица», ai_upscale «ИИ-апскейл»
- в модалке сравнения — чипы с моделью, режимом лиц, найденными лицами,
  устройством, временем обработки и предупреждениями
- строка состояния учитывает service.reachable === false («задания ждут»)
  и дописывает «· расчёт на <device>»

audit.js: 10 меток для кодов, которые раньше показывались сырыми (профиль и
фото ученика, главное фото, фото модуля, удаление/очистка уведомлений, логотип).

Проверено: node --check для всех четырёх js. Приёмка на живом стеке в этом
изменении не гонялась — чекбокс приёмки Stage 5 в TODO_PHOTO_FACE_AI.md
оставлен пустым, там же журнал раздела 5 с замечаниями для Stage 6.
2026-09-30 11:15:54 +03:00

313 lines
16 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': 'Ученики перенесены в группу',
'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 `<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();
}
})();