Skip to content

Chat Search — Design Document

Дата: 2026-02-26 Статус: Утверждён Бэкенд: Готов (оба endpoint развёрнуты)


Цель

Добавить поиск сообщений в чат: поиск внутри одного разговора и глобальный поиск по всем разговорам пользователя.


API (бэкенд готов)

GET /vendor/conversations/{id}/messages/search?q=текст&limit=50&cursor=...

Возвращает Message[] с cursor-пагинацией. Только текстовые сообщения (type: 'text'), ILIKE поиск. Минимум 2 символа.

GET /vendor/messages/search?q=текст&limit=20&cursor=...

Возвращает Message[] с дополнительным полем conversation:

json
{
  "id": 542,
  "conversation_id": 12,
  "sender_id": 5,
  "type": "text",
  "text": "Генератор в наличии",
  "status": "read",
  "created_at": "2026-02-20T14:30:00+03:00",
  "conversation": {
    "id": 12,
    "companion": { "id": 5, "name": "Иван" },
    "product": { "id": 88, "title": "Генератор BMW E46" }
  }
}

Архитектура (FSD)

Новые файлы

app/features/chat-search/
├── composables/
│   ├── useConversationSearch.ts   # Поиск внутри одного чата
│   └── useGlobalSearch.ts         # Поиск по всем чатам
└── ui/
    ├── ChatSearchBar.vue          # Панель поиска в чате (Ctrl+F стиль)
    └── GlobalSearchResults.vue    # Список результатов глобального поиска

Изменяемые файлы

  • app/entities/message/ui/MessageBubble.vue — prop highlightQuery, атрибут data-message-id
  • app/features/chat-messaging/composables/useMessages.ts — метод scrollToMessage(id)
  • app/pages/cabinet/messages/[id].vue — интеграция ChatSearchBar + обработка ?highlight=
  • app/pages/cabinet/messages/index.vue — интеграция глобального поиска
  • i18n/locales/ru.json — ключи поиска

Поиск внутри чата (In-conversation)

UX

  1. Кнопка лупы в header чата + горячая клавиша Ctrl+F / Cmd+F
  2. Панель поиска появляется между header и списком сообщений
  3. Debounced ввод (300мс), минимум 2 символа → запрос к API
  4. Счётчик 3/15, стрелки вверх/вниз, Enter/Shift+Enter для навигации
  5. Подсветка всех совпадений <mark> в загруженных сообщениях
  6. Текущее совпадение — дополнительный ring + скролл к нему
  7. Если сообщение не загружено — цикл loadMore() с лоадером до нахождения
  8. Закрытие: Escape / кнопка X — сброс всего

Composable: useConversationSearch(conversationId)

ts
// Состояние
query: Ref<string>              // v-model для инпута
results: Ref<Message[]>         // все совпадения от API
currentIndex: Ref<number>       // индекс текущего результата
isSearching: Ref<boolean>       // загрузка
isOpen: Ref<boolean>            // видимость панели

// Computed
totalMatches: ComputedRef<number>        // results.length
currentMatch: ComputedRef<number>        // currentIndex + 1
currentMessageId: ComputedRef<number | null>  // results[currentIndex].id
highlightQuery: ComputedRef<string>      // query если есть результаты, иначе ''
matchedIds: ComputedRef<Set<number>>     // для быстрой проверки

// Методы
open(): void
close(): void
goNext(): void      // циклический: last → first
goPrev(): void      // циклический: first → last

Компонент: ChatSearchBar.vue

Props: query, totalMatches, currentMatch, isSearching Emits: update:query, next, prev, close

Элементы:

  • Иконка лупы
  • Input с autofocus
  • Счётчик N/M или "Ничего не найдено"
  • Кнопки вверх/вниз (показываются при наличии результатов)
  • Кнопка X (закрыть)
  • Keyboard: Escape → close, Enter → next, Shift+Enter → prev

scrollToMessage в useMessages

ts
async function scrollToMessage(messageId: number): Promise<boolean> {
  // 1. Проверить в уже загруженных
  let el = document.querySelector(`[data-message-id="${messageId}"]`)
  if (el) {
    highlightAndScroll(el)
    return true
  }

  // 2. Догружать старые сообщения до нахождения
  while (hasMore.value) {
    await loadMore()
    await nextTick()
    el = document.querySelector(`[data-message-id="${messageId}"]`)
    if (el) {
      highlightAndScroll(el)
      return true
    }
  }

  return false // сообщение не найдено (удалено или ошибка)
}

Подсветка текста в MessageBubble

Новый prop highlightQuery?: string. Если задан и непустой:

  • Заменяет совпадения на <mark class="bg-yellow-200 dark:bg-yellow-800 rounded px-0.5">$1</mark>
  • Рендерит через v-html (только для текстовых сообщений)
  • Регулярка экранирует спецсимволы в запросе

Глобальный поиск (по всем чатам)

UX

  1. Поисковый инпут над списком разговоров на /cabinet/messages
  2. При вводе >= 2 символов — debounced запрос, результаты заменяют список чатов
  3. Группировка по разговорам: "Имя собеседника · Название товара"
  4. Каждое сообщение: текст с подсветкой + дата
  5. Клик → navigateTo(/cabinet/messages/{convId}?highlight={msgId})
  6. Кнопка "Загрузить ещё" при has_more
  7. Очистка инпута → обычный список разговоров

Composable: useGlobalSearch()

ts
// Состояние
query: Ref<string>
results: Ref<GlobalSearchMessage[]>
isSearching: Ref<boolean>
hasMore: Ref<boolean>

// Computed
grouped: ComputedRef<Array<{
  conversation: { id: number; companion: { id: number; name: string }; product: { id: number; title: string } }
  messages: GlobalSearchMessage[]
}>>

// Методы
loadMore(): Promise<void>
clear(): void

Тип GlobalSearchMessage = Message & { conversation: { id, companion, product } } — inline интерфейс в composable.

Компонент: GlobalSearchResults.vue

Props: grouped, query, isSearching, hasMore Emits: loadMore, select(conversationId, messageId)

Элементы:

  • Спиннер при загрузке (нет результатов)
  • "Ничего не найдено" (нет результатов, не загружается)
  • Группы: заголовок + список кнопок-сообщений
  • Текст с подсветкой + дата
  • Кнопка "Загрузить ещё"

Обработка ?highlight= в странице чата

При переходе из глобального поиска, query-параметр highlight=messageId запускает:

  1. Вызов scrollToMessage(messageId)
  2. Найденное сообщение получает временный ring-2 ring-yellow-400 на 2 секунды
  3. После обработки — параметр убирается из URL (чистый URL)

i18n ключи

json
"chat": {
  "searchPlaceholder": "Поиск по сообщениям...",
  "searchAllPlaceholder": "Поиск по всем чатам...",
  "noSearchResults": "Ничего не найдено",
  "searchPrev": "Предыдущее совпадение",
  "searchNext": "Следующее совпадение",
  "searchClose": "Закрыть поиск",
  "searchLoading": "Загрузка сообщений..."
}

Граф зависимостей задач

F1: i18n ключи                    (независимо)
F2: MessageBubble — highlight      (независимо)
F3: useConversationSearch          (после F2)
F4: ChatSearchBar                  (после F3)
F5: scrollToMessage в useMessages  (независимо)
F6: Интеграция в [id].vue         (после F3, F4, F5)
F7: useGlobalSearch                (независимо)
F8: GlobalSearchResults            (после F7)
F9: Интеграция в index.vue        (после F7, F8)

F1, F2, F5, F7 могут выполняться параллельно. Основной путь: F2 → F3 → F4 → F6.