feat: initial microservices project structure

Services:
- api: FastAPI gateway with JWT auth, async endpoints, WebSocket
- worker-gpu: CUDA sentence-transformers, FAISS IVFFlat, Ollama LLM
- worker-indexer: Winnowing+MinHash plagiarism detection, PDF/DOCX extraction
- worker-notifier: SMTP email notifications
- worker-gost: GOST 7.1-2003 and GOST R 7.0.5-2008 formatting

Infrastructure:
- docker-compose.yml (production) + docker-compose.dev.yml (hot reload)
- Nginx reverse proxy + WebSocket support
- PostgreSQL 16 with Alembic migrations
- Elasticsearch 8 with Russian/English analyzers
- MinIO, RabbitMQ, Redis, Ollama

Frontend:
- React 18 + Vite + TypeScript + TailwindCSS + Zustand + React Query v5
- 9 pages: Home, Search, Cabinet, Task, Check, Bibliography, Pricing, Login, Register

Scripts:
- Parser stubs: OpenAlex, КиберЛенинка, arXiv (Phase 0 - to be filled)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-05-24 19:42:39 +05:00
commit 7758315632
120 changed files with 9500 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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