Files
WhatIDo/public/js/notifications.js
dev 2afe676969 feat(notifications): центр уведомлений о системных событиях
Добавлена система уведомлений о системных и фоновых событиях (новые записи
журнала, обработка фото, авто-проверка текста, блокировки IP, бэкапы).

- backend (server.js, worker.js):
  - каталог NOTIFY_TYPES с метаданными и уровнями
  - таблицы notifications и notification_reads в db/init.sql и db/migration.sql
  - SSE-стрим GET /api/notifications/stream через Redis pub/sub с in-memory fallback
  - REST API: список, счётчик непрочитанных, отметка о прочтении, удаление, очистка
  - настройки уведомлений в settings (notify_enabled, notify_retention_days, notify_<тип>)
  - автоматическая очистка старых уведомлений по расписанию
- frontend:
  - колокольчик со счётчиком непрочитанных в шапке (admin.js)
  - страница списка уведомлений public/notifications.html и public/js/notifications.js
  - секция настроек уведомлений в public/settings.html и public/js/settings.js
  - стили для уведомлений в public/admin.css
- тесты и документация:
  - добавлены проверки в api.smoketest.js
  - обновлены README.md и AGENTS.md
2026-09-27 23:34:47 +03:00

164 lines
6.9 KiB
JavaScript

const PAGE_SIZE = 25;
let nfItems = [];
let nfTotal = 0;
let nfUnread = 0;
let nfUnreadOnly = false;
let nfLoading = false;
async function nfMarkRead(id) {
const res = await fetch(`${API}/api/notifications/${id}/read`, { method: 'POST', headers: hdr() });
if (!res.ok) { showToast('Не удалось отметить уведомление'); return; }
const item = nfItems.find(n => n.id === id);
if (item) item.read = true;
nfUnread = Math.max(0, nfUnread - 1);
if (typeof notifyList !== 'undefined') {
const shared = notifyList.find(n => n.id === id);
if (shared) shared.read = true;
}
if (nfUnreadOnly) {
nfItems = nfItems.filter(n => n.id !== id);
nfTotal = Math.max(0, nfTotal - 1);
loadNotifyPage(true);
return;
}
renderNotifyPage();
if (typeof setNotifyUnread === 'function') setNotifyUnread(nfUnread);
}
async function nfDelete(id) {
if (!confirm('Удалить уведомление у всех пользователей?')) return;
const res = await fetch(`${API}/api/notifications/${id}`, { method: 'DELETE', headers: hdr() });
if (!res.ok) { showToast('Не удалось удалить уведомление'); return; }
if (typeof notifyList !== 'undefined') notifyList = notifyList.filter(n => n.id !== id);
loadNotifyPage(true);
}
async function nfMarkAllRead() {
const res = await fetch(`${API}/api/notifications/read-all`, { method: 'POST', headers: hdr() });
if (!res.ok) { showToast('Не удалось отметить уведомления'); return; }
showToast('Все уведомления прочитаны');
if (typeof notifyList !== 'undefined') notifyList.forEach(n => { n.read = true; });
if (typeof setNotifyUnread === 'function') setNotifyUnread(0);
loadNotifyPage(true);
}
async function nfClearAll() {
if (!confirm('Удалить все уведомления у всех пользователей? Действие необратимо.')) return;
const res = await fetch(`${API}/api/notifications`, { method: 'DELETE', headers: hdr() });
if (!res.ok) { showToast('Не удалось очистить уведомления'); return; }
const data = await res.json().catch(() => ({}));
showToast(`Удалено уведомлений: ${data.deleted || 0}`);
if (typeof notifyList !== 'undefined') notifyList = [];
if (typeof setNotifyUnread === 'function') setNotifyUnread(0);
loadNotifyPage(true);
}
function onNotificationArrived() {
loadNotifyPage(true);
}
function onNotifyListChanged() {
loadNotifyPage(true);
}
document.getElementById('nfList').addEventListener('click', e => {
const readBtn = e.target.closest('[data-nf-read]');
if (readBtn) { e.preventDefault(); nfMarkRead(parseInt(readBtn.dataset.nfRead, 10)); return; }
const delBtn = e.target.closest('[data-nf-del]');
if (delBtn) { e.preventDefault(); nfDelete(parseInt(delBtn.dataset.nfDel, 10)); return; }
if (e.target.closest('a[href]')) {
const card = e.target.closest('[data-nf-card]');
if (card) nfMarkRead(parseInt(card.dataset.nfCard, 10));
}
});
document.getElementById('notifyFilter').addEventListener('click', e => {
const btn = e.target.closest('button[data-filter]');
if (!btn) return;
nfUnreadOnly = btn.dataset.filter === 'unread';
document.querySelectorAll('#notifyFilter button').forEach(b => b.classList.toggle('active', b === btn));
loadNotifyPage(true);
});
document.getElementById('nfReadAllBtn').addEventListener('click', nfMarkAllRead);
document.getElementById('nfClearBtn').addEventListener('click', nfClearAll);
document.getElementById('nfMoreBtn').addEventListener('click', () => loadNotifyPage(false));
document.getElementById('nfReloadBtn').addEventListener('click', () => loadNotifyPage(true));
(async () => {
if (!(await requireAdminPage())) return;
buildSidebar(document.body.dataset.page);
const clearBtn = document.getElementById('nfClearBtn');
if (clearBtn) clearBtn.hidden = !isAdmin();
const authStatus = document.getElementById('authStatus');
if (authStatus) authStatus.classList.add('loaded');
await loadNotifyPage(true);
})();
function nfIcon(type) {
return typeof NOTIFY_ICONS !== 'undefined' && NOTIFY_ICONS[type] ? NOTIFY_ICONS[type] : 'bell';
}
function nfLevelClass(level) {
return level === 'warning' || level === 'critical' ? ` level-${level}` : '';
}
function nfCardHtml(n) {
const when = typeof notifyTime === 'function' ? notifyTime(n.created_at) : '';
const linkAttr = typeof attrEsc === 'function' ? attrEsc(n.link) : esc(n.link);
return `<div class="notify-card${n.read ? '' : ' unread'}${nfLevelClass(n.level)}" data-nf-card="${n.id}">
<span class="notify-ic"><i data-lucide="${nfIcon(n.type)}"></i></span>
<span class="notify-body">
<span class="notify-title">${esc(n.title)}</span>
${n.body ? `<span class="notify-text">${esc(n.body)}</span>` : ''}
<span class="notify-time">${esc(when)}${n.read ? '' : ' · не прочитано'}</span>
</span>
<span class="notify-card-actions">
${n.link ? `<a class="btn-primary ghost" style="padding:6px 12px;font-size:.78rem" href="${linkAttr}">Открыть</a>` : ''}
${n.read ? '' : `<button type="button" class="notify-link" data-nf-read="${n.id}">Прочитано</button>`}
${isAdmin() ? `<button type="button" class="notify-link" style="color:#ef4444" data-nf-del="${n.id}">Удалить</button>` : ''}
</span>
</div>`;
}
function renderNotifyPage() {
const list = document.getElementById('nfList');
const state = document.getElementById('nfState');
const more = document.getElementById('nfMoreBtn');
if (!list) return;
list.innerHTML = nfItems.length
? nfItems.map(nfCardHtml).join('')
: `<div class="notify-empty">${nfUnreadOnly ? 'Непрочитанных уведомлений нет' : 'Уведомлений пока нет'}</div>`;
if (state) {
state.textContent = nfItems.length
? `Показано ${nfItems.length} из ${nfTotal} · непрочитанных: ${nfUnread}`
: `Непрочитанных: ${nfUnread}`;
}
if (more) more.hidden = nfItems.length >= nfTotal;
renderIcons();
}
async function loadNotifyPage(reset) {
if (nfLoading) return;
nfLoading = true;
const offset = reset ? 0 : nfItems.length;
try {
const q = new URLSearchParams({ limit: String(PAGE_SIZE), offset: String(offset) });
if (nfUnreadOnly) q.set('unread', '1');
const res = await fetch(`${API}/api/notifications?${q.toString()}`, { headers: hdr() });
if (!res.ok) { showToast('Ошибка загрузки уведомлений'); return; }
const data = await res.json();
const items = data.items || [];
nfItems = reset ? items : nfItems.concat(items);
nfTotal = Number(data.total) || 0;
nfUnread = Number(data.unread) || 0;
renderNotifyPage();
if (typeof setNotifyUnread === 'function') setNotifyUnread(nfUnread);
} catch {
showToast('Ошибка сети');
} finally {
nfLoading = false;
}
}