scraper / TasksTab.tsx
greeta's picture
Upload TasksTab.tsx
393e843 verified
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;