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