feat: initial microservices project structure
Services: - api: FastAPI gateway with JWT auth, async endpoints, WebSocket - worker-gpu: CUDA sentence-transformers, FAISS IVFFlat, Ollama LLM - worker-indexer: Winnowing+MinHash plagiarism detection, PDF/DOCX extraction - worker-notifier: SMTP email notifications - worker-gost: GOST 7.1-2003 and GOST R 7.0.5-2008 formatting Infrastructure: - docker-compose.yml (production) + docker-compose.dev.yml (hot reload) - Nginx reverse proxy + WebSocket support - PostgreSQL 16 with Alembic migrations - Elasticsearch 8 with Russian/English analyzers - MinIO, RabbitMQ, Redis, Ollama Frontend: - React 18 + Vite + TypeScript + TailwindCSS + Zustand + React Query v5 - 9 pages: Home, Search, Cabinet, Task, Check, Bibliography, Pricing, Login, Register Scripts: - Parser stubs: OpenAlex, КиберЛенинка, arXiv (Phase 0 - to be filled) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
201
services/frontend/src/pages/Search.tsx
Normal file
201
services/frontend/src/pages/Search.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { 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.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,
|
||||
isLoading: isPolling,
|
||||
} = useQuery({
|
||||
queryKey: ['task', taskId],
|
||||
queryFn: () => tasksApi.get(taskId!).then((r) => r.data as Task),
|
||||
enabled: !!taskId,
|
||||
refetchInterval: (data) => {
|
||||
if (!data) return 3000;
|
||||
if (data.status === 'done' || data.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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user