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:
189
services/frontend/src/pages/Bibliography.tsx
Normal file
189
services/frontend/src/pages/Bibliography.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { BookOpen, Copy, Trash2, BookMarked } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { GostCitation } from '../components/GostCitation';
|
||||
import { useBibliographyStore } from '../store/bibliography';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { api } from '../api/client';
|
||||
import type { GostResultData } from '../types';
|
||||
|
||||
export function Bibliography() {
|
||||
const { sources, removeSource, clearSources } = useBibliographyStore();
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const [style, setStyle] = useState<'7.1' | '7.0.5'>('7.1');
|
||||
const [formattedResult, setFormattedResult] = useState<GostResultData | null>(null);
|
||||
|
||||
const formatMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Создать задачу GOST форматирования
|
||||
const task = await api.post('/tasks/', {
|
||||
type: 'gost',
|
||||
input_data: {
|
||||
doc_ids: sources.map((s) => s.id),
|
||||
style,
|
||||
},
|
||||
});
|
||||
const taskId = task.data.id;
|
||||
|
||||
// Диспатчить задачу
|
||||
await api.post('/gost/format', {
|
||||
task_id: taskId,
|
||||
doc_ids: sources.map((s) => s.id),
|
||||
style,
|
||||
});
|
||||
|
||||
// Простое форматирование прямо на клиенте (используем ГОСТ-цитаты из источников)
|
||||
return {
|
||||
bibliography: sources.map((s, i) => ({
|
||||
number: i + 1,
|
||||
citation: s.gost_citation,
|
||||
doc_id: s.id,
|
||||
})),
|
||||
style,
|
||||
total: sources.length,
|
||||
} as GostResultData;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setFormattedResult(data);
|
||||
toast.success('Библиография отформатирована');
|
||||
},
|
||||
onError: () => {
|
||||
// При ошибке используем локальные ГОСТ-цитаты
|
||||
setFormattedResult({
|
||||
bibliography: sources.map((s, i) => ({
|
||||
number: i + 1,
|
||||
citation: s.gost_citation,
|
||||
doc_id: s.id,
|
||||
})),
|
||||
style,
|
||||
total: sources.length,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleFormat = () => {
|
||||
if (sources.length === 0) {
|
||||
toast.error('Добавьте источники в библиографию');
|
||||
return;
|
||||
}
|
||||
formatMutation.mutate();
|
||||
};
|
||||
|
||||
const handleCopyAll = () => {
|
||||
if (!formattedResult) return;
|
||||
const text = formattedResult.bibliography
|
||||
.map((e) => `${e.number}. ${e.citation}`)
|
||||
.join('\n');
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
toast.success('Список литературы скопирован');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Список литературы</h1>
|
||||
<p className="text-gray-500">
|
||||
Добавляйте источники из результатов поиска и форматируйте библиографию по ГОСТ.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Источники в списке */}
|
||||
{sources.length > 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-100 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Источников: {sources.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={clearSources}
|
||||
className="text-xs text-red-500 hover:text-red-700 flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Очистить всё
|
||||
</button>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{sources.map((source) => (
|
||||
<div key={source.id} className="flex items-start gap-3 px-5 py-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800 truncate">{source.title}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{source.authors.slice(0, 2).map((a) => a.last_name).join(', ')}
|
||||
{source.year && ` · ${source.year}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeSource(source.id)}
|
||||
className="p-1 text-gray-300 hover:text-red-400 transition-colors flex-shrink-0"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-50 rounded-xl border border-dashed border-gray-200 p-10 text-center">
|
||||
<BookMarked className="w-10 h-10 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-400 text-sm">
|
||||
Нажмите «В библиографию» на любом источнике из результатов поиска
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Настройки форматирования */}
|
||||
{sources.length > 0 && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-500 mr-2">Стиль ГОСТ:</label>
|
||||
<select
|
||||
value={style}
|
||||
onChange={(e) => {
|
||||
setStyle(e.target.value as '7.1' | '7.0.5');
|
||||
setFormattedResult(null);
|
||||
}}
|
||||
className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
<option value="7.1">ГОСТ 7.1-2003 (полная)</option>
|
||||
<option value="7.0.5">ГОСТ Р 7.0.5-2008 (краткая)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleFormat}
|
||||
disabled={formatMutation.isPending}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50 transition-colors ml-auto"
|
||||
>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
{formatMutation.isPending ? 'Форматирование...' : 'Сформировать список'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Результат */}
|
||||
{formattedResult && (
|
||||
<div className="bg-white rounded-xl border border-gray-100 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Список литературы · ГОСТ {style}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCopyAll}
|
||||
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-700"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
Копировать всё
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-2 py-2 divide-y divide-gray-50">
|
||||
{formattedResult.bibliography.map((entry) => (
|
||||
<GostCitation key={entry.doc_id} citation={entry.citation} number={entry.number} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
services/frontend/src/pages/Cabinet.tsx
Normal file
132
services/frontend/src/pages/Cabinet.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Crown, Search, Shield, BookOpen } from 'lucide-react';
|
||||
import { TaskCard } from '../components/TaskCard';
|
||||
import { tasksApi } from '../api/client';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { PLAN_LIMITS, type Task } from '../types';
|
||||
|
||||
const PLAN_BADGE_STYLES = {
|
||||
free: 'bg-gray-100 text-gray-600',
|
||||
student: 'bg-blue-100 text-blue-700',
|
||||
premium: 'bg-purple-100 text-purple-700',
|
||||
science: 'bg-amber-100 text-amber-700',
|
||||
};
|
||||
|
||||
export function Cabinet() {
|
||||
const { isAuthenticated, user } = useAuthStore();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
const { data: tasks, isLoading } = useQuery({
|
||||
queryKey: ['tasks'],
|
||||
queryFn: () => tasksApi.list().then((r) => r.data as Task[]),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const plan = user?.plan || 'free';
|
||||
const planInfo = PLAN_LIMITS[plan as keyof typeof PLAN_LIMITS];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Профиль */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-16 h-16 bg-brand-100 rounded-2xl flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-2xl font-bold text-brand-700">
|
||||
{user?.name?.[0]?.toUpperCase() || 'U'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{user?.name}</h2>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${PLAN_BADGE_STYLES[plan as keyof typeof PLAN_BADGE_STYLES]}`}>
|
||||
{planInfo.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm">{user?.email}</p>
|
||||
</div>
|
||||
<button className="px-4 py-2 text-sm font-medium bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors">
|
||||
Обновить план
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Лимиты */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{[
|
||||
{
|
||||
icon: Search,
|
||||
label: 'Поисков/день',
|
||||
value: planInfo.search_per_day,
|
||||
color: 'text-blue-600',
|
||||
bg: 'bg-blue-50',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
label: 'Проверок/мес',
|
||||
value: planInfo.plagiarism_per_month,
|
||||
color: 'text-purple-600',
|
||||
bg: 'bg-purple-50',
|
||||
},
|
||||
{
|
||||
icon: BookOpen,
|
||||
label: 'Изложений/мес',
|
||||
value: planInfo.summarize_per_month,
|
||||
color: 'text-emerald-600',
|
||||
bg: 'bg-emerald-50',
|
||||
},
|
||||
{
|
||||
icon: Crown,
|
||||
label: 'Одновременно',
|
||||
value: planInfo.concurrent,
|
||||
color: 'text-amber-600',
|
||||
bg: 'bg-amber-50',
|
||||
},
|
||||
].map(({ icon: Icon, label, value, color, bg }) => (
|
||||
<div key={label} className="bg-white rounded-xl border border-gray-100 p-4">
|
||||
<div className={`w-8 h-8 ${bg} rounded-lg flex items-center justify-center mb-2`}>
|
||||
<Icon className={`w-4 h-4 ${color}`} />
|
||||
</div>
|
||||
<div className="text-xl font-bold text-gray-900">
|
||||
{value === null ? '∞' : value}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Задачи */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Мои задачи {tasks && `(${tasks.length})`}
|
||||
</h3>
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-20 bg-gray-100 rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tasks && tasks.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tasks && tasks.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<p>Задач пока нет. Начните с поиска источников!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
services/frontend/src/pages/Check.tsx
Normal file
137
services/frontend/src/pages/Check.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Shield, LayoutDashboard } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { DropZone } from '../components/DropZone';
|
||||
import { documentsApi } from '../api/client';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import type { Task } from '../types';
|
||||
|
||||
export function Check() {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [submittedTask, setSubmittedTask] = useState<Task | null>(null);
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: () => documentsApi.uploadForCheck(file!),
|
||||
onSuccess: (response) => {
|
||||
const task: Task = response.data;
|
||||
setSubmittedTask(task);
|
||||
toast.success('Файл загружен, проверка начата!');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const msg = error.response?.data?.detail || 'Ошибка загрузки';
|
||||
if (error.response?.status === 401) {
|
||||
toast.error('Войдите для проверки плагиата');
|
||||
navigate('/login');
|
||||
} else {
|
||||
toast.error(msg);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
if (!isAuthenticated) {
|
||||
toast.error('Необходима авторизация');
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
upload.mutate();
|
||||
};
|
||||
|
||||
if (submittedTask) {
|
||||
return (
|
||||
<div className="max-w-xl mx-auto pt-8">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-8 text-center">
|
||||
<div className="w-16 h-16 bg-green-50 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<Shield className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Проверка запущена</h2>
|
||||
<p className="text-gray-500 mb-2">
|
||||
Файл <strong>{(submittedTask.input_data as any).filename}</strong> передан на проверку.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mb-6">
|
||||
Вы получите email когда проверка завершится. Обычно это занимает 1-3 минуты.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link
|
||||
to={`/tasks/${submittedTask.id}`}
|
||||
className="flex items-center justify-center gap-2 px-5 py-2.5 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
Следить за прогрессом
|
||||
</Link>
|
||||
<Link
|
||||
to="/cabinet"
|
||||
className="flex items-center justify-center gap-2 px-5 py-2.5 border border-gray-200 text-gray-600 rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
Личный кабинет
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Проверка плагиата</h1>
|
||||
<p className="text-gray-500">
|
||||
Загрузите документ для проверки на 4 уровнях: точные совпадения, нечёткий поиск,
|
||||
семантика и LLM-анализ парафраза.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<DropZone
|
||||
onFile={setFile}
|
||||
file={file}
|
||||
onClear={() => setFile(null)}
|
||||
/>
|
||||
|
||||
{!isAuthenticated && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm text-amber-700">
|
||||
<Link to="/login" className="underline font-medium">Войдите</Link> или{' '}
|
||||
<Link to="/register" className="underline font-medium">зарегистрируйтесь</Link>{' '}
|
||||
для проверки плагиата
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!file || upload.isPending || !isAuthenticated}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 bg-brand-600 text-white rounded-xl font-medium hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{upload.isPending ? (
|
||||
<>
|
||||
<Shield className="w-5 h-5 animate-pulse" />
|
||||
Загрузка...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="w-5 h-5" />
|
||||
Проверить на плагиат
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Описание уровней */}
|
||||
<div className="mt-8 bg-gray-50 rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">4 уровня проверки:</h3>
|
||||
<div className="space-y-2 text-sm text-gray-500">
|
||||
<div>1. <strong>Winnowing + MinHash</strong> — точные и нечёткие совпадения (~мс)</div>
|
||||
<div>2. <strong>n-граммы + Jaccard</strong> — перестановки слов (~сек)</div>
|
||||
<div>3. <strong>FAISS GPU cosine</strong> — семантическая близость (~мс)</div>
|
||||
<div>4. <strong>Ollama Llama3</strong> — анализ парафраза (~2 сек)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
services/frontend/src/pages/Home.tsx
Normal file
98
services/frontend/src/pages/Home.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Shield, BookOpen, Zap } from 'lucide-react';
|
||||
import { SearchBar } from '../components/SearchBar';
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
icon: Search,
|
||||
title: 'Семантический поиск',
|
||||
description: 'Поиск по миллионам статей через FAISS GPU + Elasticsearch BM25. Находит близкие по смыслу источники, а не только по ключевым словам.',
|
||||
color: 'text-blue-600',
|
||||
bg: 'bg-blue-50',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Проверка плагиата',
|
||||
description: '4 уровня проверки: Winnowing, MinHash, семантическое сравнение, LLM-анализ парафраза. Обнаружит даже перефразированный плагиат.',
|
||||
color: 'text-purple-600',
|
||||
bg: 'bg-purple-50',
|
||||
},
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: 'ГОСТ-библиография',
|
||||
description: 'Автоматическое форматирование по ГОСТ 7.1-2003 и ГОСТ Р 7.0.5-2008. Правильный порядок и разделители.',
|
||||
color: 'text-emerald-600',
|
||||
bg: 'bg-emerald-50',
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: 'Асинхронно',
|
||||
description: 'Закройте браузер — получите email, когда результат готов. Очередь задач, real-time обновления через WebSocket.',
|
||||
color: 'text-orange-600',
|
||||
bg: 'bg-orange-50',
|
||||
},
|
||||
];
|
||||
|
||||
export function Home() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
navigate(`/search?q=${encodeURIComponent(query)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
{/* Hero */}
|
||||
<section className="text-center pt-12 pb-4">
|
||||
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 mb-4 leading-tight">
|
||||
Академический помощник
|
||||
</h1>
|
||||
<p className="text-lg text-gray-500 mb-10 max-w-2xl mx-auto">
|
||||
Поиск научных источников, проверка плагиата и ГОСТ-библиография — всё в одном месте.
|
||||
Введите тему и система найдёт релевантные источники автоматически.
|
||||
</p>
|
||||
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<SearchBar
|
||||
onSearch={handleSearch}
|
||||
size="lg"
|
||||
placeholder="Введите тему исследования или научный вопрос..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-400 mt-4">
|
||||
Например: «нейронные сети в обработке естественного языка» или «квантовые вычисления алгоритмы»
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Фичи */}
|
||||
<section>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{FEATURES.map(({ icon: Icon, title, description, color, bg }) => (
|
||||
<div key={title} className="bg-white rounded-xl p-6 border border-gray-100 hover:shadow-md transition-shadow">
|
||||
<div className={`w-12 h-12 ${bg} rounded-xl flex items-center justify-center mb-4`}>
|
||||
<Icon className={`w-6 h-6 ${color}`} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-sm text-gray-500 leading-relaxed">{description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Источники */}
|
||||
<section className="bg-white rounded-2xl border border-gray-100 p-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Источники данных</h2>
|
||||
<p className="text-gray-500 text-sm mb-6">Поиск ведётся по открытым академическим базам данных</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{['OpenAlex', 'КиберЛенинка', 'arXiv', 'Wikipedia RU', 'Wikipedia EN'].map((name) => (
|
||||
<span key={name} className="px-4 py-2 bg-gray-50 text-gray-600 rounded-lg text-sm font-medium border border-gray-100">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
services/frontend/src/pages/Login.tsx
Normal file
90
services/frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { GraduationCap } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { authApi } from '../api/client';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import type { TokenResponse } from '../types';
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { setAuth } = useAuthStore();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const login = useMutation({
|
||||
mutationFn: () => authApi.login({ email, password }),
|
||||
onSuccess: (response) => {
|
||||
const data: TokenResponse = response.data;
|
||||
setAuth(data.user, data.access_token);
|
||||
toast.success(`Добро пожаловать, ${data.user.name}!`);
|
||||
navigate('/cabinet');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const msg = error.response?.data?.detail || 'Ошибка входа';
|
||||
toast.error(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
login.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto pt-8">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-8">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="p-3 bg-brand-600 rounded-xl">
|
||||
<GraduationCap className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 text-center mb-1">Вход</h1>
|
||||
<p className="text-gray-400 text-center text-sm mb-6">Войдите в свой аккаунт</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 block mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="ivan@example.com"
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 block mb-1">Пароль</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="••••••••"
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={login.isPending}
|
||||
className="w-full py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{login.isPending ? 'Входим...' : 'Войти'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-gray-400 mt-6">
|
||||
Нет аккаунта?{' '}
|
||||
<Link to="/register" className="text-brand-600 hover:underline font-medium">
|
||||
Зарегистрироваться
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
services/frontend/src/pages/Pricing.tsx
Normal file
122
services/frontend/src/pages/Pricing.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { PLAN_LIMITS } from '../types';
|
||||
|
||||
const PLAN_ORDER = ['free', 'student', 'premium', 'science'] as const;
|
||||
|
||||
const PLAN_FEATURES = {
|
||||
free: [
|
||||
'10 поисков в день',
|
||||
'3 краткие изложения в месяц',
|
||||
'1 проверка плагиата в месяц',
|
||||
'1 задача одновременно',
|
||||
'ГОСТ библиография',
|
||||
],
|
||||
student: [
|
||||
'Безлимитный поиск',
|
||||
'30 кратких изложений в месяц',
|
||||
'10 проверок плагиата в месяц',
|
||||
'2 задачи одновременно',
|
||||
'ГОСТ библиография',
|
||||
'Email уведомления',
|
||||
],
|
||||
premium: [
|
||||
'Безлимитный поиск',
|
||||
'Безлимитные изложения',
|
||||
'50 проверок плагиата в месяц',
|
||||
'5 задач одновременно',
|
||||
'ГОСТ библиография',
|
||||
'Приоритетная очередь',
|
||||
],
|
||||
science: [
|
||||
'Безлимитный поиск',
|
||||
'Безлимитные изложения',
|
||||
'Безлимитные проверки плагиата',
|
||||
'10 задач одновременно',
|
||||
'ГОСТ библиография',
|
||||
'Максимальный приоритет',
|
||||
'API доступ',
|
||||
],
|
||||
};
|
||||
|
||||
const POPULAR_PLAN = 'student';
|
||||
|
||||
export function Pricing() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-3">Тарифные планы</h1>
|
||||
<p className="text-gray-500 max-w-xl mx-auto">
|
||||
Начните бесплатно. Обновите план для расширенных возможностей.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{PLAN_ORDER.map((planKey) => {
|
||||
const plan = PLAN_LIMITS[planKey];
|
||||
const features = PLAN_FEATURES[planKey];
|
||||
const isPopular = planKey === POPULAR_PLAN;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={planKey}
|
||||
className={clsx(
|
||||
'relative bg-white rounded-2xl border-2 p-6 flex flex-col',
|
||||
isPopular ? 'border-brand-500 shadow-lg shadow-brand-100' : 'border-gray-100'
|
||||
)}
|
||||
>
|
||||
{isPopular && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span className="px-3 py-1 bg-brand-600 text-white text-xs font-medium rounded-full">
|
||||
Популярный
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<h3 className="text-base font-semibold text-gray-900">{plan.name}</h3>
|
||||
<div className="mt-2">
|
||||
{plan.price === 0 ? (
|
||||
<span className="text-3xl font-bold text-gray-900">Бесплатно</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-3xl font-bold text-gray-900">{plan.price}₽</span>
|
||||
<span className="text-gray-400 text-sm">/мес</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2.5 flex-1 mb-6">
|
||||
{features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2">
|
||||
<Check className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-gray-600">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
className={clsx(
|
||||
'w-full py-2.5 rounded-xl text-sm font-medium transition-colors',
|
||||
isPopular
|
||||
? 'bg-brand-600 text-white hover:bg-brand-700'
|
||||
: plan.price === 0
|
||||
? 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
: 'border border-brand-200 text-brand-700 hover:bg-brand-50'
|
||||
)}
|
||||
>
|
||||
{plan.price === 0 ? 'Начать бесплатно' : 'Выбрать план'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-6 text-center text-sm text-gray-500">
|
||||
Оплата через российские системы. При вопросах пишите на noreply@jze9.ru
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
services/frontend/src/pages/Register.tsx
Normal file
104
services/frontend/src/pages/Register.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { GraduationCap } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { authApi } from '../api/client';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import type { TokenResponse } from '../types';
|
||||
|
||||
export function Register() {
|
||||
const navigate = useNavigate();
|
||||
const { setAuth } = useAuthStore();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const register = useMutation({
|
||||
mutationFn: () => authApi.register({ name, email, password }),
|
||||
onSuccess: (response) => {
|
||||
const data: TokenResponse = response.data;
|
||||
setAuth(data.user, data.access_token);
|
||||
toast.success('Аккаунт создан! Проверьте email для подтверждения.');
|
||||
navigate('/cabinet');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const msg = error.response?.data?.detail || 'Ошибка регистрации';
|
||||
toast.error(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
register.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto pt-8">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-8">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="p-3 bg-brand-600 rounded-xl">
|
||||
<GraduationCap className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 text-center mb-1">Регистрация</h1>
|
||||
<p className="text-gray-400 text-center text-sm mb-6">Создайте аккаунт, это бесплатно</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 block mb-1">Имя</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
minLength={2}
|
||||
placeholder="Иван Иванов"
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 block mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="ivan@example.com"
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 block mb-1">Пароль</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="Минимум 8 символов"
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={register.isPending}
|
||||
className="w-full py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{register.isPending ? 'Создаём...' : 'Создать аккаунт'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-gray-400 mt-6">
|
||||
Уже есть аккаунт?{' '}
|
||||
<Link to="/login" className="text-brand-600 hover:underline font-medium">
|
||||
Войти
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
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