Appearance
Chat Search — Design Document
Дата: 2026-02-26 Статус: Утверждён Бэкенд: Готов (оба endpoint развёрнуты)
Цель
Добавить поиск сообщений в чат: поиск внутри одного разговора и глобальный поиск по всем разговорам пользователя.
API (бэкенд готов)
In-conversation search
GET /vendor/conversations/{id}/messages/search?q=текст&limit=50&cursor=...Возвращает Message[] с cursor-пагинацией. Только текстовые сообщения (type: 'text'), ILIKE поиск. Минимум 2 символа.
Global search
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— prophighlightQuery, атрибутdata-message-idapp/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
- Кнопка лупы в header чата + горячая клавиша
Ctrl+F/Cmd+F - Панель поиска появляется между header и списком сообщений
- Debounced ввод (300мс), минимум 2 символа → запрос к API
- Счётчик
3/15, стрелки вверх/вниз, Enter/Shift+Enter для навигации - Подсветка всех совпадений
<mark>в загруженных сообщениях - Текущее совпадение — дополнительный
ring+ скролл к нему - Если сообщение не загружено — цикл
loadMore()с лоадером до нахождения - Закрытие: 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
- Поисковый инпут над списком разговоров на
/cabinet/messages - При вводе >= 2 символов — debounced запрос, результаты заменяют список чатов
- Группировка по разговорам: "Имя собеседника · Название товара"
- Каждое сообщение: текст с подсветкой + дата
- Клик →
navigateTo(/cabinet/messages/{convId}?highlight={msgId}) - Кнопка "Загрузить ещё" при
has_more - Очистка инпута → обычный список разговоров
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 запускает:
- Вызов
scrollToMessage(messageId) - Найденное сообщение получает временный
ring-2 ring-yellow-400на 2 секунды - После обработки — параметр убирается из 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.