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:
@@ -568,4 +568,49 @@ body[data-page=settings] .page-head{align-items:flex-end}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce){.save-bar{animation:none}.toggle-track,.toggle-thumb{transition:none}.icon-btn.spinning svg{animation:none}}
|
||||
|
||||
/* --- Notifications --- */
|
||||
.sidebar nav a[data-nav=notifications] svg{color:#f59e0b}
|
||||
.notify-bell{position:relative}
|
||||
.notify-badge{position:absolute;left:20px;top:-3px;min-width:16px;height:16px;padding:0 4px;border-radius:999px;background:#ef4444;color:#fff;font-size:.62rem;font-weight:700;line-height:16px;text-align:center}
|
||||
.notify-badge[hidden]{display:none}
|
||||
.notify-panel{position:fixed;left:12px;bottom:96px;width:min(380px,calc(100vw - 24px));max-height:min(70vh,520px);display:flex;flex-direction:column;background:var(--card);border:1px solid var(--border);border-radius:14px;box-shadow:0 18px 40px rgba(0,0,0,.18);z-index:90;overflow:hidden}
|
||||
.notify-panel[hidden]{display:none}
|
||||
.notify-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 14px;border-bottom:1px solid var(--border);font-size:.9rem}
|
||||
.notify-list{overflow-y:auto;flex:1}
|
||||
.notify-empty{padding:22px 14px;color:var(--muted);font-size:.85rem;text-align:center}
|
||||
.notify-item{display:flex;gap:10px;padding:11px 14px;border-bottom:1px solid var(--border);text-decoration:none;color:var(--text);transition:background .15s}
|
||||
.notify-item:hover{background:var(--bg)}
|
||||
.notify-item:last-child{border-bottom:none}
|
||||
.notify-item.unread{background:rgba(37,99,235,.05)}
|
||||
.notify-item.unread .notify-title{font-weight:600}
|
||||
.notify-item.level-warning .notify-ic{background:rgba(245,158,11,.14);color:#f59e0b}
|
||||
.notify-item.level-critical .notify-ic{background:rgba(239,68,68,.14);color:#ef4444}
|
||||
.notify-ic{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;background:var(--bg);color:var(--muted);flex-shrink:0}
|
||||
.notify-ic svg{width:15px;height:15px}
|
||||
.notify-body{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.notify-title{font-size:.84rem;line-height:1.3}
|
||||
.notify-text{font-size:.76rem;color:var(--muted);line-height:1.35;overflow-wrap:anywhere}
|
||||
.notify-time{font-size:.7rem;color:var(--muted)}
|
||||
.notify-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 14px;border-top:1px solid var(--border);font-size:.78rem}
|
||||
.notify-foot a{color:var(--accent);text-decoration:none}
|
||||
.notify-foot a:hover{text-decoration:underline}
|
||||
.notify-link{background:none;border:none;color:var(--accent);font-size:.78rem;cursor:pointer;padding:0}
|
||||
.notify-link:hover{text-decoration:underline}
|
||||
.notify-types{display:flex;flex-direction:column;gap:6px;margin-top:6px}
|
||||
.notify-type-badge{font-size:.65rem;font-weight:700;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:999px;padding:1px 7px;margin-left:6px;white-space:nowrap}
|
||||
.notify-page-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px}
|
||||
.notify-page-head .grow{flex:1}
|
||||
.notify-page-list{display:flex;flex-direction:column;gap:8px}
|
||||
.notify-card{display:flex;gap:12px;padding:14px 16px;background:var(--card);border:1px solid var(--border);border-radius:12px}
|
||||
.notify-card.unread{border-color:rgba(37,99,235,.45);background:rgba(37,99,235,.04)}
|
||||
.notify-card.level-warning{border-left:3px solid #f59e0b}
|
||||
.notify-card.level-critical{border-left:3px solid #ef4444}
|
||||
.notify-card .notify-body{flex:1}
|
||||
.notify-card .notify-title{font-size:.92rem}
|
||||
.notify-card .notify-text{font-size:.82rem}
|
||||
.notify-card-actions{display:flex;flex-direction:column;gap:6px;align-items:flex-end}
|
||||
@media(max-width:1024px){
|
||||
.notify-panel{left:12px;right:12px;width:auto;bottom:12px}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+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') {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+72
-3
@@ -1,5 +1,6 @@
|
||||
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'trashPurgeDays'];
|
||||
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-bans'];
|
||||
const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled', 'trashPurgeDays', 'notifyEnabled', 'notifyRetentionDays'];
|
||||
const NOTIFY_IDS = [];
|
||||
const SECTION_IDS = ['sec-system', 'sec-stack', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-trash', 'sec-form', 'sec-notify', 'sec-bans'];
|
||||
const SPAM_MAX = 10080;
|
||||
const BAN_REASONS = {
|
||||
'honeypot': 'Антиспам-поле',
|
||||
@@ -154,6 +155,38 @@ function initSectionNav() {
|
||||
});
|
||||
}
|
||||
|
||||
async function renderNotifyTypes(s) {
|
||||
const el = document.getElementById('notifyTypes');
|
||||
if (!el) return;
|
||||
const enabledEl = document.getElementById('notifyEnabled');
|
||||
if (enabledEl) enabledEl.checked = s.notify_enabled !== 'false';
|
||||
const retentionEl = document.getElementById('notifyRetentionDays');
|
||||
if (retentionEl) retentionEl.value = s.notify_retention_days !== undefined && s.notify_retention_days !== '' ? s.notify_retention_days : '30';
|
||||
NOTIFY_IDS.length = 0;
|
||||
let types = [];
|
||||
try {
|
||||
const res = await fetch(`${API}/api/notifications/meta`, { headers: hdr() });
|
||||
if (res.ok) types = (await res.json()).types || [];
|
||||
} catch {}
|
||||
if (!types.length) {
|
||||
el.innerHTML = '<span class="hint">Не удалось загрузить список событий</span>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = types.map(t => {
|
||||
NOTIFY_IDS.push(t.key);
|
||||
if (!DIRTY_FIELDS.includes(t.key)) DIRTY_FIELDS.push(t.key);
|
||||
const raw = s[t.key];
|
||||
const value = raw !== undefined && raw !== '' ? raw : (t.default_enabled ? 'true' : 'false');
|
||||
const badge = t.admin_only ? '<span class="notify-type-badge">только админ</span>' : '';
|
||||
return `<label class="toggle">
|
||||
<input type="checkbox" id="${esc(t.key)}" ${value !== 'false' ? 'checked' : ''}>
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>${esc(t.label)}${badge}</b><small>${esc(t.hint || '')}</small></span>
|
||||
</label>`;
|
||||
}).join('');
|
||||
renderIcons();
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const [sRes, stRes] = await Promise.all([
|
||||
fetch(`${API}/api/settings`, { headers: hdr() }),
|
||||
@@ -185,6 +218,7 @@ async function loadSettings() {
|
||||
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
|
||||
if (!pe.value) pe.value = 'auto';
|
||||
document.getElementById('camEnabled').checked = s.camera_enabled !== 'false';
|
||||
await renderNotifyTypes(s);
|
||||
snapshotDirty();
|
||||
updateSpamUi();
|
||||
loadAiQueue();
|
||||
@@ -647,6 +681,18 @@ document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && document.getElementById('aiProfileModal').classList.contains('open')) closeAiProfileForm();
|
||||
});
|
||||
|
||||
function notifySettingsPayload(retentionDays) {
|
||||
const out = {
|
||||
notify_enabled: document.getElementById('notifyEnabled').checked ? 'true' : 'false',
|
||||
notify_retention_days: String(retentionDays),
|
||||
};
|
||||
NOTIFY_IDS.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) out[id] = el.checked ? 'true' : 'false';
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const v = spamInput.value;
|
||||
const n = Number(v);
|
||||
@@ -663,6 +709,12 @@ async function saveSettings() {
|
||||
showError('Проверьте поле «Время до безвозвратного удаления» (от 1 до 365 дней)');
|
||||
return;
|
||||
}
|
||||
const notifyRetentionValue = document.getElementById('notifyRetentionDays').value;
|
||||
const notifyRetentionDays = Math.round(Number(notifyRetentionValue));
|
||||
if (notifyRetentionValue === '' || !Number.isFinite(notifyRetentionDays) || notifyRetentionDays < 1 || notifyRetentionDays > 365) {
|
||||
showError('Проверьте поле «Хранение уведомлений» (от 1 до 365 дней)');
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('saveBtn');
|
||||
btn.dataset.loadingText = 'Сохранение...';
|
||||
setBtnLoading(btn, true);
|
||||
@@ -687,6 +739,7 @@ async function saveSettings() {
|
||||
photo_enhance_engine: document.getElementById('photoEngine').value,
|
||||
camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false',
|
||||
trash_purge_days: String(trashDays),
|
||||
...notifySettingsPayload(notifyRetentionDays),
|
||||
} })
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -835,6 +888,22 @@ if (systemRefreshBtn) systemRefreshBtn.addEventListener('click', () => loadSyste
|
||||
const stackRefreshBtn = document.getElementById('stackRefreshBtn');
|
||||
if (stackRefreshBtn) stackRefreshBtn.addEventListener('click', () => loadSystemInfo(stackRefreshBtn));
|
||||
|
||||
const notifyTestBtn = document.getElementById('notifyTestBtn');
|
||||
if (notifyTestBtn) notifyTestBtn.addEventListener('click', async () => {
|
||||
notifyTestBtn.classList.add('spinning');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/notifications/test`, { method: 'POST', headers: hdr() });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { showError(data.error || 'Ошибка отправки уведомления'); return; }
|
||||
if (data.delivered) showToast('Тестовое уведомление отправлено');
|
||||
else showError('Уведомление не создано: выключен общий переключатель или событие «Тестовое уведомление»');
|
||||
} catch {
|
||||
showError('Ошибка сети');
|
||||
} finally {
|
||||
notifyTestBtn.classList.remove('spinning');
|
||||
}
|
||||
});
|
||||
|
||||
const statsRefreshBtn = document.getElementById('statsRefreshBtn');
|
||||
if (statsRefreshBtn) statsRefreshBtn.addEventListener('click', async () => {
|
||||
statsRefreshBtn.classList.add('spinning');
|
||||
@@ -919,12 +988,12 @@ window.addEventListener('beforeunload', e => {
|
||||
(async () => {
|
||||
if (await requireAdminPage()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
bindDirty();
|
||||
initSectionNav();
|
||||
updateSpamUi();
|
||||
const authStatus = document.getElementById('authStatus');
|
||||
try {
|
||||
await loadSettings();
|
||||
bindDirty();
|
||||
if (authStatus) authStatus.classList.add('loaded');
|
||||
} catch {
|
||||
if (authStatus) { authStatus.classList.remove('loaded'); authStatus.classList.add('err'); authStatus.textContent = 'Ошибка загрузки настроек'; }
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Уведомления — Админ-панель</title>
|
||||
<link rel="stylesheet" href="admin.css">
|
||||
</head>
|
||||
<body data-page="notifications">
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="sidebar"></div>
|
||||
<div class="main">
|
||||
<div class="page-head">
|
||||
<h2>Уведомления</h2>
|
||||
<p class="page-sub">События системы: новые записи в журнале, обработка фото нейросетью, блокировки IP и другое</p>
|
||||
<span class="page-status" id="authStatus"><span class="spinner dark"></span> Загрузка…</span>
|
||||
<button class="head-btn" id="nfReloadBtn" type="button" title="Обновить список"><i data-lucide="rotate-ccw"></i> Обновить</button>
|
||||
</div>
|
||||
|
||||
<div class="notify-page-head">
|
||||
<div class="view-toggle" id="notifyFilter">
|
||||
<button type="button" data-filter="all" class="active">Все</button>
|
||||
<button type="button" data-filter="unread">Непрочитанные</button>
|
||||
</div>
|
||||
<span class="grow"></span>
|
||||
<button class="btn-primary ghost" id="nfReadAllBtn" type="button"><i data-lucide="check"></i> Прочитать все</button>
|
||||
<button class="btn-primary danger-ghost" id="nfClearBtn" type="button" hidden><i data-lucide="trash-2"></i> Очистить всё</button>
|
||||
</div>
|
||||
<div class="hint" id="nfState"></div>
|
||||
|
||||
<div class="notify-page-list" id="nfList"></div>
|
||||
<div style="display:flex;justify-content:center;margin-top:16px">
|
||||
<button class="btn-primary ghost" id="nfMoreBtn" type="button" hidden>Показать ещё</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="vendor/lucide.min.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
<script src="js/notifications.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -26,6 +26,7 @@
|
||||
<a href="#sec-share" data-nav="sec-share"><i data-lucide="link"></i> Публичные ссылки</a>
|
||||
<a href="#sec-form" data-nav="sec-form"><i data-lucide="file-text"></i> Форма ответа</a>
|
||||
<a href="#sec-photo" data-nav="sec-photo"><i data-lucide="camera"></i> Фото</a>
|
||||
<a href="#sec-notify" data-nav="sec-notify"><i data-lucide="bell"></i> Уведомления</a>
|
||||
<a href="#sec-ai" data-nav="sec-ai"><i data-lucide="bot"></i> ИИ</a>
|
||||
<a href="#sec-backup" data-nav="sec-backup"><i data-lucide="database"></i> Бэкапы</a>
|
||||
<a href="#sec-trash" data-nav="sec-trash"><i data-lucide="trash-2"></i> Корзина</a>
|
||||
@@ -274,6 +275,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card wide" id="sec-notify">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="bell"></i></div>
|
||||
<h3>Уведомления</h3>
|
||||
<button class="icon-btn" id="notifyTestBtn" type="button" title="Отправить тестовое уведомление" aria-label="Тестовое уведомление"><i data-lucide="send"></i></button>
|
||||
</div>
|
||||
<p class="hint">Уведомления собираются в «колокольчике» админ-панели и на странице «Уведомления». Здесь выбирается, о каких событиях сообщать: новые записи журнала, обработка фото нейросетью, блокировки IP, бэкапы и другое.</p>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="notifyEnabled">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-text"><b>Уведомления включены</b><small>Общий выключатель: при снятии галочки не создаётся ни одно уведомление</small></span>
|
||||
</label>
|
||||
<div class="settings-field">
|
||||
<label for="notifyRetentionDays">Хранение уведомлений</label>
|
||||
<div class="unit-row">
|
||||
<input type="number" id="notifyRetentionDays" class="settings-input" min="1" max="365" step="1">
|
||||
<span class="unit">дней</span>
|
||||
</div>
|
||||
<span class="hint">От 1 до 365 дней, старые уведомления удаляются автоматически</span>
|
||||
</div>
|
||||
<div class="notify-types" id="notifyTypes"><span class="hint">Загрузка…</span></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" id="sec-bans">
|
||||
<div class="card-head">
|
||||
<div class="ic"><i data-lucide="shield-off"></i></div>
|
||||
|
||||
Reference in New Issue
Block a user