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,14 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Академический помощник — поиск источников, проверка плагиата, ГОСТ-библиография" />
<title>Академический помощник</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,36 @@
{
"name": "academic-helper-frontend",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.23.0",
"@tanstack/react-query": "^5.40.0",
"zustand": "^4.5.2",
"axios": "^1.7.2",
"react-dropzone": "^14.2.3",
"react-hot-toast": "^2.4.1",
"lucide-react": "^0.390.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.3.0",
"date-fns": "^3.6.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.4.5",
"vite": "^5.2.12",
"tailwindcss": "^3.4.4",
"postcss": "^8.4.38",
"autoprefixer": "^10.4.19",
"eslint": "^9.4.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,80 @@
import axios from 'axios';
import { useAuthStore } from '../store/auth';
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api',
headers: { 'Content-Type': 'application/json' },
timeout: 30000,
});
// Добавить JWT токен к каждому запросу
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Обработка ответов: при 401 — разлогинить
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
useAuthStore.getState().logout();
}
return Promise.reject(error);
}
);
// ─── API методы ───────────────────────────────────────────────────────────────
export const authApi = {
register: (data: { email: string; password: string; name: string }) =>
api.post('/auth/register', data),
login: (data: { email: string; password: string }) =>
api.post('/auth/login', data),
me: () => api.get('/auth/me'),
verifyEmail: (token: string) =>
api.post(`/auth/verify-email/${token}`),
};
export const tasksApi = {
list: (limit = 20, offset = 0) =>
api.get('/tasks/', { params: { limit, offset } }),
get: (taskId: string) =>
api.get(`/tasks/${taskId}`),
delete: (taskId: string) =>
api.delete(`/tasks/${taskId}`),
};
export const searchApi = {
create: (data: {
query: string;
lang?: string;
year_from?: number;
year_to?: number;
category?: string;
}) => api.post('/search/', data),
};
export const documentsApi = {
uploadForCheck: (file: File) => {
const formData = new FormData();
formData.append('file', file);
return api.post('/documents/check', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120000, // 2 минуты для загрузки
});
},
};
export const reportsApi = {
get: (taskId: string) =>
api.get(`/reports/${taskId}`),
};

View File

@@ -0,0 +1,104 @@
import React, { useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import { Upload, FileText, X } from 'lucide-react';
import { clsx } from 'clsx';
interface DropZoneProps {
onFile: (file: File) => void;
file?: File | null;
onClear?: () => void;
}
const ACCEPTED_TYPES = {
'application/pdf': ['.pdf'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
'text/plain': ['.txt'],
};
const MAX_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB
const MAX_SIZE_LABEL = '100 МБ';
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
}
export function DropZone({ onFile, file, onClear }: DropZoneProps) {
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
onFile(acceptedFiles[0]);
}
},
[onFile]
);
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
onDrop,
accept: ACCEPTED_TYPES,
maxSize: MAX_SIZE_BYTES,
maxFiles: 1,
});
const rejectionError = fileRejections[0]?.errors[0]?.message;
if (file) {
return (
<div className="flex items-center gap-3 p-4 bg-brand-50 border border-brand-200 rounded-xl">
<div className="p-2 bg-brand-100 rounded-lg">
<FileText className="w-6 h-6 text-brand-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{file.name}</p>
<p className="text-xs text-gray-500">{formatFileSize(file.size)}</p>
</div>
{onClear && (
<button
onClick={onClear}
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-white transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
);
}
return (
<div>
<div
{...getRootProps()}
className={clsx(
'border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-all duration-200',
isDragActive
? 'border-brand-400 bg-brand-50'
: 'border-gray-200 hover:border-brand-300 hover:bg-gray-50'
)}
>
<input {...getInputProps()} />
<Upload
className={clsx(
'w-10 h-10 mx-auto mb-3',
isDragActive ? 'text-brand-500' : 'text-gray-300'
)}
/>
{isDragActive ? (
<p className="text-base font-medium text-brand-600">Отпустите файл для загрузки</p>
) : (
<>
<p className="text-base font-medium text-gray-700 mb-1">
Перетащите файл или нажмите для выбора
</p>
<p className="text-sm text-gray-400">
PDF, DOCX, TXT до {MAX_SIZE_LABEL}
</p>
</>
)}
</div>
{rejectionError && (
<p className="mt-2 text-sm text-red-500">{rejectionError}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { Copy, Check } from 'lucide-react';
import { useState } from 'react';
interface GostCitationProps {
citation: string;
number?: number;
}
export function GostCitation({ citation, number }: GostCitationProps) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(citation).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className="group flex items-start gap-3 py-2 px-3 rounded-lg hover:bg-gray-50 transition-colors">
{number !== undefined && (
<span className="flex-shrink-0 text-sm text-gray-400 font-mono w-6 pt-0.5 text-right">
{number}.
</span>
)}
<p className="flex-1 text-sm text-gray-700 leading-relaxed">{citation}</p>
<button
onClick={handleCopy}
className="flex-shrink-0 p-1 rounded text-gray-300 hover:text-gray-600 opacity-0 group-hover:opacity-100 transition-all"
title="Копировать"
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
);
}

View File

@@ -0,0 +1,149 @@
import React from 'react';
import { Link, NavLink, useNavigate } from 'react-router-dom';
import { GraduationCap, Search, BookOpen, Upload, LayoutDashboard, LogOut, User, BookMarked } from 'lucide-react';
import { clsx } from 'clsx';
import { useAuthStore } from '../store/auth';
interface LayoutProps {
children: React.ReactNode;
}
export function Layout({ children }: LayoutProps) {
const { isAuthenticated, user, logout } = useAuthStore();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/');
};
return (
<div className="min-h-screen bg-gray-50">
{/* Навигация */}
<nav className="bg-white border-b border-gray-100 sticky top-0 z-50">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Логотип */}
<Link
to="/"
className="flex items-center gap-2.5 text-gray-900 hover:text-brand-600 transition-colors"
>
<div className="p-1.5 bg-brand-600 rounded-lg">
<GraduationCap className="w-5 h-5 text-white" />
</div>
<span className="font-semibold text-base hidden sm:block">Академический помощник</span>
<span className="font-semibold text-base sm:hidden">АкадПомощник</span>
</Link>
{/* Центральная навигация */}
<div className="flex items-center gap-1">
<NavLink
to="/search"
className={({ isActive }) =>
clsx(
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-brand-50 text-brand-700'
: 'text-gray-600 hover:bg-gray-100'
)
}
>
<Search className="w-4 h-4" />
<span className="hidden md:block">Поиск</span>
</NavLink>
<NavLink
to="/check"
className={({ isActive }) =>
clsx(
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-brand-50 text-brand-700'
: 'text-gray-600 hover:bg-gray-100'
)
}
>
<Upload className="w-4 h-4" />
<span className="hidden md:block">Плагиат</span>
</NavLink>
<NavLink
to="/bibliography"
className={({ isActive }) =>
clsx(
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-brand-50 text-brand-700'
: 'text-gray-600 hover:bg-gray-100'
)
}
>
<BookOpen className="w-4 h-4" />
<span className="hidden md:block">Библиография</span>
</NavLink>
</div>
{/* Авторизация */}
<div className="flex items-center gap-2">
{isAuthenticated ? (
<>
<NavLink
to="/cabinet"
className={({ isActive }) =>
clsx(
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-brand-50 text-brand-700'
: 'text-gray-600 hover:bg-gray-100'
)
}
>
<LayoutDashboard className="w-4 h-4" />
<span className="hidden md:block">Кабинет</span>
</NavLink>
<div className="flex items-center gap-2 pl-2 border-l border-gray-100">
<div className="flex items-center gap-1.5">
<div className="w-8 h-8 bg-brand-100 rounded-full flex items-center justify-center">
<span className="text-xs font-semibold text-brand-700">
{user?.name?.[0]?.toUpperCase() || 'U'}
</span>
</div>
<span className="text-sm text-gray-600 hidden lg:block">{user?.name}</span>
</div>
<button
onClick={handleLogout}
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
title="Выйти"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</>
) : (
<>
<Link
to="/login"
className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
>
Войти
</Link>
<Link
to="/register"
className="px-4 py-2 text-sm font-medium bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
>
Регистрация
</Link>
</>
)}
</div>
</div>
</div>
</nav>
{/* Основной контент */}
<main className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,165 @@
import React from 'react';
import { AlertTriangle, CheckCircle, Info } from 'lucide-react';
import { clsx } from 'clsx';
import type { PlagiarismResultData } from '../types';
interface PlagiarismReportProps {
data: PlagiarismResultData;
}
function getSimilarityLevel(pct: number): {
color: string;
bgColor: string;
borderColor: string;
icon: typeof CheckCircle;
label: string;
} {
if (pct > 30) {
return {
color: 'text-red-700',
bgColor: 'bg-red-50',
borderColor: 'border-red-200',
icon: AlertTriangle,
label: 'Высокий уровень схожести',
};
}
if (pct > 10) {
return {
color: 'text-yellow-700',
bgColor: 'bg-yellow-50',
borderColor: 'border-yellow-200',
icon: Info,
label: 'Умеренный уровень схожести',
};
}
return {
color: 'text-green-700',
bgColor: 'bg-green-50',
borderColor: 'border-green-200',
icon: CheckCircle,
label: 'Низкий уровень схожести',
};
}
const METHOD_LABELS: Record<string, string> = {
exact: 'Точное совпадение',
fuzzy: 'Нечёткое совпадение',
'semantic+llm': 'Семантика + LLM',
};
export function PlagiarismReport({ data }: PlagiarismReportProps) {
const level = getSimilarityLevel(data.overall_similarity);
const Icon = level.icon;
return (
<div className="space-y-6">
{/* Итоговый показатель */}
<div className={clsx('p-6 rounded-xl border-2', level.bgColor, level.borderColor)}>
<div className="flex items-center gap-4">
<div className={clsx('text-5xl font-bold tabular-nums', level.color)}>
{data.overall_similarity.toFixed(1)}%
</div>
<div>
<div className={clsx('flex items-center gap-1.5 font-semibold text-base', level.color)}>
<Icon className="w-5 h-5" />
{level.label}
</div>
<p className="text-sm text-gray-600 mt-1">
Проверено фрагментов: {data.total_fragments} · Выявлено совпадений: {data.flagged_fragments}
</p>
</div>
</div>
{/* Прогресс-бар */}
<div className="mt-4 bg-white/60 rounded-full h-3 overflow-hidden">
<div
className={clsx('h-full rounded-full transition-all', {
'bg-red-500': data.overall_similarity > 30,
'bg-yellow-400': data.overall_similarity > 10 && data.overall_similarity <= 30,
'bg-green-500': data.overall_similarity <= 10,
})}
style={{ width: `${Math.min(data.overall_similarity, 100)}%` }}
/>
</div>
</div>
{/* Методы обнаружения */}
{data.by_method && (
<div className="grid grid-cols-3 gap-3">
{[
{ key: 'exact', label: 'Точные', count: data.by_method.exact },
{ key: 'fuzzy', label: 'Нечёткие', count: data.by_method.fuzzy },
{ key: 'semantic_llm', label: 'Семантика+LLM', count: data.by_method.semantic_llm },
].map(({ key, label, count }) => (
<div key={key} className="p-3 bg-gray-50 rounded-lg text-center">
<div className="text-2xl font-bold text-gray-800">{count}</div>
<div className="text-xs text-gray-500 mt-0.5">{label}</div>
</div>
))}
</div>
)}
{/* Список совпадений */}
{data.matches.length > 0 && (
<div>
<h3 className="text-base font-semibold text-gray-900 mb-3">
Обнаруженные совпадения ({data.matches.length})
</h3>
<div className="space-y-3">
{data.matches.map((match, i) => (
<div key={i} className="border border-gray-200 rounded-xl overflow-hidden">
{/* Шапка совпадения */}
<div className="flex items-center gap-3 px-4 py-2.5 bg-gray-50 border-b border-gray-200">
<span className="text-sm font-medium text-gray-700 flex-1 truncate">
{match.source_title}
</span>
<span className={clsx(
'text-xs font-medium px-2 py-0.5 rounded-full',
match.similarity > 70
? 'bg-red-100 text-red-700'
: match.similarity > 40
? 'bg-yellow-100 text-yellow-700'
: 'bg-gray-100 text-gray-600'
)}>
{match.similarity.toFixed(0)}%
</span>
<span className="text-xs text-gray-400 px-2 py-0.5 bg-white rounded-full border border-gray-200">
{METHOD_LABELS[match.method] || match.method}
</span>
</div>
{/* Фрагмент */}
<div className="px-4 py-3">
<p className="text-sm text-gray-700 italic leading-relaxed line-clamp-3">
«{match.fragment}»
</p>
{match.reason && (
<p className="text-xs text-gray-500 mt-2">
{match.reason}
</p>
)}
{match.source_url && (
<a
href={match.source_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-brand-600 hover:underline mt-1.5 inline-block"
>
Открыть источник
</a>
)}
</div>
</div>
))}
</div>
</div>
)}
{data.matches.length === 0 && (
<div className="text-center py-8 text-gray-500">
<CheckCircle className="w-12 h-12 text-green-400 mx-auto mb-3" />
<p className="text-base font-medium">Совпадений не обнаружено</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import React, { useState } from 'react';
import { Search } from 'lucide-react';
import { clsx } from 'clsx';
interface SearchBarProps {
onSearch: (query: string) => void;
defaultValue?: string;
placeholder?: string;
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
className?: string;
}
export function SearchBar({
onSearch,
defaultValue = '',
placeholder = 'Введите тему или запрос для поиска источников...',
size = 'md',
isLoading = false,
className,
}: SearchBarProps) {
const [query, setQuery] = useState(defaultValue);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = query.trim();
if (trimmed.length < 3) return;
onSearch(trimmed);
};
return (
<form onSubmit={handleSubmit} className={clsx('w-full', className)}>
<div className="relative flex items-center">
<Search
className={clsx(
'absolute left-4 text-gray-400 pointer-events-none',
size === 'lg' ? 'w-6 h-6' : 'w-5 h-5'
)}
/>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className={clsx(
'w-full bg-white border border-gray-200 rounded-xl shadow-sm',
'placeholder:text-gray-400 text-gray-900',
'focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent',
'transition-all duration-200',
size === 'lg' && 'pl-14 pr-36 py-5 text-lg',
size === 'md' && 'pl-12 pr-28 py-3 text-base',
size === 'sm' && 'pl-10 pr-24 py-2 text-sm'
)}
minLength={3}
maxLength={1000}
required
/>
<button
type="submit"
disabled={isLoading || query.trim().length < 3}
className={clsx(
'absolute right-2 bg-brand-600 text-white rounded-lg font-medium',
'hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed',
'transition-colors duration-200',
size === 'lg' && 'px-6 py-3 text-base',
size === 'md' && 'px-5 py-2 text-sm',
size === 'sm' && 'px-4 py-1.5 text-xs'
)}
>
{isLoading ? 'Поиск...' : 'Найти'}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,144 @@
import React from 'react';
import { ExternalLink, BookmarkPlus, BookmarkCheck, Copy } from 'lucide-react';
import { clsx } from 'clsx';
import toast from 'react-hot-toast';
import { useBibliographyStore } from '../store/bibliography';
import type { SearchSource } from '../types';
interface SourceCardProps {
source: SearchSource;
}
const SOURCE_DB_LABELS: Record<string, string> = {
openalex: 'OpenAlex',
cyberleninka: 'КиберЛенинка',
arxiv: 'arXiv',
wikipedia_ru: 'Wikipedia RU',
wikipedia_en: 'Wikipedia EN',
};
function getRelevanceColor(score: number): string {
if (score >= 0.7) return 'text-green-700 bg-green-50 border-green-200';
if (score >= 0.4) return 'text-yellow-700 bg-yellow-50 border-yellow-200';
return 'text-gray-600 bg-gray-50 border-gray-200';
}
export function SourceCard({ source }: SourceCardProps) {
const { addSource, removeSource, hasSource } = useBibliographyStore();
const inBibliography = hasSource(source.id);
const handleToggleBibliography = () => {
if (inBibliography) {
removeSource(source.id);
toast.success('Удалено из библиографии');
} else {
addSource(source);
toast.success('Добавлено в библиографию');
}
};
const handleCopyCitation = () => {
navigator.clipboard.writeText(source.gost_citation).then(() => {
toast.success('ГОСТ-цитата скопирована');
});
};
const authorsStr = source.authors
.slice(0, 3)
.map((a) => `${a.last_name} ${a.initials || ''}`.trim())
.join(', ');
return (
<div className="bg-white rounded-xl border border-gray-100 p-5 hover:border-gray-200 hover:shadow-sm transition-all duration-200">
{/* Заголовок и значки */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-gray-900 leading-snug line-clamp-2 mb-1">
{source.title}
</h3>
<div className="flex items-center gap-2 flex-wrap">
{authorsStr && (
<span className="text-sm text-gray-500">{authorsStr}</span>
)}
{source.year && (
<span className="text-sm text-gray-400">{source.year}</span>
)}
{source.journal && (
<span className="text-sm text-gray-400 italic">{source.journal}</span>
)}
</div>
</div>
{/* Бейдж релевантности */}
<div className={clsx(
'flex-shrink-0 px-2 py-1 rounded-lg text-xs font-medium border',
getRelevanceColor(source.relevance_score)
)}>
{(source.relevance_score * 100).toFixed(0)}%
</div>
</div>
{/* Аннотация */}
{source.abstract && (
<p className="text-sm text-gray-600 line-clamp-3 mb-3">
{source.abstract}
</p>
)}
{/* ГОСТ-цитата */}
<div className="bg-gray-50 rounded-lg p-3 mb-3">
<p className="text-xs text-gray-500 mb-1 font-medium">ГОСТ-цитата:</p>
<p className="text-xs text-gray-700 leading-relaxed">{source.gost_citation}</p>
</div>
{/* Действия */}
<div className="flex items-center gap-2 flex-wrap">
{/* Источник */}
<span className="px-2 py-1 bg-gray-100 text-gray-500 text-xs rounded-md">
{SOURCE_DB_LABELS[source.source_db] || source.source_db}
</span>
<div className="ml-auto flex items-center gap-1.5">
{/* Открыть источник */}
{source.url && (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<ExternalLink className="w-3 h-3" />
Открыть
</a>
)}
{/* Копировать цитату */}
<button
onClick={handleCopyCitation}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<Copy className="w-3 h-3" />
Цитата
</button>
{/* В библиографию */}
<button
onClick={handleToggleBibliography}
className={clsx(
'inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors',
inBibliography
? 'bg-brand-600 text-white hover:bg-brand-700'
: 'bg-white text-brand-600 border border-brand-200 hover:bg-brand-50'
)}
>
{inBibliography ? (
<><BookmarkCheck className="w-3 h-3" />Добавлено</>
) : (
<><BookmarkPlus className="w-3 h-3" />В библиографию</>
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { clsx } from 'clsx';
import type { TaskStatus } from '../types';
interface StatusBadgeProps {
status: TaskStatus;
className?: string;
}
const STATUS_CONFIG: Record<TaskStatus, { label: string; className: string }> = {
queued: {
label: 'В очереди',
className: 'bg-gray-100 text-gray-600 border-gray-200',
},
processing: {
label: 'Выполняется',
className: 'bg-blue-50 text-blue-700 border-blue-200 animate-pulse',
},
done: {
label: 'Готово',
className: 'bg-green-50 text-green-700 border-green-200',
},
failed: {
label: 'Ошибка',
className: 'bg-red-50 text-red-700 border-red-200',
},
};
export function StatusBadge({ status, className }: StatusBadgeProps) {
const config = STATUS_CONFIG[status];
return (
<span
className={clsx(
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border',
config.className,
className
)}
>
{status === 'processing' && (
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />
)}
{status === 'done' && (
<span className="w-1.5 h-1.5 rounded-full bg-green-500" />
)}
{status === 'failed' && (
<span className="w-1.5 h-1.5 rounded-full bg-red-500" />
)}
{status === 'queued' && (
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" />
)}
{config.label}
</span>
);
}

View File

@@ -0,0 +1,103 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale';
import { Search, FileText, BookOpen, AlignLeft, ChevronRight } from 'lucide-react';
import { clsx } from 'clsx';
import { StatusBadge } from './StatusBadge';
import type { Task } from '../types';
interface TaskCardProps {
task: Task;
}
const TYPE_CONFIG = {
search: {
icon: Search,
label: 'Поиск источников',
color: 'text-blue-600',
bg: 'bg-blue-50',
},
plagiarism: {
icon: FileText,
label: 'Проверка плагиата',
color: 'text-purple-600',
bg: 'bg-purple-50',
},
gost: {
icon: BookOpen,
label: 'ГОСТ библиография',
color: 'text-emerald-600',
bg: 'bg-emerald-50',
},
summarize: {
icon: AlignLeft,
label: 'Краткое изложение',
color: 'text-orange-600',
bg: 'bg-orange-50',
},
};
function getTaskSummary(task: Task): string {
if (task.status === 'queued') {
const pos = task.queue_position;
return pos ? `Позиция в очереди: ${pos}` : 'Ожидает выполнения';
}
if (task.status === 'processing') return 'Выполняется...';
if (task.status === 'failed') return task.error || 'Произошла ошибка';
const result = task.result as Record<string, unknown> | undefined;
if (!result) return 'Результат готов';
if (task.type === 'search') {
const total = result.total as number;
return `Найдено ${total} источников`;
}
if (task.type === 'plagiarism') {
const sim = result.overall_similarity as number;
return `Схожесть: ${sim?.toFixed(1)}%`;
}
if (task.type === 'gost') {
const count = (result.bibliography as unknown[])?.length;
return `${count} записей в библиографии`;
}
return 'Результат готов';
}
export function TaskCard({ task }: TaskCardProps) {
const config = TYPE_CONFIG[task.type] || TYPE_CONFIG.search;
const Icon = config.icon;
const summary = getTaskSummary(task);
return (
<Link
to={`/tasks/${task.id}`}
className="block group"
>
<div className="flex items-center gap-4 p-4 bg-white rounded-xl border border-gray-100 hover:border-brand-200 hover:shadow-sm transition-all duration-200">
{/* Иконка типа */}
<div className={clsx('p-2.5 rounded-lg flex-shrink-0', config.bg)}>
<Icon className={clsx('w-5 h-5', config.color)} />
</div>
{/* Контент */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-gray-900">{config.label}</span>
<StatusBadge status={task.status} />
</div>
<p className="text-sm text-gray-500 truncate">{summary}</p>
<p className="text-xs text-gray-400 mt-0.5">
{formatDistanceToNow(new Date(task.created_at), {
addSuffix: true,
locale: ru,
})}
</p>
</div>
{/* Стрелка */}
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-brand-500 flex-shrink-0 transition-colors" />
</div>
</Link>
);
}

View File

@@ -0,0 +1,52 @@
import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import type { Task } from '../types';
/**
* Хук для WebSocket подключения к задаче.
* Обновляет кэш React Query при получении обновления статуса.
*/
export function useTaskWebSocket(taskId: string | undefined, enabled: boolean) {
const queryClient = useQueryClient();
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
if (!taskId || !enabled) return;
const wsUrl = `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ws/tasks/${taskId}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data === 'pong') return;
// Обновить кэш задачи
queryClient.setQueryData<Task>(['task', taskId], (old) => {
if (!old) return old;
return { ...old, ...data };
});
} catch (e) {
console.warn('Ошибка парсинга WebSocket сообщения:', e);
}
};
ws.onerror = () => {
console.warn(`WebSocket ошибка для задачи ${taskId}`);
};
// Keepalive ping каждые 30 секунд
const pingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send('ping');
}
}, 30000);
return () => {
clearInterval(pingInterval);
ws.close();
wsRef.current = null;
};
}, [taskId, enabled, queryClient]);
}

View File

@@ -0,0 +1,10 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
}
}

View File

@@ -0,0 +1,59 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'react-hot-toast';
import { Layout } from './components/Layout';
import { Home } from './pages/Home';
import { Search } from './pages/Search';
import { Check } from './pages/Check';
import { Bibliography } from './pages/Bibliography';
import { Cabinet } from './pages/Cabinet';
import { Task } from './pages/Task';
import { Pricing } from './pages/Pricing';
import { Login } from './pages/Login';
import { Register } from './pages/Register';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30000,
retry: 1,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/search" element={<Search />} />
<Route path="/check" element={<Check />} />
<Route path="/bibliography" element={<Bibliography />} />
<Route path="/cabinet" element={<Cabinet />} />
<Route path="/tasks/:taskId" element={<Task />} />
<Route path="/pricing" element={<Pricing />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
</Routes>
</Layout>
</BrowserRouter>
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
borderRadius: '12px',
fontSize: '14px',
},
}}
/>
</QueryClientProvider>
</React.StrictMode>
);

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

View File

@@ -0,0 +1,44 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { User } from '../types';
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
setAuth: (user: User, token: string) => void;
updateUser: (user: Partial<User>) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
isAuthenticated: false,
setAuth: (user, token) =>
set({ user, token, isAuthenticated: true }),
updateUser: (updates) => {
const current = get().user;
if (current) {
set({ user: { ...current, ...updates } });
}
},
logout: () =>
set({ user: null, token: null, isAuthenticated: false }),
}),
{
name: 'auth-storage',
// Не сохранять методы в localStorage, только данные
partialize: (state) => ({
user: state.user,
token: state.token,
isAuthenticated: state.isAuthenticated,
}),
}
)
);

View File

@@ -0,0 +1,39 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { SearchSource } from '../types';
interface BibliographyState {
sources: SearchSource[];
addSource: (source: SearchSource) => void;
removeSource: (id: number) => void;
clearSources: () => void;
hasSource: (id: number) => boolean;
}
export const useBibliographyStore = create<BibliographyState>()(
persist(
(set, get) => ({
sources: [],
addSource: (source) => {
const existing = get().sources.find((s) => s.id === source.id);
if (!existing) {
set((state) => ({ sources: [...state.sources, source] }));
}
},
removeSource: (id) => {
set((state) => ({
sources: state.sources.filter((s) => s.id !== id),
}));
},
clearSources: () => set({ sources: [] }),
hasSource: (id) => get().sources.some((s) => s.id === id),
}),
{
name: 'bibliography-storage',
}
)
);

View File

@@ -0,0 +1,179 @@
// ─── Типы данных для Академического помощника ─────────────────────────────────
export type TaskStatus = 'queued' | 'processing' | 'done' | 'failed';
export type TaskType = 'search' | 'plagiarism' | 'summarize' | 'gost';
export type UserPlan = 'free' | 'student' | 'premium' | 'science';
// ─── Пользователь ─────────────────────────────────────────────────────────────
export interface User {
id: number;
email: string;
name: string;
plan: UserPlan;
is_verified: boolean;
}
// ─── Источник в результатах поиска ────────────────────────────────────────────
export interface AuthorInfo {
last_name: string;
first_name?: string;
initials?: string;
}
export interface SearchSource {
id: number;
title: string;
authors: AuthorInfo[];
year: number | null;
journal: string | null;
abstract: string | null;
url: string | null;
doi: string | null;
relevance_score: number;
gost_citation: string;
source_db: string;
}
export interface SearchResultData {
sources: SearchSource[];
total: number;
query: string;
}
// ─── Отчёт о плагиате ─────────────────────────────────────────────────────────
export interface PlagiarismMatch {
fragment: string;
position_start: number;
position_end: number;
similarity: number;
method: 'exact' | 'fuzzy' | 'semantic+llm';
confidence?: number;
reason?: string;
source_title: string;
source_url: string | null;
source_db: string;
}
export interface PlagiarismResultData {
overall_similarity: number;
matches: PlagiarismMatch[];
total_fragments: number;
flagged_fragments: number;
by_method?: {
exact: number;
fuzzy: number;
semantic_llm: number;
};
}
// ─── Библиография ─────────────────────────────────────────────────────────────
export interface BibliographyEntry {
number: number;
citation: string;
doc_id: number;
}
export interface GostResultData {
bibliography: BibliographyEntry[];
style: string;
total: number;
}
// ─── Задача ───────────────────────────────────────────────────────────────────
export type TaskResult = SearchResultData | PlagiarismResultData | GostResultData | null;
export interface Task {
id: string;
type: TaskType;
status: TaskStatus;
queue_position?: number;
eta_seconds?: number;
input_data: Record<string, unknown>;
result?: TaskResult;
error?: string;
created_at: string;
}
// ─── API ответы ───────────────────────────────────────────────────────────────
export interface TokenResponse {
access_token: string;
token_type: string;
user: User;
}
export interface ApiError {
detail: string;
}
// ─── Запросы ──────────────────────────────────────────────────────────────────
export interface SearchRequest {
query: string;
lang?: string;
year_from?: number;
year_to?: number;
category?: string;
}
export interface RegisterRequest {
email: string;
password: string;
name: string;
}
export interface LoginRequest {
email: string;
password: string;
}
// ─── Лимиты тарифных планов ───────────────────────────────────────────────────
export interface PlanLimits {
name: string;
price: number;
search_per_day: number | null;
summarize_per_month: number | null;
plagiarism_per_month: number | null;
concurrent: number;
}
export const PLAN_LIMITS: Record<UserPlan, PlanLimits> = {
free: {
name: 'Бесплатный',
price: 0,
search_per_day: 10,
summarize_per_month: 3,
plagiarism_per_month: 1,
concurrent: 1,
},
student: {
name: 'Студенческий',
price: 199,
search_per_day: null,
summarize_per_month: 30,
plagiarism_per_month: 10,
concurrent: 2,
},
premium: {
name: 'Премиум',
price: 499,
search_per_day: null,
summarize_per_month: null,
plagiarism_per_month: 50,
concurrent: 5,
},
science: {
name: 'Научный',
price: 999,
search_per_day: null,
summarize_per_month: null,
plagiarism_per_month: null,
concurrent: 10,
},
};

View File

@@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
brand: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
},
animation: {
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
});