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:
jze9
2026-05-24 19:42:39 +05:00
commit 7758315632
120 changed files with 9500 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}