From 8838b09737d45d8ae1c1c864a9c4feada8bbf62e Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 10 Mar 2026 18:09:43 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D0=B8=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B8=20=D1=81=D1=87=D0=B5=D1=82?= =?UTF-8?q?=D1=87=D0=B8=D0=BA=D0=BE=D0=B2=20=D1=87=D0=B0=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend изменения: - Оптимизирована загрузка уведомлений: только 50 unread для badges - Упрощены проверки is_read в AppShell и NotificationCenter - Разделены useNotifications (для badges) и notifications page (для списка) - Добавлены визуальные индикаторы в список чатов (badges, pin, notifications) - Улучшена сортировка: закрепленные чаты показываются первыми Связано с backend оптимизацией denormalized unread_count --- frontend/src/app/messages/page.tsx | 41 ++++++++++-- frontend/src/app/notifications/page.tsx | 65 +++++++++++++++++-- frontend/src/components/AppShell.tsx | 3 +- .../src/components/NotificationCenter.tsx | 16 ++--- frontend/src/hooks/useApi.ts | 21 +++--- frontend/src/lib/api.ts | 16 ++++- 6 files changed, 130 insertions(+), 32 deletions(-) diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx index 50d13b7f..31bd6032 100644 --- a/frontend/src/app/messages/page.tsx +++ b/frontend/src/app/messages/page.tsx @@ -5,7 +5,7 @@ import { AppShell } from "../../components/AppShell"; import { apiClient } from "@/lib/api"; import { resolveMediaUrl } from "@/lib/url"; import type { Chat } from "@/types/api"; -import { Search, MessageCircle } from "lucide-react"; +import { Search, MessageCircle, Pin, BellOff } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; import { useUser } from "@/contexts/UserContext"; @@ -236,7 +236,17 @@ export default function MessagesPage() { const filteredChats = useMemo(() => { const q = search.trim().toLowerCase(); - const sorted = [...chats].sort((a, b) => getChatSortTimestamp(b) - getChatSortTimestamp(a)); + + // Сортировка: закрепленные сверху, потом по времени последнего сообщения + const sorted = [...chats].sort((a, b) => { + // Сначала сортируем по закрепленности + const aPinned = a.is_pinned ? 1 : 0; + const bPinned = b.is_pinned ? 1 : 0; + if (aPinned !== bPinned) return bPinned - aPinned; + + // Затем по времени последнего сообщения + return getChatSortTimestamp(b) - getChatSortTimestamp(a); + }); if (!q) return sorted; @@ -284,7 +294,11 @@ export default function MessagesPage() { 0 + ? "border-sky-100 bg-sky-50/30" + : "border-transparent" + }`} >
@@ -305,13 +319,30 @@ export default function MessagesPage() { {isChatOnline(chat, user?.id) ? ( ) : null} + {(chat.unread_count ?? 0) > 0 ? ( + + {chat.unread_count! > 99 ? '99+' : chat.unread_count} + + ) : null}
-

{chatTitle}

+
+

0 ? "font-bold text-gray-900" : "font-semibold text-gray-900" + }`}>{chatTitle}

+ {chat.is_pinned ? ( + + ) : null} + {chat.notifications_enabled === false ? ( + + ) : null} +
{formatTime(chat.last_message?.created_at)}
-

+

0 ? "font-medium text-gray-700" : "text-gray-500" + }`}> {chat.last_message?.content || "Нет сообщений"}

diff --git a/frontend/src/app/notifications/page.tsx b/frontend/src/app/notifications/page.tsx index 395f5157..276dfe45 100644 --- a/frontend/src/app/notifications/page.tsx +++ b/frontend/src/app/notifications/page.tsx @@ -1,23 +1,80 @@ 'use client'; import { AppShell } from '@/components/AppShell'; -import { useNotifications } from '@/hooks/useApi'; +import { apiClient } from '@/lib/api'; import { Bell, Check, CheckCheck, Filter, Search, Trash2, Settings } from 'lucide-react'; -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { formatDistanceToNow } from 'date-fns'; import { ru } from 'date-fns/locale/ru'; import { getVerbCategory, getVerbName } from '@/lib/verbTranslations'; import Link from 'next/link'; export default function NotificationsPage() { - const { notifications: notificationsData, loading, markAsRead, markAllAsRead, deleteNotification, deleteAllRead } = useNotifications(); - const notifications = Array.isArray(notificationsData) ? notificationsData : []; + // Локальное состояние для ВСЕХ уведомлений (не только непрочитанных) + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [filterRead, setFilterRead] = useState<'all' | 'unread' | 'read'>('all'); const [filterCategory, setFilterCategory] = useState(''); const [filtersOpen, setFiltersOpen] = useState(false); + // Загрузка всех уведомлений (100 последних) + useEffect(() => { + async function fetchAllNotifications() { + try { + const data = await apiClient.getNotifications({ page_size: 100 }); + const notifs = data.notifications || data.results || data; + setNotifications(Array.isArray(notifs) ? notifs : []); + } catch (err) { + console.error('Ошибка загрузки уведомлений:', err); + } finally { + setLoading(false); + } + } + + fetchAllNotifications(); + }, []); + + // Функции для операций с уведомлениями + const markAsRead = async (id: number) => { + try { + await apiClient.markNotificationAsRead(id); + setNotifications(prev => + prev.map(n => (n.id === id ? { ...n, is_read: true } : n)) + ); + } catch (err) { + console.error('Ошибка отметки уведомления:', err); + } + }; + + const markAllAsRead = async () => { + try { + await apiClient.markAllNotificationsAsRead(); + setNotifications(prev => prev.map(n => ({ ...n, is_read: true }))); + } catch (err) { + console.error('Ошибка отметки всех уведомлений:', err); + } + }; + + const deleteNotification = async (id: number) => { + try { + await apiClient.deleteNotification(id); + setNotifications(prev => prev.filter(n => n.id !== id)); + } catch (err) { + console.error('Ошибка удаления уведомления:', err); + } + }; + + const deleteAllRead = async () => { + try { + await apiClient.deleteAllReadNotifications(); + setNotifications(prev => prev.filter(n => !n.is_read)); + } catch (err) { + console.error('Ошибка удаления прочитанных:', err); + } + }; + // Уникальные категории const categories = useMemo(() => { const cats = new Set(); diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index ad075928..f02b691f 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -250,7 +250,8 @@ function LeftNavContent({ onNavigate }: LeftNavContentProps) { const counts: Record = {}; notifications.forEach((n: any) => { - if (!n.category || n.is_read) return; // Только непрочитанные + // Пропускаем прочитанные + if (!n.category || n.is_read) return; const category = getVerbCategory(n.category); counts[category] = (counts[category] || 0) + 1; }); diff --git a/frontend/src/components/NotificationCenter.tsx b/frontend/src/components/NotificationCenter.tsx index aa1af224..753dd6af 100644 --- a/frontend/src/components/NotificationCenter.tsx +++ b/frontend/src/components/NotificationCenter.tsx @@ -53,10 +53,7 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen }, [isOpen]); const handleNotificationClick = async (notification: any) => { - // v2 API использует unread вместо is_read (инвертированное) - const isUnread = notification.unread ?? !notification.is_read; - - if (isUnread) { + if (!notification.is_read) { await markAsRead(notification.id); } @@ -135,8 +132,7 @@ export function NotificationCenter({ variant = 'default', isOpen: externalIsOpen ) : (
    {notifications.slice(0, 20).map((notification) => { - // v2 API: unread вместо is_read (инвертированное) - const isUnread = notification.unread ?? !notification.is_read; + const isUnread = !notification.is_read; // v2 API: timestamp вместо created_at const timestamp = notification.timestamp || notification.created_at; // v2 API: verb + description вместо title + message @@ -212,10 +208,7 @@ export function NotificationPanel({ onClose }: { onClose?: () => void }) { const notifications = Array.isArray(notificationsData) ? notificationsData : []; const handleNotificationClick = async (notification: any) => { - // v2 API использует unread вместо is_read (инвертированное) - const isUnread = notification.unread ?? !notification.is_read; - - if (isUnread) { + if (!notification.is_read) { await markAsRead(notification.id); } if (notification.action_url) { @@ -262,8 +255,7 @@ export function NotificationPanel({ onClose }: { onClose?: () => void }) { ) : (
      {notifications.slice(0, 20).map((notification) => { - // v2 API: unread вместо is_read (инвертированное) - const isUnread = notification.unread ?? !notification.is_read; + const isUnread = !notification.is_read; // v2 API: timestamp вместо created_at const timestamp = notification.timestamp || notification.created_at; // v2 API: verb + description вместо title + message diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 57fa2553..553f76f3 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -179,13 +179,18 @@ export function useNotifications() { useEffect(() => { async function fetchNotifications() { try { - const data = await apiClient.getNotifications(); - // Бэкенд возвращает { notifications: [...] } + // Для бейджей загружаем только непрочитанные (первые 50) + const data = await apiClient.getNotifications({ + page_size: 50, + unread_only: true + }); + // Бэкенд возвращает { notifications: [...], unread_count: N } const notifs = data.notifications || data.results || data; // Убеждаемся что notifs это массив const notificationsArray = Array.isArray(notifs) ? notifs : []; setNotifications(notificationsArray); - setUnreadCount(notificationsArray.filter((n: any) => !n.is_read).length); + // Используем unread_count из API или считаем сами + setUnreadCount(data.unread_count ?? notificationsArray.length); } catch (err) { setError(err as Error); setNotifications([]); // В случае ошибки устанавливаем пустой массив @@ -256,7 +261,7 @@ export function useNotifications() { }); // Обновляем счетчик если непрочитанное - if (data.notification.unread) { + if (!data.notification.is_read) { setUnreadCount(prev => prev + 1); } } @@ -311,7 +316,7 @@ export function useNotifications() { try { await apiClient.markNotificationAsRead(id); setNotifications(prev => - prev.map(n => (n.id === id ? { ...n, is_read: true, unread: false } : n)) + prev.map(n => (n.id === id ? { ...n, is_read: true } : n)) ); setUnreadCount(prev => Math.max(0, prev - 1)); } catch (err) { @@ -322,7 +327,7 @@ export function useNotifications() { const markAllAsRead = async () => { try { await apiClient.markAllNotificationsAsRead(); - setNotifications(prev => prev.map(n => ({ ...n, is_read: true, unread: false }))); + setNotifications(prev => prev.map(n => ({ ...n, is_read: true }))); setUnreadCount(0); } catch (err) { console.error('Failed to mark all notifications as read:', err); @@ -335,7 +340,7 @@ export function useNotifications() { setNotifications(prev => prev.filter(n => n.id !== id)); // Если удаленное было непрочитанным, уменьшаем счетчик const deletedNotif = notifications.find(n => n.id === id); - if (deletedNotif && (deletedNotif.unread ?? !deletedNotif.is_read)) { + if (deletedNotif && !deletedNotif.is_read) { setUnreadCount(prev => Math.max(0, prev - 1)); } } catch (err) { @@ -346,7 +351,7 @@ export function useNotifications() { const deleteAllRead = async () => { try { const result = await apiClient.deleteAllReadNotifications(); - setNotifications(prev => prev.filter(n => n.unread ?? !n.is_read)); + setNotifications(prev => prev.filter(n => !n.is_read)); return result.count; } catch (err) { console.error('Failed to delete all read notifications:', err); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8505bf29..98610dbe 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -787,8 +787,20 @@ class ApiClient { } // Уведомления - async getNotifications(): Promise { - return this.request('/api/v1/notifications/'); + async getNotifications(params?: { page?: number; page_size?: number; unread_only?: boolean }): Promise { + const queryParams = new URLSearchParams(); + if (params?.page) queryParams.append('page', String(params.page)); + if (params?.page_size) queryParams.append('page_size', String(params.page_size)); + if (params?.unread_only) queryParams.append('unread_only', 'true'); + + const url = queryParams.toString() + ? `/api/v1/notifications/?${queryParams.toString()}` + : '/api/v1/notifications/'; + return this.request(url); + } + + async getUnreadNotificationsCount(): Promise<{ count: number }> { + return this.request('/api/v1/notifications/count/'); } async markNotificationAsRead(id: number): Promise {