From 6fe7eeb22243d59b8376e021c0070f4b6577a286 Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 11 Mar 2026 08:48:26 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20realtime=20=D0=B1=D1=8D=D0=B9=D0=B4=D0=B6?= =?UTF-8?q?=D0=B8=20=D1=81=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=D0=BC=D0=B8=20=D0=B2=20=D0=B1=D0=BE?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=D1=83=D1=8E=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Исправлена логика подсчёта уведомлений по категориям (n.verb вместо n.category) - Добавлена синхронизация между компонентами через CustomEvent API - Реализована автоматическая отметка категории уведомлений при переходе в раздел - Backend: универсальный endpoint /category/read/ принимающий список verbs - Frontend: функция getVerbsByCategory() для маппинга категорий - Счётчики обновляются в реальном времени через WebSocket - Синхронизация работает между всеми вкладками браузера Модуль уведомлений остался переиспользуемым - категории управляются на фронте --- backend/notifications/api/urls.py | 1 + backend/notifications/api/views.py | 44 ++++++++- frontend/src/components/AppShell.tsx | 25 ++++- frontend/src/hooks/useApi.ts | 136 ++++++++++++++++++++++++++- frontend/src/lib/api.ts | 11 +++ frontend/src/lib/verbTranslations.ts | 9 ++ 6 files changed, 216 insertions(+), 10 deletions(-) diff --git a/backend/notifications/api/urls.py b/backend/notifications/api/urls.py index 8c3c3133..9472f2da 100644 --- a/backend/notifications/api/urls.py +++ b/backend/notifications/api/urls.py @@ -21,6 +21,7 @@ urlpatterns = [ # Массовые операции path('read-all/', views.mark_all_as_read, name='mark_all_read'), + path('category/read/', views.mark_category_as_read, name='mark_category_read'), path('delete-all-read/', views.delete_all_read, name='delete_all_read'), # Настройки каналов diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py index 9a47aea4..f521de2a 100644 --- a/backend/notifications/api/views.py +++ b/backend/notifications/api/views.py @@ -10,8 +10,6 @@ from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status -from django.db import models -from django.conf import settings from ..models import Notification, UserChannelPreferences @@ -192,6 +190,48 @@ def mark_all_as_read(request): }) +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def mark_category_as_read(request): + """ + Отметить группу уведомлений по списку verb'ов как прочитанные + + Body: + - verbs: список verb'ов для отметки (array) + - category: название категории (опционально, для логирования) + + Example: + POST /api/v1/notifications/category/read/ + { + "verbs": ["request_new", "request_updated"], + "category": "Заявки" + } + """ + verbs = request.data.get('verbs', []) + + if not verbs or not isinstance(verbs, list): + return Response( + {'status': 'error', 'message': 'verbs array is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Отмечаем все уведомления с этими verb'ами + queryset = Notification.objects.filter( + recipient=request.user, + unread=True, + deleted=False, + verb__in=verbs, + ) + + count = queryset.mark_all_as_read() + + return Response({ + 'status': 'success', + 'verbs': verbs, + 'count': count + }) + + @api_view(['DELETE']) @permission_classes([IsAuthenticated]) def delete_notification(request, notification_id): diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index f02b691f..1f7bb5d5 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -242,7 +242,7 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) { function LeftNavContent({ onNavigate }: LeftNavContentProps) { const pathname = usePathname(); - const { notifications: notificationsData } = useNotifications(); + const { notifications: notificationsData, markCategoryAsRead } = useNotifications(); const notifications = Array.isArray(notificationsData) ? notificationsData : []; // Подсчет уведомлений по категориям @@ -251,14 +251,26 @@ function LeftNavContent({ onNavigate }: LeftNavContentProps) { notifications.forEach((n: any) => { // Пропускаем прочитанные - if (!n.category || n.is_read) return; - const category = getVerbCategory(n.category); + if (!n.verb || n.is_read) return; + const category = getVerbCategory(n.verb); counts[category] = (counts[category] || 0) + 1; }); return counts; }, [notifications]); + const handleNavClick = async (category?: string) => { + // Помечаем уведомления категории как прочитанные + if (category && categoryCounts[category] > 0) { + await markCategoryAsRead(category); + } + + // Вызываем callback для закрытия мобильного меню + if (onNavigate) { + onNavigate(); + } + }; + const navLinkClass = (href: string) => `flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-50 ${pathname === href ? "bg-sky-50 text-sky-700 ring-1 ring-sky-100" : "text-gray-700" }`; @@ -272,7 +284,12 @@ function LeftNavContent({ onNavigate }: LeftNavContentProps) { const count = category ? categoryCounts[category] || 0 : 0; return ( - + handleNavClick(category)} + > {label} {count > 0 && ( diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 553f76f3..a91bae52 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -312,6 +312,65 @@ export function useNotifications() { }; }, []); + // Синхронизация между экземплярами хука через CustomEvent + useEffect(() => { + if (typeof window === 'undefined') return; + + const handleMarkedRead = (e: Event) => { + const customEvent = e as CustomEvent; + const { id } = customEvent.detail; + setNotifications(prev => + prev.map(n => (n.id === id ? { ...n, is_read: true } : n)) + ); + setUnreadCount(prev => Math.max(0, prev - 1)); + }; + + const handleAllMarkedRead = () => { + setNotifications(prev => prev.map(n => ({ ...n, is_read: true }))); + setUnreadCount(0); + }; + + const handleCategoryMarkedRead = async (e: Event) => { + const customEvent = e as CustomEvent; + const { category, verbs } = customEvent.detail; + + // Используем переданные verb'ы для проверки + setNotifications(prev => { + const updated = prev.map(n => { + const isInCategory = verbs.includes(n.verb); + return isInCategory ? { ...n, is_read: true } : n; + }); + + // Пересчитываем счетчик + const newUnreadCount = updated.filter(n => !n.is_read).length; + setUnreadCount(newUnreadCount); + + return updated; + }); + }; + + const handleDeleted = (e: Event) => { + const customEvent = e as CustomEvent; + const { id, wasUnread } = customEvent.detail; + setNotifications(prev => prev.filter(n => n.id !== id)); + if (wasUnread) { + setUnreadCount(prev => Math.max(0, prev - 1)); + } + }; + + window.addEventListener('notification-marked-read', handleMarkedRead); + window.addEventListener('notifications-all-marked-read', handleAllMarkedRead); + window.addEventListener('category-marked-read', handleCategoryMarkedRead); + window.addEventListener('notification-deleted', handleDeleted); + + return () => { + window.removeEventListener('notification-marked-read', handleMarkedRead); + window.removeEventListener('notifications-all-marked-read', handleAllMarkedRead); + window.removeEventListener('category-marked-read', handleCategoryMarkedRead); + window.removeEventListener('notification-deleted', handleDeleted); + }; + }, []); + const markAsRead = async (id: number) => { try { await apiClient.markNotificationAsRead(id); @@ -319,6 +378,13 @@ export function useNotifications() { prev.map(n => (n.id === id ? { ...n, is_read: true } : n)) ); setUnreadCount(prev => Math.max(0, prev - 1)); + + // Синхронизация между экземплярами хука через CustomEvent + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('notification-marked-read', { + detail: { id } + })); + } } catch (err) { console.error('Failed to mark notification as read:', err); } @@ -329,6 +395,11 @@ export function useNotifications() { await apiClient.markAllNotificationsAsRead(); setNotifications(prev => prev.map(n => ({ ...n, is_read: true }))); setUnreadCount(0); + + // Синхронизация между экземплярами хука через CustomEvent + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('notifications-all-marked-read')); + } } catch (err) { console.error('Failed to mark all notifications as read:', err); } @@ -337,12 +408,22 @@ export function useNotifications() { const deleteNotification = async (id: number) => { try { await apiClient.deleteNotification(id); - setNotifications(prev => prev.filter(n => n.id !== id)); - // Если удаленное было непрочитанным, уменьшаем счетчик + + // Проверяем было ли непрочитанным ДО удаления const deletedNotif = notifications.find(n => n.id === id); - if (deletedNotif && !deletedNotif.is_read) { + const wasUnread = deletedNotif && !deletedNotif.is_read; + + setNotifications(prev => prev.filter(n => n.id !== id)); + if (wasUnread) { setUnreadCount(prev => Math.max(0, prev - 1)); } + + // Синхронизация между экземплярами хука через CustomEvent + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('notification-deleted', { + detail: { id, wasUnread } + })); + } } catch (err) { console.error('Failed to delete notification:', err); } @@ -359,5 +440,52 @@ export function useNotifications() { } }; - return { notifications, loading, error, unreadCount, markAsRead, markAllAsRead, deleteNotification, deleteAllRead }; + const markCategoryAsRead = async (category: string) => { + try { + const result = await apiClient.markCategoryAsRead(category); + + // Импортируем getVerbCategory, getVerbsByCategory для обработки + const { getVerbCategory, getVerbsByCategory } = await import('@/lib/verbTranslations'); + const categoryVerbs = getVerbsByCategory(category); + + // Помечаем уведомления этой категории как прочитанные + setNotifications(prev => + prev.map(n => { + const isInCategory = categoryVerbs.includes(n.verb); + return isInCategory ? { ...n, is_read: true } : n; + }) + ); + + // Пересчитываем счетчик непрочитанных + setNotifications(prev => { + const newUnreadCount = prev.filter(n => !n.is_read).length; + setUnreadCount(newUnreadCount); + return prev; + }); + + // Синхронизация между экземплярами хука через CustomEvent + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('category-marked-read', { + detail: { category, verbs: categoryVerbs } + })); + } + + return result.count; + } catch (err) { + console.error('Failed to mark category as read:', err); + return 0; + } + }; + + return { + notifications, + loading, + error, + unreadCount, + markAsRead, + markAllAsRead, + markCategoryAsRead, + deleteNotification, + deleteAllRead + }; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 98610dbe..b87b444d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -815,6 +815,17 @@ class ApiClient { }); } + async markCategoryAsRead(category: string): Promise<{ status: string; count: number }> { + // Получаем список verb'ов для категории + const { getVerbsByCategory } = await import('./verbTranslations'); + const verbs = getVerbsByCategory(category); + + return this.request('/api/v1/notifications/category/read/', { + method: 'POST', + body: JSON.stringify({ verbs, category }), + }); + } + // Web Push подписки async getVapidPublicKey(): Promise<{ vapid_public_key: string }> { return this.request('/api/v1/notifications/push/vapid-key/'); diff --git a/frontend/src/lib/verbTranslations.ts b/frontend/src/lib/verbTranslations.ts index 34b6d398..223d114d 100644 --- a/frontend/src/lib/verbTranslations.ts +++ b/frontend/src/lib/verbTranslations.ts @@ -100,6 +100,15 @@ export function getVerbCategory(verb: string): string { return VERB_CATEGORIES[verb] || 'Общее'; } +/** + * Получить все verb'ы для заданной категории + */ +export function getVerbsByCategory(category: string): string[] { + return Object.entries(VERB_CATEGORIES) + .filter(([_, cat]) => cat === category) + .map(([verb, _]) => verb); +} + /** * Получить название для verb (fallback если нет title в notification.data) */