| 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<TasksTabProps> = ({ |
| session, |
| scraperUrl = import.meta.env.VITE_SCRAPER_URL || 'http://localhost:8000', |
| }) => { |
| const [tasks, setTasks] = useState<FipiTask[]>([]); |
| const [attempts, setAttempts] = useState<TaskAttempt[]>([]); |
| const [localAttempts, setLocalAttempts] = useState<TaskAttempt[]>([]); |
| const [answerDrafts, setAnswerDrafts] = useState<Record<number, string>>({}); |
| const [checkResults, setCheckResults] = useState<Record<number, CheckAnswerResult>>({}); |
| const [selectedTaskId, setSelectedTaskId] = useState<number | null>(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<string | null>(null); |
| const [checkingTaskId, setCheckingTaskId] = useState<number | null>(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<number, TaskAttempt>(); |
| 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<number, number>(); |
| 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 ( |
| <div className="flex justify-center py-20"> |
| <Loader /> |
| </div> |
| ); |
| } |
|
|
| return ( |
| <div className="mx-auto max-w-6xl space-y-6 pb-12 animate-fade-in"> |
| <div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm"> |
| <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> |
| <div> |
| <h2 className="text-3xl font-serif font-bold text-refined-dark">Практика по заданиям ФИПИ</h2> |
| <p className="mt-1 text-stone-500"> |
| Задания подгружаются из базы автоматически. Проверка ответа доступна для задач из банка ФИПИ. |
| </p> |
| </div> |
| <div className="flex flex-col gap-3 sm:flex-row"> |
| <input |
| value={search} |
| onChange={(event) => 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" |
| /> |
| <button |
| onClick={fetchTasks} |
| className="rounded-full bg-stone-100 px-5 py-3 text-sm font-medium text-stone-700 transition hover:bg-stone-200" |
| > |
| Обновить список |
| </button> |
| </div> |
| </div> |
| |
| <div className="mt-5 flex flex-wrap gap-2"> |
| {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 ( |
| <button |
| key={filter} |
| onClick={() => setFilterType(filter)} |
| className={`rounded-full px-4 py-2 text-sm font-medium transition ${ |
| filterType === filter |
| ? 'bg-refined-red text-white' |
| : 'bg-stone-100 text-stone-600 hover:bg-stone-200' |
| }`} |
| > |
| {filter === 'all' ? 'Все' : filter === 'checkable' ? 'С проверкой' : filter} |
| {' '} |
| ({count}) |
| </button> |
| ); |
| })} |
| </div> |
| </div> |
| |
| <div className="grid gap-4 md:grid-cols-4"> |
| <div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm"> |
| <p className="text-xs font-bold uppercase text-stone-500">Всего заданий</p> |
| <p className="mt-2 text-3xl font-serif font-bold text-refined-dark">{tasks.length}</p> |
| </div> |
| <div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm"> |
| <p className="text-xs font-bold uppercase text-stone-500">Решено</p> |
| <p className="mt-2 text-3xl font-serif font-bold text-refined-dark">{taskStats.solvedTasks}</p> |
| </div> |
| <div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm"> |
| <p className="text-xs font-bold uppercase text-stone-500">Ошибок</p> |
| <p className="mt-2 text-3xl font-serif font-bold text-refined-dark">{taskStats.incorrectAttempts}</p> |
| </div> |
| <div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm"> |
| <p className="text-xs font-bold uppercase text-stone-500">Точность</p> |
| <p className="mt-2 text-3xl font-serif font-bold text-refined-dark">{taskStats.accuracy}%</p> |
| </div> |
| </div> |
| |
| {(statsLoading || taskStats.hardestTasks.length > 0) && ( |
| <div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm"> |
| <h3 className="font-serif text-xl font-bold text-refined-dark">Статистика ошибок</h3> |
| {statsLoading ? ( |
| <div className="mt-4 flex justify-center"> |
| <Loader /> |
| </div> |
| ) : taskStats.hardestTasks.length === 0 ? ( |
| <p className="mt-3 text-stone-500">Пока нет ошибок по сохранённым попыткам.</p> |
| ) : ( |
| <div className="mt-4 space-y-3"> |
| {taskStats.hardestTasks.map(({ task, wrongCount }) => ( |
| <div key={task!.id} className="rounded-xl border border-stone-200 bg-stone-50 p-4"> |
| <div className="flex items-start justify-between gap-4"> |
| <div> |
| <p className="font-semibold text-refined-dark">{task!.title}</p> |
| <p className="mt-1 text-sm text-stone-500">{sourceLabel(task!)}</p> |
| </div> |
| <span className="rounded-full bg-red-100 px-3 py-1 text-xs font-medium text-red-700"> |
| Ошибок: {wrongCount} |
| </span> |
| </div> |
| </div> |
| ))} |
| </div> |
| )} |
| </div> |
| )} |
| |
| {error && ( |
| <div className="rounded-2xl border border-red-200 bg-red-50 p-6 text-red-700"> |
| {error} |
| </div> |
| )} |
| |
| <div className="space-y-4"> |
| {filteredTasks.map((task) => { |
| const latestAttempt = latestAttemptByTask.get(task.id); |
| const isExpanded = selectedTaskId === task.id; |
| const checkResult = checkResults[task.id]; |
| return ( |
| <div key={task.id} className="rounded-2xl border border-stone-200 bg-white shadow-sm"> |
| <div className="flex flex-col gap-4 p-6 md:flex-row md:items-start md:justify-between"> |
| <div className="min-w-0 flex-1"> |
| <div className="flex flex-wrap items-center gap-2"> |
| <span className="rounded-full bg-stone-100 px-3 py-1 text-xs font-medium text-stone-600"> |
| {sourceLabel(task)} |
| </span> |
| {task.can_check_answer && ( |
| <span className="rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700"> |
| Можно проверить |
| </span> |
| )} |
| {latestAttempt && ( |
| <span |
| className={`rounded-full px-3 py-1 text-xs font-medium ${ |
| latestAttempt.is_correct |
| ? 'bg-emerald-100 text-emerald-700' |
| : 'bg-red-100 text-red-700' |
| }`} |
| > |
| {latestAttempt.is_correct ? 'Последний ответ верный' : 'Последний ответ неверный'} |
| </span> |
| )} |
| </div> |
| <h3 className="mt-3 text-xl font-semibold text-refined-dark">{task.title}</h3> |
| <p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm text-stone-600">{task.content}</p> |
| </div> |
| <button |
| onClick={() => setSelectedTaskId(isExpanded ? null : task.id)} |
| className="rounded-full bg-stone-100 px-4 py-2 text-sm font-medium text-stone-700 transition hover:bg-stone-200" |
| > |
| {isExpanded ? 'Скрыть' : 'Открыть'} |
| </button> |
| </div> |
| |
| {isExpanded && ( |
| <div className="border-t border-stone-200 px-6 py-6"> |
| <div className="rounded-xl border border-stone-200 bg-refined-paper p-5"> |
| <p className="whitespace-pre-wrap text-stone-700">{task.content}</p> |
| |
| {task.variants && task.variants.length > 0 && ( |
| <div className="mt-5"> |
| <p className="mb-2 text-sm font-semibold text-refined-dark">Варианты из задания</p> |
| <div className="space-y-2"> |
| {task.variants.map((variant, index) => ( |
| <div key={`${task.id}-variant-${index}`} className="rounded-lg bg-white px-3 py-2 text-sm text-stone-700"> |
| {variant} |
| </div> |
| ))} |
| </div> |
| </div> |
| )} |
| |
| <div className="mt-6 space-y-3"> |
| {task.can_check_answer ? ( |
| <> |
| <label className="block text-sm font-semibold text-refined-dark"> |
| Введите ответ |
| </label> |
| <div className="flex flex-col gap-3 md:flex-row"> |
| <input |
| value={answerDrafts[task.id] || ''} |
| onChange={(event) => |
| 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" |
| /> |
| <button |
| onClick={() => submitAnswer(task)} |
| disabled={checkingTaskId === task.id} |
| className="rounded-xl bg-refined-red px-5 py-3 text-sm font-medium text-white transition hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60" |
| > |
| {checkingTaskId === task.id ? 'Проверяю...' : 'Проверить'} |
| </button> |
| </div> |
| <p className="text-xs text-stone-500"> |
| Для статистики попытки сохраняются в Supabase, если пользователь авторизован. |
| </p> |
| </> |
| ) : ( |
| <div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800"> |
| Для этой задачи автоматическая проверка пока недоступна. Проверка работает для задач из динамического банка ФИПИ. |
| </div> |
| )} |
| |
| {checkResult && ( |
| <div |
| className={`rounded-xl p-4 text-sm font-medium ${ |
| checkResult.is_correct |
| ? 'bg-emerald-50 text-emerald-700' |
| : 'bg-red-50 text-red-700' |
| }`} |
| > |
| <p>{checkResult.message}</p> |
| <p className="mt-1 text-xs opacity-80"> |
| Отправлено: {checkResult.submitted_answer} |
| </p> |
| </div> |
| )} |
| </div> |
| |
| <a |
| href={task.source_url} |
| target="_blank" |
| rel="noopener noreferrer" |
| className="mt-5 inline-flex items-center gap-2 text-sm font-medium text-refined-red hover:underline" |
| > |
| Открыть источник |
| </a> |
| </div> |
| </div> |
| )} |
| </div> |
| ); |
| })} |
| </div> |
|
|
| {!error && filteredTasks.length === 0 && ( |
| <div className="rounded-2xl border border-stone-200 bg-white p-10 text-center text-stone-500 shadow-sm"> |
| По текущему фильтру заданий не найдено. |
| </div> |
| )} |
| </div> |
| ); |
| }; |
|
|
| export default TasksTab; |
|
|