continued working on session fetch

This commit is contained in:
rngsurrounded
2025-03-02 22:28:57 +09:00
parent 50c808671a
commit 7de03ecf86
12 changed files with 431 additions and 314 deletions
@@ -1,63 +1,63 @@
import React from 'react'; // import React from 'react';
import { Link } from 'react-router-dom'; // import { Link } from 'react-router-dom';
import { Task } from "@/shared/types"; // import { Task } from "@/shared/types";
import { Settings, Plus } from 'lucide-react'; // import { Settings, Plus } from 'lucide-react';
import { Button } from "@/components/ui/button"; // import { Button } from "@/components/ui/button";
interface ConstructorHeaderProps { // interface ConstructorHeaderProps {
title: string; // title: string;
tasks: Task[]; // tasks: Task[];
competitionId: string; // competitionId: string;
onAddTaskClick: () => void; // onAddTaskClick: () => void;
} // }
const ConstructorHeader: React.FC<ConstructorHeaderProps> = ({ // const ConstructorHeader: React.FC<ConstructorHeaderProps> = ({
title, // title,
tasks, // tasks,
competitionId, // competitionId,
onAddTaskClick // onAddTaskClick
}) => { // }) => {
return ( // return (
<header className="bg-white shadow-sm sticky top-0 z-30 w-full"> // <header className="bg-white shadow-sm sticky top-0 z-30 w-full">
<div className="mx-auto max-w-6xl px-4"> // <div className="mx-auto max-w-6xl px-4">
<div className="py-4 text-center"> // <div className="py-4 text-center">
<h1 className="font-hse-sans text-xl font-semibold"> // <h1 className="font-hse-sans text-xl font-semibold">
{title} // {title}
</h1> // </h1>
</div> // </div>
<div className="flex items-center justify-center gap-4 pb-4 overflow-x-auto no-scrollbar"> // <div className="flex items-center justify-center gap-4 pb-4 overflow-x-auto no-scrollbar">
<Link // <Link
to={`/constructor/${competitionId}/tasks/settings`} // to={`/constructor/${competitionId}/tasks/settings`}
className="bg-gray-100 text-gray-700 rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer // className="bg-gray-100 text-gray-700 rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
transition-all hover:bg-gray-200 flex-shrink-0 flex items-center" // transition-all hover:bg-gray-200 flex-shrink-0 flex items-center"
> // >
<Settings size={16} className="mr-1" /> // <Settings size={16} className="mr-1" />
</Link> // </Link>
{tasks.map((task) => ( // {tasks.map((task) => (
<Link // <Link
key={task.id} // key={task.id}
to={`/constructor/${competitionId}/tasks/${task.id}`} // to={`/constructor/${competitionId}/tasks/${task.id}`}
className="bg-blue-100 text-blue-700 rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer // className="bg-blue-100 text-blue-700 rounded-lg px-3 py-1.5 font-medium text-sm font-hse-sans cursor-pointer
transition-all hover:bg-blue-200 flex-shrink-0" // transition-all hover:bg-blue-200 flex-shrink-0"
> // >
{task.number} // {task.number}
</Link> // </Link>
))} // ))}
<Button // <Button
variant="ghost" // variant="ghost"
size="sm" // size="sm"
className="rounded-lg flex items-center px-2 h-8" // className="rounded-lg flex items-center px-2 h-8"
onClick={onAddTaskClick} // onClick={onAddTaskClick}
> // >
<Plus size={18} /> // <Plus size={18} />
</Button> // </Button>
</div> // </div>
</div> // </div>
</header> // </header>
); // );
}; // };
export default ConstructorHeader; // export default ConstructorHeader;
@@ -1,89 +1,89 @@
import { useState } from "react"; // import { useState } from "react";
import { useParams, Navigate, useNavigate } from "react-router-dom"; // import { useParams, Navigate, useNavigate } from "react-router-dom";
import { Task, TaskStatus } from "@/shared/types"; // import { Task, TaskStatus } from "@/shared/types";
import ConstructorHeader from "./components/ConstructorHeader"; // import ConstructorHeader from "./components/ConstructorHeader";
import TaskCreationModal from "./modules/TaskCreationModal"; // import TaskCreationModal from "./modules/TaskCreationModal";
const CompetitionConstructor = () => { // const CompetitionConstructor = () => {
const { id, taskId } = useParams<{ id: string; taskId?: string }>(); // const { id, taskId } = useParams<{ id: string; taskId?: string }>();
const navigate = useNavigate(); // const navigate = useNavigate();
const [competitionTitle, setCompetitionTitle] = useState("Новая олимпиада"); // const [competitionTitle, setCompetitionTitle] = useState("Новая олимпиада");
const [tasks, setTasks] = useState<Task[]>([]); // const [tasks, setTasks] = useState<Task[]>([]);
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); // const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const isSettings = taskId === "settings"; // const isSettings = taskId === "settings";
const handleOpenTaskModal = () => { // const handleOpenTaskModal = () => {
setIsTaskModalOpen(true); // setIsTaskModalOpen(true);
}; // };
const handleCloseTaskModal = () => { // const handleCloseTaskModal = () => {
setIsTaskModalOpen(false); // setIsTaskModalOpen(false);
}; // };
const handleCreateTask = (taskData: Partial<Task>) => { // const handleCreateTask = (taskData: Partial<Task>) => {
const newTask: Task = { // const newTask: Task = {
id: `task-${Date.now()}`, // id: `task-${Date.now()}`,
number: taskData.number || `${tasks.length + 1}`, // number: taskData.number || `${tasks.length + 1}`,
status: TaskStatus.Uncleared, // status: TaskStatus.Uncleared,
solutionType: taskData.solutionType || "input", // solutionType: taskData.solutionType || "input",
description: taskData.description || "", // description: taskData.description || "",
requirements: taskData.requirements, // requirements: taskData.requirements,
attachments: taskData.attachments || [] // attachments: taskData.attachments || []
}; // };
setTasks([...tasks, newTask]); // setTasks([...tasks, newTask]);
setIsTaskModalOpen(false); // setIsTaskModalOpen(false);
navigate(`/constructor/${id}/tasks/${newTask.id}`); // navigate(`/constructor/${id}/tasks/${newTask.id}`);
}; // };
if (!taskId) { // if (!taskId) {
if (tasks.length > 0) { // if (tasks.length > 0) {
return <Navigate to={`/constructor/${id}/tasks/${tasks[0].id}`} replace />; // return <Navigate to={`/constructor/${id}/tasks/${tasks[0].id}`} replace />;
} else { // } else {
return <Navigate to={`/constructor/${id}/tasks/settings`} replace />; // return <Navigate to={`/constructor/${id}/tasks/settings`} replace />;
} // }
} // }
return ( // return (
<div className="flex flex-col min-h-screen"> // <div className="flex flex-col min-h-screen">
<ConstructorHeader // <ConstructorHeader
title={competitionTitle} // title={competitionTitle}
tasks={tasks} // tasks={tasks}
competitionId={id || ""} // competitionId={id || ""}
onAddTaskClick={handleOpenTaskModal} // onAddTaskClick={handleOpenTaskModal}
/> // />
<TaskCreationModal // <TaskCreationModal
isOpen={isTaskModalOpen} // isOpen={isTaskModalOpen}
onClose={handleCloseTaskModal} // onClose={handleCloseTaskModal}
onCreateTask={handleCreateTask} // onCreateTask={handleCreateTask}
taskCount={tasks.length} // taskCount={tasks.length}
/> // />
<main className="flex-1 bg-[#F8F8F8] pb-8"> // <main className="flex-1 bg-[#F8F8F8] pb-8">
<div className="max-w-6xl mx-auto px-4 py-6"> // <div className="max-w-6xl mx-auto px-4 py-6">
{isSettings ? ( // {isSettings ? (
<div className="bg-white rounded-lg p-6 shadow-sm"> // <div className="bg-white rounded-lg p-6 shadow-sm">
<h2 className="text-2xl font-semibold mb-6 font-hse-sans">Настройки олимпиады</h2> // <h2 className="text-2xl font-semibold mb-6 font-hse-sans">Настройки олимпиады</h2>
<p className="text-gray-500 font-hse-sans"> // <p className="text-gray-500 font-hse-sans">
Здесь будет форма настроек олимпиады // Здесь будет форма настроек олимпиады
</p> // </p>
</div> // </div>
) : ( // ) : (
<div className="bg-white rounded-lg p-6 shadow-sm"> // <div className="bg-white rounded-lg p-6 shadow-sm">
<h2 className="text-2xl font-semibold mb-6 font-hse-sans"> // <h2 className="text-2xl font-semibold mb-6 font-hse-sans">
{`Редактирование задачи ${tasks.find(t => t.id === taskId)?.number || ""}`} // {`Редактирование задачи ${tasks.find(t => t.id === taskId)?.number || ""}`}
</h2> // </h2>
<p className="text-gray-500 font-hse-sans"> // <p className="text-gray-500 font-hse-sans">
Здесь будет форма редактирования задачи // Здесь будет форма редактирования задачи
</p> // </p>
</div> // </div>
)} // )}
</div> // </div>
</main> // </main>
</div> // </div>
); // );
}; // };
export default CompetitionConstructor; // export default CompetitionConstructor;
@@ -1,101 +1,101 @@
import React, { useState } from 'react'; // import React, { useState } from 'react';
import { // import {
Dialog, // Dialog,
DialogContent, // DialogContent,
DialogHeader, // DialogHeader,
DialogTitle, // DialogTitle,
DialogFooter // DialogFooter
} from "@/components/ui/dialog"; // } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; // import { Button } from "@/components/ui/button";
import { Task } from "@/shared/types"; // import { Task } from "@/shared/types";
import TaskNumberField from './components/TaskNumberField'; // import TaskNumberField from './components/TaskNumberField';
import TaskDescriptionField from './components/TaskDescriptionField'; // import TaskDescriptionField from './components/TaskDescriptionField';
import TaskRequirementsField from './components/TaskRequirementsField'; // import TaskRequirementsField from './components/TaskRequirementsField';
import TaskSolutionTypeSelector from './components/TaskSolutionTypeSelector'; // import TaskSolutionTypeSelector from './components/TaskSolutionTypeSelector';
import TaskFileAttachments from './components/TaskFileAttachments'; // import TaskFileAttachments from './components/TaskFileAttachments';
interface TaskCreationModalProps { // interface TaskCreationModalProps {
isOpen: boolean; // isOpen: boolean;
onClose: () => void; // onClose: () => void;
onCreateTask: (task: Partial<Task>) => void; // onCreateTask: (task: Partial<Task>) => void;
taskCount: number; // taskCount: number;
} // }
const TaskCreationModal: React.FC<TaskCreationModalProps> = ({ // const TaskCreationModal: React.FC<TaskCreationModalProps> = ({
isOpen, // isOpen,
onClose, // onClose,
onCreateTask, // onCreateTask,
taskCount // taskCount
}) => { // }) => {
const [number, setNumber] = useState(`${taskCount + 1}`); // const [number, setNumber] = useState(`${taskCount + 1}`);
const [description, setDescription] = useState(''); // const [description, setDescription] = useState('');
const [requirements, setRequirements] = useState(''); // const [requirements, setRequirements] = useState('');
const [solutionType, setSolutionType] = useState<'input' | 'file' | 'code'>('input'); // const [solutionType, setSolutionType] = useState<'input' | 'file' | 'code'>('input');
const [attachedFiles, setAttachedFiles] = useState<File[]>([]); // const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
const handleSubmit = () => { // const handleSubmit = () => {
const newTask: Partial<Task> = { // const newTask: Partial<Task> = {
number, // number,
description, // description,
requirements: requirements || undefined, // requirements: requirements || undefined,
solutionType, // solutionType,
attachments: attachedFiles.map(file => file.name) // attachments: attachedFiles.map(file => file.name)
}; // };
onCreateTask(newTask); // onCreateTask(newTask);
setNumber(`${taskCount + 1}`); // setNumber(`${taskCount + 1}`);
setDescription(''); // setDescription('');
setRequirements(''); // setRequirements('');
setSolutionType('input'); // setSolutionType('input');
setAttachedFiles([]); // setAttachedFiles([]);
}; // };
return ( // return (
<Dialog open={isOpen} onOpenChange={onClose}> // <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px] font-hse-sans"> // <DialogContent className="sm:max-w-[600px] font-hse-sans">
<DialogHeader> // <DialogHeader>
<DialogTitle className="text-xl">Создание новой задачи</DialogTitle> // <DialogTitle className="text-xl">Создание новой задачи</DialogTitle>
</DialogHeader> // </DialogHeader>
<div className="grid gap-4 py-4"> // <div className="grid gap-4 py-4">
<TaskNumberField // <TaskNumberField
number={number} // number={number}
onChange={setNumber} // onChange={setNumber}
/> // />
<TaskDescriptionField // <TaskDescriptionField
description={description} // description={description}
onChange={setDescription} // onChange={setDescription}
/> // />
<TaskRequirementsField // <TaskRequirementsField
requirements={requirements} // requirements={requirements}
onChange={setRequirements} // onChange={setRequirements}
/> // />
<TaskSolutionTypeSelector // <TaskSolutionTypeSelector
solutionType={solutionType} // solutionType={solutionType}
onChange={setSolutionType} // onChange={setSolutionType}
/> // />
<TaskFileAttachments // <TaskFileAttachments
files={attachedFiles} // files={attachedFiles}
onChange={setAttachedFiles} // onChange={setAttachedFiles}
/> // />
</div> // </div>
<DialogFooter> // <DialogFooter>
<Button type="button" variant="outline" onClick={onClose}> // <Button type="button" variant="outline" onClick={onClose}>
Отмена // Отмена
</Button> // </Button>
<Button type="button" onClick={handleSubmit}> // <Button type="button" onClick={handleSubmit}>
Создать задачу // Создать задачу
</Button> // </Button>
</DialogFooter> // </DialogFooter>
</DialogContent> // </DialogContent>
</Dialog> // </Dialog>
); // );
}; // };
export default TaskCreationModal; // export default TaskCreationModal;
@@ -3,20 +3,34 @@ import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math'; import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex'; import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
import { Task } from "@/shared/types"; import { Task } from '@/shared/types/task';
import { useQuery } from '@tanstack/react-query';
import { getTaskAttachments } from '@/shared/api/session';
import { FileIcon, Loader2 } from 'lucide-react';
import { useParams } from 'react-router-dom';
interface TaskContentProps { interface TaskContentProps {
task: Task; task: Task;
} }
const TaskContent: React.FC<TaskContentProps> = ({ task }) => { const TaskContent: React.FC<TaskContentProps> = ({ task }) => {
const { id: competitionId } = useParams<{ id: string }>();
const attachmentsQuery = useQuery({
queryKey: ['taskAttachments', competitionId, task.id],
queryFn: () => getTaskAttachments(competitionId || '', task.id),
enabled: !!(competitionId && task.id),
});
const attachments = attachmentsQuery.data || [];
return ( return (
<div className="flex-1 bg-white rounded-lg p-6"> <div className="flex-1 bg-white rounded-lg p-6">
<h2 className="text-3xl font-semibold mb-6 font-hse-sans"> <h2 className="text-3xl font-semibold mb-6 font-hse-sans">
Задача {task.number} Задача {task.in_competition_position}
</h2> </h2>
<div className="prose prose-lg max-w-none text-gray-700 font-hse-sans"> <div className="prose prose-lg max-w-none text-gray-700 font-hse-sans mb-6">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkMath]} remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeKatex]} rehypePlugins={[rehypeKatex]}
@@ -24,8 +38,43 @@ const TaskContent: React.FC<TaskContentProps> = ({ task }) => {
{task.description} {task.description}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
{attachmentsQuery.isLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-gray-400 mr-2" />
<span className="text-gray-500 font-hse-sans">Загрузка файлов...</span>
</div>
) : attachments.length > 0 ? (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-3 font-hse-sans">Прикрепленные файлы</h3>
<div className="flex flex-col gap-2">
{attachments.map((attachment) => (
<a
key={attachment.id}
href={attachment.file}
download
className="flex items-center p-3 border rounded-md hover:bg-gray-50 transition-colors"
>
<FileIcon size={18} className="text-blue-500 mr-2" />
<span className="font-hse-sans">
{getFileNameFromUrl(attachment.file)}
</span>
</a>
))}
</div>
</div>
) : null}
</div> </div>
); );
}; };
const getFileNameFromUrl = (url: string): string => {
try {
const parts = url.split('/');
return parts[parts.length - 1];
} catch (e) {
return 'Файл';
}
};
export default TaskContent; export default TaskContent;
@@ -1,23 +1,32 @@
import { useState } from "react"; import { useState } from "react";
import { useParams, Navigate } from "react-router-dom"; import { useParams, Navigate } from "react-router-dom";
import { mockSolutions } from "@/shared/mocks/mocks";
import CompetitionHeader from "./components/CompetitionHeader"; import CompetitionHeader from "./components/CompetitionHeader";
import TaskContent from "./components/TaskContent"; import TaskContent from "./components/TaskContent";
import TaskSolution from "./modules/TaskSolution"; import TaskSolution from "./modules/TaskSolution";
import { getCompetitionTasks } from "@/shared/api/session"; import { getCompetitionTasks, submitTaskSolution } from "@/shared/api/session";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
const CompetitionSession = () => { const CompetitionSession = () => {
const { id, taskId } = useParams<{ id: string; taskId?: string }>(); const { id, taskId } = useParams<{ id: string; taskId?: string }>();
const [answer, setAnswer] = useState(""); const [answer, setAnswer] = useState("");
const competitionId = id || ""; const competitionId = id || "";
const queryClient = useQueryClient();
const tasksQuery = useQuery({ const tasksQuery = useQuery({
queryKey: ["competitionTasks", competitionId], queryKey: ["competitionTasks", competitionId],
queryFn: () => getCompetitionTasks(competitionId), queryFn: () => getCompetitionTasks(competitionId),
enabled: !!competitionId, enabled: !!competitionId,
// refetchOnWindowFocus: false, });
const submitMutation = useMutation({
mutationFn: () => submitTaskSolution(competitionId, taskId || "", answer),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['submissionHistory', competitionId, taskId]
});
setAnswer("");
}
}); });
const tasks = tasksQuery.data || []; const tasks = tasksQuery.data || [];
@@ -35,14 +44,9 @@ const CompetitionSession = () => {
); );
} }
const handleSubmit = async () => { const handleSubmit = () => {
if (!currentTask || !competitionId) return; if (!currentTask || !competitionId || !answer.trim()) return;
submitMutation.mutate();
try {
console.log("Solution submitted successfully");
} catch (err) {
console.error("Failed to submit solution:", err);
}
}; };
return ( return (
@@ -69,7 +73,7 @@ const CompetitionSession = () => {
<TaskContent task={currentTask} /> <TaskContent task={currentTask} />
<TaskSolution <TaskSolution
task={currentTask} task={currentTask}
solutions={mockSolutions} solutions={[]}
answer={answer} answer={answer}
setAnswer={setAnswer} setAnswer={setAnswer}
onSubmit={handleSubmit} onSubmit={handleSubmit}
@@ -1,31 +1,21 @@
import React, { useState } from 'react'; import React from 'react';
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import SolutionHistorySheet from '../SolutionHistorySheet';
import { Solution } from "@/shared/types";
import { mockSolutions } from '@/shared/mocks/mocks';
interface ActionButtonsProps { interface ActionButtonsProps {
onSubmit: () => void; onSubmit: () => void;
solutionHistory?: Solution[]; onHistoryClick: () => void;
} }
const ActionButtons: React.FC<ActionButtonsProps> = ({ const ActionButtons: React.FC<ActionButtonsProps> = ({
onSubmit, onSubmit,
solutionHistory = mockSolutions onHistoryClick
}) => { }) => {
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const handleHistoryClick = () => {
setIsHistoryOpen(true);
};
return ( return (
<>
<div className="flex gap-8"> <div className="flex gap-8">
<Button <Button
variant="ghost" variant="ghost"
className="font-hse-sans bg-white hover:bg-gray-100" className="font-hse-sans bg-white hover:bg-gray-100"
onClick={handleHistoryClick} onClick={onHistoryClick}
> >
История История
</Button> </Button>
@@ -36,13 +26,6 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
Отправить решение Отправить решение
</Button> </Button>
</div> </div>
{/* чуть-чуть рак */}
<SolutionHistorySheet
isOpen={isHistoryOpen}
onOpenChange={setIsHistoryOpen}
solutions={solutionHistory}
/>
</>
); );
}; };
@@ -3,18 +3,20 @@ import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle } from "@/comp
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X } from "lucide-react"; import { X } from "lucide-react";
import SolutionStatus from '../SolutionStatus'; import SolutionStatus from '../SolutionStatus';
import { Solution } from "@/shared/types"; import { Solution } from '@/shared/types/task';
interface SolutionHistorySheetProps { interface SolutionHistorySheetProps {
isOpen: boolean; isOpen: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
solutions: Solution[]; solutions: Solution[];
maxPoints: number
} }
const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({ const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
isOpen, isOpen,
onOpenChange, onOpenChange,
solutions solutions,
maxPoints
}) => { }) => {
return ( return (
<Sheet open={isOpen} onOpenChange={onOpenChange}> <Sheet open={isOpen} onOpenChange={onOpenChange}>
@@ -34,7 +36,7 @@ const SolutionHistorySheet: React.FC<SolutionHistorySheetProps> = ({
{solutions.length > 0 ? ( {solutions.length > 0 ? (
solutions.map((solution, index) => ( solutions.map((solution, index) => (
<div key={index} className="w-full"> <div key={index} className="w-full">
<SolutionStatus solution={solution} /> <SolutionStatus solution={solution} maxPoints={maxPoints} />
</div> </div>
)) ))
) : ( ) : (
@@ -1,41 +1,26 @@
import React from 'react'; import React from 'react';
import { Solution, TaskStatus } from "@/shared/types"; import { Solution } from '@/shared/types/task';
import { getTaskBgColor, getTaskTextColor } from '@/pages/CompetitionSession/utils/utils'; import { getSolutionBgColor, getSolutionTextColor, getStatusText } from '@/pages/CompetitionSession/utils/utils';
interface SolutionStatusProps { interface SolutionStatusProps {
solution: Solution; solution: Solution;
maxPoints: number;
} }
const SolutionStatus: React.FC<SolutionStatusProps> = ({ solution }) => { const SolutionStatus: React.FC<SolutionStatusProps> = ({ solution, maxPoints }) => {
const getStatusText = (status: TaskStatus, score?: number, maxScore?: number) => {
switch (status) {
case TaskStatus.Checking:
return 'На проверке';
case TaskStatus.Wrong:
return 'Неверный ответ';
case TaskStatus.Correct:
return `Зачтено ${maxScore}/${maxScore} баллов`;
case TaskStatus.Partial:
return `Зачтено ${score}/${maxScore} баллов`;
case TaskStatus.Uncleared:
return 'Не решено';
default:
return '';
}
};
return ( return (
<div className={`${getTaskBgColor(solution.status)} rounded-lg p-4 relative`}> <div className={`${getSolutionBgColor(solution.status, solution.earned_points, maxPoints)} rounded-lg p-4 relative`}>
<div className="flex flex-col"> <div className="flex flex-col">
<span className={`${getTaskTextColor(solution.status)} font-medium`}> <span className={`${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)} font-medium`}>
Решение {solution.id} Решение {solution.id}
</span> </span>
<span className={`${getTaskTextColor(solution.status)} mt-1`}> <span className={`${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)} mt-1`}>
{getStatusText(solution.status, solution.score, solution.maxScore)} {getStatusText(solution.status, solution.earned_points, maxPoints)}
</span> </span>
</div> </div>
<div className={`absolute bottom-2 right-3 text-xs ${getTaskTextColor(solution.status)}`}> <div className={`absolute bottom-2 right-3 text-xs ${getSolutionTextColor(solution.status, solution.earned_points, maxPoints)}`}>
{solution.date} {solution.timestamp}
</div> </div>
</div> </div>
); );
@@ -1,10 +1,14 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { Solution, Task } from "@/shared/types"; import { useParams } from 'react-router-dom';
import { Task, TaskType, Solution } from '@/shared/types/task';
import { useQuery } from '@tanstack/react-query';
import { getTaskSolutionHistory } from '@/shared/api/session';
import SolutionStatus from './components/SolutionStatus'; import SolutionStatus from './components/SolutionStatus';
import InputSolution from './components/InputSolution'; import InputSolution from './components/InputSolution';
import FileSolution from './components/FileSolution'; import FileSolution from './components/FileSolution';
import CodeSolution from './components/CodeSolution'; import CodeSolution from './components/CodeSolution';
import ActionButtons from './components/ActionButtons'; import ActionButtons from './components/ActionButtons';
import SolutionHistorySheet from './components/SolutionHistorySheet';
interface TaskSolutionProps { interface TaskSolutionProps {
task: Task; task: Task;
@@ -12,7 +16,6 @@ interface TaskSolutionProps {
answer: string; answer: string;
setAnswer: (value: string) => void; setAnswer: (value: string) => void;
onSubmit: () => void; onSubmit: () => void;
} }
const TaskSolution: React.FC<TaskSolutionProps> = ({ const TaskSolution: React.FC<TaskSolutionProps> = ({
@@ -24,16 +27,30 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
}) => { }) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const { id: competitionId } = useParams<{ id: string }>();
const solutionsQuery = useQuery({
queryKey: ['solutionHistory', competitionId, task.id],
queryFn: () => getTaskSolutionHistory(competitionId || '', task.id),
enabled: !!(competitionId && task.id),
});
const solutionHistory = solutionsQuery.data || [];
const handleOpenHistory = () => {
setIsHistoryOpen(true);
};
return ( return (
<div className="md:w-[500px] flex flex-col gap-4"> <div className="md:w-[500px] flex flex-col gap-4">
<SolutionStatus solution={solutions[0]} /> <SolutionStatus solution={solutions[0]} maxPoints={task.points}/>
{task.solutionType === 'input' && ( {task.type === TaskType.INPUT && (
<InputSolution answer={answer} setAnswer={setAnswer} /> <InputSolution answer={answer} setAnswer={setAnswer} />
)} )}
{task.solutionType === 'file' && ( {task.type === TaskType.FILE && (
<FileSolution <FileSolution
selectedFile={selectedFile} selectedFile={selectedFile}
setSelectedFile={setSelectedFile} setSelectedFile={setSelectedFile}
@@ -41,11 +58,21 @@ const TaskSolution: React.FC<TaskSolutionProps> = ({
/> />
)} )}
{task.solutionType === 'code' && ( {task.type === TaskType.CODE && (
<CodeSolution answer={answer} setAnswer={setAnswer} /> <CodeSolution answer={answer} setAnswer={setAnswer} />
)} )}
<ActionButtons onSubmit={onSubmit} /> <ActionButtons
onSubmit={onSubmit}
onHistoryClick={handleOpenHistory}
/>
<SolutionHistorySheet
isOpen={isHistoryOpen}
onOpenChange={setIsHistoryOpen}
solutions={solutionHistory}
maxPoints={task.points}
/>
</div> </div>
); );
}; };
@@ -1,4 +1,5 @@
import { TaskStatus } from "@/shared/types"; import { TaskStatus } from "@/shared/types";
import { SolutionStatus } from "@/shared/types/task";
const getTaskBgColor = (status: TaskStatus): string => { const getTaskBgColor = (status: TaskStatus): string => {
switch (status) { switch (status) {
case TaskStatus.Uncleared: return "bg-[var(--color-task-uncleared)]"; case TaskStatus.Uncleared: return "bg-[var(--color-task-uncleared)]";
@@ -19,4 +20,39 @@ const getTaskTextColor = (status: TaskStatus): string => {
} }
}; };
export {getTaskBgColor, getTaskTextColor} const getSolutionBgColor = (status: SolutionStatus, earned_points: number, maxPoints: number): string => {
switch (status) {
case SolutionStatus.SENT: return "text-[var(--color-task-uncleared)]";
case SolutionStatus.CHECKING: return "text-[var(--color-task-checking)]";
case SolutionStatus.CHECKED: {
if (earned_points === 0) return "text-[var(--color-task-wrong)]";
else if (earned_points === maxPoints) "text-[var(--color-task-correct)]";
return "text-[var(--color-task-partial)]";
}
}
}
const getSolutionTextColor = (status: SolutionStatus, earned_points: number, maxPoints: number): string => {
switch (status) {
case SolutionStatus.SENT: return "text-[var(--color-task-text-uncleared)]";
case SolutionStatus.CHECKING: return "text-[var(--color-task-text-checking)]";
case SolutionStatus.CHECKED: {
if (earned_points === 0) return "text-[var(--color-task-text-wrong)]";
else if (earned_points === maxPoints) "text-[var(--color-task-text-correct)]";
return "text-[var(--color-task-text-partial)]";
}
}
}
const getStatusText = (status: SolutionStatus, earned_points: number, maxPoints: number): string => {
switch (status) {
case SolutionStatus.SENT: return "Решение отправлено";
case SolutionStatus.CHECKING: return "Решение проверяется";
case SolutionStatus.CHECKED: {
if (earned_points === 0) return "Неверный ответ";
else if (earned_points === maxPoints) `Зачтено ${maxPoints}/${maxPoints} баллов`;
return `Зачтено ${earned_points}/${maxPoints} баллов`;
}
}
}
export {getTaskBgColor, getTaskTextColor, getSolutionBgColor, getSolutionTextColor, getStatusText}
+10 -1
View File
@@ -1,10 +1,19 @@
import { userFetch } from "."; import { userFetch } from ".";
import { Task } from "../types/task"; import { Task, Solution, TaskAttachment } from "../types/task";
export const getCompetitionTasks = async (competitionId: string) => { export const getCompetitionTasks = async (competitionId: string) => {
return await userFetch<Task[]>(`/competitions/${competitionId}/tasks`); return await userFetch<Task[]>(`/competitions/${competitionId}/tasks`);
}; };
export const getTaskSolutionHistory = async (competitionId: string, taskId: string) => {
return await userFetch<Solution[]>(`/competitions/${competitionId}/tasks/${taskId}/history`);
};
export const getTaskAttachments = async (competitionId: string, taskId: string) => {
return await userFetch<TaskAttachment[]>(`/competitions/${competitionId}/tasks/${taskId}/attachments`);
};
export const submitTaskSolution = async ( export const submitTaskSolution = async (
competitionId: string, competitionId: string,
taskId: string, taskId: string,
+23 -1
View File
@@ -1,4 +1,4 @@
export interface Task { interface Task {
id: string; id: string;
title: string; title: string;
description: string; description: string;
@@ -7,8 +7,30 @@ export interface Task {
points: number; points: number;
} }
export interface TaskAttachment {
id: string;
file: string;
public: boolean;
}
enum TaskType { enum TaskType {
INPUT = "input", INPUT = "input",
FILE = "file", FILE = "file",
CODE = "code", CODE = "code",
} }
enum SolutionStatus {
SENT = "sent",
CHECKING = "checking",
CHECKED = "checked",
}
interface Solution {
id: string,
status: SolutionStatus,
timestamp: string,
earned_points: number
}
export type {Task, Solution}
export {TaskType, SolutionStatus}