- Заменен лишний div.container на стандартный container для единообразия - Заменен класс .card на .ios-card в секции активности для устранения конфликтов стилей - Добавлен card-list.css в base.html для корректного отображения карточек новостей - Добавлены CSS правила для правильного поведения .row и колонок в grid-layout - Исправлена проблема с выходом календаря за границы страницы - Убраны видимые скроллбары у modal-body и modal-content для чистого UI
590 lines
22 KiB
JavaScript
590 lines
22 KiB
JavaScript
/**
|
||
* Менеджер уведомлений - подключение к 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 = `
|
||
<div class="notification-empty-state">
|
||
<i class="bi-exclamation-triangle"></i>
|
||
<div>Ошибка загрузки уведомлений</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
}
|
||
|
||
renderNotifications(notifications) {
|
||
const listElement = document.getElementById('notificationList');
|
||
if (!listElement) return;
|
||
|
||
if (notifications.length === 0) {
|
||
listElement.innerHTML = `
|
||
<div class="notification-empty-state">
|
||
<i class="bi-bell-slash"></i>
|
||
<div>Нет новых уведомлений</div>
|
||
</div>
|
||
`;
|
||
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 `
|
||
<div class="notification-item ${!notification.is_read ? 'unread' : ''}"
|
||
data-notification-id="${notification.id}"
|
||
data-action-url="${notification.action_url || ''}">
|
||
<div class="notification-icon">
|
||
<i class="${notification.icon} ${iconColorClass}"></i>
|
||
</div>
|
||
<div class="notification-content">
|
||
<div class="notification-title">${this.escapeHtml(notification.title)}</div>
|
||
<div class="notification-message">${this.escapeHtml(notification.message)}</div>
|
||
<div class="notification-time">${timeAgo}</div>
|
||
</div>
|
||
${!notification.is_read ? '<div class="notification-unread-badge"></div>' : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 = `
|
||
<div class="toast align-items-center border-0 bg-${notification.color} text-white" role="alert" aria-live="assertive" aria-atomic="true">
|
||
<div class="d-flex">
|
||
<div class="toast-body">
|
||
<i class="${notification.icon} me-2"></i>
|
||
<strong>${this.escapeHtml(notification.title)}</strong>
|
||
<div class="small">${this.escapeHtml(notification.message)}</div>
|
||
</div>
|
||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Добавить в контейнер
|
||
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');
|
||
}
|
||
});
|