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:
141
services/frontend/src/pages/Task.tsx
Normal file
141
services/frontend/src/pages/Task.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import { useParams, Navigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { SourceCard } from '../components/SourceCard';
|
||||
import { PlagiarismReport } from '../components/PlagiarismReport';
|
||||
import { GostCitation } from '../components/GostCitation';
|
||||
import { tasksApi } from '../api/client';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { useTaskWebSocket } from '../hooks/useTaskPolling';
|
||||
import type { Task as TaskType, SearchResultData, PlagiarismResultData, GostResultData } from '../types';
|
||||
|
||||
export function Task() {
|
||||
const { taskId } = useParams<{ taskId: string }>();
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
if (!taskId) return <Navigate to="/cabinet" replace />;
|
||||
|
||||
const { data: task, isLoading } = useQuery({
|
||||
queryKey: ['task', taskId],
|
||||
queryFn: () => tasksApi.get(taskId).then((r) => r.data as TaskType),
|
||||
refetchInterval: (data) => {
|
||||
if (!data) return 3000;
|
||||
return (data.status === 'done' || data.status === 'failed') ? false : 3000;
|
||||
},
|
||||
});
|
||||
|
||||
useTaskWebSocket(taskId, !!task && task.status !== 'done' && task.status !== 'failed');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-8 h-8 text-brand-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return <Navigate to="/cabinet" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Шапка */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/cabinet" className="p-2 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 capitalize">
|
||||
{{
|
||||
search: 'Поиск источников',
|
||||
plagiarism: 'Проверка плагиата',
|
||||
gost: 'ГОСТ библиография',
|
||||
summarize: 'Краткое изложение',
|
||||
}[task.type] || task.type}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<StatusBadge status={task.status} />
|
||||
<span className="text-xs text-gray-400">{task.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* В процессе */}
|
||||
{(task.status === 'queued' || task.status === 'processing') && (
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-10 text-center">
|
||||
<Loader2 className="w-10 h-10 text-brand-400 animate-spin mx-auto mb-3" />
|
||||
<p className="text-base font-medium text-gray-700">
|
||||
{task.status === 'queued' ? 'Задача в очереди...' : 'Выполняется...'}
|
||||
</p>
|
||||
{task.queue_position && (
|
||||
<p className="text-sm text-gray-400 mt-1">Позиция: {task.queue_position}</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-400 mt-1">Вы получите email когда задача завершится</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ошибка */}
|
||||
{task.status === 'failed' && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
|
||||
<p className="font-medium text-red-700 mb-1">Задача завершилась с ошибкой</p>
|
||||
<p className="text-sm text-red-500">{task.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Результаты поиска */}
|
||||
{task.status === 'done' && task.type === 'search' && task.result && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Запрос: <strong>«{(task.input_data as any).query}»</strong>
|
||||
{' · '}{(task.result as SearchResultData).total} источников
|
||||
</p>
|
||||
{(task.result as SearchResultData).sources.map((source) => (
|
||||
<SourceCard key={source.id} source={source} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Отчёт о плагиате */}
|
||||
{task.status === 'done' && task.type === 'plagiarism' && task.result && (
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-6">
|
||||
<h2 className="text-base font-semibold text-gray-900 mb-4">
|
||||
Файл: {(task.input_data as any).filename}
|
||||
</h2>
|
||||
<PlagiarismReport data={task.result as PlagiarismResultData} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ГОСТ библиография */}
|
||||
{task.status === 'done' && task.type === 'gost' && task.result && (
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base font-semibold text-gray-900">
|
||||
Список литературы (ГОСТ {(task.result as GostResultData).style}-2003)
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
const text = (task.result as GostResultData).bibliography
|
||||
.map((e) => `${e.number}. ${e.citation}`)
|
||||
.join('\n');
|
||||
navigator.clipboard.writeText(text);
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs font-medium text-brand-600 border border-brand-200 rounded-lg hover:bg-brand-50 transition-colors"
|
||||
>
|
||||
Копировать всё
|
||||
</button>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{(task.result as GostResultData).bibliography.map((entry) => (
|
||||
<GostCitation key={entry.doc_id} citation={entry.citation} number={entry.number} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user