/** * Менеджер уведомлений - подключение к WebSocket и управление */ import { getNotifications, invalidateNotifications } from '../api/notificationsApi.js'; class NotificationManager { constructor() { this.ws = null; this.reconnectAttempts = 0; this.maxReconnectAttempts = 5; this.reconnectDelay = 1000; this.isConnected = false; this.notificationPermission = 'default'; this.soundEnabled = this.loadSoundPreference(); this.notificationSound = null; this.init(); } /** * Загрузить настройку звука из localStorage */ loadSoundPreference() { const saved = localStorage.getItem('notificationSoundEnabled'); return saved === null ? true : saved === 'true'; } /** * Сохранить настройку звука */ saveSoundPreference(enabled) { this.soundEnabled = enabled; localStorage.setItem('notificationSoundEnabled', enabled.toString()); } /** * Воспроизвести звук уведомления */ playNotificationSound() { if (!this.soundEnabled) return; if (!this.notificationSound) { // Создаем короткий звук через Web Audio API const audioContext = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); oscillator.connect(gainNode); gainNode.connect(audioContext.destination); oscillator.frequency.value = 800; // Частота звука oscillator.type = 'sine'; gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3); oscillator.start(audioContext.currentTime); oscillator.stop(audioContext.currentTime + 0.3); } } init() { this.requestNotificationPermission(); this.connectWebSocket(); this.loadInitialNotifications(); this.setupDropdown(); } /** * Запросить разрешение на показ браузерных уведомлений */ async requestNotificationPermission() { if (!('Notification' in window)) { console.warn('[Notifications] Browser does not support notifications'); return; } if (Notification.permission === 'granted') { this.notificationPermission = 'granted'; console.log('[Notifications] Permission already granted'); return; } if (Notification.permission !== 'denied') { try { const permission = await Notification.requestPermission(); this.notificationPermission = permission; console.log('[Notifications] Permission:', permission); } catch (error) { console.error('[Notifications] Error requesting permission:', error); } } } connectWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws/notifications/`; console.log('[Notifications] Connecting to:', wsUrl); try { this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { console.log('[Notifications] WebSocket connected'); this.isConnected = true; this.reconnectAttempts = 0; this.updateConnectionStatus(true); }; this.ws.onmessage = (event) => { this.handleMessage(event); }; this.ws.onclose = () => { console.log('[Notifications] WebSocket disconnected'); this.isConnected = false; this.updateConnectionStatus(false); this.attemptReconnect(); }; this.ws.onerror = (error) => { console.error('[Notifications] WebSocket error:', error); }; } catch (error) { console.error('[Notifications] Failed to create WebSocket:', error); this.attemptReconnect(); } } handleMessage(event) { try { const data = JSON.parse(event.data); // Игнорируем ping сообщения для keepalive (не логируем) if (data.type === 'ping') { return; } console.log('[Notifications] Received:', data); switch (data.type) { case 'unread_count': this.updateBadge(data.count); break; case 'new_notification': this.showNewNotification(data.notification); this.updateBadge(data.notification.unread_count); // Инвалидируем кеш при получении нового уведомления invalidateNotifications(); break; case 'count_update': this.updateBadge(data.count); // Инвалидируем кеш при изменении счетчика invalidateNotifications(); break; default: console.warn('[Notifications] Unknown message type:', data.type); } } catch (error) { console.error('[Notifications] Error parsing message:', error); } } attemptReconnect() { if (this.reconnectAttempts < this.maxReconnectAttempts) { this.reconnectAttempts++; const delay = this.reconnectDelay * this.reconnectAttempts; console.log(`[Notifications] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`); setTimeout(() => { this.connectWebSocket(); }, delay); } else { console.error('[Notifications] Max reconnect attempts reached'); } } updateConnectionStatus(connected) { // Визуальная индикация статуса подключения (опционально) const badge = document.getElementById('notificationBadge'); if (badge) { if (!connected) { badge.style.backgroundColor = '#dc3545'; // красный при отключении } else { badge.style.backgroundColor = ''; // вернуть стандартный цвет } } } updateBadge(count) { const badge = document.getElementById('notificationBadge'); if (badge) { if (count > 0) { badge.textContent = count > 99 ? '99+' : count; badge.classList.remove('d-none'); } else { badge.classList.add('d-none'); } } } async loadInitialNotifications() { try { // Используем кешированный API вместо прямого fetch const data = await getNotifications({ page: 1, page_size: 5, unread_only: true }); console.log('[Notifications] Loaded data:', data); this.renderNotifications(data.notifications || []); this.updateBadge(data.total || 0); } catch (error) { console.error('[Notifications] Error loading notifications:', error); // Показываем заглушку при ошибке const list = document.getElementById('notificationList'); if (list) { list.innerHTML = `
Ошибка загрузки уведомлений
`; } } } renderNotifications(notifications) { const listElement = document.getElementById('notificationList'); if (!listElement) return; if (notifications.length === 0) { listElement.innerHTML = `
Нет новых уведомлений
`; return; } listElement.innerHTML = notifications.map(n => this.renderNotificationItem(n)).join(''); // Добавить обработчики событий listElement.querySelectorAll('[data-notification-id]').forEach(item => { item.addEventListener('click', (e) => { e.preventDefault(); const notificationId = item.dataset.notificationId; const actionUrl = item.dataset.actionUrl; this.markAsRead(notificationId).then(() => { this.closeDropdown(); // Закрыть dropdown if (actionUrl) { window.location.href = actionUrl; } }); }); }); } renderNotificationItem(notification) { const iconColorClass = `text-${notification.color}`; const timeAgo = this.getTimeAgo(notification.created_at); return `
${this.escapeHtml(notification.title)}
${this.escapeHtml(notification.message)}
${timeAgo}
${!notification.is_read ? '
' : ''}
`; } showNewNotification(notification) { // Показать нативное браузерное уведомление this.showBrowserNotification(notification); // Обновить список в dropdown this.loadInitialNotifications(); // Воспроизвести звук (опционально) this.playNotificationSound(); } /** * Показать нативное браузерное уведомление */ showBrowserNotification(notification) { if (!('Notification' in window)) { console.warn('[Notifications] Browser notifications not supported'); // Fallback to toast this.showToast(notification); return; } if (Notification.permission !== 'granted') { console.warn('[Notifications] Permission not granted'); // Fallback to toast this.showToast(notification); return; } // Определить иконку по категории const iconMap = { 'communications': '💬', 'documents': '📄', 'requests': '📋', 'calendar': '📅', 'department': '👥', 'profile': '👤', 'feed': '📰', 'system': '⚙️' }; const options = { body: notification.message, icon: '/static/img/logo.png', // Можно заменить на специальную иконку badge: '/static/img/favicon-32.png', tag: `notification-${notification.id}`, requireInteraction: notification.priority === 'urgent', silent: false, data: { notificationId: notification.id, actionUrl: notification.action_url } }; try { const browserNotification = new Notification( `${iconMap[notification.category] || '🔔'} ${notification.title}`, options ); // Обработать клик по уведомлению browserNotification.onclick = (event) => { event.preventDefault(); window.focus(); // Отметить как прочитанное this.markAsRead(notification.id); // Перейти по ссылке if (notification.action_url) { window.location.href = notification.action_url; } browserNotification.close(); }; // Автозакрытие через 10 секунд (если не urgent) if (notification.priority !== 'urgent') { setTimeout(() => { browserNotification.close(); }, 10000); } console.log('[Notifications] Browser notification shown:', notification.title); } catch (error) { console.error('[Notifications] Error showing browser notification:', error); // Fallback to toast this.showToast(notification); } } showToast(notification) { // Создать toast элемент const toastHtml = ` `; // Добавить в контейнер let toastContainer = document.getElementById('toastContainer'); if (!toastContainer) { toastContainer = document.createElement('div'); toastContainer.id = 'toastContainer'; toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3'; toastContainer.style.zIndex = '9999'; document.body.appendChild(toastContainer); } toastContainer.insertAdjacentHTML('beforeend', toastHtml); // Показать toast const toastElement = toastContainer.lastElementChild; const toast = new bootstrap.Toast(toastElement, { autohide: true, delay: 5000 }); toast.show(); // Удалить после скрытия toastElement.addEventListener('hidden.bs.toast', () => { toastElement.remove(); }); } playNotificationSound() { // Опциональный звук уведомления try { const audio = new Audio('/static/sounds/notification.mp3'); audio.volume = 0.3; audio.play().catch(() => { // Игнорировать ошибки воспроизведения }); } catch (error) { // Звук не критичен } } async markAsRead(notificationId) { try { const response = await fetch(`/api/notifications/${notificationId}/read/`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.getCsrfToken() } }); if (response.ok) { console.log('[Notifications] Marked as read:', notificationId); } } catch (error) { console.error('[Notifications] Error marking as read:', error); } } async markAllAsRead() { try { const response = await fetch('/api/notifications/read-all/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.getCsrfToken() } }); if (response.ok) { console.log('[Notifications] All marked as read'); this.loadInitialNotifications(); } } catch (error) { console.error('[Notifications] Error marking all as read:', error); } } getTimeAgo(dateString) { const date = new Date(dateString); const now = new Date(); const seconds = Math.floor((now - date) / 1000); if (seconds < 60) return 'только что'; if (seconds < 3600) return `${Math.floor(seconds / 60)} мин назад`; if (seconds < 86400) return `${Math.floor(seconds / 3600)} ч назад`; if (seconds < 604800) return `${Math.floor(seconds / 86400)} дн назад`; return date.toLocaleDateString('ru-RU'); } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } getCsrfToken() { return document.querySelector('[name=csrfmiddlewaretoken]')?.value || document.cookie.match(/csrftoken=([^;]+)/)?.[1] || ''; } /** * Управление dropdown меню */ setupDropdown() { const toggle = document.getElementById('notificationToggle'); const dropdown = document.getElementById('notificationDropdown'); if (!toggle || !dropdown) return; // Клик по кнопке toggle.addEventListener('click', (e) => { e.stopPropagation(); this.toggleDropdown(); }); // Клик вне dropdown - закрыть document.addEventListener('click', (e) => { if (!dropdown.contains(e.target) && !toggle.contains(e.target)) { this.closeDropdown(); } }); // Позиционирование при открытии this.positionDropdown(); window.addEventListener('resize', () => this.positionDropdown()); window.addEventListener('scroll', () => this.positionDropdown()); } toggleDropdown() { const dropdown = document.getElementById('notificationDropdown'); if (dropdown.style.display === 'none' || !dropdown.style.display) { this.openDropdown(); } else { this.closeDropdown(); } } openDropdown() { const dropdown = document.getElementById('notificationDropdown'); dropdown.style.display = 'flex'; this.positionDropdown(); } closeDropdown() { const dropdown = document.getElementById('notificationDropdown'); dropdown.style.display = 'none'; } positionDropdown() { const toggle = document.getElementById('notificationToggle'); const dropdown = document.getElementById('notificationDropdown'); if (!toggle || !dropdown || dropdown.style.display === 'none') return; const rect = toggle.getBoundingClientRect(); const dropdownWidth = 360; const margin = 16; // отступ от края экрана // Позиция по вертикали dropdown.style.top = `${rect.bottom + 8}px`; // Позиция по горизонтали - адаптивная const isMobile = window.innerWidth < 768; if (isMobile) { // На мобильных - центрируем с отступами от краёв dropdown.style.left = `${margin}px`; dropdown.style.right = `${margin}px`; dropdown.style.width = 'auto'; } else { // На десктопе - справа от кнопки const rightPosition = window.innerWidth - rect.right; // Проверяем, не выходит ли за левый край if (rect.right - dropdownWidth < margin) { // Если выходит - выравниваем по левому краю с отступом dropdown.style.left = `${margin}px`; dropdown.style.right = 'auto'; } else { // Обычное выравнивание по правому краю кнопки dropdown.style.right = `${rightPosition}px`; dropdown.style.left = 'auto'; } dropdown.style.width = `${dropdownWidth}px`; } } } // Инициализация при загрузке страницы document.addEventListener('DOMContentLoaded', function() { if (document.getElementById('notificationBell')) { window.notificationManager = new NotificationManager(); console.log('[Notifications] Manager initialized'); } });