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