Postgres/Redis/RabbitMQ/MinIO/Ollama теперь общие серверы сети (адреса в .env), локально в докере остаётся только Elasticsearch + app-сервисы. Старый docker-compose.yml (полностью автономный стек) сохранён как docker-compose.selfhosted.yml.example на случай отдельного GPU-сервера в будущем. Добавлен docker-compose.prod.yml (app + свой nginx с TLS, собирающий фронтенд в статику). Makefile переписан под три режима (dev/test-up/prod). Мелкая чистка неиспользуемых импортов во фронтенде.
199 lines
7.5 KiB
TypeScript
199 lines
7.5 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||
import { Filter, Loader2 } from 'lucide-react';
|
||
import toast from 'react-hot-toast';
|
||
import { SearchBar } from '../components/SearchBar';
|
||
import { SourceCard } from '../components/SourceCard';
|
||
import { StatusBadge } from '../components/StatusBadge';
|
||
import { searchApi, tasksApi } from '../api/client';
|
||
import { useAuthStore } from '../store/auth';
|
||
import { useTaskWebSocket } from '../hooks/useTaskPolling';
|
||
import type { Task, SearchResultData } from '../types';
|
||
|
||
export function Search() {
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const navigate = useNavigate();
|
||
const { isAuthenticated } = useAuthStore();
|
||
|
||
const query = searchParams.get('q') || '';
|
||
const [taskId, setTaskId] = useState<string | null>(null);
|
||
const [lang, setLang] = useState('');
|
||
const [yearFrom, setYearFrom] = useState('');
|
||
const [yearTo, setYearTo] = useState('');
|
||
|
||
// Создать задачу поиска
|
||
const createSearch = useMutation({
|
||
mutationFn: searchApi.create,
|
||
onSuccess: (response) => {
|
||
const task: Task = response.data;
|
||
setTaskId(task.public_id);
|
||
},
|
||
onError: (error: any) => {
|
||
const msg = error.response?.data?.detail || 'Ошибка поиска';
|
||
if (error.response?.status === 401) {
|
||
toast.error('Войдите, чтобы выполнять поиск');
|
||
navigate('/login');
|
||
} else {
|
||
toast.error(msg);
|
||
}
|
||
},
|
||
});
|
||
|
||
// Поллинг задачи
|
||
const { data: task } = useQuery({
|
||
queryKey: ['task', taskId],
|
||
queryFn: () => tasksApi.get(taskId!).then((r) => r.data as Task),
|
||
enabled: !!taskId,
|
||
refetchInterval: (query) => {
|
||
const status = query.state.data?.status;
|
||
if (status === 'done' || status === 'failed') return false;
|
||
return 3000;
|
||
},
|
||
});
|
||
|
||
// WebSocket для real-time обновлений
|
||
useTaskWebSocket(
|
||
taskId ?? undefined,
|
||
!!taskId && task?.status !== 'done' && task?.status !== 'failed'
|
||
);
|
||
|
||
// Запустить поиск при изменении query
|
||
useEffect(() => {
|
||
if (query && query.length >= 3 && isAuthenticated) {
|
||
createSearch.mutate({
|
||
query,
|
||
lang: lang || undefined,
|
||
year_from: yearFrom ? parseInt(yearFrom) : undefined,
|
||
year_to: yearTo ? parseInt(yearTo) : undefined,
|
||
});
|
||
}
|
||
}, [query]);
|
||
|
||
const handleSearch = (newQuery: string) => {
|
||
setTaskId(null);
|
||
setSearchParams({ q: newQuery });
|
||
};
|
||
|
||
const result = task?.result as SearchResultData | undefined;
|
||
const isLoading = createSearch.isPending || (!!taskId && task?.status === 'queued') || task?.status === 'processing';
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Поисковая строка */}
|
||
<SearchBar
|
||
onSearch={handleSearch}
|
||
defaultValue={query}
|
||
isLoading={isLoading}
|
||
/>
|
||
|
||
<div className="flex gap-6">
|
||
{/* Боковая панель фильтров */}
|
||
<aside className="w-56 flex-shrink-0 hidden lg:block">
|
||
<div className="bg-white rounded-xl border border-gray-100 p-4 sticky top-24">
|
||
<div className="flex items-center gap-2 mb-4">
|
||
<Filter className="w-4 h-4 text-gray-400" />
|
||
<span className="text-sm font-medium text-gray-700">Фильтры</span>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="text-xs text-gray-500 mb-1 block">Язык</label>
|
||
<select
|
||
value={lang}
|
||
onChange={(e) => setLang(e.target.value)}
|
||
className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||
>
|
||
<option value="">Все</option>
|
||
<option value="ru">Русский</option>
|
||
<option value="en">English</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs text-gray-500 mb-1 block">Год с</label>
|
||
<input
|
||
type="number"
|
||
value={yearFrom}
|
||
onChange={(e) => setYearFrom(e.target.value)}
|
||
placeholder="2000"
|
||
min={1900}
|
||
max={2100}
|
||
className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs text-gray-500 mb-1 block">Год по</label>
|
||
<input
|
||
type="number"
|
||
value={yearTo}
|
||
onChange={(e) => setYearTo(e.target.value)}
|
||
placeholder="2024"
|
||
min={1900}
|
||
max={2100}
|
||
className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
{/* Результаты */}
|
||
<main className="flex-1 min-w-0">
|
||
{/* Статус задачи */}
|
||
{task && task.status !== 'done' && (
|
||
<div className="bg-white rounded-xl border border-gray-100 p-6 text-center">
|
||
<div className="flex items-center justify-center gap-3 mb-2">
|
||
<Loader2 className="w-5 h-5 text-brand-500 animate-spin" />
|
||
<StatusBadge status={task.status} />
|
||
</div>
|
||
{task.queue_position && (
|
||
<p className="text-sm text-gray-500">Позиция в очереди: {task.queue_position}</p>
|
||
)}
|
||
{task.eta_seconds && (
|
||
<p className="text-sm text-gray-400">Ожидаемое время: ~{task.eta_seconds} сек</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Результаты поиска */}
|
||
{task?.status === 'done' && result && (
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-gray-500">
|
||
Найдено: <strong>{result.total}</strong> источников
|
||
</p>
|
||
{result.sources.map((source) => (
|
||
<SourceCard key={source.id} source={source} />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Ошибка */}
|
||
{task?.status === 'failed' && (
|
||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
|
||
<p className="text-red-700 font-medium">Ошибка поиска</p>
|
||
<p className="text-red-500 text-sm mt-1">{task.error}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Пустое состояние */}
|
||
{!query && !task && (
|
||
<div className="text-center py-16 text-gray-400">
|
||
<p>Введите запрос для поиска источников</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Не авторизован */}
|
||
{query && !isAuthenticated && (
|
||
<div className="bg-brand-50 border border-brand-200 rounded-xl p-6 text-center">
|
||
<p className="text-brand-700 font-medium mb-2">Необходима авторизация</p>
|
||
<p className="text-brand-600 text-sm">Войдите или зарегистрируйтесь для выполнения поиска</p>
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|