diff --git a/apps/backend/chatballs/conversations/apps.py b/apps/backend/chatballs/conversations/apps.py index 130ba7d..5de9d0a 100644 --- a/apps/backend/chatballs/conversations/apps.py +++ b/apps/backend/chatballs/conversations/apps.py @@ -1,8 +1,12 @@ -from django.apps import AppConfig - - -class ConversationsConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - label = "conversations" - name = "chatballs.conversations" - verbose_name = "Conversations (contacts, dialogs, messages)" +from django.apps import AppConfig + + +class ConversationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + label = "conversations" + name = "chatballs.conversations" + verbose_name = "Conversations (contacts, dialogs, messages)" + + def ready(self) -> None: + # Свежесть диалога поддерживает сигнал: сообщения создаются в семи местах. + from chatballs.conversations import signals # noqa: F401 diff --git a/apps/backend/chatballs/conversations/migrations/0020_conversation_last_message_at_and_more.py b/apps/backend/chatballs/conversations/migrations/0020_conversation_last_message_at_and_more.py new file mode 100644 index 0000000..5cda925 --- /dev/null +++ b/apps/backend/chatballs/conversations/migrations/0020_conversation_last_message_at_and_more.py @@ -0,0 +1,74 @@ +"""Свежесть диалога — отдельным полем, порядок инбокса — по индексу. + +Раньше список сортировался по агрегату max(messages.created_at): такой ключ не +ложится ни в индекс, ни в курсор окна, поэтому каждый запрос инбокса собирал +GROUP BY по всей ленте сообщений. Поле заполняется здесь и дальше держится +сигналом (chatballs.conversations.signals). +""" + +import django.utils.timezone +from django.conf import settings +from django.contrib.postgres.operations import TrigramExtension +from django.db import migrations, models + +CONTACT_TRGM_INDEXES = """ +CREATE INDEX IF NOT EXISTS contact_name_trgm + ON conversations_contact USING gin ((UPPER(name)) gin_trgm_ops); +CREATE INDEX IF NOT EXISTS contact_phone_trgm + ON conversations_contact USING gin ((UPPER(phone)) gin_trgm_ops); +""" + +DROP_CONTACT_TRGM_INDEXES = """ +DROP INDEX IF EXISTS contact_name_trgm; +DROP INDEX IF EXISTS contact_phone_trgm; +""" + +BACKFILL = """ +UPDATE conversations_conversation AS c +SET last_message_at = COALESCE( + (SELECT MAX(m.created_at) FROM conversations_message AS m WHERE m.conversation_id = c.id), + c.last_activity_at +) +""" + + +class Migration(migrations.Migration): + + dependencies = [ + ('channels', '0008_remove_channel_allow_checkout_actions_and_more'), + ('conversations', '0019_alter_message_kind'), + ('identity', '0032_remove_organization_tax_regime_and_more'), + ('integrations', '0007_integration_feature_flags'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + # Поиск по контактам идёт подстрокой — без триграмм это перебор таблицы. + TrigramExtension(), + migrations.AddField( + model_name='conversation', + name='last_message_at', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.RunSQL(sql=BACKFILL, reverse_sql=migrations.RunSQL.noop), + # Функциональный индекс с opclass заводится сырым SQL: Django 5.2 + # рендерит его как «(UPPER(name) gin_trgm_ops)» — без скобок вокруг + # самого выражения, и Postgres такой синтаксис отвергает. + migrations.RunSQL(sql=CONTACT_TRGM_INDEXES, reverse_sql=DROP_CONTACT_TRGM_INDEXES), + migrations.AddIndex( + model_name='conversation', + index=models.Index( + fields=['organization', '-last_message_at', '-id'], name='conv_inbox_order' + ), + ), + migrations.AddIndex( + model_name='conversation', + index=models.Index(fields=['contact', '-last_activity_at'], name='conv_contact_recent'), + ), + migrations.AddIndex( + model_name='message', + index=models.Index( + fields=['conversation', 'created_at', 'id'], name='conv_message_window' + ), + ), + ] diff --git a/apps/backend/chatballs/conversations/models.py b/apps/backend/chatballs/conversations/models.py index bcb7353..2575930 100644 --- a/apps/backend/chatballs/conversations/models.py +++ b/apps/backend/chatballs/conversations/models.py @@ -2,6 +2,7 @@ from django.conf import settings from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.search import SearchVector from django.db import models +from django.utils import timezone from chatballs.tenancy.models import TenantRelationModel @@ -36,6 +37,11 @@ class Contact(models.Model): ) created_at = models.DateTimeField(auto_now_add=True) + # Триграммные индексы для поиска подстрокой (имя, телефон) заведены в + # миграции 0020 сырым SQL: Django 5.2 рендерит функциональный индекс с + # opclass без внутренних скобок — «(UPPER(name) gin_trgm_ops)», и Postgres + # такой синтаксис не принимает. + def __str__(self) -> str: return self.name or f"contact:{self.id}" @@ -192,10 +198,27 @@ class Conversation(models.Model): previous_conversation = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True, related_name="+") created_at = models.DateTimeField(auto_now_add=True) last_activity_at = models.DateTimeField(auto_now_add=True, db_index=True) + # Время последнего сообщения — порядок инбокса. Отдельно от last_activity_at, + # который двигают и служебные действия (перехват, возврат AI): по нему список + # сортировать нельзя. У диалога без сообщений — время создания, поэтому поле + # непустое и по нему работает и индекс, и курсор окна (api.pagination). + last_message_at = models.DateTimeField(default=timezone.now) class Meta: ordering = ["-last_activity_at"] - indexes = [models.Index(fields=["channel", "lifecycle"])] + indexes = [ + models.Index(fields=["channel", "lifecycle"]), + # Порядок инбокса: организация → свежесть. Раньше сортировка шла по + # агрегату max(messages.created_at) и в индекс не ложилась. + models.Index( + fields=["organization", "-last_message_at", "-id"], name="conv_inbox_order" + ), + # Список контактов спрашивает по каждому контакту его свежий диалог + # и число открытых — подзапросами по этой паре. + models.Index( + fields=["contact", "-last_activity_at"], name="conv_contact_recent" + ), + ] constraints = [ # Диалог всегда принадлежит контакту. models.CheckConstraint( @@ -304,6 +327,10 @@ class Message(TenantRelationModel): SearchVector("text", config="russian"), name="conv_message_text_fts", ), + # Окно истории идёт ровно по этому ключу: диалог → время → id. + models.Index( + fields=["conversation", "created_at", "id"], name="conv_message_window" + ), ] def __str__(self) -> str: diff --git a/apps/backend/chatballs/conversations/selectors.py b/apps/backend/chatballs/conversations/selectors.py index 5ee447e..b078ef5 100644 --- a/apps/backend/chatballs/conversations/selectors.py +++ b/apps/backend/chatballs/conversations/selectors.py @@ -5,13 +5,11 @@ from django.db.models import ( DateTimeField, F, IntegerField, - Max, Q, QuerySet, Value, When, ) -from django.db.models.functions import Coalesce from chatballs.api.pagination import SortKey from chatballs.conversations.models import ( @@ -36,21 +34,20 @@ def conversations_for_context(context: TenantContext) -> QuerySet[Conversation]: ) .prefetch_related("labels") # Инбокс сортируется по времени последнего сообщения (а не по служебной - # активности вроде claim/takeover); fallback — last_activity_at для - # диалогов без сообщений. Coalesce вместо nulls_last: ключ сортировки - # обязан быть непустым, иначе курсор окна не сравнить (api.pagination). - .annotate(sort_at=Coalesce(Max("messages__created_at"), F("last_activity_at"))) - .order_by("-sort_at", "-id") + # активности вроде claim/takeover). Поле денормализовано и держится + # сигналом: агрегат max(messages.created_at) не ложился ни в индекс, ни + # в курсор окна — на каждый запрос выходил GROUP BY по всей ленте. + .order_by("-last_message_at", "-id") ) # Ключи сортировки инбокса. Порядок и правило сравнения курсора — одно и то же # знание, поэтому оно живёт здесь, а не разъезжается по view. -ACTIVITY_KEYS = (SortKey("sort_at"), SortKey("id")) +ACTIVITY_KEYS = (SortKey("last_message_at"), SortKey("id")) WAITING_KEYS = ( SortKey("_waiting_rank", descending=False), SortKey("_wait_at", descending=False), - SortKey("sort_at"), + SortKey("last_message_at"), SortKey("id"), ) # Заглушка ключа ожидания для диалогов, которые человека не ждут: ключ окна @@ -68,18 +65,18 @@ def order_conversations( дольше всех ждущий выше», остальные — по убыванию активности. """ if sort != "waiting": - return queryset.order_by("-sort_at", "-id"), ACTIVITY_KEYS + return queryset.order_by("-last_message_at", "-id"), ACTIVITY_KEYS waits = Q(lifecycle=LifecycleState.OPEN, control_mode=ControlMode.PAUSED) ordered = queryset.annotate( _waiting_rank=Case( When(waits, then=Value(0)), default=Value(1), output_field=IntegerField() ), _wait_at=Case( - When(waits, then=F("sort_at")), + When(waits, then=F("last_message_at")), default=Value(_NOT_WAITING_AT), output_field=DateTimeField(), ), - ).order_by("_waiting_rank", "_wait_at", "-sort_at", "-id") + ).order_by("_waiting_rank", "_wait_at", "-last_message_at", "-id") return ordered, WAITING_KEYS diff --git a/apps/backend/chatballs/conversations/serializers.py b/apps/backend/chatballs/conversations/serializers.py index 9d4b3c6..80d738e 100644 --- a/apps/backend/chatballs/conversations/serializers.py +++ b/apps/backend/chatballs/conversations/serializers.py @@ -1,3 +1,5 @@ +from django.db.models import Count, Max, Q + from chatballs.integrations.features import features_payload from chatballs.conversations.models import ( ConnectionIdentity, @@ -40,6 +42,54 @@ def message_payload(message: Message) -> dict[str, object]: return payload +def last_messages_for(conversation_ids: list[int]) -> dict[int, Message]: + """Последняя реплика каждого диалога страницы — одним запросом. + + Превью строки списка раньше спрашивалось на каждый диалог: тридцать строк + инбокса стоили тридцати запросов, и обновление списка раз в четыре секунды + множило их на число операторов. + """ + if not conversation_ids: + return {} + rows = ( + Message.objects.filter(conversation_id__in=conversation_ids) + .exclude(author_type=MessageAuthor.SYSTEM) + .select_related("author_user") + .order_by("conversation_id", "-created_at", "-id") + .distinct("conversation_id") + ) + return {message.conversation_id: message for message in rows} + + +def pending_counts_for(conversation_ids: list[int], read_map: dict[int, int]) -> dict[int, int]: + """Бейджи непрочитанных для страницы — двумя запросами вместо строки-на-строку. + + Считается хвост клиентских сообщений: те, что пришли после последнего + ответа AI или оператора и которых просматривающий ещё не открывал. + """ + if not conversation_ids: + return {} + answered = dict( + Message.objects.filter(conversation_id__in=conversation_ids) + .exclude(author_type=MessageAuthor.CONTACT) + .values("conversation_id") + .annotate(last_id=Max("id")) + .values_list("conversation_id", "last_id") + ) + tail = Q() + for conversation_id in conversation_ids: + threshold = max(read_map.get(conversation_id, 0), answered.get(conversation_id, 0)) + tail |= Q(conversation_id=conversation_id, id__gt=threshold) + counts = ( + Message.objects.filter(author_type=MessageAuthor.CONTACT) + .filter(tail) + .values("conversation_id") + .annotate(total=Count("id")) + .values_list("conversation_id", "total") + ) + return dict(counts) + + def _last_message(conversation: Conversation) -> Message | None: # Превью строки списка — последняя реплика клиента/AI/сотрудника; системные # события («AI передал диалог») в превью не показываются (дизайн-базлайн v2, B). @@ -123,6 +173,8 @@ def conversation_payload( detailed: bool = False, last_read_id: int = 0, viewer_id: int | None = None, + last_message: Message | None = None, + pending_count: int | None = None, ) -> dict[str, object]: """Карточка диалога. @@ -130,7 +182,9 @@ def conversation_payload( собственным окном (`/messages/`), иначе открытие диалога с тысячей реплик тянуло бы их все, да ещё и на каждом обновлении карточки. """ - last = None if detailed else _last_message(conversation) + # Списку превью и бейдж считает страница целиком (last_messages_for, + # pending_counts_for); поштучный расчёт остаётся для одиночных ответов. + last = None if detailed else (last_message or _last_message(conversation)) channel = conversation.channel payload = { "id": conversation.id, @@ -212,5 +266,7 @@ def conversation_payload( ).exists() else: payload["lastMessage"] = message_payload(last) if last else None - payload["pendingCount"] = _pending_count(conversation, last_read_id) + payload["pendingCount"] = ( + pending_count if pending_count is not None else _pending_count(conversation, last_read_id) + ) return payload diff --git a/apps/backend/chatballs/conversations/signals.py b/apps/backend/chatballs/conversations/signals.py new file mode 100644 index 0000000..1034ba9 --- /dev/null +++ b/apps/backend/chatballs/conversations/signals.py @@ -0,0 +1,27 @@ +"""Сигналы домена диалогов. + +Единственная задача — держать `Conversation.last_message_at` в согласии с +лентой. Сообщения создаются в семи местах (приём из каналов, ответ оператора, +голосовые, файлы, события звонков, демо-данные), поэтому обновление живёт в +сигнале: иначе достаточно одного забытого места, чтобы диалог перестал +подниматься в инбоксе. +""" + +from django.db.models.signals import post_save +from django.dispatch import receiver + +from chatballs.conversations.models import Conversation, Message + + +@receiver(post_save, sender=Message, dispatch_uid="conversations.touch_last_message_at") +def touch_last_message_at(sender, instance: Message, created: bool, **kwargs) -> None: + """Свежесть диалога двигается только вперёд. + + Условие `__lt` защищает от задним числом импортированной переписки: старое + сообщение не должно опускать диалог в инбоксе. + """ + if not created or instance.created_at is None: + return + Conversation.objects.filter( + id=instance.conversation_id, last_message_at__lt=instance.created_at + ).update(last_message_at=instance.created_at) diff --git a/apps/backend/chatballs/conversations/test_query_budget.py b/apps/backend/chatballs/conversations/test_query_budget.py new file mode 100644 index 0000000..87ef957 --- /dev/null +++ b/apps/backend/chatballs/conversations/test_query_budget.py @@ -0,0 +1,136 @@ +"""Число запросов на страницу не зависит от размера набора. + +Это и есть защита от возврата исходной беды: список или лента, которые +разрастаются вместе с базой, начинаются с одного лишнего запроса на строку. +Числа здесь не зафиксированы намеренно — сравниваются два прогона на разном +объёме данных, поэтому тест не ломается от посторонних изменений и ловит ровно +N+1. +""" + +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext + +from chatballs.channels.models import Channel +from chatballs.conversations.models import ( + Contact, + Conversation, + LifecycleState, + Message, + MessageAuthor, +) +from chatballs.identity.bootstrap import bootstrap_owner +from chatballs.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from chatballs.integrations.models import ( + Integration, + IntegrationKind, + IntegrationProvider, +) +from chatballs.testing import TenantAPIClient as APIClient + + +class QueryBudgetTests(TestCase): + def setUp(self) -> None: + bootstrap_owner(email="owner@example.com", password="temporary-password") + self.organization = Organization.objects.get(slug="demo") + self.channel = Channel.objects.create( + organization=self.organization, code="line", name="Линия" + ) + self.connection_row = Integration.objects.create( + organization=self.organization, + kind=IntegrationKind.MESSENGER, + provider=IntegrationProvider.TELEGRAM, + name="bot", + channel=self.channel, + ) + self.client = APIClient() + self.client.login(username="owner@example.com", password="temporary-password") + + def _conversation(self, name: str, messages: int = 1) -> Conversation: + contact = Contact.objects.create(organization=self.organization, name=name) + conversation = Conversation.objects.create( + organization=self.organization, + channel=self.channel, + connection=self.connection_row, + contact=contact, + lifecycle=LifecycleState.OPEN, + ) + for index in range(messages): + Message.objects.create( + conversation=conversation, + author_type=MessageAuthor.CONTACT, + text=f"{name} — реплика {index}", + ) + return conversation + + def _employee(self, index: int) -> None: + user = HumanUser.objects.create_user( + email=f"member{index:03d}@example.com", + password="Password-123", + full_name=f"Сотрудник {index:03d}", + ) + OrganizationMembership.objects.create( + user=user, + organization=self.organization, + role=EmployeeRole.EMPLOYEE, + position_title="Оператор", + ) + + def _queries(self, url: str) -> int: + with CaptureQueriesContext(connection) as captured: + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + return len(captured) + + def _assert_flat(self, url: str, grow) -> None: + """Сколько бы строк ни добавилось, запросов должно остаться столько же.""" + # Первый запрос после входа тянет за собой сессию и контекст аренды — + # прогреваем, иначе сравнивались бы разные вещи. + self._queries(url) + before = self._queries(url) + grow() + after = self._queries(url) + self.assertEqual(after, before, f"{url}: запросов стало {after} вместо {before}") + + def test_inbox_window_does_not_grow_with_dialogs(self) -> None: + for index in range(3): + self._conversation(f"Клиент {index:02d}") + self._assert_flat( + "/api/v1/conversations/?limit=30", + lambda: [self._conversation(f"Ещё клиент {index:02d}") for index in range(12)], + ) + + def test_history_window_does_not_grow_with_messages(self) -> None: + conversation = self._conversation("Иван", messages=5) + self._assert_flat( + f"/api/v1/conversations/{conversation.id}/messages/?limit=50", + lambda: [ + Message.objects.create( + conversation=conversation, + author_type=MessageAuthor.CONTACT, + text=f"Ещё реплика {index}", + ) + for index in range(30) + ], + ) + + def test_clients_page_does_not_grow_with_contacts(self) -> None: + for index in range(3): + self._conversation(f"Контакт {index:02d}") + self._assert_flat( + "/api/v1/conversations/clients/", + lambda: [self._conversation(f"Новый контакт {index:02d}") for index in range(12)], + ) + + def test_employees_page_does_not_grow_with_staff(self) -> None: + for index in range(3): + self._employee(index) + self._assert_flat( + "/api/v1/employees/", + lambda: [self._employee(index) for index in range(10, 22)], + ) diff --git a/apps/backend/chatballs/conversations/views.py b/apps/backend/chatballs/conversations/views.py index fb455c2..90fe8e7 100644 --- a/apps/backend/chatballs/conversations/views.py +++ b/apps/backend/chatballs/conversations/views.py @@ -22,12 +22,12 @@ from chatballs.conversations.selectors import ( order_conversations, visible_conversations_for, ) -from chatballs.conversations.serializers import conversation_payload, message_payload - -# Окно инбокса и окно истории: размеры продуктовые, клиент может запросить -# меньше, больше — только до потолка api.pagination. -LIST_WINDOW_SIZE = 30 -MESSAGE_WINDOW_SIZE = 50 +from chatballs.conversations.serializers import ( + conversation_payload, + last_messages_for, + message_payload, + pending_counts_for, +) from chatballs.conversations.services import ( ClaimError, claim_conversation, @@ -43,6 +43,11 @@ from chatballs.identity.group_models import EmployeeGroup from chatballs.identity.models import OrganizationMembership from chatballs.identity.policy import ResourceScope, authorize, can_administer_access +# Окно инбокса и окно истории: размеры продуктовые, клиент может запросить +# меньше, больше — только до потолка api.pagination. +LIST_WINDOW_SIZE = 30 +MESSAGE_WINDOW_SIZE = 50 + class ConversationListView(ConversationViewBase): def get(self, request: Request) -> Response: @@ -113,6 +118,11 @@ class ConversationListView(ConversationViewBase): user=request.user, conversation__in=page.items ).values_list("conversation_id", "last_read_message_id") ) + # Превью и бейдж — на всю страницу разом: построчно это давало по два + # запроса на диалог при обновлении списка раз в четыре секунды. + conversation_ids = [conversation.id for conversation in page.items] + previews = last_messages_for(conversation_ids) + pending = pending_counts_for(conversation_ids, read_map) return Response( window_payload( page, @@ -120,6 +130,8 @@ class ConversationListView(ConversationViewBase): c, last_read_id=read_map.get(c.id, 0), viewer_id=request.user.id, + last_message=previews.get(c.id), + pending_count=pending.get(c.id, 0), ), # Счётчик над списком показывает весь охват с учётом фильтров, # а не число уже загруженных строк. diff --git a/apps/backend/chatballs/identity/employee_selectors.py b/apps/backend/chatballs/identity/employee_selectors.py index 11258c6..a30704f 100644 --- a/apps/backend/chatballs/identity/employee_selectors.py +++ b/apps/backend/chatballs/identity/employee_selectors.py @@ -6,8 +6,9 @@ from __future__ import annotations -from django.db.models import Q, QuerySet +from django.db.models import Prefetch, Q, QuerySet +from chatballs.identity.group_models import EmployeeGroupMember from chatballs.identity.models import OrganizationMembership ROLE_ANY = "all" @@ -15,8 +16,17 @@ ROLE_ANY = "all" def employees_for(organization_id: int, params) -> QuerySet[OrganizationMembership]: employees = ( - OrganizationMembership.objects.select_related("user") - .prefetch_related("group_links__group") + # organization нужен для адреса аватара, группы — с их порядком: обе + # связи берутся здесь, иначе payload спрашивал бы их на каждую строку. + OrganizationMembership.objects.select_related("user", "organization") + .prefetch_related( + Prefetch( + "group_links", + queryset=EmployeeGroupMember.objects.select_related("group").order_by( + "group__name" + ), + ) + ) .filter(organization_id=organization_id) ) role = params.get("role") diff --git a/apps/backend/chatballs/identity/employee_support.py b/apps/backend/chatballs/identity/employee_support.py index 2289089..3c18f1a 100644 --- a/apps/backend/chatballs/identity/employee_support.py +++ b/apps/backend/chatballs/identity/employee_support.py @@ -11,9 +11,11 @@ def employee_payload( *, include_detail: bool = False, ) -> dict[str, object]: + # Порядок групп задаёт выборка (employees_for): своя сортировка здесь + # отменяла бы prefetch и давала запрос на каждого сотрудника в списке. groups = [ {"id": link.group_id, "name": link.group.name} - for link in profile.group_links.select_related("group").order_by("group__name") + for link in profile.group_links.all() ] payload: dict[str, object] = { "id": profile.user_id, diff --git a/apps/internal-ui/src/features/administration/useAudit.ts b/apps/internal-ui/src/features/administration/useAudit.ts index 8d1b2d2..ca9eca0 100644 --- a/apps/internal-ui/src/features/administration/useAudit.ts +++ b/apps/internal-ui/src/features/administration/useAudit.ts @@ -1,61 +1,53 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; -import { ApiError } from "../../api/client"; +import { useDebounced } from "../../shared/useDebounced"; +import { usePagedResource } from "../../shared/usePagedResource"; import { loadAudit } from "./api"; import { EMPTY_AUDIT_QUERY, type AuditPayload, type AuditQuery } from "./model"; // Журнал аудита: фильтры и страница живут на сервере — журнал растёт без -// предела, и отдать его целиком нельзя. Поиск отложен, чтобы не слать запрос на -// каждую букву; смена любого фильтра возвращает на первую страницу — иначе -// человек остаётся на странице 7 отфильтрованного списка из двух событий. - -const SEARCH_DELAY_MS = 300; +// предела, и отдать его целиком нельзя. Страницей, отложенным поиском и +// защитой от гонок занимается общий usePagedResource: журнал был единственным +// списком со своей копией этой механики. export function useAudit() { const [query, setQueryState] = useState(EMPTY_AUDIT_QUERY); - const [payload, setPayload] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(EMPTY_AUDIT_QUERY.q); - - useEffect(() => { - const timer = window.setTimeout(() => setDebouncedSearch(query.q), SEARCH_DELAY_MS); - return () => window.clearTimeout(timer); - }, [query.q]); - - // Ответы медленного запроса не должны затирать более свежий: считаем поколения. - const generation = useRef(0); - - const effective: AuditQuery = { ...query, q: debouncedSearch }; - const { period, category, actor, result, page } = effective; - - const reload = useCallback(async () => { - const current = ++generation.current; - setLoading(true); - setError(""); - try { - const next = await loadAudit({ q: debouncedSearch, period, category, actor, result, page }); - if (current === generation.current) setPayload(next); - } catch (loadError) { - if (current !== generation.current) return; - setError(loadError instanceof ApiError ? loadError.message : "Не удалось загрузить журнал"); - } finally { - if (current === generation.current) setLoading(false); - } - }, [debouncedSearch, period, category, actor, result, page]); - - useEffect(() => { void reload(); }, [reload]); + const settledSearch = useDebounced(query.q); + const filters = useMemo( + () => ({ + q: settledSearch, + period: query.period, + category: query.category, + actor: query.actor, + result: query.result, + }), + [query.actor, query.category, query.period, query.result, settledSearch], + ); + const load = useCallback( + (page: number) => loadAudit({ ...filters, page }), + [filters], + ); + const journal = usePagedResource(load, filters, "Не удалось загрузить журнал"); const setQuery = useCallback((patch: Partial) => { - setQueryState((prev) => ({ - ...prev, - ...patch, - // Страницу сбрасывает всё, кроме явной смены страницы. - page: patch.page ?? 1, - })); - }, []); + // Страницу двигает подвал списка, фильтры — состояние запроса; смена + // фильтра сама возвращает на первую страницу (usePagedResource). + if (patch.page !== undefined) { + journal.setPage(patch.page); + return; + } + setQueryState((prev) => ({ ...prev, ...patch })); + }, [journal]); const reset = useCallback(() => setQueryState(EMPTY_AUDIT_QUERY), []); - return { query, setQuery, reset, payload, loading, error, reload }; + return { + query: { ...query, page: journal.page }, + setQuery, + reset, + payload: journal.payload, + loading: journal.loading, + error: journal.errorText, + reload: journal.reload, + }; } diff --git a/apps/internal-ui/src/shared/usePagedResource.ts b/apps/internal-ui/src/shared/usePagedResource.ts index 10a167f..ba8d1e6 100644 --- a/apps/internal-ui/src/shared/usePagedResource.ts +++ b/apps/internal-ui/src/shared/usePagedResource.ts @@ -15,7 +15,10 @@ export type PagedPayload = { const EMPTY: PagedPayload = { items: [], page: 1, pageSize: 0, total: 0, pageCount: 1 }; -export type PagedResource = { +export type PagedResource = { + /** Ответ целиком: у некоторых списков в нём есть и свои блоки — например + * справочники фильтров журнала аудита. */ + payload: P | null; items: T[]; page: number; pageCount: number; @@ -27,12 +30,14 @@ export type PagedResource = { reload: () => Promise; }; -export function usePagedResource( - load: (page: number) => Promise>, +// Тип записи выводится из самого ответа: у части списков в нём есть и свои +// блоки (например справочники фильтров журнала аудита), поэтому параметр — ответ. +export function usePagedResource

>( + load: (page: number) => Promise

, filters: unknown, errorMessage = "Не удалось загрузить список", -): PagedResource { - const [payload, setPayload] = useState>(EMPTY as PagedPayload); +): PagedResource { + const [payload, setPayload] = useState

(null); const [loading, setLoading] = useState(true); const [errorText, setErrorText] = useState(""); // Загрузчик пересоздаётся на каждый рендер — держим его в ref, чтобы эффект @@ -76,14 +81,17 @@ export function usePagedResource( void reload(); }, [key, reload]); + // Пока ответа нет, список ведёт себя как пустая первая страница. + const current = payload ?? (EMPTY as PagedPayload); return { - items: payload.items, + payload, + items: current.items, // Сервер возвращает существующую страницу: если записи удалили из-под // открытой страницы, он отдаст последнюю, и подвал покажет именно её. - page: payload.page, - pageCount: payload.pageCount, - pageSize: payload.pageSize, - total: payload.total, + page: current.page, + pageCount: current.pageCount, + pageSize: current.pageSize, + total: current.total, loading, errorText, setPage,