From 59f89d51135f32da84417b3c7a4a593feedb043b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D1=82=D0=B0=D0=BB=D0=B8=D0=B9=20=D0=9B=D0=B0?= =?UTF-8?q?=D0=B2=D1=88=D0=BE=D0=BD=D0=BE=D0=BA?= <114582703+valavshonok@users.noreply.github.com> Date: Sun, 2 Nov 2025 13:15:12 +0300 Subject: [PATCH] upload mission --- src/App.tsx | 97 +----------- .../{problems => missions}/icon-error.svg | 0 .../{problems => missions}/icon-success.svg | 0 .../icons/{problems => missions}/index.ts | 0 src/pages/Home.tsx | 4 +- src/pages/Mission.tsx | 26 ++++ src/redux/slices/auth.ts | 10 +- src/redux/slices/missions.ts | 146 ++++++++++++++++++ src/redux/store.ts | 2 + src/views/home/auth/Login.tsx | 7 +- src/views/home/auth/Register.tsx | 5 +- src/views/home/menu/Menu.tsx | 3 +- .../MissionItem.tsx} | 8 +- .../Problems.tsx => missions/Missions.tsx} | 16 +- src/views/mission/UploadMissionForm.tsx | 101 ++++++++++++ .../codeeditor/CodeEditor.tsx | 0 .../statement/Mission.tsx} | 2 +- .../statement/Statement.tsx | 0 18 files changed, 312 insertions(+), 115 deletions(-) rename src/assets/icons/{problems => missions}/icon-error.svg (100%) rename src/assets/icons/{problems => missions}/icon-success.svg (100%) rename src/assets/icons/{problems => missions}/index.ts (100%) create mode 100644 src/pages/Mission.tsx create mode 100644 src/redux/slices/missions.ts rename src/views/home/{problems/ProblemItem.tsx => missions/MissionItem.tsx} (92%) rename src/views/home/{problems/Problems.tsx => missions/Missions.tsx} (98%) create mode 100644 src/views/mission/UploadMissionForm.tsx rename src/views/{problem => mission}/codeeditor/CodeEditor.tsx (100%) rename src/views/{problem/statement/Proble.tsx => mission/statement/Mission.tsx} (99%) rename src/views/{problem => mission}/statement/Statement.tsx (100%) diff --git a/src/App.tsx b/src/App.tsx index 55f9dea..474940a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,9 +5,11 @@ import { Route, Routes } from "react-router-dom"; // import { Input } from "./components/input/Input"; // import { Switch } from "./components/switch/Switch"; import Home from "./pages/Home"; -import CodeEditor from "./views/problem/codeeditor/CodeEditor"; -import Statement from "./views/problem/statement/Statement"; -import ProblemStatement from "./views/problem/statement/Proble"; +import CodeEditor from "./views/mission/codeeditor/CodeEditor"; +import Statement from "./views/mission/statement/Statement"; +import MissionStatement from "./views/mission/statement/Mission"; +import Mission from "./pages/Mission"; +import UploadMissionForm from "./views/mission/UploadMissionForm"; function App() { return ( @@ -15,97 +17,14 @@ function App() {
} /> + } /> + }/>
} /> } /> - } /> + } /> - - {/* { - document.documentElement.setAttribute( - "data-theme", - state ? "dark" : "light" - ); - }} - /> -
- { - console.log(state); - }} - /> - { - console.log(state); - }} - /> - { - console.log(state); - }} - /> - - { - console.log(state); - }} - /> - { }} label="test" color="default" defaultState={true}/> - { }} label="test" color="primary" defaultState={true}/> - { }} label="test" color="secondary" defaultState={true}/> - { }} label="test" color="success" defaultState={true}/> - { }} label="test" color="warning" defaultState={true}/> - { }} label="test" color="danger" defaultState={true}/> - { }} color="default" defaultState={true}/> - { }} color="primary" defaultState={true}/> - { }} color="secondary" defaultState={true}/> - { }} color="success" defaultState={true}/> - { }} color="warning" defaultState={true}/> - { }} color="danger" defaultState={true}/> - - -
- - { }} text="Button" className="m-5" /> - { }} text="Button" className="m-5" /> - { }} text="Button" disabled className="m-5" /> - { }} text="Button" className="m-5" /> - { }} text="Button" className="m-5" /> - { }} text="Button" disabled className="m-5" /> -
-
-
*/} ); } diff --git a/src/assets/icons/problems/icon-error.svg b/src/assets/icons/missions/icon-error.svg similarity index 100% rename from src/assets/icons/problems/icon-error.svg rename to src/assets/icons/missions/icon-error.svg diff --git a/src/assets/icons/problems/icon-success.svg b/src/assets/icons/missions/icon-success.svg similarity index 100% rename from src/assets/icons/problems/icon-success.svg rename to src/assets/icons/missions/icon-success.svg diff --git a/src/assets/icons/problems/index.ts b/src/assets/icons/missions/index.ts similarity index 100% rename from src/assets/icons/problems/index.ts rename to src/assets/icons/missions/index.ts diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 1cc8c5a..4f41306 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -6,7 +6,7 @@ import Menu from "../views/home/menu/Menu"; import { useAppDispatch, useAppSelector } from "../redux/hooks"; import { useEffect } from "react"; import { fetchWhoAmI } from "../redux/slices/auth"; -import Problems from "../views/home/problems/Problems"; +import Missions from "../views/home/missions/Missions"; import Articles from "../views/home/articles/Articles"; import Groups from "../views/home/groups/Groups"; import Contests from "../views/home/contests/Contests"; @@ -31,7 +31,7 @@ const Home = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/pages/Mission.tsx b/src/pages/Mission.tsx new file mode 100644 index 0000000..751f89c --- /dev/null +++ b/src/pages/Mission.tsx @@ -0,0 +1,26 @@ +import { useParams, Navigate } from 'react-router-dom'; + + + +const Mission = () => { + + // Получаем параметры из URL + const { missionId } = useParams<{ missionId: string }>(); + + // Если missionId нет, редиректим на /home + if (!missionId) { + return ; + } + + + return ( +
+ +
+ {missionId} +
+
+ ); +}; + +export default Mission; diff --git a/src/redux/slices/auth.ts b/src/redux/slices/auth.ts index ba9e681..cd40719 100644 --- a/src/redux/slices/auth.ts +++ b/src/redux/slices/auth.ts @@ -6,7 +6,7 @@ interface AuthState { jwt: string | null; refreshToken: string | null; username: string | null; - status: "idle" | "loading" | "succeeded" | "failed"; + status: "idle" | "loading" | "successful" | "failed"; error: string | null; } @@ -97,7 +97,7 @@ const authSlice = createSlice({ state.error = null; }); builder.addCase(registerUser.fulfilled, (state, action: PayloadAction<{ jwt: string; refreshToken: string }>) => { - state.status = "succeeded"; + state.status = "successful"; axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`; state.jwt = action.payload.jwt; state.refreshToken = action.payload.refreshToken; @@ -113,7 +113,7 @@ const authSlice = createSlice({ state.error = null; }); builder.addCase(loginUser.fulfilled, (state, action: PayloadAction<{ jwt: string; refreshToken: string }>) => { - state.status = "succeeded"; + state.status = "successful"; axios.defaults.headers.common['Authorization'] = `Bearer ${action.payload.jwt}`; state.jwt = action.payload.jwt; state.refreshToken = action.payload.refreshToken; @@ -129,7 +129,7 @@ const authSlice = createSlice({ state.error = null; }); builder.addCase(refreshToken.fulfilled, (state, action: PayloadAction<{ username: string }>) => { - state.status = "succeeded"; + state.status = "successful"; state.username = action.payload.username; }); builder.addCase(refreshToken.rejected, (state, action: PayloadAction) => { @@ -143,7 +143,7 @@ const authSlice = createSlice({ state.error = null; }); builder.addCase(fetchWhoAmI.fulfilled, (state, action: PayloadAction<{ username: string }>) => { - state.status = "succeeded"; + state.status = "successful"; state.username = action.payload.username; }); builder.addCase(fetchWhoAmI.rejected, (state, action: PayloadAction) => { diff --git a/src/redux/slices/missions.ts b/src/redux/slices/missions.ts new file mode 100644 index 0000000..571942d --- /dev/null +++ b/src/redux/slices/missions.ts @@ -0,0 +1,146 @@ +import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; +import axios from "../../axios"; + +// Типы данных +interface Statement { + id: number; + language: string; + statementTexts: Record; + mediaFiles?: { id: number; fileName: string; mediaUrl: string }[]; +} + +interface Mission { + id: number; + authorId: number; + name: string; + difficulty: number; + tags: string[]; + createdAt: string; + updatedAt: string; + statements: Statement[] | null; +} + +interface MissionsState { + missions: Mission[]; + currentMission: Mission | null; + hasNextPage: boolean; + status: "idle" | "loading" | "successful" | "failed"; + error: string | null; +} + +// Инициализация состояния +const initialState: MissionsState = { + missions: [], + currentMission: null, + hasNextPage: false, + status: "idle", + error: null, +}; + +// AsyncThunk: Получение списка миссий +export const fetchMissions = createAsyncThunk( + "missions/fetchMissions", + async ( + { page = 0, pageSize = 10, tags }: { page?: number; pageSize?: number; tags?: string[] }, + { rejectWithValue } + ) => { + try { + const params: any = { page, pageSize }; + if (tags) params.tags = tags; + const response = await axios.get("/missions", { params }); + return response.data; // { hasNextPage, missions } + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || "Failed to fetch missions"); + } + } +); + +// AsyncThunk: Получение миссии по id +export const fetchMissionById = createAsyncThunk( + "missions/fetchMissionById", + async (id: number, { rejectWithValue }) => { + try { + const response = await axios.get(`/missions/${id}`); + return response.data; // Mission + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || "Failed to fetch mission"); + } + } +); + +// AsyncThunk: Загрузка миссии +export const uploadMission = createAsyncThunk( + "missions/uploadMission", + async ( + { file, name, difficulty, tags }: { file: File; name: string; difficulty: number; tags: string[] }, + { rejectWithValue } + ) => { + try { + const formData = new FormData(); + formData.append("MissionFile", file); + formData.append("Name", name); + formData.append("Difficulty", difficulty.toString()); + tags.forEach(tag => formData.append("Tags", tag)); + + const response = await axios.post("/missions/upload", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return response.data; // Mission + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || "Failed to upload mission"); + } + } +); + +// Slice +const missionsSlice = createSlice({ + name: "missions", + initialState, + reducers: {}, + extraReducers: (builder) => { + // fetchMissions + builder.addCase(fetchMissions.pending, (state) => { + state.status = "loading"; + state.error = null; + }); + builder.addCase(fetchMissions.fulfilled, (state, action: PayloadAction<{ missions: Mission[]; hasNextPage: boolean }>) => { + state.status = "successful"; + state.missions = action.payload.missions; + state.hasNextPage = action.payload.hasNextPage; + }); + builder.addCase(fetchMissions.rejected, (state, action: PayloadAction) => { + state.status = "failed"; + state.error = action.payload; + }); + + // fetchMissionById + builder.addCase(fetchMissionById.pending, (state) => { + state.status = "loading"; + state.error = null; + }); + builder.addCase(fetchMissionById.fulfilled, (state, action: PayloadAction) => { + state.status = "successful"; + state.currentMission = action.payload; + }); + builder.addCase(fetchMissionById.rejected, (state, action: PayloadAction) => { + state.status = "failed"; + state.error = action.payload; + }); + + // uploadMission + builder.addCase(uploadMission.pending, (state) => { + state.status = "loading"; + state.error = null; + }); + builder.addCase(uploadMission.fulfilled, (state, action: PayloadAction) => { + state.status = "successful"; + state.missions.unshift(action.payload); // Добавляем новую миссию в начало списка + }); + builder.addCase(uploadMission.rejected, (state, action: PayloadAction) => { + state.status = "failed"; + state.error = action.payload; + }); + }, +}); + +export const missionsReducer = missionsSlice.reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts index 509071b..834d822 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,6 +1,7 @@ import { configureStore } from "@reduxjs/toolkit"; import { authReducer } from "./slices/auth"; import { storeReducer } from "./slices/store"; +import { missionsReducer } from "./slices/missions"; // использование @@ -17,6 +18,7 @@ export const store = configureStore({ //user: userReducer, auth: authReducer, store: storeReducer, + missions: missionsReducer, }, }); diff --git a/src/views/home/auth/Login.tsx b/src/views/home/auth/Login.tsx index fc06b64..7301b12 100644 --- a/src/views/home/auth/Login.tsx +++ b/src/views/home/auth/Login.tsx @@ -4,7 +4,7 @@ import { Input } from "../../../components/input/Input"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; import { Link, useNavigate } from "react-router-dom"; import { loginUser } from "../../../redux/slices/auth"; -import { cn } from "../../../lib/cn"; +// import { cn } from "../../../lib/cn"; import { setMenuActivePage } from "../../../redux/slices/store"; import { Balloon } from "../../../assets/icons/auth"; import { SecondaryButton } from "../../../components/button/SecondaryButton"; @@ -18,13 +18,14 @@ const Login = () => { const [password, setPassword] = useState(""); const [submitClicked, setSubmitClicked] = useState(false); - const { status, error, jwt } = useAppSelector((state) => state.auth); + const { status, jwt } = useAppSelector((state) => state.auth); - const [err, setErr] = useState(""); + // const [err, setErr] = useState(""); // После успешного логина useEffect(() => { + console.log(submitClicked); dispatch(setMenuActivePage("account")) }, []); diff --git a/src/views/home/auth/Register.tsx b/src/views/home/auth/Register.tsx index e2a6e8f..38f0e12 100644 --- a/src/views/home/auth/Register.tsx +++ b/src/views/home/auth/Register.tsx @@ -4,7 +4,7 @@ import { Input } from "../../../components/input/Input"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks"; import { useNavigate } from "react-router-dom"; import { registerUser } from "../../../redux/slices/auth"; -import { cn } from "../../../lib/cn"; +// import { cn } from "../../../lib/cn"; import { setMenuActivePage } from "../../../redux/slices/store"; import { Balloon } from "../../../assets/icons/auth"; import { Link } from "react-router-dom"; @@ -23,11 +23,12 @@ const Register = () => { const [confirmPassword, setConfirmPassword] = useState(""); const [submitClicked, setSubmitClicked] = useState(false); - const { status, error, jwt } = useAppSelector((state) => state.auth); + const { status, jwt } = useAppSelector((state) => state.auth); // После успешной регистрации — переход в систему useEffect(() => { + console.log(submitClicked); dispatch(setMenuActivePage("account")) }, []); diff --git a/src/views/home/menu/Menu.tsx b/src/views/home/menu/Menu.tsx index 63ae749..ddf329b 100644 --- a/src/views/home/menu/Menu.tsx +++ b/src/views/home/menu/Menu.tsx @@ -6,11 +6,12 @@ import { useAppSelector } from "../../../redux/hooks"; const Menu = () => { const menuItems = [ {text: "Главная", href: "/home", icon: Home, page: "home" }, - {text: "Задачи", href: "/home/problems", icon: Clipboard, page: "problems" }, + {text: "Задачи", href: "/home/missions", icon: Clipboard, page: "missions" }, {text: "Статьи", href: "/home/articles", icon: Openbook, page: "articles" }, {text: "Группы", href: "/home/groups", icon: Users, page: "groups" }, {text: "Контесты", href: "/home/contests", icon: Cup, page: "contests" }, {text: "Аккаунт", href: "/home/account", icon: Account, page: "account" }, + {text: "Загрузка", href: "/upload", icon: Account, page: "p" }, ]; const activePage = useAppSelector((state) => state.store.menu.activePage); diff --git a/src/views/home/problems/ProblemItem.tsx b/src/views/home/missions/MissionItem.tsx similarity index 92% rename from src/views/home/problems/ProblemItem.tsx rename to src/views/home/missions/MissionItem.tsx index 5410cd5..8c679b2 100644 --- a/src/views/home/problems/ProblemItem.tsx +++ b/src/views/home/missions/MissionItem.tsx @@ -1,7 +1,7 @@ import { cn } from "../../../lib/cn"; -import { IconError, IconSuccess } from "../../../assets/icons/problems"; +import { IconError, IconSuccess } from "../../../assets/icons/missions"; -export interface ProblemItemProps { +export interface MissionItemProps { id: number; authorId: number; name: string; @@ -26,7 +26,7 @@ export function formatBytesToMB(bytes: number): string { return `${megabytes} МБ`; } -const ProblemItem: React.FC = ({ +const MissionItem: React.FC = ({ id, name, difficulty, timeLimit, memoryLimit, type, status }) => { console.log(id); @@ -66,4 +66,4 @@ const ProblemItem: React.FC = ({ ); }; -export default ProblemItem; +export default MissionItem; diff --git a/src/views/home/problems/Problems.tsx b/src/views/home/missions/Missions.tsx similarity index 98% rename from src/views/home/problems/Problems.tsx rename to src/views/home/missions/Missions.tsx index 5c1ae29..5969183 100644 --- a/src/views/home/problems/Problems.tsx +++ b/src/views/home/missions/Missions.tsx @@ -1,11 +1,11 @@ -import ProblemItem from "./ProblemItem"; +import MissionItem from "./MissionItem"; import { SecondaryButton } from "../../../components/button/SecondaryButton"; import { useAppDispatch } from "../../../redux/hooks"; import { useEffect } from "react"; import { setMenuActivePage } from "../../../redux/slices/store"; -export interface Problem { +export interface Mission { id: number; authorId: number; name: string; @@ -18,11 +18,11 @@ export interface Problem { } -const Problems = () => { +const Missions = () => { const dispatch = useAppDispatch(); - const problems: Problem[] = [ + const missions: Mission[] = [ { "id": 1, "authorId": 1, @@ -466,7 +466,7 @@ const Problems = () => { ]; useEffect(() => { - dispatch(setMenuActivePage("problems")) + dispatch(setMenuActivePage("missions")) }, []); return ( @@ -490,8 +490,8 @@ const Problems = () => {
- {problems.map((v, i) => ( - + {missions.map((v, i) => ( + ))}
@@ -504,4 +504,4 @@ const Problems = () => { ); }; -export default Problems; +export default Missions; diff --git a/src/views/mission/UploadMissionForm.tsx b/src/views/mission/UploadMissionForm.tsx new file mode 100644 index 0000000..f5ea22f --- /dev/null +++ b/src/views/mission/UploadMissionForm.tsx @@ -0,0 +1,101 @@ +import React, { useState } from "react"; +import { useAppDispatch, useAppSelector } from "../../redux/hooks"; +import { uploadMission } from "../../redux/slices/missions"; + +const UploadMissionForm: React.FC = () => { + const dispatch = useAppDispatch(); + const { status, error } = useAppSelector(state => state.missions); + + // Локальные состояния формы + const [name, setName] = useState(""); + const [difficulty, setDifficulty] = useState(1); + const [tags, setTags] = useState([]); + const [tagsValue, setTagsValue] = useState(""); + const [file, setFile] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!file) return alert("Выберите файл миссии!"); + + try { + dispatch(uploadMission({ file, name, difficulty, tags })); + + alert("Миссия успешно загружена!"); + setName(""); + setDifficulty(1); + setTags([]); + setFile(null); + } catch (err) { + console.error(err); + alert("Ошибка при загрузке миссии: " + err); + } + }; + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + setFile(e.target.files[0]); + } + }; + + const handleTagsChange = (e: React.ChangeEvent) => { + setTagsValue(e.target.value); + const value = e.target.value; + const tagsArray = value.split(",").map(tag => tag.trim()).filter(tag => tag); + setTags(tagsArray); + }; + + return ( +
+
+ + setName(e.target.value)} + className="w-full border px-2 py-1" + required + /> +
+ +
+ + setDifficulty(Number(e.target.value))} + className="w-full border px-2 py-1" + required + /> +
+ +
+ + +
+ +
+ + +
+ + + + {status === "failed" && error &&

{error}

} +
+ ); +}; + +export default UploadMissionForm; diff --git a/src/views/problem/codeeditor/CodeEditor.tsx b/src/views/mission/codeeditor/CodeEditor.tsx similarity index 100% rename from src/views/problem/codeeditor/CodeEditor.tsx rename to src/views/mission/codeeditor/CodeEditor.tsx diff --git a/src/views/problem/statement/Proble.tsx b/src/views/mission/statement/Mission.tsx similarity index 99% rename from src/views/problem/statement/Proble.tsx rename to src/views/mission/statement/Mission.tsx index e1a3ce6..e597ad3 100644 --- a/src/views/problem/statement/Proble.tsx +++ b/src/views/mission/statement/Mission.tsx @@ -10,7 +10,7 @@ declare global { } } -export default function ProblemStatement() { +export default function MissionStatement() { const containerRef = useRef(null); const legend = "В честь юбилея ректорат ЮФУ решил запустить акцию <<Сто и десять кексов>>. \r\n\r\n $x$, $a_i^2 + b_i^2 \le a_{i+1}^2$ В каждом корпусе университета открылась лавка с кексами, в которой каждый студент может получить бесплатные кексы.\r\n\r\nНе прошло и пары минут после открытия, как к лавкам набежали студенты и образовалось много очередей. Но самая большая очередь образовалась в главном корпусе ЮФУ. Изначально в этой очереди стояло $n$ студентов, но потом в течение следующих $m$ минут какие-то студенты приходили и вставали в очередь, а какие-то уходили.\r\n\r\nЗа каждым студентом закреплен номер его зачетной книжки, будем называть это число номером студента. У каждого студента будет уникальный номер, по которому можно однозначно его идентифицировать. Будем считать, что каждую минуту происходило одно из следующих событий:\r\n\r\n\\begin{enumerate}\r\n \\item Студент с номером $x$ пришел и встал перед студентом с номером $y$;\r\n \\item Студент с номером $x$ пришел и встал в конец очереди;\r\n \\item Студент с номером $x$ ушел из очереди; возможно, он потом вернется.\r\n\\end{enumerate}\r\n\r\nАналитикам стало интересно, а какой будет очередь после $m$ минут? \r\n\r\nПомогите им и сообщите конечное состояние очереди.\r\n\r\n"; const htmlContent = ` diff --git a/src/views/problem/statement/Statement.tsx b/src/views/mission/statement/Statement.tsx similarity index 100% rename from src/views/problem/statement/Statement.tsx rename to src/views/mission/statement/Statement.tsx