Добавлена система уведомлений о системных и фоновых событиях (новые записи журнала, обработка фото, авто-проверка текста, блокировки 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
164 lines
6.9 KiB
JavaScript
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;
|
|
}
|
|
} |