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
This commit is contained in:
+220
@@ -49,6 +49,7 @@ function buildSidebar(active) {
|
||||
const base = [
|
||||
{ page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' },
|
||||
{ page: 'journal', label: 'Журнал', icon: 'book-open' },
|
||||
{ page: 'notifications', label: 'Уведомления', icon: 'bell' },
|
||||
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' },
|
||||
{ page: 'groups', label: 'Группы', icon: 'users' },
|
||||
{ page: 'files', label: 'Файлы', icon: 'folder' },
|
||||
@@ -81,12 +82,14 @@ function buildSidebar(active) {
|
||||
</nav>
|
||||
<div class="bottom">
|
||||
<div class="version" id="sidebarVersion"></div>
|
||||
<a href="#" class="notify-bell" id="notifyBell" title="Уведомления"><i data-lucide="bell"></i>Уведомления<span class="notify-badge" id="notifyBadge" hidden>0</span></a>
|
||||
<a href="/" data-nav="home"><i data-lucide="home"></i>На главную</a>
|
||||
<a class="logout" href="#" data-action="logout"><i data-lucide="log-out"></i>Выйти</a>
|
||||
</div>`;
|
||||
renderIcons();
|
||||
loadVersion();
|
||||
loadBrand();
|
||||
initNotifications();
|
||||
}
|
||||
|
||||
async function loadBrand() {
|
||||
@@ -122,6 +125,223 @@ async function loadVersion() {
|
||||
} catch { el.textContent = ''; }
|
||||
}
|
||||
|
||||
// --- Notifications ---
|
||||
const NOTIFY_ICONS = {
|
||||
'entry.new': 'book-open',
|
||||
'entry.ai.corrected': 'sparkles',
|
||||
'entry.ai.error': 'bot',
|
||||
'photo.job.done': 'image',
|
||||
'photo.job.error': 'image-off',
|
||||
'ip.ban': 'shield-off',
|
||||
'backup.restore': 'database',
|
||||
'backup.create': 'download',
|
||||
'system.test': 'send',
|
||||
};
|
||||
const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' };
|
||||
|
||||
let notifyList = [];
|
||||
let notifyUnread = 0;
|
||||
let notifyStream = null;
|
||||
let notifyReconnectTimer = null;
|
||||
let desktopNotify = localStorage.getItem('notifyDesktop') === '1';
|
||||
|
||||
function notifyIcon(type) { return NOTIFY_ICONS[type] || 'bell'; }
|
||||
|
||||
function attrEsc(s) { return esc(String(s === null || s === undefined ? '' : s).replace(/"/g, '"')); }
|
||||
|
||||
function notifyTime(ts) {
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const diff = Date.now() - d.getTime();
|
||||
if (diff < 60000) return 'только что';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)} мин назад`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)} ч назад`;
|
||||
return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function setNotifyUnread(n) {
|
||||
notifyUnread = Number(n) || 0;
|
||||
const badge = document.getElementById('notifyBadge');
|
||||
if (!badge) return;
|
||||
badge.textContent = notifyUnread > 99 ? '99+' : String(notifyUnread);
|
||||
badge.hidden = notifyUnread <= 0;
|
||||
}
|
||||
|
||||
function notifyItemHtml(n, expanded) {
|
||||
const level = NOTIFY_LEVELS[n.level] ? ` level-${n.level}` : '';
|
||||
return `<a href="#" class="notify-item${n.read ? '' : ' unread'}${level}" data-notify-id="${n.id}" data-notify-link="${attrEsc(n.link || '')}">
|
||||
<span class="notify-ic"><i data-lucide="${notifyIcon(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(notifyTime(n.created_at))}</span>
|
||||
</span>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function notifyPanelEl() {
|
||||
let panel = document.getElementById('notifyPanel');
|
||||
if (panel) return panel;
|
||||
panel = document.createElement('div');
|
||||
panel.className = 'notify-panel';
|
||||
panel.id = 'notifyPanel';
|
||||
panel.hidden = true;
|
||||
document.body.appendChild(panel);
|
||||
return panel;
|
||||
}
|
||||
|
||||
function renderNotifyPanel() {
|
||||
const panel = notifyPanelEl();
|
||||
if (!panel) return;
|
||||
const items = notifyList.slice(0, 12);
|
||||
panel.innerHTML = `
|
||||
<div class="notify-head">
|
||||
<b>Уведомления</b>
|
||||
<button type="button" class="notify-link" id="notifyReadAll">Прочитать все</button>
|
||||
</div>
|
||||
<div class="notify-list">
|
||||
${items.length ? items.map(n => notifyItemHtml(n, false)).join('') : '<div class="notify-empty">Пока уведомлений нет</div>'}
|
||||
</div>
|
||||
<div class="notify-foot">
|
||||
<a href="notifications.html">Все уведомления${notifyUnread ? ` (${notifyUnread})` : ''}</a>
|
||||
<button type="button" class="notify-link" id="notifyDesktopToggle">${desktopNotify ? 'В браузере: вкл' : 'В браузере: выкл'}</button>
|
||||
</div>`;
|
||||
renderIcons();
|
||||
}
|
||||
|
||||
async function loadNotifications() {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/notifications?limit=20`, { headers: hdr() });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
notifyList = data.items || [];
|
||||
setNotifyUnread(data.unread);
|
||||
renderNotifyPanel();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function toggleNotifyPanel(force) {
|
||||
const panel = notifyPanelEl();
|
||||
if (!panel) return;
|
||||
const open = force === undefined ? panel.hidden : !!force;
|
||||
panel.hidden = !open;
|
||||
if (open) { renderNotifyPanel(); loadNotifications(); }
|
||||
}
|
||||
async function markNotifyRead(id) {
|
||||
const item = notifyList.find(n => n.id === id);
|
||||
if (item && !item.read) {
|
||||
item.read = true;
|
||||
setNotifyUnread(Math.max(0, notifyUnread - 1));
|
||||
renderNotifyPanel();
|
||||
}
|
||||
try { await fetch(`${API}/api/notifications/${id}/read`, { method: 'POST', headers: hdr() }); } catch {}
|
||||
}
|
||||
|
||||
async function markAllNotifyRead() {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/notifications/read-all`, { method: 'POST', headers: hdr() });
|
||||
if (!res.ok) return;
|
||||
notifyList.forEach(n => { n.read = true; });
|
||||
setNotifyUnread(0);
|
||||
renderNotifyPanel();
|
||||
if (typeof onNotifyListChanged === 'function') onNotifyListChanged();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function showDesktopNotification(n) {
|
||||
if (!desktopNotify) return;
|
||||
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return;
|
||||
try {
|
||||
const note = new Notification(n.title, { body: n.body || '', tag: 'whatido-' + n.id });
|
||||
note.onclick = () => {
|
||||
window.focus();
|
||||
if (n.link) location.href = n.link;
|
||||
note.close();
|
||||
};
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function toggleDesktopNotifications() {
|
||||
if (desktopNotify) {
|
||||
desktopNotify = false;
|
||||
localStorage.setItem('notifyDesktop', '0');
|
||||
renderNotifyPanel();
|
||||
return;
|
||||
}
|
||||
if (typeof Notification === 'undefined') { showToast('Браузер не поддерживает уведомления'); return; }
|
||||
let perm = Notification.permission;
|
||||
if (perm === 'default') {
|
||||
try { perm = await Notification.requestPermission(); } catch {}
|
||||
}
|
||||
if (perm !== 'granted') { showToast('Разрешение на уведомления не выдано'); return; }
|
||||
desktopNotify = true;
|
||||
localStorage.setItem('notifyDesktop', '1');
|
||||
renderNotifyPanel();
|
||||
showToast('Уведомления в браузере включены');
|
||||
}
|
||||
|
||||
function handleIncomingNotification(n) {
|
||||
notifyList.unshift(n);
|
||||
if (notifyList.length > 40) notifyList.length = 40;
|
||||
setNotifyUnread(notifyUnread + 1);
|
||||
renderNotifyPanel();
|
||||
if (n.level === 'warning' || n.level === 'critical') showToast(n.title);
|
||||
showDesktopNotification(n);
|
||||
if (typeof onNotificationArrived === 'function') onNotificationArrived(n);
|
||||
}
|
||||
|
||||
function connectNotifyStream() {
|
||||
if (notifyStream) { notifyStream.close(); notifyStream = null; }
|
||||
const connect = () => {
|
||||
const t = sessionStorage.getItem('authToken');
|
||||
if (!t) return;
|
||||
notifyStream = new EventSource(`${API}/api/notifications/stream?token=${encodeURIComponent(t)}`);
|
||||
notifyStream.addEventListener('ready', ev => {
|
||||
try { setNotifyUnread(JSON.parse(ev.data).unread); } catch {}
|
||||
});
|
||||
notifyStream.addEventListener('notification', ev => {
|
||||
let n = null;
|
||||
try { n = JSON.parse(ev.data); } catch { return; }
|
||||
if (!n || !n.id) return;
|
||||
handleIncomingNotification(n);
|
||||
});
|
||||
notifyStream.onerror = () => {
|
||||
if (notifyStream) { notifyStream.close(); notifyStream = null; }
|
||||
clearTimeout(notifyReconnectTimer);
|
||||
notifyReconnectTimer = setTimeout(connect, 5000);
|
||||
};
|
||||
};
|
||||
connect();
|
||||
window.addEventListener('beforeunload', () => { if (notifyStream) notifyStream.close(); });
|
||||
}
|
||||
|
||||
function initNotifications() {
|
||||
const bell = document.getElementById('notifyBell');
|
||||
const panel = notifyPanelEl();
|
||||
if (!bell || !panel) return;
|
||||
bell.addEventListener('click', e => { e.preventDefault(); toggleNotifyPanel(); });
|
||||
panel.addEventListener('click', e => {
|
||||
if (e.target.closest('#notifyReadAll')) { markAllNotifyRead(); return; }
|
||||
if (e.target.closest('#notifyDesktopToggle')) { toggleDesktopNotifications(); return; }
|
||||
const item = e.target.closest('[data-notify-id]');
|
||||
if (!item) return;
|
||||
e.preventDefault();
|
||||
const id = parseInt(item.dataset.notifyId, 10);
|
||||
const link = item.dataset.notifyLink;
|
||||
toggleNotifyPanel(false);
|
||||
markNotifyRead(id);
|
||||
if (link) location.href = link;
|
||||
});
|
||||
document.addEventListener('click', e => {
|
||||
if (panel.hidden) return;
|
||||
if (e.target.closest('#notifyPanel') || e.target.closest('#notifyBell')) return;
|
||||
toggleNotifyPanel(false);
|
||||
});
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) toggleNotifyPanel(false); });
|
||||
loadNotifications();
|
||||
connectNotifyStream();
|
||||
}
|
||||
|
||||
// --- Common UI ---
|
||||
function renderIcons() {
|
||||
if (window.lucide && typeof window.lucide.createIcons === 'function') {
|
||||
|
||||
Reference in New Issue
Block a user