From 1b39b8c77f314c42a200be388f9f6d297b33b69d 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: Thu, 6 Nov 2025 15:09:10 +0300 Subject: [PATCH] dont work --- src/App.tsx | 5 + src/pages/ContestEditor.tsx | 336 ++++++++++++++++++ src/redux/slices/contests.ts | 205 +++++++++-- .../home/account/contests/ContestsBlock.tsx | 1 - .../home/account/contests/MyContestItem.tsx | 2 +- src/views/home/contests/ModalCreate.tsx | 39 +- 6 files changed, 546 insertions(+), 42 deletions(-) create mode 100644 src/pages/ContestEditor.tsx diff --git a/src/App.tsx b/src/App.tsx index 6d0ec91..609203e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import Home from './pages/Home'; import Mission from './pages/Mission'; import ArticleEditor from './pages/ArticleEditor'; import Article from './pages/Article'; +import ContestEditor from './pages/ContestEditor'; function App() { return ( @@ -20,6 +21,10 @@ function App() { path="/article/create/*" element={} /> + } + /> } /> } /> diff --git a/src/pages/ContestEditor.tsx b/src/pages/ContestEditor.tsx new file mode 100644 index 0000000..669adc7 --- /dev/null +++ b/src/pages/ContestEditor.tsx @@ -0,0 +1,336 @@ +import { useEffect, useState } from 'react'; +import Header from '../views/articleeditor/Header'; +import { PrimaryButton } from '../components/button/PrimaryButton'; +import { Input } from '../components/input/Input'; +import { useAppDispatch, useAppSelector } from '../redux/hooks'; +import { + createContest, + CreateContestBody, + fetchContestById, +} from '../redux/slices/contests'; +import DateRangeInput from '../components/input/DateRangeInput'; +import { useQuery } from '../hooks/useQuery'; +import { useNavigate } from 'react-router-dom'; +import { fetchMissionById, Mission } from '../redux/slices/missions'; +import { ReverseButton } from '../components/button/ReverseButton'; + +/** + * Страница создания / редактирования контеста + */ +const ContestEditor = () => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const status = useAppSelector( + (state) => state.contests.createContest.status, + ); + + const [missionIdInput, setMissionIdInput] = useState(''); + + const query = useQuery(); + const back = query.get('back') ?? undefined; + const contestId = Number(query.get('contestId') ?? undefined); + const refactor = !!contestId; + + const [contest, setContest] = useState({ + name: '', + description: '', + scheduleType: 'AlwaysOpen', + visibility: 'Public', + startsAt: null, + endsAt: null, + attemptDurationMinutes: null, + maxAttempts: null, + allowEarlyFinish: false, + groupId: null, + missionIds: [], + articleIds: [], + }); + + const [missions, setMissions] = useState([]); + + const { contest: contestById, status: contestByIdstatus } = useAppSelector( + (state) => state.contests.fetchContestById, + ); + console.log(contestByIdstatus, contestById); + useEffect(() => { + if (status === 'successful') { + } + }, [status]); + + const handleChange = (key: keyof CreateContestBody, value: any) => { + setContest((prev) => ({ ...prev, [key]: value })); + }; + + const handleSubmit = () => { + dispatch(createContest(contest)); + }; + + const addMission = () => { + const id = Number(missionIdInput.trim()); + if (!id || contest.missionIds?.includes(id)) return; + dispatch(fetchMissionById(id)) + .unwrap() + .then((mission) => { + setMissions((prev) => [...prev, mission]); + setContest((prev) => ({ + ...prev, + missionIds: [...(prev.missionIds ?? []), id], + })); + setMissionIdInput(''); + }) + .catch((err) => { + console.error('Ошибка при загрузке миссии:', err); + }); + }; + + const removeMission = (removeId: number) => { + setContest({ + ...contest, + missionIds: contest.missionIds?.filter((v) => v !== removeId), + }); + setMissions(missions.filter((v) => v.id != removeId)); + }; + + useEffect(() => { + if (refactor) { + dispatch(fetchContestById(contestId)); + } + }, [refactor]); + + useEffect(() => { + if (refactor && contestByIdstatus == 'successful' && contestById) { + setContest({ + ...contestById, + missionIds: [], + visibility: 'Public', + scheduleType: 'AlwaysOpen', + }); + } + }, [contestById]); + + return ( +
+
navigate(back || '/home/contests')} /> + +
+ {/* Левая панешь */} +
+
+

+ +
+
+ {refactor + ? `Редактирвоание контеста #${contestId} \"${contestById?.name}\"` + : 'Создать контест'} +
+ + handleChange('name', v)} + defaultState={contest.name ?? ''} + /> + + handleChange('description', v)} + defaultState={contest.description ?? ''} + /> + +
+
+ + +
+ +
+ + +
+
+ + {/* Даты начала и конца */} +
+ +
+ + {/* Продолжительность и лимиты */} +
+ + handleChange( + 'attemptDurationMinutes', + Number(v), + ) + } + /> + + handleChange('maxAttempts', Number(v)) + } + /> +
+ + {/* Разрешить раннее завершение */} +
+ + handleChange( + 'allowEarlyFinish', + e.target.checked, + ) + } + /> + +
+ + {/* Кнопки */} +
+ {refactor ? ( + <> + + + + ) : ( + + )} +
+
+
+
+ + {/* Правая панель */} +
+
+

+ + {/* Блок для тегов */} +
+
+ { + setMissionIdInput(v); + }} + defaultState={missionIdInput} + placeholder="458" + onKeyDown={(e) => { + if (e.key == 'Enter') addMission(); + }} + /> + +
+
+ {missions.map((v, i) => ( +
+ {v.id} + {v.name} + +
+ ))} +
+
+
+
+
+
+ ); +}; + +export default ContestEditor; diff --git a/src/redux/slices/contests.ts b/src/redux/slices/contests.ts index 1bbcae5..a5c0656 100644 --- a/src/redux/slices/contests.ts +++ b/src/redux/slices/contests.ts @@ -15,7 +15,7 @@ export interface Mission { updatedAt: string; timeLimitMilliseconds: number; memoryLimitBytes: number; - statements: null; + statements: string; } export interface Member { @@ -24,18 +24,22 @@ export interface Member { role: string; } +export interface Group { + groupId: number; + groupName: string; +} + export interface Contest { id: number; name: string; description: string; - scheduleType: string; + scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; startsAt: string; endsAt: string; - attemptDurationMinutes: number | null; - maxAttempts: number | null; - allowEarlyFinish: boolean | null; - groupId: number | null; - groupName: string | null; + attemptDurationMinutes: number; + maxAttempts: number; + allowEarlyFinish: boolean; + groups: Group[]; missions: Mission[]; articles: any[]; members: Member[]; @@ -47,20 +51,18 @@ interface ContestsResponse { } export interface CreateContestBody { - name?: string | null; - description?: string | null; + name: string; + description: string; scheduleType: 'AlwaysOpen' | 'FixedWindow' | 'RollingWindow'; visibility: 'Public' | 'GroupPrivate'; - startsAt?: string | null; - endsAt?: string | null; - attemptDurationMinutes?: number | null; - maxAttempts?: number | null; - allowEarlyFinish?: boolean | null; - groupId?: number | null; - missionIds?: number[] | null; - articleIds?: number[] | null; - participantIds?: number[] | null; - organizerIds?: number[] | null; + startsAt: string; + endsAt: string; + attemptDurationMinutes: number; + maxAttempts: number; + allowEarlyFinish: boolean; + groupId: number; + missionIds: number[]; + articleIds: number[]; } // ===================== @@ -77,12 +79,22 @@ interface ContestsState { error: string | null; }; fetchContestById: { - contest: Contest | null; + contest: Contest; status: Status; error: string | null; }; createContest: { - contest: Contest | null; + contest: Contest; + status: Status; + error: string | null; + }; + // 🆕 Добавляем updateContest и deleteContest + updateContest: { + contest: Contest; + status: Status; + error: string | null; + }; + deleteContest: { status: Status; error: string | null; }; @@ -107,12 +119,63 @@ const initialState: ContestsState = { error: null, }, fetchContestById: { - contest: null, + contest: { + id: 0, + name: '', + description: '', + scheduleType: 'AlwaysOpen', + startsAt: '', + endsAt: '', + attemptDurationMinutes: 0, + maxAttempts: 0, + allowEarlyFinish: false, + groups: [], + missions: [], + articles: [], + members: [], + }, status: 'idle', error: null, }, createContest: { - contest: null, + contest: { + id: 0, + name: '', + description: '', + scheduleType: 'AlwaysOpen', + startsAt: '', + endsAt: '', + attemptDurationMinutes: 0, + maxAttempts: 0, + allowEarlyFinish: false, + groups: [], + missions: [], + articles: [], + members: [], + }, + status: 'idle', + error: null, + }, + updateContest: { + contest: { + id: 0, + name: '', + description: '', + scheduleType: 'AlwaysOpen', + startsAt: '', + endsAt: '', + attemptDurationMinutes: 0, + maxAttempts: 0, + allowEarlyFinish: false, + groups: [], + missions: [], + articles: [], + members: [], + }, + status: 'idle', + error: null, + }, + deleteContest: { status: 'idle', error: null, }, @@ -191,13 +254,51 @@ export const createContest = createAsyncThunk( }, ); +// 🆕 Обновление контеста +export const updateContest = createAsyncThunk( + 'contests/update', + async ( + { + contestId, + ...contestData + }: { contestId: number } & CreateContestBody, + { rejectWithValue }, + ) => { + try { + const response = await axios.patch( + `/contests/${contestId}`, + contestData, + ); + return response.data; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Failed to update contest', + ); + } + }, +); + +// 🆕 Удаление контеста +export const deleteContest = createAsyncThunk( + 'contests/delete', + async (contestId: number, { rejectWithValue }) => { + try { + await axios.delete(`/contests/${contestId}`); + return contestId; + } catch (err: any) { + return rejectWithValue( + err.response?.data?.message || 'Failed to delete contest', + ); + } + }, +); + // Контесты, созданные мной export const fetchMyContests = createAsyncThunk( 'contests/fetchMyContests', async (_, { rejectWithValue }) => { try { const response = await axios.get('/contests/my'); - // Возвращаем просто массив контестов return response.data; } catch (err: any) { return rejectWithValue( @@ -238,8 +339,15 @@ const contestsSlice = createSlice({ name: 'contests', initialState, reducers: { - clearSelectedContest: (state) => { - state.fetchContestById.contest = null; + // 🆕 Сброс статусов + setContestStatus: ( + state, + action: PayloadAction<{ key: keyof ContestsState; status: Status }>, + ) => { + const { key, status } = action.payload; + if (state[key]) { + (state[key] as any).status = status; + } }, }, extraReducers: (builder) => { @@ -295,7 +403,48 @@ const contestsSlice = createSlice({ state.createContest.error = action.payload; }); - // fetchMyContests + // 🆕 updateContest + builder.addCase(updateContest.pending, (state) => { + state.updateContest.status = 'loading'; + state.updateContest.error = null; + }); + builder.addCase( + updateContest.fulfilled, + (state, action: PayloadAction) => { + state.updateContest.status = 'successful'; + state.updateContest.contest = action.payload; + }, + ); + builder.addCase(updateContest.rejected, (state, action: any) => { + state.updateContest.status = 'failed'; + state.updateContest.error = action.payload; + }); + + // 🆕 deleteContest + builder.addCase(deleteContest.pending, (state) => { + state.deleteContest.status = 'loading'; + state.deleteContest.error = null; + }); + builder.addCase( + deleteContest.fulfilled, + (state, action: PayloadAction) => { + state.deleteContest.status = 'successful'; + // Удалим контест из списков + state.fetchContests.contests = + state.fetchContests.contests.filter( + (c) => c.id !== action.payload, + ); + state.fetchMyContests.contests = + state.fetchMyContests.contests.filter( + (c) => c.id !== action.payload, + ); + }, + ); + builder.addCase(deleteContest.rejected, (state, action: any) => { + state.deleteContest.status = 'failed'; + state.deleteContest.error = action.payload; + }); + // fetchMyContests builder.addCase(fetchMyContests.pending, (state) => { state.fetchMyContests.status = 'loading'; @@ -342,5 +491,5 @@ const contestsSlice = createSlice({ // Экспорты // ===================== -export const { clearSelectedContest } = contestsSlice.actions; +export const { setContestStatus } = contestsSlice.actions; export const contestsReducer = contestsSlice.reducer; diff --git a/src/views/home/account/contests/ContestsBlock.tsx b/src/views/home/account/contests/ContestsBlock.tsx index f946fd0..879c1a3 100644 --- a/src/views/home/account/contests/ContestsBlock.tsx +++ b/src/views/home/account/contests/ContestsBlock.tsx @@ -60,7 +60,6 @@ const ContestsBlock: FC = ({ id={v.id} name={v.name} startAt={v.startsAt} - statusRegister={'reg'} duration={ new Date(v.endsAt).getTime() - new Date(v.startsAt).getTime() diff --git a/src/views/home/account/contests/MyContestItem.tsx b/src/views/home/account/contests/MyContestItem.tsx index 3d2e56e..1fc3785 100644 --- a/src/views/home/account/contests/MyContestItem.tsx +++ b/src/views/home/account/contests/MyContestItem.tsx @@ -93,7 +93,7 @@ const ContestItem: React.FC = ({ onClick={(e) => { e.stopPropagation(); navigate( - `/contest/editor?back=/home/account/articles&articleId=${id}`, + `/contest/create?back=/home/account/contests&contestId=${id}`, ); }} /> diff --git a/src/views/home/contests/ModalCreate.tsx b/src/views/home/contests/ModalCreate.tsx index 54abe1a..39d9998 100644 --- a/src/views/home/contests/ModalCreate.tsx +++ b/src/views/home/contests/ModalCreate.tsx @@ -4,9 +4,13 @@ import { PrimaryButton } from '../../../components/button/PrimaryButton'; import { SecondaryButton } from '../../../components/button/SecondaryButton'; import { Input } from '../../../components/input/Input'; import { useAppDispatch, useAppSelector } from '../../../redux/hooks'; -import { createContest } from '../../../redux/slices/contests'; +import { + createContest, + setContestStatus, +} from '../../../redux/slices/contests'; import { CreateContestBody } from '../../../redux/slices/contests'; import DateRangeInput from '../../../components/input/DateRangeInput'; +import { useNavigate } from 'react-router-dom'; interface ModalCreateContestProps { active: boolean; @@ -18,6 +22,7 @@ const ModalCreateContest: FC = ({ setActive, }) => { const dispatch = useAppDispatch(); + const navigate = useNavigate(); const status = useAppSelector( (state) => state.contests.createContest.status, ); @@ -27,21 +32,29 @@ const ModalCreateContest: FC = ({ description: '', scheduleType: 'AlwaysOpen', visibility: 'Public', - startsAt: null, - endsAt: null, - attemptDurationMinutes: null, - maxAttempts: null, + startsAt: '', + endsAt: '', + attemptDurationMinutes: 0, + maxAttempts: 0, allowEarlyFinish: false, - groupId: null, - missionIds: null, - articleIds: null, - participantIds: null, - organizerIds: null, + groupId: 0, + missionIds: [], + articleIds: [], }); + const contest = useAppSelector( + (state) => state.contests.createContest.contest, + ); + useEffect(() => { if (status === 'successful') { - setActive(false); + console.log('navigate'); + navigate( + `/contest/create?back=/home/account/contests&contestId=${contest.id}`, + ); + dispatch( + setContestStatus({ key: 'createContest', status: 'idle' }), + ); } }, [status]); @@ -176,7 +189,9 @@ const ModalCreateContest: FC = ({ {/* Кнопки */}
{ + handleSubmit(); + }} text="Создать" disabled={status === 'loading'} />