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). Мелкая чистка неиспользуемых импортов во фронтенде.
140 lines
5.7 KiB
TypeScript
140 lines
5.7 KiB
TypeScript
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: (query) => {
|
||
const status = query.state.data?.status;
|
||
return (status === 'done' || 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.public_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">
|
||
Найдено {(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">
|
||
Результат проверки плагиата
|
||
</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>
|
||
);
|
||
}
|