import React, { useEffect, useMemo, useState } from 'react'; import type { Session } from '@supabase/supabase-js'; import Loader from './Loader'; import { isSupabaseConfigured, supabase } from '../services/supabaseClient'; interface FipiTask { id: number; title: string; content: string; task_type: string | null; source_url: string; scraped_at: string; variants?: string[]; can_check_answer?: boolean; } interface TaskAttempt { id?: number; task_id: number; submitted_answer: string; is_correct: boolean; check_status: string; created_at: string; } interface CheckAnswerResult { success: boolean; is_correct: boolean; status_code: string; status_label: string; submitted_answer: string; normalized_answer: string; message: string; } interface TasksTabProps { session: Session | null; scraperUrl?: string; } const FILTERS = ['all', 'checkable', 'writing', 'test', 'reading', 'listening', 'other'] as const; const sourceLabel = (task: FipiTask) => { if (task.source_url.includes('questions.php')) return 'Банк ФИПИ'; if (task.source_url.includes('otkrytyye-varianty-kim-ege')) return 'Открытый вариант'; if (task.source_url.includes('demoversii-specifikacii-kodifikatory')) return 'Демоверсия'; return 'Источник ФИПИ'; }; const TasksTab: React.FC = ({ session, scraperUrl = import.meta.env.VITE_SCRAPER_URL || 'http://localhost:8000', }) => { const [tasks, setTasks] = useState([]); const [attempts, setAttempts] = useState([]); const [localAttempts, setLocalAttempts] = useState([]); const [answerDrafts, setAnswerDrafts] = useState>({}); const [checkResults, setCheckResults] = useState>({}); const [selectedTaskId, setSelectedTaskId] = useState(null); const [filterType, setFilterType] = useState<(typeof FILTERS)[number]>('checkable'); const [search, setSearch] = useState(''); const [loading, setLoading] = useState(true); const [statsLoading, setStatsLoading] = useState(false); const [error, setError] = useState(null); const [checkingTaskId, setCheckingTaskId] = useState(null); const fetchTasks = async () => { setLoading(true); setError(null); try { const response = await fetch(`${scraperUrl}/api/tasks`); if (!response.ok) { throw new Error('Не удалось загрузить задания.'); } const data = await response.json(); const taskList = (Array.isArray(data) ? data : data.tasks || []) as FipiTask[]; setTasks(taskList); } catch (err) { setError(err instanceof Error ? err.message : 'Не удалось загрузить задания.'); } finally { setLoading(false); } }; const fetchAttempts = async () => { if (!session || !isSupabaseConfigured()) { setAttempts([]); return; } setStatsLoading(true); try { const { data, error: selectError } = await supabase .from('task_attempts') .select('*') .eq('user_id', session.user.id) .order('created_at', { ascending: false }); if (selectError) { throw selectError; } setAttempts((data || []) as TaskAttempt[]); } catch { setAttempts([]); } finally { setStatsLoading(false); } }; useEffect(() => { fetchTasks(); }, [scraperUrl]); useEffect(() => { fetchAttempts(); }, [session]); const allAttempts = session ? attempts : localAttempts; const latestAttemptByTask = useMemo(() => { const latest = new Map(); for (const attempt of allAttempts) { if (!latest.has(attempt.task_id)) { latest.set(attempt.task_id, attempt); } } return latest; }, [allAttempts]); const taskStats = useMemo(() => { const totalAttempts = allAttempts.length; const incorrectAttempts = allAttempts.filter((attempt) => !attempt.is_correct).length; const correctAttempts = totalAttempts - incorrectAttempts; const solvedTasks = new Set(allAttempts.filter((attempt) => attempt.is_correct).map((attempt) => attempt.task_id)).size; const accuracy = totalAttempts === 0 ? 0 : Math.round((correctAttempts / totalAttempts) * 100); const wrongByTask = new Map(); for (const attempt of allAttempts) { if (!attempt.is_correct) { wrongByTask.set(attempt.task_id, (wrongByTask.get(attempt.task_id) || 0) + 1); } } const hardestTasks = Array.from(wrongByTask.entries()) .sort((left, right) => right[1] - left[1]) .slice(0, 5) .map(([taskId, wrongCount]) => ({ task: tasks.find((task) => task.id === taskId), wrongCount, })) .filter((item) => item.task); return { totalAttempts, incorrectAttempts, correctAttempts, solvedTasks, accuracy, hardestTasks, }; }, [allAttempts, tasks]); const filteredTasks = useMemo(() => { const normalizedQuery = search.trim().toLowerCase(); return tasks .filter((task) => { if (filterType === 'checkable') { return Boolean(task.can_check_answer); } if (filterType !== 'all') { return task.task_type === filterType; } return true; }) .filter((task) => { if (!normalizedQuery) { return true; } return ( task.title.toLowerCase().includes(normalizedQuery) || task.content.toLowerCase().includes(normalizedQuery) ); }) .sort((left, right) => Number(Boolean(right.can_check_answer)) - Number(Boolean(left.can_check_answer))); }, [filterType, search, tasks]); const submitAnswer = async (task: FipiTask) => { const draft = (answerDrafts[task.id] || '').trim(); if (!draft) { setCheckResults((current) => ({ ...current, [task.id]: { success: false, is_correct: false, status_code: 'validation_error', status_label: 'Пустой ответ', submitted_answer: draft, normalized_answer: '', message: 'Введите ответ перед проверкой.', }, })); return; } setCheckingTaskId(task.id); try { const response = await fetch(`${scraperUrl}/api/tasks/${task.id}/check-answer`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ answer: draft }), }); const payload = (await response.json()) as CheckAnswerResult | { detail?: string }; if (!response.ok) { throw new Error('detail' in payload && payload.detail ? payload.detail : 'Проверка не удалась.'); } const result = payload as CheckAnswerResult; setCheckResults((current) => ({ ...current, [task.id]: result })); const attempt: TaskAttempt = { task_id: task.id, submitted_answer: draft, is_correct: result.is_correct, check_status: result.status_code, created_at: new Date().toISOString(), }; if (session && isSupabaseConfigured()) { const { error: insertError } = await supabase.from('task_attempts').insert({ user_id: session.user.id, task_id: task.id, submitted_answer: draft, is_correct: result.is_correct, check_status: result.status_code, }); if (!insertError) { await fetchAttempts(); } } else { setLocalAttempts((current) => [attempt, ...current]); } } catch (err) { setCheckResults((current) => ({ ...current, [task.id]: { success: false, is_correct: false, status_code: 'request_error', status_label: 'Ошибка', submitted_answer: draft, normalized_answer: draft, message: err instanceof Error ? err.message : 'Проверка не удалась.', }, })); } finally { setCheckingTaskId(null); } }; if (loading) { return (
); } return (

Практика по заданиям ФИПИ

Задания подгружаются из базы автоматически. Проверка ответа доступна для задач из банка ФИПИ.

setSearch(event.target.value)} placeholder="Поиск по тексту задания" className="rounded-full border border-stone-300 px-4 py-3 text-sm text-stone-700 outline-none transition focus:border-refined-red" />
{FILTERS.map((filter) => { const count = filter === 'all' ? tasks.length : filter === 'checkable' ? tasks.filter((task) => task.can_check_answer).length : tasks.filter((task) => task.task_type === filter).length; if (count === 0 && filter !== 'all') { return null; } return ( ); })}

Всего заданий

{tasks.length}

Решено

{taskStats.solvedTasks}

Ошибок

{taskStats.incorrectAttempts}

Точность

{taskStats.accuracy}%

{(statsLoading || taskStats.hardestTasks.length > 0) && (

Статистика ошибок

{statsLoading ? (
) : taskStats.hardestTasks.length === 0 ? (

Пока нет ошибок по сохранённым попыткам.

) : (
{taskStats.hardestTasks.map(({ task, wrongCount }) => (

{task!.title}

{sourceLabel(task!)}

Ошибок: {wrongCount}
))}
)}
)} {error && (
{error}
)}
{filteredTasks.map((task) => { const latestAttempt = latestAttemptByTask.get(task.id); const isExpanded = selectedTaskId === task.id; const checkResult = checkResults[task.id]; return (
{sourceLabel(task)} {task.can_check_answer && ( Можно проверить )} {latestAttempt && ( {latestAttempt.is_correct ? 'Последний ответ верный' : 'Последний ответ неверный'} )}

{task.title}

{task.content}

{isExpanded && (

{task.content}

{task.variants && task.variants.length > 0 && (

Варианты из задания

{task.variants.map((variant, index) => (
{variant}
))}
)}
{task.can_check_answer ? ( <>
setAnswerDrafts((current) => ({ ...current, [task.id]: event.target.value, })) } placeholder="Например: 25 или СЛОВО" className="flex-1 rounded-xl border border-stone-300 px-4 py-3 text-sm text-stone-700 outline-none transition focus:border-refined-red" />

Для статистики попытки сохраняются в Supabase, если пользователь авторизован.

) : (
Для этой задачи автоматическая проверка пока недоступна. Проверка работает для задач из динамического банка ФИПИ.
)} {checkResult && (

{checkResult.message}

Отправлено: {checkResult.submitted_answer}

)}
Открыть источник
)}
); })}
{!error && filteredTasks.length === 0 && (
По текущему фильтру заданий не найдено.
)}
); }; export default TasksTab;