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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user