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:
+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 = 'Ошибка загрузки настроек'; }
|
||||
|
||||
Reference in New Issue
Block a user